الغلطة
EgyGomla بتساعد التجار المصريين يلاقوا المصانع والموردين ويطلبوا عروض أسعار. بنيتها من الألف للياء: الويب واللوحات والـ backend وتطبيقات Flutter. الموقع شغّال، وتطبيقات الموبايل لسه مانزلتش على المتاجر.
الـ edge عندي في Caddy واحد، وكل سطح له hostname:
egygomla.com: الموقع العامapp.egygomla.com: تطبيق المشتريfactory.egygomla.com: بوابة البايع (المصنع)admin.egygomla.com: لوحة الإدارةapi.egygomla.com: الـ APIid.egygomla.com: الـ Keycloak، وهو الـ OIDC issuer
وفوق ده قررت إن كل role ليه email لوحده. كنت شايف إنه أنضف حل.
ليه كانت غلط
جمهور المنتج هو المصانع والمستوردين وتجار الجملة والتجزئة، وتاجر الجملة بيشتري من المصنع وبيبيع للتاجر الأصغر، يعني هو الطرفين.
بالتصميم الأول لازم يعمل email تاني وحساب تاني وpassword تاني، ويفضل يبدّل بينهم. يعني كنت بعاقب users كتير عشان بيستخدمو المنصة أكتر.
والموضوع مش بس تعب: الحسابين مابيتشاركوش كتالوج ولا محادثات ولا تقييمات، والمنصة مش عارفة إنهم نفس البيزنس.
Amazon وNoon بيعملوها إزاي
قعدت أسرش وبصيت على الاتنين. على Amazon بتشتري من الموقع الرئيسي، وفي دليل التسجيل بتاعها بتقولك تقدر تبيع بنفس الـ email والـ password بتوع حساب الشراء. وNoon نفس الفكرة: الشراء على الموقع الرئيسي، ولوحة البايع في portal لوحدها على دومين تاني (login.noon.partners).
يعني login واحد، والشراء هو الباب الأساسي، والبيع سطح تاني.
الـ email مش الـ role
الدرس اللي خدته: الـ email بيمثل الشخص، أما بايع ومشتري فدي حاجات الشخص بيعملها. ولو ربطت الـ identity بالـ role، أي حد عنده أكتر من role هو اللي هيدفع التمن.
المشكلة كانت فين فعلًا
كنت فاكر إن المشكلة في الـ data model. عندي OrganizationType فيها BUYER | SUPPLIER | PLATFORM، وتعليق في الـ schema بيقول إن organization واحدة ماينفعش تبقى الاتنين. فافترضت إن الـ enum هو المانع. بس طلع لأ، معظم الحاجات كانت موجودة:
- الـ membership بين user وorganization، فالشخص ممكن يبقى عضو في organization مشتري وorganization بايع.
- فيه endpoint بيرجّع قايمة بالـ organizations، والـ guard بيحدد الـ organization من header مع كل request وبيتأكد من الـ membership ونوعها.
اللي كان مانع التاجر قاعدة واحدة في الـ onboarding:
// مختصرة من الـ ADR
if (onboarding.kind === "PROVISIONED") {
throw new DomainValidationError(
"This account already exists. Use an invitation to join another organization.",
);
}والقاعدة دي كانت مقصودة. قرار قبلها اعتمد على (issuer, subject) من الـ identity provider كمفتاح idempotency طبيعي لتأسيس الـ organization، فـ"تأسيس واحد لكل identity" هو اللي المفتاح ده بيشتريه، وتمنه اتكتب: مفيش organization تانية بالـ self-service.
يعني المشكلة ماكنتش في الـ type، كانت في قاعدة التأسيس، ومعرفة ده وفّرت عليا تغيير أكبر بكتير.
التصميم: link مش merge
القرار بيسيب الـ enum تلات قيم متنافية، وبيضيّق قاعدة التأسيس بدل ما يلغيها: تأسيس واحد لكل identity لكل جانب من السوق.
-- توضيحي ومبسّط من التصميم، مش الـ schema الفعلية
ALTER TABLE organizations
ADD CONSTRAINT organizations_one_founding_per_side
UNIQUE (founder_user_id, type);يعني الشخص يؤسس organization مشتري واحدة وorganization بايع واحدة، وتاني مشتري يترفض. والمفتاح فضل طبيعي وفي الـ database.
البديل الواضح كان إني أشيل الـ type وأحط flags زي can_buy وcan_sell. الـ ADR رفضه لسببين. الأول إن عندنا تلاتاشر composite foreign key بتثبّت كل خانة طرف على organization من النوع الصح، والـ flags معناها تفك الشبكة دي كلها مرة واحدة. والتاني إن نوع رابع اسمه BOTH أسوأ: كل CHECK يتحول لـ IN ('SUPPLIER','BOTH') والضمانة تختفي من غير ما test يحمرّ.
فالـ organizations يفضلوا صفين منفصلين مربوطين برابط معلن. والتوثيق مش بيعدّي الرابط: شارة المورد بتخص الـ organization بتاعته بس، والتقييم شهادة على المورد ومش حاجة جانب الشراء بيرثها.
منع إن الشخص يبيع لنفسه
دي أهم حتة. النهارده أي organization مش ممكن تدعو نفسها كمورد، لأنها مش النوعين. وبعد كده العرض محتاج صف دعوة، والترسية محتاجة عرض، والتقييم مربوط بالترسية. السلسلة دي كلها شغالة بسبب إن النوعين متنافيين على صف واحد.
ولو شخص واحد بقى له صف في كل جانب، السلسلة مابتحمي حاجة: صف المشتري بيدعو صف البايع، وبيرسي عليه، وبيقيّمه خمس نجوم. فالتصميم لازم يرجّع الحماية دي صراحةً:
-- توضيحي. شكل القيد مش الـ DDL الفعلي
ALTER TABLE rfq_recipients
ADD CONSTRAINT rfq_recipients_not_self_dealing
CHECK (buyer_link_group_id <> supplier_link_group_id);وفيه تفصيلة اتغيرت بين الرسمة والتنفيذ. في الرسمة كان الـ link group بيقبل NULL. بس الـ foreign key في PostgreSQL هو MATCH SIMPLE افتراضيًا: لو أي عمود فيه NULL، القيد بيعدّي من غير ما يتفحص. يعني أي كاتب ينسى العمود يطفّي القاعدة بالإغفال ومحدش يعرف. فالـ organization اللي ملهاش رابط معلن بقت مجموعة من واحد، والـ id بتاعها هو الـ group، والقيد بقى <> بسيط على عمودين NOT NULL.
والتصميم كاتب حدوده:
- القيد بيمسك الرابط المعلن بس. لو حد عمل organizationين بـ emailين من غير ما يعلن، هيعدّوا زي النهارده.
- تاجر يشوف أسعار منافسيه من طلب عروض أسعار حقيقي بتاعه، ده سلوك سوق مش مشكلة schema. مفيش مفتاح بيقفله، بس المنصة على الأقل هتشوفه لما الرابط يبقى موجود.
والرابط ده للعرض بس، مابيوسّعش أي قراءة. القراءات متحددة بالـ organization id اللي الـ guard طلّعه من الـ membership، وفيه test في الـ architecture بيكسر الـ build لو عمود الرابط وصل لـ query أو authorization أو presenter.
الـ login والـ session بين الـ subdomains
أول ما تسمع "identity واحدة على كذا hostname" تفتكر cookies مشتركة. بس مش كده.
- كل hostname هو origin لوحده. الـ cookies من نوع
__Host-مفيهاشDomain، فالـ session على سطح مابتتشاركش مع سطح تاني، حتى بالغلط. - الـ identity هي اللي بتتشارك، عن طريق الـ issuer. الـ Keycloak على
id.هو الـ OIDC issuer، والـ external identities متخزنة بـ(issuer, subject)، يعني اسم الـ issuer حدّ في الـ data model مش مجرد branding. - كل application هو OIDC client بـ redirect URLs حرفية، والـ wildcards ممنوعة.
- الـ organization الشغالة بتتحدد مع كل request وبتتراجع على الـ membership. الـ header بيسمّي، والسيرفر هو اللي يقرر.
الانتقال بين البوابتين بيمشي على نمط OIDC المعتاد: التطبيق التاني يحوّلك للـ issuer، والـ issuer عارفك، وترجع. أنا بوصف ده من الـ config والـ ADRs، مش من test مسجّل. والتصميم عايز لينك صريح بين البوابتين من غير تبديل صامت.
لو عندك accounts مكررة بالفعل
أنا ماعملتش merge لـ accounts مكررة في EgyGomla، ومفيش هنا ادعاء بكده. دي نصايح عامة:
- اعدّ الأول قبل ما تغيّر أي حاجة. الـ migrations عندي بتعدّ الصفوف اللي هتكسر القاعدة الجديدة وتقف وتسمّيهم، بدل ما تعدّلهم أو تمسحهم. اعمل كده مع المكرر: email، تليفون، رقم ضريبي، اسم الشركة.
- ماتعملش auto-merge على تطابق. تشابه البيانات مش دليل إنه نفس الشخص، والـ merge صعب يترجّع.
- خلّي الشخص يأكد الاتنين: يدخل على كل حساب، وبعدين اربط الـ organizationين بـ identity واحدة. اربطهم، ماتدمجهمش في صف واحد.
- سيب الكتالوج والتاريخ في مكانهم، واعمل expand وbackfill وبعدين constraints.
- لو العمود الجديد
NOT NULLمن غير default، كل حاجة بتكتب (حتى الـ seed والـ scripts) لازم تبعته قبل الـ migration. - في الآخر هيفضل ناس مش هتربط أبدًا، وده مقبول لو الحد مكتوب.
لو رجعت بالزمن
- أبدأ من يوم اليوزر اللي بيستخدم المنصة كل يوم قبل ما أقرر كل صفحة محتاجة إيه.
- ألاقي القيد فين فعلًا قبل ما أبني حواليه. أنا لمت الـ enum، والمانع كان قاعدة onboarding أنا اخترتها بإيدي.
- أكتب سلسلة الـ abuse قبل الـ happy path. نظام التقييم كان مستند على خاصية مفيش قيد بيقولها صراحة.
- ماشحنش مسار المؤسسة التانية لوحده. الـ ADR بيقول لازم يتشحن مع قيود منع البيع للنفس، وإلا بيبقى "إيميل تاني" بقى "نقرة".
- أقول إيه اللي متقرر وإيه اللي اتشحن. المقال ده بيوصف قرار، مش حاجة قدام التجار.
محمد الجوهري (Mohamed Elgohary)، Mobile & Product Engineer، القاهرة. mohamedelgohary.dev

