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:
co-authored by
Claude Fable 5
parent
c67453aeba
commit
9f7bc31408
@@ -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)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,9 +37,19 @@ export class AuthController {
|
|||||||
@Body('code') code: string,
|
@Body('code') code: string,
|
||||||
// كود دعوة اختياري — يُسجَّل للمستخدم الجديد وحده (المجموعة L).
|
// كود دعوة اختياري — يُسجَّل للمستخدم الجديد وحده (المجموعة L).
|
||||||
@Body('referral_code') referralCode?: string,
|
@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');
|
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')
|
@Post('refresh')
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export class AuthService {
|
|||||||
code: string,
|
code: string,
|
||||||
deviceId?: string,
|
deviceId?: string,
|
||||||
referralCode?: string,
|
referralCode?: string,
|
||||||
|
appRole: string = 'rider',
|
||||||
) {
|
) {
|
||||||
const tenant = await this.resolveTenant(tenantSlug);
|
const tenant = await this.resolveTenant(tenantSlug);
|
||||||
// نفس التطبيع بالضبط — وإلا فشل التحقق لمجرد أن المستخدم كتب الرقم
|
// نفس التطبيع بالضبط — وإلا فشل التحقق لمجرد أن المستخدم كتب الرقم
|
||||||
@@ -139,15 +140,19 @@ export class AuthService {
|
|||||||
await this.redis.del(this.otpKey(tenant.id, canonical), attemptsKey);
|
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) {
|
if (!user) {
|
||||||
user = await this.usersService.create(tenant.id, canonical);
|
user = await this.usersService.create(tenant.id, canonical, role);
|
||||||
// كود الدعوة يُسجَّل **للمستخدم الجديد وحده** (المجموعة L): قبوله عند
|
// كود الدعوة يُسجَّل **للمستخدم الجديد وحده** (المجموعة L): قبوله عند
|
||||||
// كل دخول كان يعني أن مستخدماً قديماً يُحال بعد سنة من استعماله.
|
// كل دخول كان يعني أن مستخدماً قديماً يُحال بعد سنة من استعماله.
|
||||||
// لا يرمي عند كود خاطئ — فشل الإحالة يجب ألّا يمنع أحداً من الدخول.
|
// لا يرمي عند كود خاطئ — فشل الإحالة يجب ألّا يمنع أحداً من الدخول.
|
||||||
if (referralCode) {
|
if (referralCode) {
|
||||||
try {
|
try {
|
||||||
await this.referrals.register(tenant.id, user.id, 'rider', referralCode);
|
await this.referrals.register(tenant.id, user.id, role, referralCode);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.logger.warn(`referral registration failed for ${user.id}: ${e?.message}`);
|
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 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));
|
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 };
|
return { ...(await this.users.findById(tenant.id, u.id)), created: !existing };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export enum UserRole {
|
|||||||
|
|
||||||
// القيد الحقيقي بعد التشفير (docs/17 — D1 إضافة): `phone` عشوائي التشفير
|
// القيد الحقيقي بعد التشفير (docs/17 — D1 إضافة): `phone` عشوائي التشفير
|
||||||
// فلا يصلح فهرساً فريداً؛ `phone_bidx` الحتمي هو من يمنع الازدواج فعلياً.
|
// فلا يصلح فهرساً فريداً؛ `phone_bidx` الحتمي هو من يمنع الازدواج فعلياً.
|
||||||
|
// الدور جزء من القيد (قرار المالك 2026-07-20 — ب): الرقم الواحد يملك هوية
|
||||||
|
// راكب وأخرى سائق منفصلتين، فالتفرّد على (مستأجر + بصمة الرقم + الدور).
|
||||||
@Entity('users')
|
@Entity('users')
|
||||||
@Index(['tenant_id', 'phone_bidx'], { unique: true })
|
@Index(['tenant_id', 'phone_bidx', 'role'], { unique: true })
|
||||||
export class User {
|
export class User {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -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> {
|
async findById(tenantId: string, id: string): Promise<User | null> {
|
||||||
return this.userRepository.findOne({ where: { tenant_id: tenantId, id } });
|
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> {
|
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);
|
return existing ?? this.create(tenantId, phone, role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user