From 9f7bc314088d2c3af9210ebfe495c72a01db8712 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Mon, 20 Jul 2026 15:21:01 +0300 Subject: [PATCH] feat(auth): separate rider/driver identities per phone Same phone can now hold distinct rider and driver accounts (and wallets) within a tenant. Unique constraint moves from (tenant, phone_bidx) to (tenant, phone_bidx, role); login creates/finds the record matching the app's x-app-role header. Fixes rider-app login returning role:driver for a phone previously registered as a driver. Co-Authored-By: Claude Fable 5 --- ...21980000000-SeparateRiderDriverIdentity.ts | 31 +++++++++++++++++++ backend/src/modules/auth/auth.controller.ts | 12 ++++++- backend/src/modules/auth/auth.service.ts | 11 +++++-- .../modules/users/admin-users.controller.ts | 5 +-- .../src/modules/users/entities/user.entity.ts | 4 ++- backend/src/modules/users/users.service.ts | 20 +++++++++++- 6 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 backend/src/database/migrations/1721980000000-SeparateRiderDriverIdentity.ts diff --git a/backend/src/database/migrations/1721980000000-SeparateRiderDriverIdentity.ts b/backend/src/database/migrations/1721980000000-SeparateRiderDriverIdentity.ts new file mode 100644 index 0000000..779fced --- /dev/null +++ b/backend/src/database/migrations/1721980000000-SeparateRiderDriverIdentity.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * فصل هوية الراكب عن السائق (قرار المالك 2026-07-20 — الخيار ب). + * + * قبل: القيد الفريد `(tenant_id, phone_bidx)` يعني مستخدماً واحداً لكل رقم في + * كل مستأجر — فتسجيل الرقم كسائق (`setRole('driver')`) يقلب سجلّه للأبد ويكسر + * وضع الراكب لنفس الرقم. بعد: الرقم الواحد يملك سجلّاً للراكب وآخر للسائق + * (ومحفظتين منفصلتين تلقائياً لأنها مربوطة بـ `user_id`). + * + * التغيير: القيد الفريد يصبح `(tenant_id, phone_bidx, role)`. لا فقدان بيانات — + * السجلات القائمة تبقى كما هي؛ الجديد فقط أنه يُسمح بسجلٍّ ثانٍ بدور مختلف. + */ +export class SeparateRiderDriverIdentity1721980000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`); + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx_role" + ON tripz_users (tenant_id, phone_bidx, role) + `); + } + + public async down(q: QueryRunner): Promise { + await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx_role"`); + // العودة قد تفشل لو وُجد رقم بدورين — لا يمكن استعادة قيد أضيق مع بيانات تخالفه. + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx" + ON tripz_users (tenant_id, phone_bidx) + `); + } +} diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 3707c67..a27cfb3 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -37,9 +37,19 @@ export class AuthController { @Body('code') code: string, // كود دعوة اختياري — يُسجَّل للمستخدم الجديد وحده (المجموعة L). @Body('referral_code') referralCode?: string, + // أي تطبيق يسجّل الدخول — راكب أم سائق (قرار المالك 2026-07-20 — ب). + // هويتان منفصلتان لنفس الرقم؛ الافتراض راكب لو غاب. + @Headers('x-app-role') appRole?: string, ) { if (!tenantId) throw new UnauthorizedException('Tenant ID (x-tenant-id) is required'); - return this.authService.verifyOtp(tenantId, phone, code, deviceId, referralCode); + return this.authService.verifyOtp( + tenantId, + phone, + code, + deviceId, + referralCode, + appRole, + ); } @Post('refresh') diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index c0d802d..c5c524f 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -110,6 +110,7 @@ export class AuthService { code: string, deviceId?: string, referralCode?: string, + appRole: string = 'rider', ) { const tenant = await this.resolveTenant(tenantSlug); // نفس التطبيع بالضبط — وإلا فشل التحقق لمجرد أن المستخدم كتب الرقم @@ -139,15 +140,19 @@ export class AuthService { await this.redis.del(this.otpKey(tenant.id, canonical), attemptsKey); } - let user = await this.usersService.findByPhone(tenant.id, canonical); + // الدور مقيّد لصلاحيات معروفة فقط — لا يُسمح لتطبيق بطلب دور أدمن/موزّع. + // راكب وسائق هويتان منفصلتان لنفس الرقم (قرار المالك 2026-07-20 — ب)، + // فالبحث والإنشاء بالدور القادم من التطبيق (`x-app-role`). + const role = appRole === 'driver' ? 'driver' : 'rider'; + let user = await this.usersService.findByPhoneAndRole(tenant.id, canonical, role); if (!user) { - user = await this.usersService.create(tenant.id, canonical); + user = await this.usersService.create(tenant.id, canonical, role); // كود الدعوة يُسجَّل **للمستخدم الجديد وحده** (المجموعة L): قبوله عند // كل دخول كان يعني أن مستخدماً قديماً يُحال بعد سنة من استعماله. // لا يرمي عند كود خاطئ — فشل الإحالة يجب ألّا يمنع أحداً من الدخول. if (referralCode) { try { - await this.referrals.register(tenant.id, user.id, 'rider', referralCode); + await this.referrals.register(tenant.id, user.id, role, referralCode); } catch (e: any) { this.logger.warn(`referral registration failed for ${user.id}: ${e?.message}`); } diff --git a/backend/src/modules/users/admin-users.controller.ts b/backend/src/modules/users/admin-users.controller.ts index 9424d1c..6b401ff 100644 --- a/backend/src/modules/users/admin-users.controller.ts +++ b/backend/src/modules/users/admin-users.controller.ts @@ -80,9 +80,10 @@ export class AdminUsersController { // يفشل لو كتبه الأدمن بصيغة مختلفة عن التي دخل بها المستخدم أول مرة. const canonical = this.phones.normalize(body.phone, tenant.countryPack); - const existing = await this.users.findByPhone(tenant.id, canonical); + // هوية لكل دور (قرار المالك 2026-07-20 — ب): نضمن وجود سجلٍّ بهذا الدور + // بدل قلب دور سجلٍّ قائم (قلبه كان يصطدم بالقيد الفريد الجديد المتضمّن الدور). + const existing = await this.users.findByPhoneAndRole(tenant.id, canonical, body.role); const u = existing ?? (await this.users.create(tenant.id, canonical, body.role)); - if (existing) await this.users.setRole(tenant.id, existing.id, body.role); return { ...(await this.users.findById(tenant.id, u.id)), created: !existing }; } diff --git a/backend/src/modules/users/entities/user.entity.ts b/backend/src/modules/users/entities/user.entity.ts index b013360..0f3c445 100644 --- a/backend/src/modules/users/entities/user.entity.ts +++ b/backend/src/modules/users/entities/user.entity.ts @@ -10,8 +10,10 @@ export enum UserRole { // القيد الحقيقي بعد التشفير (docs/17 — D1 إضافة): `phone` عشوائي التشفير // فلا يصلح فهرساً فريداً؛ `phone_bidx` الحتمي هو من يمنع الازدواج فعلياً. +// الدور جزء من القيد (قرار المالك 2026-07-20 — ب): الرقم الواحد يملك هوية +// راكب وأخرى سائق منفصلتين، فالتفرّد على (مستأجر + بصمة الرقم + الدور). @Entity('users') -@Index(['tenant_id', 'phone_bidx'], { unique: true }) +@Index(['tenant_id', 'phone_bidx', 'role'], { unique: true }) export class User { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index 0bb3726..b14d636 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -23,6 +23,24 @@ export class UsersService { }); } + /** + * بحث مقيّد بالدور (قرار المالك 2026-07-20 — ب): راكب وسائق هويتان منفصلتان + * لنفس الرقم، فمسار الدخول لكل تطبيق يبحث عن سجلّ دوره هو فقط. + */ + async findByPhoneAndRole( + tenantId: string, + phone: string, + role: string, + ): Promise { + return this.userRepository.findOne({ + where: { + tenant_id: tenantId, + phone_bidx: blindIndex(phone), + role: role as UserRole, + }, + }); + } + async findById(tenantId: string, id: string): Promise { return this.userRepository.findOne({ where: { tenant_id: tenantId, id } }); } @@ -38,7 +56,7 @@ export class UsersService { } async findOrCreate(tenantId: string, phone: string, role: string = 'rider'): Promise { - const existing = await this.findByPhone(tenantId, phone); + const existing = await this.findByPhoneAndRole(tenantId, phone, role); return existing ?? this.create(tenantId, phone, role); }