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 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-20 15:21:01 +03:00
co-authored by Claude Fable 5
parent c67453aeba
commit 9f7bc31408
6 changed files with 75 additions and 8 deletions
@@ -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<void> {
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<void> {
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)
`);
}
}
+11 -1
View File
@@ -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')
+8 -3
View File
@@ -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}`);
}
@@ -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 };
}
@@ -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;
+19 -1
View File
@@ -23,6 +23,24 @@ export class UsersService {
});
}
/**
* بحث مقيّد بالدور (قرار المالك 2026-07-20 — ب): راكب وسائق هويتان منفصلتان
* لنفس الرقم، فمسار الدخول لكل تطبيق يبحث عن سجلّ دوره هو فقط.
*/
async findByPhoneAndRole(
tenantId: string,
phone: string,
role: string,
): Promise<User | null> {
return this.userRepository.findOne({
where: {
tenant_id: tenantId,
phone_bidx: blindIndex(phone),
role: role as UserRole,
},
});
}
async findById(tenantId: string, id: string): Promise<User | null> {
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<User> {
const existing = await this.findByPhone(tenantId, phone);
const existing = await this.findByPhoneAndRole(tenantId, phone, role);
return existing ?? this.create(tenantId, phone, role);
}