باك إند واحد وكافيهات كتير: Multi-tenancy بـ Express و PostgreSQL
إزاي قهوتي بيفصل منيو وموظفين وطلبات كل كافيه عن التاني في API واحد وقاعدة بيانات واحدة: المحل مكتوب على كل صف، وبيتحدد من قاعدة البيانات مش من التوكن، واختبارات بتحاول تعدّي الحدود.
أحمد عبد المجيد
قراءة دقيقتين
قهوتي منصة طلبات بيشترك فيها كافيهات كتير. كل كافيه ليه المنيو والموظفين والعملاء والطلبات بتوعه، وما ينفعش أي واحد يشوف حاجة التاني. وكلهم عايشين في Express API واحد وقاعدة بيانات PostgreSQL واحدة. فيه طرق أتقل لكده، زي قاعدة بيانات لكل كافيه. قهوتي بيستخدم أبسط طريقة، والمهم هو إيه اللي بيخلّي الطريقة البسيطة آمنة.

١. كل صف عارف هو تبع أنهي محل#
حوالي تلات أرباع الـ 100 جدول اللي في قاعدة البيانات فيهم عمود shop_id بيشاور على جدول المحلات. والباقي تابع لجدول أب فيه العمود ده، زي أصناف الأوردر. وعدم التكرار كمان على مستوى المحل: نفس الإيميل يقدر يسجّل في محلين مختلفين، وأرقام الأوردرات في كل محل بتبدأ من 1.
CREATE UNIQUE INDEX IF NOT EXISTS users_shop_email_uq ON users (shop_id, lower(email)) WHERE shop_id IS NOT NULL;
-- on the orders table
UNIQUE (shop_id, number)أرقام الأوردرات بتيجي من عدّاد في صف المحل نفسه. جملة UPDATE واحدة بتزوّده وترجّع الرقم، فأوردرين اتعملوا في نفس اللحظة ما ينفعش ياخدوا نفس الرقم.
UPDATE shops SET next_order_number = next_order_number + 1 WHERE id = $1 RETURNING next_order_number - 1 AS number٢. المحل بيتحدد من قاعدة البيانات، مش من الطلب#
الطلب اللي جاي من مستخدم مسجّل معاه توكن، بس الـ API مش بيصدّق التوكن في المستخدم تبع أنهي محل. مع كل طلب الـ middleware بيحمّل صف المستخدم من جديد ويقرا المحل منه. مفيش header للمحل ولا ID للمحل في الرابط يقدر العميل يغيّره.
const { rows } = await query(
`SELECT u.*, s.is_active AS shop_active
FROM users u LEFT JOIN shops s ON s.id = u.shop_id
WHERE u.id = $1`,
[payload.sub],
);
const user = rows[0];
if (!user || user.deleted_at) throw unauthorized("accountGone");
// ...
req.user = user;وفيه فايدة جانبية: لما موظف يتشال، ده بيتطبق من أول طلب بعدها، من غير ما نستنى التوكن يخلص. الاستثناء الوحيد هو صاحب أكتر من محل. يقدر يبدّل بينهم، والمحل اللي في التوكن بيتقبل بس بعد ما نتأكد من جدول فيه المحلات اللي اتسمح له بيها.
٣. كل استعلام بيقول عايز بيانات مين#
مفيش ORM ومفيش فلتر مستخبي. الاستعلامات SQL عادي وكل واحد بيسمّي المحل: req.user.shop_id مكتوبة أكتر من 450 مرة في الباك إند. وأي قراءة أو تعديل بالـ ID بيحط المحل في الـ WHERE كمان.
adminRouter.delete("/categories/:id", can("menu"), async (req, res) => {
const { rows: [gone] } = await query(`DELETE FROM categories WHERE id = $1 AND shop_id = $2 RETURNING *`, [idParam(req), req.user.shop_id]);
if (!gone) throw notFound("category");
await audit(req, { action: "category.delete", entity: "category", entityId: gone.id, label: gone.name });
res.json({ data: { ok: true } });
});لو الصف تبع محل تاني، الرد 404، زيه زي ID مش موجود أصلاً.
الـ API عمره ما بيأكّد إن فيه سجل عند محل تاني. والـ IDs اللي جوه جسم الطلب بتتعامل بنفس الطريقة: الصنف ما ينفعش يتحط تحت قسم بتاع محل تاني، والسلة اللي فيها صنف من محل تاني بتترفض بعدّ الصفوف اللي رجعت.
const { rows } = await db.query(
`SELECT id, name, name_ar, price, options, bundle_items, category_id, kind, shippable, retail_stock FROM products WHERE shop_id = $1 AND is_available AND id = ANY($2)`,
[shopId, ids],
);
if (rows.length !== ids.length) throw badRequest("itemsUnavailable");٤. اختبارات بتحاول تعدّي الحدود#
أي قاعدة بتفضل ماشية لحد اليوم اللي حد ينساها فيه. عشان كده الاختبارات بتتصرف زي صاحب محل فضولي: بتدخل بحساب محل وتحاول توصل لأصناف محل تاني.
test("a shop cannot touch another shop's data", async () => {
const other = (await api().get(`/api/shops/${nileBeans.id}/menu`)).body.data.categories[0].products[0];
const res = await api().put(`/api/admin/products/${other.id}`).set(auth(owner.token)).send({ price: 1 });
assert.equal(res.status, 404);
const cat = await api().post("/api/admin/products").set(auth(owner.token)).send({ name: "X", price: 1, categoryId: other.categoryId });
assert.equal(cat.status, 400);
});اللي التصميم ده مفيهوش هو شبكة أمان جوه قاعدة البيانات نفسها. الـ row-level security في PostgreSQL كانت هترفض أي استعلام نسي فلتر المحل؛ هنا، الفلتر المنسي غلطة ما بيمسكهاش غير اختبار أو مراجعة. ده تمن SQL بسيط ومقروء، والـ row-level security هي الخطوة الجاية لو ناس أكتر بدأت تكتب استعلامات.
نصيحتي لأي حد بيبني حاجة زي كده#
- حط المحل على كل صف، وفي كل قيد عدم تكرار.
- اقرا المحل من قاعدة البيانات مع كل طلب، ومش من اللي العميل بيبعته.
- رد بـ 404 على بيانات محل تاني، مش 403.
- اكتب اختبارات بتهاجم الفصل بتاعك، وخليك صريح في اللي قاعدة البيانات مش بتفرضه.

