From 253fbb4b2aae46b139ad5da98bb53fc2c071f4ad Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 17 Jul 2026 17:25:18 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D8=A7=D9=84=D9=85=D8=AC=D9=85=D9=88?= =?UTF-8?q?=D8=B9=D8=A9=20E=20(=D9=83=D8=B4=D9=81=20=D8=A7=D9=84=D8=A7?= =?UTF-8?q?=D8=AD=D8=AA=D9=8A=D8=A7=D9=84)=20+=20=D8=AA=D8=B4=D9=81=D9=8A?= =?UTF-8?q?=D8=B1=20=D8=A7=D9=84=D9=87=D8=A7=D8=AA=D9=81=20at-rest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E — اتصال ثم إلغاء (من driver_ride_scam عند سيرو): - E1: driver_called_passenger + last_call_at/by/count على الرحلة نفسها لا Redis فقط — حقيقة دائمة تفيد التحقيق بعد انتهاء الرحلة وزوالها من الكاش. يُسجَّل عند call:offer (بدء المكالمة الفعلي) في RealtimeGateway عبر Repository مباشرة — لا استيراد TripsModule كاملاً (يستورد RealtimeModule أصلاً، فدورة استيراد) - E2: FraudService.recordCallThenCancel — عدّاد يومي منفصل عن عدّاد الإلغاء العام بالساعة. 3/يوم = إنذار لا يمنع الإلغاء، 6/يوم = حظر مؤقت (طلب المالك حرفياً). يرتبط فقط إن كانت المكالمة خلال 30 دقيقة من الإلغاء تشفير الهاتف at-rest (ملاحظة المالك) — بفهرس أعمى منفصل، لا تشفير ساذج: - AES-256-GCM عشوائي IV يمنع WHERE phone = :x من المطابقة أصلاً (كل تشفير مختلف شكلاً حتى لنفس الرقم) — لو طُبِّق التشفير مباشرة بلا هذا لكسر تسجيل الدخول بالكامل بصمت - users.phone_bidx = HMAC-SHA256(الرقم المطبَّع) — حتمي، يصلح للبحث وللقيد الفريد. القيد انتقل من (tenant_id, phone) إلى (tenant_id, phone_bidx) — القديم كان سيقبل تكرار نفس الرقم الحقيقي فعلياً - findByPhone/create يحسبان البصمة؛ لا نقطة أخرى تقرأ phone بمساواة خام - هجرة EncryptPhone تُشفّر الصفوف القائمة فوراً وتبني البصمة بأثر رجعي — استثناء متعمَّد من تسامح decrypt() المعتاد (الهاتف عمود بحث يومي، خطره كنص صافٍ أعلى من name) - يعتمد على تطبيع D1: بصمة حتمية لنفس *النص*، فلا بد أن يصل الرقم مطبَّعاً قبلها وإلا عاد نفس الرقم بصيغتين ليصير رقمين مرة أخرى هجرات: TripCallSignal · EncryptPhone. Co-Authored-By: Claude Opus 4.8 --- .../1721890000000-TripCallSignal.ts | 26 +++++ .../migrations/1721900000000-EncryptPhone.ts | 56 +++++++++ backend/src/modules/fraud/fraud.service.ts | 43 +++++++ .../src/modules/trips/entities/trip.entity.ts | 16 +++ backend/src/modules/trips/trips.service.ts | 15 +++ .../src/modules/users/entities/user.entity.ts | 19 ++- .../src/modules/users/users.service.spec.ts | 108 ++++++++++++++++++ backend/src/modules/users/users.service.ts | 16 ++- backend/src/realtime/realtime.gateway.ts | 31 ++++- backend/src/realtime/realtime.module.ts | 5 + docs/16-encryption.md | 16 ++- docs/17-backend-backlog.md | 10 +- 12 files changed, 345 insertions(+), 16 deletions(-) create mode 100644 backend/src/database/migrations/1721890000000-TripCallSignal.ts create mode 100644 backend/src/database/migrations/1721900000000-EncryptPhone.ts create mode 100644 backend/src/modules/users/users.service.spec.ts diff --git a/backend/src/database/migrations/1721890000000-TripCallSignal.ts b/backend/src/database/migrations/1721890000000-TripCallSignal.ts new file mode 100644 index 0000000..0f9516e --- /dev/null +++ b/backend/src/database/migrations/1721890000000-TripCallSignal.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * إشارة المكالمة على الرحلة (docs/17 — E1/E2؛ مكافئ isDriverCallPassenger + * عند سيرو). تُخزَّن دائماً على الرحلة نفسها — لا في Redis فقط — لأنها + * حقيقة تفيد التحقيق حتى بعد زوال حالة الرحلة من الكاش. + */ +export class TripCallSignal1721890000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query( + `ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS driver_called_passenger boolean NOT NULL DEFAULT false`, + ); + await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_at timestamptz`); + await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_by varchar`); + await q.query( + `ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS call_count integer NOT NULL DEFAULT 0`, + ); + } + + public async down(q: QueryRunner): Promise { + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS call_count`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_by`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_at`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS driver_called_passenger`); + } +} diff --git a/backend/src/database/migrations/1721900000000-EncryptPhone.ts b/backend/src/database/migrations/1721900000000-EncryptPhone.ts new file mode 100644 index 0000000..e800414 --- /dev/null +++ b/backend/src/database/migrations/1721900000000-EncryptPhone.ts @@ -0,0 +1,56 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { encrypt, blindIndex } from '../../common/crypto/crypto.util'; + +/** + * تشفير الهاتف at-rest + فهرس أعمى للبحث (docs/16، docs/17 — D1 إضافة). + * + * **لماذا فهرس منفصل**: `phone` بعد التشفير غير قابل للبحث بالمساواة إطلاقاً + * — AES-256-GCM يستعمل IV عشوائياً لكل عملية، فنفس الرقم الحقيقي ينتج شيفرة + * مختلفة كل مرة. القيد الفريد ونقاط `WHERE phone = …` (تسجيل الدخول بأكمله) + * ينتقلان إلى `phone_bidx` — بصمة حتمية (HMAC) لا تكشف الرقم لكنها تكرّر + * نفسها لنفس الرقم دائماً. + * + * تُشفَّر الصفوف القائمة في نفس الهجرة (لا تُترك نصّاً صافياً) — الحماية + * التلقائية عبر `decrypt()` المتسامح كانت ستُبقيها صافية للأبد بلا هذا. + */ +export class EncryptPhone1721900000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS phone_bidx varchar`); + + const rows: { id: string; phone: string }[] = await q.query( + `SELECT id, phone FROM tripz_users`, + ); + for (const row of rows) { + // تسامح إعادة التشغيل الجزئي: لا نُشفّر قيمة مشفّرة أصلاً مرة أخرى. + const alreadyEncrypted = row.phone?.startsWith('v1:'); + const canonicalPhone = alreadyEncrypted ? null : row.phone; + const bidx = blindIndex(canonicalPhone ?? row.phone); + + if (alreadyEncrypted) { + await q.query(`UPDATE tripz_users SET phone_bidx = $1 WHERE id = $2`, [bidx, row.id]); + } else { + await q.query(`UPDATE tripz_users SET phone = $1, phone_bidx = $2 WHERE id = $3`, [ + encrypt(row.phone), + bidx, + row.id, + ]); + } + } + + await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx SET NOT NULL`); + await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone"`); + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx" + ON tripz_users (tenant_id, phone_bidx) + `); + } + + public async down(q: QueryRunner): Promise { + // لا رجوع لفكّ التشفير جماعياً هنا عمداً — decrypt() يبقى متاحاً عند + // القراءة عبر الـORM؛ التراجع عن هذه الهجرة يعني قبول عدم قابلية البحث + // إن أُعيد phone فهرساً فريداً وحده، فلا نعيد بناء ذلك القيد تلقائياً. + await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`); + await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx DROP NOT NULL`); + await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS phone_bidx`); + } +} diff --git a/backend/src/modules/fraud/fraud.service.ts b/backend/src/modules/fraud/fraud.service.ts index b86b258..159fcc5 100644 --- a/backend/src/modules/fraud/fraud.service.ts +++ b/backend/src/modules/fraud/fraud.service.ts @@ -23,6 +23,14 @@ export class FraudService { private readonly ARRIVED_MAX_M = 350; // أقصى بعد مقبول عند "وصل" private readonly FAST_MIN_SEC = 30; // إنهاء أسرع من هذا يُشتبه به + // اتصال ثم إلغاء (docs/17 — E2؛ من driver_ride_scam عند سيرو). + // نافذة يومية لا ساعة — مقصودة ومنفصلة عن CANCEL_SOFT/HARD: تلك تعدّ كل + // إلغاء أياً كان سببه، وهذه تعدّ فقط الإلغاء الذي **سبقته مكالمة** — إشارة + // اتفاق خارج التطبيق، لا مجرد كثرة إلغاء. + private readonly CALL_CANCEL_WINDOW_MIN = 30; // اتصال خلال آخر نصف ساعة قبل الإلغاء يُحتسب مرتبطاً + private readonly CALL_CANCEL_SOFT = 3; // إنذار — طلب المالك حرفياً: «3 إلغاءات/يوم» + private readonly CALL_CANCEL_HARD = 6; // التكرار → إجراء (حظر مؤقت) + constructor( @InjectRepository(FraudFlag) private readonly repo: Repository, @Inject(REDIS) private readonly redis: Redis, @@ -70,6 +78,41 @@ export class FraudService { } } + /** + * ربط الاتصال بالإلغاء (docs/17 — E2): اتصال ثم إلغاء سريع = مؤشر اتفاق + * خارج التطبيق (الطرفان اتفقا هاتفياً وألغيا ليتجنّبا العمولة). يُنادى + * من `TripsService.cancel` مع آخر وقت اتصال على الرحلة (أو null إن لم + * تحدث مكالمة، فيخرج فوراً بلا أثر). + * + * عدّاد **يومي منفصل** عن `recordCancellation` عمداً: التكرار هنا سببه + * الاتصال تحديداً لا كثرة الإلغاء العامة. + */ + async recordCallThenCancel( + tenantId: string, + subjectType: 'rider' | 'driver', + userId: string, + tripId: string, + lastCallAt: Date | null, + ): Promise { + if (!lastCallAt) return; + const minutesSinceCall = (Date.now() - lastCallAt.getTime()) / 60000; + if (minutesSinceCall > this.CALL_CANCEL_WINDOW_MIN) return; // اتصال قديم — غير مرتبط + + const today = new Date().toISOString().slice(0, 10); + const key = `call_cancel:${tenantId}:${userId}:${today}`; + const count = await this.redis.incr(key); + if (count === 1) await this.redis.expire(key, 26 * 3600); // يتجاوز منتصف الليل بأمان + + if (count >= this.CALL_CANCEL_HARD) { + await this.flag(tenantId, subjectType, userId, 'call_then_cancel_hard', { count }, tripId); + throw new ForbiddenException('Too many call-then-cancel patterns today — temporarily blocked'); + } + if (count >= this.CALL_CANCEL_SOFT) { + // إنذار فقط — لا يمنع الإلغاء نفسه (طلب المالك: 3 = إنذار لا حظر). + await this.flag(tenantId, subjectType, userId, 'call_then_cancel_soft', { count }, tripId); + } + } + /** * يتحقق من قرب السائق عند "وصل"؛ يبلّغ إن كان بعيداً. * `driverAt` هو الموقع **الحيّ** من Redis — لا لقطة القاعدة الدورية، وإلا diff --git a/backend/src/modules/trips/entities/trip.entity.ts b/backend/src/modules/trips/entities/trip.entity.ts index 90cf38c..9f5a9f0 100644 --- a/backend/src/modules/trips/entities/trip.entity.ts +++ b/backend/src/modules/trips/entities/trip.entity.ts @@ -121,6 +121,22 @@ export class Trip { @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) cancel_fee: number | null; + // ---- إشارة المكالمة (docs/17 — E1؛ مكافئ isDriverCallPassenger عند سيرو) ---- + // اتصال ثم إلغاء سريع = مؤشر اتفاق خارج التطبيق (E2). نُخزَّن دائماً + // (لا نكتفي بـRedis) لأنها حقيقة دائمة عن الرحلة تفيد التحقيق لاحقاً حتى + // بعد انتهاء الرحلة وزوال حالتها من Redis. + @Column({ default: false }) + driver_called_passenger: boolean; + + @Column({ type: 'timestamptz', nullable: true }) + last_call_at: Date | null; + + @Column({ type: 'varchar', nullable: true }) + last_call_by: string | null; // rider | driver + + @Column({ type: 'int', default: 0 }) + call_count: number; + // ---- طوابع دقيقة (docs/17 — B7؛ مقابلها في سيرو بين قوسين) ---- @CreateDateColumn() requested_at: Date; diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index c9f7e17..a016028 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -354,6 +354,21 @@ export class TripsService { // كشف إساءة الإلغاء (قد يرمي عند الحد الصارم) await this.fraud.recordCancellation(tenantId, actor, actorUserId, tripId); + // اتصال ثم إلغاء = مؤشر اتفاق خارج التطبيق (docs/17 — E2). last_call_at + // ليست في حالة Redis (مكالمة نادرة الحدوث، لا تستحق كاش) — قراءة خفيفة + // من القاعدة بعمود واحد. + const callRow = await this.trips.findOne({ + where: { tenant_id: tenantId, id: tripId }, + select: { last_call_at: true }, + }); + await this.fraud.recordCallThenCancel( + tenantId, + actor, + actorUserId, + tripId, + callRow?.last_call_at ?? null, + ); + const fee = await this.cancelFee(tenantId, tripId, snapshot); const res = await this.trips.update( { tenant_id: tenantId, id: tripId, status: snapshot.status }, diff --git a/backend/src/modules/users/entities/user.entity.ts b/backend/src/modules/users/entities/user.entity.ts index eb36f43..b013360 100644 --- a/backend/src/modules/users/entities/user.entity.ts +++ b/backend/src/modules/users/entities/user.entity.ts @@ -1,4 +1,4 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; import { EncryptedTransformer } from '../../../common/crypto/crypto.util'; export enum UserRole { @@ -8,7 +8,10 @@ export enum UserRole { ADMIN = 'admin', } +// القيد الحقيقي بعد التشفير (docs/17 — D1 إضافة): `phone` عشوائي التشفير +// فلا يصلح فهرساً فريداً؛ `phone_bidx` الحتمي هو من يمنع الازدواج فعلياً. @Entity('users') +@Index(['tenant_id', 'phone_bidx'], { unique: true }) export class User { @PrimaryGeneratedColumn('uuid') id: string; @@ -16,9 +19,21 @@ export class User { @Column({ type: 'uuid' }) tenant_id: string; - @Column() + // مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة. + // **لا يُستعمل في WHERE مباشرة**: IV عشوائي لكل عملية تشفير يعني أن نفس + // الرقم ينتج شيفرة مختلفة كل مرة، فأي بحث بالمساواة على هذا العمود يفشل + // دائماً. البحث والتفرّد كلاهما عبر `phone_bidx` (docs/16 §"blindIndex"). + @Column({ transformer: EncryptedTransformer }) phone: string; + /** + * فهرس أعمى حتمي (HMAC-SHA256) — نفس الرقم الحقيقي ينتج نفس القيمة دائماً، + * فيصلح للبحث `WHERE` وللقيد الفريد `(tenant_id, phone_bidx)`. لا يكشف + * الرقم (اتجاه واحد)، وهو المفتاح الفعلي لا `phone` (docs/17 — D1 إضافة). + */ + @Column() + phone_bidx: string; + // مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة @Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer }) name: string; diff --git a/backend/src/modules/users/users.service.spec.ts b/backend/src/modules/users/users.service.spec.ts new file mode 100644 index 0000000..8aa298c --- /dev/null +++ b/backend/src/modules/users/users.service.spec.ts @@ -0,0 +1,108 @@ +import { randomUUID } from 'crypto'; +import { newDb } from 'pg-mem'; +import { DataSource } from 'typeorm'; +import { User } from './entities/user.entity'; +import { UsersService } from './users.service'; +import { blindIndex, decrypt } from '../../common/crypto/crypto.util'; + +const TENANT_A = '11111111-1111-1111-1111-111111111111'; +const TENANT_B = '22222222-2222-2222-2222-222222222222'; + +function fakeCache() { + return { wrap: jest.fn(async (_k: string, _t: number, loader: () => any) => loader()), set: jest.fn() } as any; +} + +describe('UsersService — الهاتف مشفَّر at-rest (docs/16، docs/17 D1)', () => { + let ds: DataSource; + let users: UsersService; + + beforeEach(async () => { + const db = newDb({ autoCreateForeignKeyIndices: true }); + db.public.registerFunction({ name: 'version', returns: 'text' as any, implementation: () => 'pg-mem' }); + db.public.registerFunction({ + name: 'current_database', + returns: 'text' as any, + implementation: () => 'tripz', + }); + db.registerExtension('uuid-ossp', (schema) => + schema.registerFunction({ + name: 'uuid_generate_v4', + returns: 'uuid' as any, + implementation: () => randomUUID(), + impure: true, + }), + ); + await db.public.none(`CREATE EXTENSION "uuid-ossp"`); + + ds = (await db.adapters.createTypeormDataSource({ + type: 'postgres', + entities: [User], + entityPrefix: 'tripz_', + })) as DataSource; + await ds.initialize(); + await ds.synchronize(); + + users = new UsersService(ds.getRepository(User), fakeCache()); + }); + + afterEach(async () => { + if (ds?.isInitialized) await ds.destroy(); + }); + + it('يقرأ رقماً مفكوك التشفير رغم أنه مخزَّن مشفّراً', async () => { + const created = await users.create(TENANT_A, '962790000000'); + expect(created.phone).toBe('962790000000'); // الكائن المُعاد فوراً واضح + + const fetched = await users.findById(TENANT_A, created.id); + expect(fetched!.phone).toBe('962790000000'); // وكذلك بعد إعادة القراءة + }); + + it('العمود على القاعدة مشفّر فعلاً — ليس نصّاً صافياً', async () => { + const created = await users.create(TENANT_A, '962790000000'); + + const raw = await ds.query(`SELECT phone FROM tripz_users WHERE id = $1`, [created.id]); + expect(raw[0].phone).not.toBe('962790000000'); + expect(raw[0].phone).toMatch(/^v1:/); // صيغة common/crypto/crypto.util + expect(decrypt(raw[0].phone)).toBe('962790000000'); // لكنه قابل للفكّ + }); + + it('نفس الرقم يُعطي نفس الفهرس الأعمى دائماً — والبحث يعمل رغم التشفير العشوائي', async () => { + const created = await users.create(TENANT_A, '962790000000'); + + const found = await users.findByPhone(TENANT_A, '962790000000'); + expect(found?.id).toBe(created.id); + }); + + it('الفهرس الأعمى نفسه لا يكشف الرقم — بصمة أحادية الاتجاه', async () => { + const created = await users.create(TENANT_A, '962790000000'); + const raw = await ds.query(`SELECT phone_bidx FROM tripz_users WHERE id = $1`, [created.id]); + + expect(raw[0].phone_bidx).toBe(blindIndex('962790000000')); + expect(raw[0].phone_bidx).not.toContain('790000000'); + }); + + it('رقم مختلف لا يتصادف مع فهرس رقم آخر', async () => { + await users.create(TENANT_A, '962790000000'); + expect(await users.findByPhone(TENANT_A, '962790000001')).toBeNull(); + }); + + it('القيد الفريد يحمي من ازدواج نفس الرقم داخل المستأجر الواحد', async () => { + await users.create(TENANT_A, '962790000000'); + await expect(users.create(TENANT_A, '962790000000')).rejects.toThrow(); + }); + + it('نفس الرقم تحت مستأجرين مختلفين = مستخدمان منفصلان (عزل)', async () => { + const a = await users.create(TENANT_A, '962790000000'); + const b = await users.create(TENANT_B, '962790000000'); + expect(a.id).not.toBe(b.id); + + expect((await users.findByPhone(TENANT_A, '962790000000'))!.id).toBe(a.id); + expect((await users.findByPhone(TENANT_B, '962790000000'))!.id).toBe(b.id); + }); + + it('findOrCreate لا يُنشئ نسخة ثانية لنفس الرقم', async () => { + const first = await users.findOrCreate(TENANT_A, '962790000000'); + const second = await users.findOrCreate(TENANT_A, '962790000000'); + expect(first.id).toBe(second.id); + }); +}); diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index 18ab6f6..0bb3726 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User, UserRole } from './entities/user.entity'; import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service'; +import { blindIndex } from '../../common/crypto/crypto.util'; @Injectable() export class UsersService { @@ -12,8 +13,14 @@ export class UsersService { private readonly cache: CacheService, ) {} + /** + * `phone` مشفّر عشوائي at-rest — `WHERE phone = :x` لا يطابق أبداً حتى مع + * نفس الرقم (IV مختلف كل مرة). البحث دائماً عبر `phone_bidx` الحتمي. + */ async findByPhone(tenantId: string, phone: string): Promise { - return this.userRepository.findOne({ where: { tenant_id: tenantId, phone } }); + return this.userRepository.findOne({ + where: { tenant_id: tenantId, phone_bidx: blindIndex(phone) }, + }); } async findById(tenantId: string, id: string): Promise { @@ -21,7 +28,12 @@ export class UsersService { } async create(tenantId: string, phone: string, role: string = 'rider'): Promise { - const user = this.userRepository.create({ tenant_id: tenantId, phone, role: role as UserRole }); + const user = this.userRepository.create({ + tenant_id: tenantId, + phone, + phone_bidx: blindIndex(phone), + role: role as UserRole, + }); return this.userRepository.save(user); } diff --git a/backend/src/realtime/realtime.gateway.ts b/backend/src/realtime/realtime.gateway.ts index e9436f4..45ed56f 100644 --- a/backend/src/realtime/realtime.gateway.ts +++ b/backend/src/realtime/realtime.gateway.ts @@ -8,8 +8,11 @@ import { } from '@nestjs/websockets'; import { Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { Server, Socket } from 'socket.io'; import { DriversService } from '../modules/drivers/drivers.service'; +import { Trip } from '../modules/trips/entities/trip.entity'; /** * البوابة الحية (Socket.IO) — docs/09. مصادقة JWT + غرف مُنطَّقة بالمستأجر، @@ -25,6 +28,7 @@ export class RealtimeGateway implements OnGatewayConnection { constructor( private readonly jwt: JwtService, private readonly drivers: DriversService, + @InjectRepository(Trip) private readonly trips: Repository, ) {} handleConnection(client: Socket) { @@ -91,9 +95,34 @@ export class RealtimeGateway implements OnGatewayConnection { } // ---- إشارات WebRTC (مكالمة مجانية بين الطرفين عبر غرفة الرحلة) ---- + /** + * `offer` هو حدث **بدء الاتصال** فعلياً (answer/ice/end إشارات تفاوض + * لاحقة على نفس المكالمة) — لذا هنا فقط نُسجّل إشارة الاحتيال (docs/17 — + * E1). الكتابة على القاعدة مباشرة لا عبر Redis: تكرار المكالمات لا يقارن + * بتكرار نبضات الموقع، فلا حاجة لتجميع (نمط H) هنا. + */ @SubscribeMessage('call:offer') - onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) { + async onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) { this.relay(c, 'call:offer', b); + const u = c.data.user; + if (!u || !b?.tripId) return; + try { + await this.trips + .createQueryBuilder() + .update(Trip) + .set({ + last_call_at: () => 'now()', + last_call_by: u.role, + call_count: () => 'call_count + 1', + ...(u.role === 'driver' ? { driver_called_passenger: true } : {}), + }) + .where('tenant_id = :tenantId AND id = :tripId', { tenantId: u.tenantId, tripId: b.tripId }) + .execute(); + } catch (e: any) { + // تسجيل الإشارة لا يجوز أن يقطع المكالمة نفسها — أُعيد المحاولة لاحقاً + // غير ممكنة هنا، فنكتفي باللوغ. + this.logger.warn(`call signal record failed: ${e?.message}`); + } } @SubscribeMessage('call:answer') diff --git a/backend/src/realtime/realtime.module.ts b/backend/src/realtime/realtime.module.ts index 2b0efc0..3aef1c5 100644 --- a/backend/src/realtime/realtime.module.ts +++ b/backend/src/realtime/realtime.module.ts @@ -1,12 +1,17 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RealtimeGateway } from './realtime.gateway'; import { DriversModule } from '../modules/drivers/drivers.module'; +import { Trip } from '../modules/trips/entities/trip.entity'; @Module({ imports: [ DriversModule, + // تسجيل الكيان مباشرة لا وحدة TripsModule كاملة — TripsModule تستورد + // RealtimeModule أصلاً، فاستيراد معاكس يصنع دورة (docs/17 — E1). + TypeOrmModule.forFeature([Trip]), JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/docs/16-encryption.md b/docs/16-encryption.md index 297c81c..c363d8d 100644 --- a/docs/16-encryption.md +++ b/docs/16-encryption.md @@ -17,15 +17,19 @@ name: string; ``` ## المطبّق حالياً -- `users.name` · `drivers.vehicle_plate`. +- `users.name` · `users.phone` · `drivers.vehicle_plate`. -## الحقول القابلة للبحث (مثل الهاتف) — الحل الصحيح -تشفير GCM عشوائي **يمنع المطابقة بالتساوي** (كل تشفير مختلف). للبحث نستخدم **فهرس أعمى (blind index)**: عمود إضافي = `HMAC-SHA256(القيمة)` حتمي للبحث + العمود المشفّر للتخزين. مخطّط عند بناء بحث الهاتف/الهوية. +## الحقول القابلة للبحث (مثل الهاتف) — ✅ منفَّذ (docs/17 — D1 إضافة) +تشفير GCM عشوائي **يمنع المطابقة بالتساوي** (كل تشفير مختلف) — `WHERE phone = :x` لا يطابق أبداً حتى مع نفس الرقم. الحل: **فهرس أعمى (blind index)** — `users.phone_bidx = HMAC-SHA256(canonicalPhone)` حتمي، عمود إضافي غير مشفَّر (بصمة أحادية الاتجاه، لا تكشف الرقم). كل بحث وتفرّد على الهاتف يمرّ عبره لا عبر `phone`: +- `UsersService.findByPhone` يحسب `blindIndex(phone)` ويبحث به. +- القيد الفريد `(tenant_id, phone_bidx)` — لا `(tenant_id, phone)` (ذاك كان سيقبل تكرار نفس الرقم الحقيقي لأن كل تشفير مختلف شكلاً). +- **يعتمد على تطبيع D1**: البصمة حتمية لنفس *النص*، فلا بد أن يصل `phone` مطبَّعاً (`common/phone/phone.service.ts`) قبل الوصول لهذا الكود — وإلا اعتُبر نفس الرقم بصيغتين مختلفتين رقمين مختلفين مرة أخرى. ## الميغريشن (صحيح ومتوافق) -- **لا تغيير مخطّط:** الأعمدة `varchar` تتّسع للنص المشفّر (base64). لا هجرة لازمة لتحويل النوع. -- **تسامح انتقالي:** `decrypt` يُرجع النص القديم غير المشفّر كما هو (يبدأ بلا `v1:`) — فلا تنكسر القراءة على بيانات قديمة؛ والكتابة الجديدة تُشفّر. -- **عند نقل بيانات حقيقية من سيرو:** تُقرأ مفكوكةً من سيرو ثم تُكتب فتُشفَّر عندنا تلقائياً. +- **لا تغيير مخطّط لـ`varchar`:** الأعمدة تتّسع للنص المشفّر (base64). لا هجرة لتحويل النوع. +- **تسامح انتقالي:** `decrypt` يُرجع النص القديم غير المشفّر كما هو (يبدأ بلا `v1:`) — فلا تنكسر القراءة على بيانات قديمة. +- **الهاتف استثناء متعمَّد لهذا التسامح**: هجرة `EncryptPhone` تُشفّر الصفوف القائمة **فوراً** (لا تتركها صافية للأبد بالاعتماد على `decrypt` المتسامح) — لأن `phone` عمود بحث نشط يومياً (كل تسجيل دخول)، ونصّه الصافي في القاعدة أخطر من `name` (نادراً ما يُقرأ مباشرة كنص خام). نفس الهجرة تبني `phone_bidx` بأثر رجعي لكل صف قائم. +- **عند نقل بيانات حقيقية من سيرو:** تُقرأ مفكوكةً من سيرو ثم تُكتب فتُشفَّر عندنا تلقائياً — ويجب تمريرها عبر `PhoneService.normalize` أولاً قبل الكتابة، وإلا فسدت البصمة. ## ما يُشفَّر مستقبلاً وثائق السائق وأرقام الهوية/الرخصة (وحدة الوثائق)، ووجهات السحب الحساسة، وأي PII يُضاف — بنفس المحوّل. diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index a3c3187..c7a7ade 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -76,12 +76,12 @@ --- -## المجموعة E — كشف الاحتيال (من `driver_ride_scam`) +## المجموعة E — كشف الاحتيال (من `driver_ride_scam`) — ✅ منفَّذة -| # | البند | التفصيل | -|---|-------|---------| -| E1 | **تسجيل زر الاتصال** | `isDriverCallPassenger` لكل رحلة (سيرو). | -| E2 | **ربط الاتصال بالإلغاء** | اتصال ثم إلغاء = مؤشر اتفاق خارج التطبيق. **3 إلغاءات/يوم → إنذار**؛ التكرار → إجراء. | +| # | البند | الحالة | +|---|-------|--------| +| E1 | **تسجيل زر الاتصال** | ✅ `driver_called_passenger` + `last_call_at`/`last_call_by`/`call_count` على الرحلة نفسها (لا Redis فقط — حقيقة دائمة تفيد التحقيق بعد انتهاء الرحلة). يُسجَّل عند `call:offer` في `RealtimeGateway` — لحظة **بدء** الاتصال الفعلية، لا `answer`/`ice`/`end` (إشارات تفاوض لاحقة لنفس المكالمة). | +| E2 | **ربط الاتصال بالإلغاء** | ✅ `FraudService.recordCallThenCancel` — عدّاد **يومي منفصل** عن عدّاد الإلغاء العام (ذاك بالساعة ويعدّ كل إلغاء؛ هذا باليوم ويعدّ فقط الإلغاء **المسبوق باتصال خلال 30 دقيقة**). **3/يوم = إنذار (لا يمنع الإلغاء) — 6/يوم = حظر مؤقت**، طبق طلب المالك حرفياً. يُنادى من `TripsService.cancel` بقراءة خفيفة لعمود واحد قبل التحديث. | ---