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,
|
||||
// كود دعوة اختياري — يُسجَّل للمستخدم الجديد وحده (المجموعة 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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user