feat: المجموعة A (زمن حقيقي + FCM + Redis) + إصلاح سباق المحفظة

المجموعة A (docs/17):
- A1: FCM على كل انتقال حالة (priority high) + حذف التوكنات الميتة
- A2: common/i18n (ar/en) + عمود users.language — الإشعارات بلغة المستخدم
- A3: TripStateService — حالة الرحلة الجارية في Redis hash (TTL 6س)؛
  الانتقال صار UPDATE شرطي + قراءة واحدة بدل ~5 استعلامات
- A4: قبول ذرّي — CAS بـLua في Redis + UPDATE ... WHERE status='searching'
  كحَكَم نهائي؛ أول سائق يفوز والباقي يُرفضون بلا لمس القاعدة
- A5: مجموعة العروض في Redis + بث trip:offer_taken و FCM لبقية السائقين
- A6: GET /trips/available — السائق يسحب الطلبات القريبة
- A7: FCM data-only بحمولة كاملة للـoverlay

I1 — إصلاح سباق المحفظة (ثغرة مالية):
- credit/debit كانا read-modify-write على balance بلا قفل → خصمان متزامنان
  يكتبان فوق بعضهما. صارا UPDATE ذرّي واحد بشرط balance >= :amount،
  والقيد+الرصيد في معاملة واحدة
- wallet_txns.balance_after للتدقيق + CHECK (balance >= 0) كشبكة أمان
- إنشاء المحفظة عبر ON CONFLICT DO NOTHING (سباق ثانٍ كان كامناً)

الاختبارات تعمل على السيرفر (docs/15):
- npm test صار جزءاً من مرحلة builder — فشل اختبار = فشل بناء = لا نشر
- pg-mem + ioredis-mock: بلا شبكة وبلا قاعدة حقيقية
- scripts/wallet-race-test.mjs للتزامن الحقيقي على السيرفر

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 02:23:00 +03:00
co-authored by Claude Opus 4.8
parent b7e91e5de6
commit 9d6b752ea8
28 changed files with 1372 additions and 166 deletions
+3 -2
View File
@@ -4,5 +4,6 @@ npm-debug.log
.env
.git
.gitignore
test
**/*.spec.ts
# ملاحظة: ملفات *.spec.ts تدخل السياق عمداً — الاختبارات تعمل داخل مرحلة
# builder على السيرفر (راجع docs/15). tsconfig.build.json يستثنيها من dist،
# فلا تصل صورة التشغيل.
+4
View File
@@ -8,6 +8,10 @@ COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# بوابة الجودة: الاختبارات تعمل هنا — أي على السيرفر وقت البناء، لا على الماك
# (قاعدة docs/15). تعتمد pg-mem و ioredis-mock فقط: بلا شبكة وبلا قاعدة حقيقية.
# فشل أي اختبار = فشل البناء = لا نشر لكود مكسور.
RUN npm test
# ---- runtime ----
FROM node:22-alpine AS runtime
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
};
+2
View File
@@ -51,7 +51,9 @@
"@types/jest": "^29.5.12",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"ioredis-mock": "^8.13.1",
"jest": "^29.7.0",
"pg-mem": "^3.0.14",
"ts-jest": "^29.2.0",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
+103
View File
@@ -0,0 +1,103 @@
// اختبار تسابق المحفظة (docs/17 — I1): يثبت أن المال لا يُفقد ولا يُخلق تحت التزامن.
// يستخدم fetch المدمج (Node 18+) — بلا تبعيات.
//
// لماذا سكربت منفصل: pg-mem في اختبارات jest أحادي الخيط فيثبت صحة الـSQL فقط،
// أما التزامن الحقيقي على نفس الصف فيحتاج Postgres حقيقياً.
//
// التشغيل (حاوية node مؤقتة على شبكة tripz):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/wallet-race-test.mjs 100 5
// الوسائط: <عدد محاولات الخصم المتزامنة> <مبلغ كل خصم>
const BASE = process.env.BASE || 'http://localhost:4010/api';
const TENANT = process.env.TENANT || 'siro';
const ATTEMPTS = parseInt(process.argv[2] || '100', 10);
const AMOUNT = parseFloat(process.argv[3] || '5');
const CODE = '1234';
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
async function api(method, path, token, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { ...H(token), 'x-tenant-id': TENANT },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text().catch(() => '');
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch { /* نص غير JSON */ }
return { ok: res.ok, status: res.status, body: json };
}
async function token(phone) {
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
if (!r.ok) throw new Error(`auth failed: ${r.status} ${JSON.stringify(r.body)}`);
return r.body.access_token;
}
async function main() {
// مبلغ يكفي نصف المحاولات فقط — النصف الآخر *يجب* أن يُرفض.
const expectedSuccesses = Math.floor(ATTEMPTS / 2);
const funded = expectedSuccesses * AMOUNT;
const phone = `0796${String(Date.now()).slice(-6)}`;
const t = await token(phone);
await api('POST', '/wallet/topup', t, { amount: funded });
const before = await api('GET', '/wallet', t);
const startBalance = Number(before.body.balance);
console.log(`المحفظة: ${phone} | الرصيد الابتدائي = ${startBalance}`);
if (startBalance !== funded) {
console.log(`⚠️ الشحن لم يطابق المتوقع (${funded}) — أوقفت الاختبار.`);
process.exit(1);
}
// كل الطلبات تنطلق دفعة واحدة على نفس الصف.
console.log(`إطلاق ${ATTEMPTS} خصم متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`);
const t0 = Date.now();
const results = await Promise.all(
Array.from({ length: ATTEMPTS }, () =>
api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch((e) => ({
ok: false,
status: 0,
body: { message: String(e) },
})),
),
);
const elapsed = Date.now() - t0;
const ok = results.filter((r) => r.ok).length;
const rejected = results.filter((r) => !r.ok).length;
const after = await api('GET', '/wallet', t);
const endBalance = Number(after.body.balance);
const spent = startBalance - endBalance;
console.log('');
console.log(`نجح : ${ok} (المتوقع ${expectedSuccesses})`);
console.log(`رُفض : ${rejected}`);
console.log(`الرصيد : ${startBalance} → ${endBalance} (خُصم ${spent})`);
console.log(`الزمن : ${elapsed}ms`);
console.log('');
// المحكّات الثلاثة
const checks = [
['الرصيد لم يصبح سالباً', endBalance >= 0],
['عدد النجاحات = ما يسمح به الرصيد', ok === expectedSuccesses],
['المخصوم = النجاحات × المبلغ (لا مال ضائع/مخلوق)', Math.abs(spent - ok * AMOUNT) < 1e-6],
];
let failed = 0;
for (const [name, pass] of checks) {
console.log(`${pass ? '✅' : '❌'} ${name}`);
if (!pass) failed++;
}
console.log('');
console.log(failed === 0 ? '✅ لا سباق: المحفظة ذرّية.' : `❌ ${failed} محكّ فشل — يوجد سباق.`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('خطأ:', e.message);
process.exit(1);
});
+2
View File
@@ -5,6 +5,7 @@ import { ThrottlerModule } from '@nestjs/throttler';
import configuration from './config/configuration';
import { TenantMiddleware } from './common/tenant/tenant.middleware';
import { RedisModule } from './common/redis/redis.module';
import { I18nModule } from './common/i18n/i18n.module';
import { SeedModule } from './common/seed/seed.module';
import { HealthModule } from './modules/health/health.module';
import { TenantsModule } from './modules/tenants/tenants.module';
@@ -54,6 +55,7 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }]),
RedisModule, // عالمي — عميل Redis للمطابقة و OTP
I18nModule, // عالمي — ترجمة نصوص الإشعارات
NabehModule, // عالمي — إرسال OTP واتساب
NotificationsModule, // عالمي — FCM
StorageModule, // عالمي — تخزين ملفات الوثائق
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Global()
@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}
@@ -0,0 +1,32 @@
import { I18nService } from './i18n.service';
describe('I18nService', () => {
const i18n = new I18nService();
it('يترجم للعربية افتراضياً ويستبدل المتغيّرات', () => {
const m = i18n.t(null, 'trip.assigned', { driverName: 'أحمد' });
expect(m.title).toBe('تم قبول رحلتك');
expect(m.body).toBe('سائقك أحمد في الطريق إليك');
});
it('يحترم لغة المستخدم الإنجليزية', () => {
const m = i18n.t('en', 'trip.driver_arrived');
expect(m.title).toBe('Driver has arrived');
});
it('يطبّع وسوم اللغة المركّبة (ar-JO, EN_us)', () => {
expect(i18n.normalize('ar-JO')).toBe('ar');
expect(i18n.normalize('EN_us')).toBe('en');
expect(i18n.normalize('fr')).toBe('ar'); // غير مدعومة → الافتراضية
expect(i18n.normalize(undefined)).toBe('ar');
});
it('يترك المتغيّر كما هو إذا لم تُمرَّر قيمته', () => {
const m = i18n.t('ar', 'trip.completed', { currency: 'JOD' });
expect(m.body).toBe('وصلت إلى وجهتك. الأجرة {fare} JOD');
});
it('لا يرمي عند مفتاح مفقود', () => {
expect(() => i18n.t('ar', 'does.not.exist')).not.toThrow();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { Injectable, Logger } from '@nestjs/common';
import { CATALOGS, DEFAULT_LANG, Lang, Message, SUPPORTED_LANGS } from './messages';
/** ترجمة نصوص الإشعارات (docs/17 — A2). العربية هي الافتراض. */
@Injectable()
export class I18nService {
private readonly logger = new Logger('I18n');
/** يطبّع أي وسم لغة (ar-JO, AR, en_US) إلى لغة مدعومة، وإلا الافتراضية. */
normalize(lang?: string | null): Lang {
const base = (lang ?? '').toLowerCase().split(/[-_]/)[0];
return (SUPPORTED_LANGS as string[]).includes(base) ? (base as Lang) : DEFAULT_LANG;
}
/** يترجم مفتاحاً مع استبدال المتغيّرات. المفتاح المفقود يرجع كما هو (لا يكسر الإرسال). */
t(lang: string | null | undefined, key: string, params: Record<string, any> = {}): Message {
const l = this.normalize(lang);
const msg = CATALOGS[l][key] ?? CATALOGS[DEFAULT_LANG][key];
if (!msg) {
this.logger.warn(`missing i18n key "${key}"`);
return { title: key, body: '' };
}
return {
title: this.interpolate(msg.title, params),
body: this.interpolate(msg.body, params),
};
}
private interpolate(tpl: string, params: Record<string, any>): string {
return tpl.replace(/\{(\w+)\}/g, (match, name) =>
params[name] == null ? match : String(params[name]),
);
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* نصوص الإشعارات لكل لغة. المفتاح يُشتق من نوع الحدث (راجع docs/17 — A2).
* كل مدخل: عنوان + نص. المتغيّرات بصيغة {name}.
*/
export type Lang = 'ar' | 'en';
export const DEFAULT_LANG: Lang = 'ar';
export const SUPPORTED_LANGS: Lang[] = ['ar', 'en'];
export interface Message {
title: string;
body: string;
}
type Catalog = Record<string, Message>;
const ar: Catalog = {
'trip.assigned': { title: 'تم قبول رحلتك', body: 'سائقك {driverName} في الطريق إليك' },
'trip.driver_arriving': { title: 'السائق في الطريق', body: 'سائقك متوجّه إلى نقطة الانطلاق' },
'trip.driver_arrived': { title: 'وصل السائق', body: 'سائقك ينتظرك في نقطة الانطلاق' },
'trip.in_progress': { title: 'بدأت الرحلة', body: 'رحلتك جارية الآن — رحلة موفقة' },
'trip.completed': { title: 'انتهت الرحلة', body: 'وصلت إلى وجهتك. الأجرة {fare} {currency}' },
'trip.paid': { title: 'تم الدفع', body: 'تم استلام مبلغ {fare} {currency}' },
'trip.cancelled': { title: 'أُلغيت الرحلة', body: 'تم إلغاء الرحلة' },
'trip.cancelled_fee': { title: 'أُلغيت الرحلة', body: 'تم إلغاء الرحلة ورسم الإلغاء {fee} {currency}' },
'trip.no_drivers': { title: 'لا يوجد سائقون', body: 'لم نجد سائقاً متاحاً قريباً منك. حاول مجدداً' },
'trip.expired': { title: 'انتهت مهلة الطلب', body: 'انتهت مهلة طلب الرحلة دون قبول' },
'trip.offer': { title: 'طلب رحلة جديد', body: 'راكب على بعد {distanceKm} كم — الأجرة {fare} {currency}' },
'trip.offer_taken': { title: 'الرحلة لم تعد متاحة', body: 'قبِل الطلبَ سائق آخر' },
'chat.message': { title: 'رسالة جديدة', body: '{preview}' },
};
const en: Catalog = {
'trip.assigned': { title: 'Your ride is accepted', body: 'Your driver {driverName} is on the way' },
'trip.driver_arriving': { title: 'Driver on the way', body: 'Your driver is heading to the pickup point' },
'trip.driver_arrived': { title: 'Driver has arrived', body: 'Your driver is waiting at the pickup point' },
'trip.in_progress': { title: 'Trip started', body: 'Your trip is now in progress — have a good ride' },
'trip.completed': { title: 'Trip finished', body: 'You have arrived. Fare {fare} {currency}' },
'trip.paid': { title: 'Payment received', body: 'Payment of {fare} {currency} received' },
'trip.cancelled': { title: 'Trip cancelled', body: 'The trip was cancelled' },
'trip.cancelled_fee': { title: 'Trip cancelled', body: 'The trip was cancelled with a {fee} {currency} cancellation fee' },
'trip.no_drivers': { title: 'No drivers', body: 'No driver available nearby. Please try again' },
'trip.expired': { title: 'Request expired', body: 'The ride request expired without being accepted' },
'trip.offer': { title: 'New ride request', body: 'Rider {distanceKm} km away — fare {fare} {currency}' },
'trip.offer_taken': { title: 'Ride no longer available', body: 'Another driver accepted the request' },
'chat.message': { title: 'New message', body: '{preview}' },
};
export const CATALOGS: Record<Lang, Catalog> = { ar, en };
@@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** لغة المستخدم — تُستخدم لترجمة الإشعارات (docs/17 — A2). */
export class UserLanguage1721800000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS language varchar NOT NULL DEFAULT 'ar'`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS language`);
}
}
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* دفتر المحفظة (docs/17 — I1):
* - `balance_after` لتدقيق كل حركة ومطابقتها بالرصيد.
* - قيد `balance >= 0` — شبكة أمان أخيرة على مستوى القاعدة: أي مسار كتابة
* يتجاوز الخصم الذرّي سيفشل بدل أن يُنتج رصيداً سالباً بصمت.
*/
export class WalletLedger1721810000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_wallet_txns ADD COLUMN IF NOT EXISTS balance_after numeric(12,3)`,
);
await q.query(`
DO $$ BEGIN
ALTER TABLE tripz_wallets ADD CONSTRAINT tripz_wallets_balance_non_negative CHECK (balance >= 0);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_wallets DROP CONSTRAINT IF EXISTS tripz_wallets_balance_non_negative`,
);
await q.query(`ALTER TABLE tripz_wallet_txns DROP COLUMN IF EXISTS balance_after`);
}
}
@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { DeviceToken } from './entities/device-token.entity';
import { NotificationsService } from './notifications.service';
import { NotificationsController } from './notifications.controller';
import { UsersModule } from '../users/users.module';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([DeviceToken])],
imports: [TypeOrmModule.forFeature([DeviceToken]), UsersModule],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
@@ -1,11 +1,19 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { DeviceToken } from './entities/device-token.entity';
import { I18nService } from '../../common/i18n/i18n.service';
import { UsersService } from '../users/users.service';
export interface PushOptions {
/** رسالة بيانات فقط — لا يعرضها النظام، يتولّاها التطبيق (overlay أندرويد، docs/17 A7). */
dataOnly?: boolean;
}
/**
* إشعارات FCM. تسجيل التوكنات + إرسال أفضل جهد (best-effort).
* النصوص تُترجَم حسب لغة المستخدم (docs/17 — A2).
* بلا FCM_SERVER_KEY: يُسجَّل فقط دون إرسال.
*/
@Injectable()
@@ -15,6 +23,8 @@ export class NotificationsService {
constructor(
@InjectRepository(DeviceToken) private readonly tokens: Repository<DeviceToken>,
private readonly config: ConfigService,
private readonly i18n: I18nService,
private readonly users: UsersService,
) {}
async register(tenantId: string, userId: string, token: string, platform = 'android') {
@@ -30,30 +40,111 @@ export class NotificationsService {
);
}
/**
* الإرسال المترجَم — الواجهة المفضّلة. `key` مفتاح من كتالوج الترجمة
* و`params` متغيّراته؛ `data` حمولة التطبيق (tripId, type, …).
*/
async sendLocalized(
tenantId: string,
userId: string,
key: string,
params: Record<string, any> = {},
data: Record<string, any> = {},
opts: PushOptions = {},
) {
const lang = await this.users.getLanguage(tenantId, userId);
const { title, body } = this.i18n.t(lang, key, params);
return this.sendToUser(tenantId, userId, title, body, data, opts);
}
/** نفس الشيء لعدة مستخدمين — يُستخدم لبث «الرحلة لم تعد متاحة» (A5). */
async sendLocalizedToMany(
tenantId: string,
userIds: string[],
key: string,
params: Record<string, any> = {},
data: Record<string, any> = {},
opts: PushOptions = {},
) {
await Promise.all(
userIds.map((id) =>
this.sendLocalized(tenantId, id, key, params, data, opts).catch((e: any) =>
this.logger.warn(`push to ${id} failed: ${e?.message}`),
),
),
);
}
/** إرسال بنص جاهز (غير مترجَم) — للحالات الخاصة فقط. */
async sendToUser(
tenantId: string,
userId: string,
title: string,
body: string,
data: Record<string, any> = {},
opts: PushOptions = {},
) {
const rows = await this.tokens.find({ where: { tenant_id: tenantId, user_id: userId } });
return this.dispatch(rows, title, body, data, opts);
}
// ---- داخلي ----
private async dispatch(
rows: DeviceToken[],
title: string,
body: string,
data: Record<string, any>,
opts: PushOptions,
) {
const key = this.config.get<string>('fcm.serverKey');
const rows = await this.tokens.find({ where: { tenant_id: tenantId, user_id: userId } });
if (!key || rows.length === 0) {
this.logger.debug(`push skip (key=${!!key} tokens=${rows.length}) "${title}"`);
return;
}
const endpoint = this.config.get<string>('fcm.endpoint')!;
for (const r of rows) {
try {
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `key=${key}` },
body: JSON.stringify({ to: r.token, notification: { title, body }, data }),
});
} catch (e: any) {
this.logger.warn(`push failed: ${e?.message}`);
}
// FCM يقبل نصوصاً فقط في data — والـoverlay يقرأ العنوان/النص من هنا.
const payload = this.stringifyData({ ...data, title, body });
const stale: string[] = [];
await Promise.all(
rows.map(async (r) => {
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `key=${key}` },
body: JSON.stringify({
to: r.token,
// أولوية عالية: يوقظ التطبيق في الخلفية (شرط الـoverlay).
priority: 'high',
...(opts.dataOnly ? {} : { notification: { title, body } }),
data: payload,
}),
});
const json: any = await res.json().catch(() => null);
if (json?.results?.[0]?.error === 'NotRegistered' ||
json?.results?.[0]?.error === 'InvalidRegistration') {
stale.push(r.token);
}
} catch (e: any) {
this.logger.warn(`push failed: ${e?.message}`);
}
}),
);
// توكن ميت = إرسال ضائع لكل رحلة لاحقة؛ نحذفه فور ما يخبرنا FCM.
if (stale.length > 0) {
await this.tokens.delete({ token: In(stale) });
this.logger.debug(`removed ${stale.length} stale token(s)`);
}
}
private stringifyData(data: Record<string, any>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(data)) {
if (v == null) continue;
out[k] = typeof v === 'object' ? JSON.stringify(v) : String(v);
}
return out;
}
}
@@ -0,0 +1,112 @@
import RedisMock from 'ioredis-mock';
import { TripStateService } from './trip-state.service';
import { Trip } from './entities/trip.entity';
const TENANT = 't1';
function makeTrip(overrides: Partial<Trip> = {}): Trip {
return {
id: 'trip-1',
tenant_id: TENANT,
rider_id: 'rider-1',
driver_id: null,
service_class: 'economy',
is_round_trip: false,
city: 'amman',
origin_lat: 31.95,
origin_lng: 35.91,
dest_lat: 31.98,
dest_lng: 35.87,
status: 'searching',
distance_km: 4.2,
duration_min: 11,
quoted_fare: 3.75,
currency: 'JOD',
payment_method: 'cash',
assigned_at: null,
...overrides,
} as Trip;
}
describe('TripStateService', () => {
let redis: any;
let state: TripStateService;
beforeEach(() => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
state = new TripStateService(redis);
});
it('يحفظ الحالة ويقرأها بأنواعها الصحيحة', async () => {
await state.save(makeTrip(), 'driver-user-1');
const s = await state.get(TENANT, 'trip-1');
expect(s).not.toBeNull();
expect(s!.status).toBe('searching');
expect(s!.driver_user_id).toBe('driver-user-1');
expect(s!.origin_lat).toBeCloseTo(31.95);
expect(s!.quoted_fare).toBe(3.75);
expect(s!.assigned_at).toBeNull();
});
it('يرجع null لرحلة غير موجودة (فيرجع المُنادي للقاعدة)', async () => {
expect(await state.get(TENANT, 'ghost')).toBeNull();
});
describe('claim — القبول الذرّي', () => {
it('أول سائق يفوز والثاني يخسر', async () => {
await state.save(makeTrip());
const at = new Date();
expect(await state.claim(TENANT, 'trip-1', 'd1', 'u1', at)).toBe('won');
expect(await state.claim(TENANT, 'trip-1', 'd2', 'u2', at)).toBe('lost');
const s = await state.get(TENANT, 'trip-1');
expect(s!.status).toBe('assigned');
expect(s!.driver_id).toBe('d1');
expect(s!.driver_user_id).toBe('u1');
expect(s!.assigned_at?.toISOString()).toBe(at.toISOString());
});
it('فائز واحد فقط عند تزامن عدة سائقين', async () => {
await state.save(makeTrip());
const at = new Date();
const results = await Promise.all(
Array.from({ length: 20 }, (_, i) =>
state.claim(TENANT, 'trip-1', `d${i}`, `u${i}`, at),
),
);
expect(results.filter((r) => r === 'won')).toHaveLength(1);
expect(results.filter((r) => r === 'lost')).toHaveLength(19);
});
it('يرجع unknown عند غياب المفتاح ليحسم الأمر على القاعدة', async () => {
expect(await state.claim(TENANT, 'gone', 'd1', 'u1', new Date())).toBe('unknown');
});
});
it('setStatus لا يُنشئ مفتاحاً لرحلة غير جارية', async () => {
await state.setStatus(TENANT, 'ghost', 'completed');
expect(await state.get(TENANT, 'ghost')).toBeNull();
});
it('clear يمسح الحالة والعروض معاً', async () => {
await state.save(makeTrip());
await state.addOffers(TENANT, 'trip-1', ['u1', 'u2']);
await state.clear(TENANT, 'trip-1');
expect(await state.get(TENANT, 'trip-1')).toBeNull();
expect(await state.takeLosingOffers(TENANT, 'trip-1', 'u1')).toEqual([]);
});
it('takeLosingOffers يستثني الفائز ويستهلك المجموعة مرة واحدة', async () => {
await state.addOffers(TENANT, 'trip-1', ['u1', 'u2', 'u3']);
const losers = await state.takeLosingOffers(TENANT, 'trip-1', 'u2');
expect(losers.sort()).toEqual(['u1', 'u3']);
// الاستدعاء الثاني فارغ — لا إشعارات مكرّرة لبقية السائقين
expect(await state.takeLosingOffers(TENANT, 'trip-1', 'u2')).toEqual([]);
});
});
@@ -0,0 +1,173 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';
import { REDIS } from '../../common/redis/redis.module';
import { Trip, TripStatus } from './entities/trip.entity';
/** حالة الرحلة الجارية كما تعيش في Redis. القيم كلها نصوص (hash). */
export interface TripState {
tripId: string;
status: TripStatus;
rider_id: string;
driver_id: string | null;
/** user_id للسائق — مخزَّن ليُبَثّ له بلا استعلام drivers في كل انتقال. */
driver_user_id: string | null;
service_class: string;
origin_lat: number;
origin_lng: number;
dest_lat: number;
dest_lng: number;
distance_km: number | null;
quoted_fare: number | null;
currency: string | null;
assigned_at: Date | null;
}
/** نتيجة محاولة القبول الذرّي. */
export type ClaimResult = 'won' | 'lost' | 'unknown';
const ACTIVE_TTL_SEC = 6 * 60 * 60; // رحلة جارية لا تتجاوز 6 ساعات واقعياً
const OFFERS_TTL_SEC = 15 * 60;
/**
* حالة الرحلة الجارية في Redis (docs/17 — A3).
* القاعدة تبقى مصدر الحقيقة الدائمة؛ هذه الطبقة تلغي القراءات المتكررة
* في كل انتقال حالة. أي مفتاح مفقود = رجوع للقاعدة (لا يُعتمد عليه كحقيقة).
*/
@Injectable()
export class TripStateService {
private readonly logger = new Logger('TripState');
constructor(@Inject(REDIS) private readonly redis: Redis) {}
private key(tenantId: string, tripId: string) {
return `trip:${tenantId}:${tripId}`;
}
private offersKey(tenantId: string, tripId: string) {
return `trip:offers:${tenantId}:${tripId}`;
}
/** يكتب لقطة الرحلة عند الإنشاء أو بعد أي تغيير دائم. */
async save(trip: Trip, driverUserId: string | null = null): Promise<void> {
const k = this.key(trip.tenant_id, trip.id);
await this.redis
.multi()
.hset(k, {
tripId: trip.id,
status: trip.status,
rider_id: trip.rider_id,
driver_id: trip.driver_id ?? '',
driver_user_id: driverUserId ?? '',
service_class: trip.service_class,
origin_lat: String(trip.origin_lat),
origin_lng: String(trip.origin_lng),
dest_lat: String(trip.dest_lat),
dest_lng: String(trip.dest_lng),
distance_km: trip.distance_km == null ? '' : String(trip.distance_km),
quoted_fare: trip.quoted_fare == null ? '' : String(trip.quoted_fare),
currency: trip.currency ?? '',
assigned_at: trip.assigned_at ? trip.assigned_at.toISOString() : '',
})
.expire(k, ACTIVE_TTL_SEC)
.exec();
}
async get(tenantId: string, tripId: string): Promise<TripState | null> {
const h = await this.redis.hgetall(this.key(tenantId, tripId));
if (!h || !h.status) return null;
return {
tripId,
status: h.status as TripStatus,
rider_id: h.rider_id,
driver_id: h.driver_id || null,
driver_user_id: h.driver_user_id || null,
service_class: h.service_class,
origin_lat: Number(h.origin_lat),
origin_lng: Number(h.origin_lng),
dest_lat: Number(h.dest_lat),
dest_lng: Number(h.dest_lng),
distance_km: h.distance_km ? Number(h.distance_km) : null,
quoted_fare: h.quoted_fare ? Number(h.quoted_fare) : null,
currency: h.currency || null,
assigned_at: h.assigned_at ? new Date(h.assigned_at) : null,
};
}
async setStatus(tenantId: string, tripId: string, status: TripStatus): Promise<void> {
const k = this.key(tenantId, tripId);
// لا نُنشئ المفتاح إن كان مفقوداً — الحالة الجارية فقط تُحدَّث هنا.
if ((await this.redis.exists(k)) === 0) return;
await this.redis.multi().hset(k, 'status', status).expire(k, ACTIVE_TTL_SEC).exec();
}
/** الرحلة انتهت (completed/paid/cancelled/…): لا داعي لإبقائها في الذاكرة. */
async clear(tenantId: string, tripId: string): Promise<void> {
await this.redis.del(this.key(tenantId, tripId), this.offersKey(tenantId, tripId));
}
/**
* يُسقط اللقطة وحدها (بلا العروض) عندما تتبيّن مخالفتها للقاعدة —
* الاستدعاء التالي يعيد بناءها من الحقيقة الدائمة.
*/
async invalidate(tenantId: string, tripId: string): Promise<void> {
await this.redis.del(this.key(tenantId, tripId));
}
/**
* قبول ذرّي (docs/17 — A4): يضبط الحالة إلى assigned فقط إذا كانت searching.
* السائق الأول يربح؛ الباقي يحصلون على 'lost' بلا لمس القاعدة.
* 'unknown' = المفتاح مفقود (انتهت صلاحيته/إعادة تشغيل) → على المُنادي أن
* يحسم الأمر عبر UPDATE شرطي على القاعدة.
*/
async claim(
tenantId: string,
tripId: string,
driverId: string,
driverUserId: string,
assignedAt: Date,
): Promise<ClaimResult> {
const script = `
local status = redis.call('HGET', KEYS[1], 'status')
if not status then return 'unknown' end
if status ~= 'searching' then return 'lost' end
redis.call('HSET', KEYS[1], 'status', 'assigned',
'driver_id', ARGV[1], 'driver_user_id', ARGV[2],
'assigned_at', ARGV[3])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return 'won'
`;
try {
const res = await this.redis.eval(
script,
1,
this.key(tenantId, tripId),
driverId,
driverUserId,
assignedAt.toISOString(),
String(ACTIVE_TTL_SEC),
);
return res as ClaimResult;
} catch (e: any) {
// فشل Redis لا يوقف الرحلة — القاعدة تحسم عبر الـUPDATE الشرطي.
this.logger.warn(`claim failed, falling back to DB: ${e?.message}`);
return 'unknown';
}
}
// ---- تتبّع العروض (A5) ----
/** يسجّل السائقين الذين عُرضت عليهم الرحلة لإبلاغهم عند فوز غيرهم. */
async addOffers(tenantId: string, tripId: string, driverUserIds: string[]): Promise<void> {
if (driverUserIds.length === 0) return;
const k = this.offersKey(tenantId, tripId);
await this.redis.multi().sadd(k, ...driverUserIds).expire(k, OFFERS_TTL_SEC).exec();
}
/** يرجع بقية السائقين المعروض عليهم باستثناء الفائز. */
async takeLosingOffers(tenantId: string, tripId: string, winnerUserId: string): Promise<string[]> {
const k = this.offersKey(tenantId, tripId);
const all = await this.redis.smembers(k);
await this.redis.del(k);
return all.filter((id) => id !== winnerUserId);
}
}
@@ -23,6 +23,12 @@ export class TripsController {
return this.trips.listForRider(user.tenantId, user.userId);
}
// السائق يسحب الطلبات المتاحة قربه (بديل/مكمّل للعرض المباشر)
@Get('available')
available(@CurrentUser() user: AuthUser) {
return this.trips.availableForDriver(user.tenantId, user.userId);
}
@Get(':id')
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.trips.get(user.tenantId, id);
+4 -1
View File
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Trip } from './entities/trip.entity';
import { TripEvent } from './entities/trip-event.entity';
import { TripsService } from './trips.service';
import { TripStateService } from './trip-state.service';
import { TripsController } from './trips.controller';
import { MapsModule } from '../maps/maps.module';
import { TariffModule } from '../tariff/tariff.module';
@@ -11,6 +12,7 @@ import { DriversModule } from '../drivers/drivers.module';
import { RealtimeModule } from '../../realtime/realtime.module';
import { FraudModule } from '../fraud/fraud.module';
import { WalletModule } from '../wallet/wallet.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
@@ -22,9 +24,10 @@ import { WalletModule } from '../wallet/wallet.module';
RealtimeModule,
FraudModule,
WalletModule,
UsersModule,
],
controllers: [TripsController],
providers: [TripsService],
providers: [TripsService, TripStateService],
exports: [TripsService],
})
export class TripsModule {}
+298 -98
View File
@@ -17,6 +17,8 @@ import { RealtimeGateway } from '../../realtime/realtime.gateway';
import { FraudService } from '../fraud/fraud.service';
import { WalletService } from '../wallet/wallet.service';
import { NotificationsService } from '../notifications/notifications.service';
import { TripState, TripStateService } from './trip-state.service';
import { UsersService } from '../users/users.service';
/** رسوم الإلغاء حسب مرحلة الرحلة (docs/04). */
const CANCEL_FEE_BY_STAGE: Partial<Record<TripStatus, number>> = {
@@ -40,6 +42,9 @@ const TRANSITIONS: Record<TripStatus, TripStatus[]> = {
expired: [],
};
/** الحالات النهائية — تُمسح من Redis لأنها لم تعد "جارية". */
const TERMINAL: TripStatus[] = ['paid', 'cancelled', 'no_drivers', 'expired'];
export interface RequestTripDto {
origin: { lat: number; lng: number };
destination: { lat: number; lng: number };
@@ -64,6 +69,8 @@ export class TripsService {
private readonly fraud: FraudService,
private readonly wallet: WalletService,
private readonly notifications: NotificationsService,
private readonly state: TripStateService,
private readonly users: UsersService,
) {}
get(tenantId: string, id: string): Promise<Trip | null> {
@@ -137,61 +144,59 @@ export class TripsService {
payment_method: (dto as any).payment_method ?? 'cash',
});
trip = await this.trips.save(trip);
await this.state.save(trip);
await this.recordEvent(trip, null, 'searching', 'rider');
// مطابقة وبث عروض للسائقين القريبين من نفس فئة الخدمة
const nearby = await this.matching.findNearby(
tenantId,
serviceClass,
dto.origin.lat,
dto.origin.lng,
);
for (const n of nearby) {
const driver = await this.drivers.findById(tenantId, n.driverId);
if (driver && driver.verification_status === 'approved') {
this.gateway.offerToDriver(tenantId, driver.user_id, {
tripId: trip.id,
pickup: { lat: trip.origin_lat, lng: trip.origin_lng },
dropoff: { lat: trip.dest_lat, lng: trip.dest_lng },
distanceKm: n.distanceKm,
quotedFare,
currency,
});
}
}
const offered = await this.offerToNearbyDrivers(tenantId, trip, dto.origin);
this.gateway.dispatch(tenantId, 'trip:new', { tripId: trip.id });
return { trip, offeredDrivers: nearby.length };
return { trip, offeredDrivers: offered };
}
/** قبول سائق للرحلة (أول قبول يفوز). */
/** قبول سائق للرحلة (أول قبول يفوز — docs/17 A4). */
async accept(tenantId: string, tripId: string, driverUserId: string) {
const trip = await this.getOr404(tenantId, tripId);
if (trip.status !== 'searching') {
throw new BadRequestException('Trip is no longer available');
}
const driver = await this.drivers.findByUser(tenantId, driverUserId);
if (!driver) throw new ForbiddenException('Not a driver');
if (driver.verification_status !== 'approved') {
throw new ForbiddenException('Driver not approved');
}
trip.driver_id = driver.id;
trip.assigned_at = new Date();
await this.applyTransition(trip, 'assigned', 'driver');
const assignedAt = new Date();
// 1) حسم سريع في Redis: الخاسر يخرج بلا لمس القاعدة إطلاقاً.
const claim = await this.state.claim(tenantId, tripId, driver.id, driverUserId, assignedAt);
if (claim === 'lost') throw new BadRequestException('Trip is no longer available');
this.gateway.tripUpdate(tenantId, trip.rider_id, {
tripId: trip.id,
status: 'assigned',
driverId: driver.id,
});
await this.notifications.sendToUser(
tenantId,
trip.rider_id,
'تم قبول رحلتك',
'سائقك في الطريق إليك',
{ tripId: trip.id, type: 'trip_assigned' },
// 2) القاعدة هي الحَكَم النهائي: UPDATE شرطي — لا يمرّ إلا إذا كانت searching.
const res = await this.trips.update(
{ tenant_id: tenantId, id: tripId, status: 'searching' },
{ status: 'assigned', driver_id: driver.id, assigned_at: assignedAt },
);
if (!res.affected) {
// Redis سمح والقاعدة رفضت = المفتاح كان متأخّراً عن الحقيقة. نمسحه بدل
// إعادة كتابته: هنا لا نعرف user_id للسائق الفائز، وحفظ حالة ناقصة يجعل
// snapshot() يثق بها فيحرم الفائز من إشعارات بقية الرحلة. والعروض تبقى
// كما هي — الفائز وحده من يستهلكها ليُبلغ بقية السائقين.
await this.state.invalidate(tenantId, tripId);
throw new BadRequestException('Trip is no longer available');
}
const trip = await this.getOr404(tenantId, tripId);
await this.recordEvent(trip, 'searching', 'assigned', 'driver');
const driverUser = await this.users.findById(tenantId, driverUserId);
await this.notifyParties(tenantId, trip, 'assigned', driverUserId, {
driverName: driverUser?.name ?? '',
vehicle: {
make: driver.vehicle_make,
model: driver.vehicle_model,
plate: driver.vehicle_plate,
color: driver.vehicle_color,
},
});
// 3) بقية السائقين: العرض مات — أزِله من شاشتهم فوراً (A5).
await this.cancelLosingOffers(tenantId, tripId, driverUserId);
return trip;
}
@@ -202,57 +207,71 @@ export class TripsService {
toStatus: TripStatus,
actor: 'rider' | 'driver',
) {
const trip = await this.getOr404(tenantId, tripId);
const snapshot = await this.snapshot(tenantId, tripId);
this.assertTransition(snapshot.status, toStatus);
const patch: Partial<Trip> = { status: toStatus };
// كشف احتيال عند نقاط حسّاسة
if (toStatus === 'driver_arrived' && trip.driver_id) {
const drv = await this.drivers.findById(tenantId, trip.driver_id);
if (toStatus === 'driver_arrived' && snapshot.driver_id) {
const drv = await this.drivers.findById(tenantId, snapshot.driver_id);
if (drv) {
await this.fraud.checkArrivedProximity(
tenantId,
drv.user_id,
{ last_lat: drv.last_lat, last_lng: drv.last_lng },
{ lat: trip.origin_lat, lng: trip.origin_lng },
trip.id,
{ lat: snapshot.origin_lat, lng: snapshot.origin_lng },
tripId,
);
}
}
if (toStatus === 'completed') {
trip.completed_at = new Date();
if (trip.quoted_fare != null) trip.final_fare = trip.quoted_fare;
if (trip.driver_id) {
const drv = await this.drivers.findById(tenantId, trip.driver_id);
if (drv) {
await this.fraud.checkFastCompletion(
tenantId,
drv.user_id,
trip.assigned_at,
trip.distance_km == null ? null : Number(trip.distance_km),
trip.id,
);
}
patch.completed_at = new Date();
if (snapshot.quoted_fare != null) patch.final_fare = snapshot.quoted_fare;
if (snapshot.driver_user_id) {
await this.fraud.checkFastCompletion(
tenantId,
snapshot.driver_user_id,
snapshot.assigned_at,
snapshot.distance_km,
tripId,
);
}
}
await this.applyTransition(trip, toStatus, actor);
// UPDATE شرطي على الحالة السابقة: يمنع تسابق تحديثين على نفس الرحلة.
const res = await this.trips.update(
{ tenant_id: tenantId, id: tripId, status: snapshot.status },
patch,
);
if (!res.affected) {
throw new BadRequestException(`Invalid transition ${snapshot.status} -> ${toStatus}`);
}
const trip = await this.getOr404(tenantId, tripId);
await this.syncState(trip, snapshot.driver_user_id, toStatus);
await this.recordEvent(trip, snapshot.status, toStatus, actor);
// تسوية المحفظة عند الدفع (payment_method === wallet)
if (toStatus === 'paid' && trip.payment_method === 'wallet' && trip.final_fare != null) {
const fare = Number(trip.final_fare);
const drv = trip.driver_id ? await this.drivers.findById(tenantId, trip.driver_id) : null;
try {
await this.wallet.debit(tenantId, trip.rider_id, fare, 'trip_fare', trip.id);
if (drv) await this.wallet.credit(tenantId, drv.user_id, fare, 'trip_earning', trip.id);
if (snapshot.driver_user_id) {
await this.wallet.credit(
tenantId,
snapshot.driver_user_id,
fare,
'trip_earning',
trip.id,
);
}
} catch (e: any) {
this.logger.warn(`wallet settle failed: ${e?.message}`);
}
}
// بث لطرفَي الرحلة
this.gateway.tripUpdate(tenantId, trip.rider_id, { tripId: trip.id, status: toStatus });
if (trip.driver_id) {
const drv = await this.drivers.findById(tenantId, trip.driver_id);
if (drv) this.gateway.tripUpdate(tenantId, drv.user_id, { tripId: trip.id, status: toStatus });
}
await this.notifyParties(tenantId, trip, toStatus, snapshot.driver_user_id);
return trip;
}
@@ -263,36 +282,71 @@ export class TripsService {
actor: 'rider' | 'driver',
actorUserId: string,
) {
const trip = await this.getOr404(tenantId, tripId);
const snapshot = await this.snapshot(tenantId, tripId);
this.assertTransition(snapshot.status, 'cancelled');
// كشف إساءة الإلغاء (قد يرمي عند الحد الصارم)
await this.fraud.recordCancellation(tenantId, actor, actorUserId, trip.id);
await this.fraud.recordCancellation(tenantId, actor, actorUserId, tripId);
const fee = CANCEL_FEE_BY_STAGE[trip.status] ?? 0;
trip.cancelled_by = actor;
trip.cancel_fee = fee;
await this.applyTransition(trip, 'cancelled', actor);
const fee = CANCEL_FEE_BY_STAGE[snapshot.status] ?? 0;
const res = await this.trips.update(
{ tenant_id: tenantId, id: tripId, status: snapshot.status },
{ status: 'cancelled', cancelled_by: actor, cancel_fee: fee },
);
if (!res.affected) throw new BadRequestException('Trip is no longer cancellable');
// أبلغ الطرف الآخر
this.gateway.tripUpdate(tenantId, trip.rider_id, {
tripId: trip.id,
status: 'cancelled',
cancelledBy: actor,
cancelFee: fee,
});
if (trip.driver_id) {
const drv = await this.drivers.findById(tenantId, trip.driver_id);
if (drv) {
this.gateway.tripUpdate(tenantId, drv.user_id, {
tripId: trip.id,
status: 'cancelled',
cancelledBy: actor,
});
}
const trip = await this.getOr404(tenantId, tripId);
await this.syncState(trip, snapshot.driver_user_id, 'cancelled');
await this.recordEvent(trip, snapshot.status, 'cancelled', actor);
// الإلغاء وهي searching: العرض معلّق عند سائقين — أزِله.
if (snapshot.status === 'searching') {
await this.cancelLosingOffers(tenantId, tripId, actorUserId);
}
await this.notifyParties(tenantId, trip, 'cancelled', snapshot.driver_user_id, {
fee,
currency: trip.currency ?? '',
cancelledBy: actor,
});
return trip;
}
/**
* الرحلات المتاحة قرب السائق (docs/17 — A6): يسحبها بنفسه بدل انتظار العرض.
* تُقرأ الحالة من القاعدة (searching) ثم تُصفّى بالمسافة.
*/
async availableForDriver(tenantId: string, driverUserId: string, radiusKm = 5) {
const driver = await this.drivers.findByUser(tenantId, driverUserId);
if (!driver) throw new ForbiddenException('Not a driver');
if (driver.verification_status !== 'approved') {
throw new ForbiddenException('Driver not approved');
}
if (driver.last_lat == null || driver.last_lng == null) return [];
const open = await this.trips.find({
where: {
tenant_id: tenantId,
status: 'searching',
service_class: driver.service_class,
},
order: { requested_at: 'DESC' },
take: 50,
});
return open
.map((t) => ({
trip: t,
distanceKm: haversineKm(driver.last_lat!, driver.last_lng!, t.origin_lat, t.origin_lng),
}))
.filter((r) => r.distanceKm <= radiusKm)
.sort((a, b) => a.distanceKm - b.distanceKm)
.map(({ trip, distanceKm }) => ({
...this.offerPayload(trip, Number(distanceKm.toFixed(3))),
requestedAt: trip.requested_at,
}));
}
// ---- داخلي ----
private async getOr404(tenantId: string, id: string): Promise<Trip> {
@@ -301,17 +355,151 @@ export class TripsService {
return trip;
}
private async applyTransition(trip: Trip, to: TripStatus, source: string) {
const allowed = TRANSITIONS[trip.status] ?? [];
if (!allowed.includes(to)) {
throw new BadRequestException(
`Invalid transition ${trip.status} -> ${to}`,
);
/**
* حالة الرحلة الجارية — من Redis أولاً (docs/17 A3)، ومن القاعدة عند غيابها.
* الغرض: إلغاء قراءة الرحلة + قراءة السائق في كل انتقال.
*/
private async snapshot(tenantId: string, tripId: string): Promise<TripState> {
const cached = await this.state.get(tenantId, tripId);
if (cached) return cached;
const trip = await this.getOr404(tenantId, tripId);
const drv = trip.driver_id ? await this.drivers.findById(tenantId, trip.driver_id) : null;
await this.state.save(trip, drv?.user_id ?? null);
return {
tripId: trip.id,
status: trip.status,
rider_id: trip.rider_id,
driver_id: trip.driver_id,
driver_user_id: drv?.user_id ?? null,
service_class: trip.service_class,
origin_lat: trip.origin_lat,
origin_lng: trip.origin_lng,
dest_lat: trip.dest_lat,
dest_lng: trip.dest_lng,
distance_km: trip.distance_km == null ? null : Number(trip.distance_km),
quoted_fare: trip.quoted_fare == null ? null : Number(trip.quoted_fare),
currency: trip.currency ?? null,
assigned_at: trip.assigned_at,
};
}
private assertTransition(from: TripStatus, to: TripStatus) {
if (!(TRANSITIONS[from] ?? []).includes(to)) {
throw new BadRequestException(`Invalid transition ${from} -> ${to}`);
}
const from = trip.status;
trip.status = to;
await this.trips.save(trip);
await this.recordEvent(trip, from, to, source);
}
private async syncState(trip: Trip, driverUserId: string | null, to: TripStatus) {
if (TERMINAL.includes(to)) {
await this.state.clear(trip.tenant_id, trip.id);
} else {
await this.state.save(trip, driverUserId);
}
}
/** حمولة العرض/الإشعار — كل ما يحتاجه الـoverlay ليقرّر بلا نداء إضافي (A7). */
private offerPayload(trip: Trip, distanceKm: number) {
return {
tripId: trip.id,
pickup: { lat: trip.origin_lat, lng: trip.origin_lng },
dropoff: { lat: trip.dest_lat, lng: trip.dest_lng },
distanceKm,
tripDistanceKm: trip.distance_km == null ? null : Number(trip.distance_km),
tripDurationMin: trip.duration_min == null ? null : Number(trip.duration_min),
quotedFare: trip.quoted_fare == null ? null : Number(trip.quoted_fare),
currency: trip.currency ?? null,
serviceClass: trip.service_class,
paymentMethod: trip.payment_method,
isRoundTrip: trip.is_round_trip,
};
}
/** يبثّ العرض عبر WebSocket + FCM ويسجّل من عُرض عليهم (A1/A5/A7). */
private async offerToNearbyDrivers(
tenantId: string,
trip: Trip,
origin: { lat: number; lng: number },
): Promise<number> {
const nearby = await this.matching.findNearby(
tenantId,
trip.service_class,
origin.lat,
origin.lng,
);
const offeredUserIds: string[] = [];
for (const n of nearby) {
const driver = await this.drivers.findById(tenantId, n.driverId);
if (!driver || driver.verification_status !== 'approved') continue;
const payload = this.offerPayload(trip, n.distanceKm);
this.gateway.offerToDriver(tenantId, driver.user_id, payload);
offeredUserIds.push(driver.user_id);
// data-only: التطبيق يرسم الـoverlay بنفسه حتى وهو في الخلفية.
await this.notifications
.sendLocalized(
tenantId,
driver.user_id,
'trip.offer',
{
distanceKm: n.distanceKm,
fare: trip.quoted_fare ?? '—',
currency: trip.currency ?? '',
},
{ type: 'trip_offer', ...payload },
{ dataOnly: true },
)
.catch((e: any) => this.logger.warn(`offer push failed: ${e?.message}`));
}
await this.state.addOffers(tenantId, trip.id, offeredUserIds);
return offeredUserIds.length;
}
/** «الرحلة لم تعد متاحة» لبقية السائقين — WebSocket + FCM (A5). */
private async cancelLosingOffers(tenantId: string, tripId: string, winnerUserId: string) {
const losers = await this.state.takeLosingOffers(tenantId, tripId, winnerUserId);
if (losers.length === 0) return;
for (const userId of losers) {
this.gateway.offerTaken(tenantId, userId, { tripId });
}
await this.notifications.sendLocalizedToMany(
tenantId,
losers,
'trip.offer_taken',
{},
{ type: 'trip_offer_taken', tripId },
{ dataOnly: true },
);
}
/** بث + FCM لطرفَي الرحلة على كل انتقال (docs/17 — A1). */
private async notifyParties(
tenantId: string,
trip: Trip,
status: TripStatus,
driverUserId: string | null,
extra: Record<string, any> = {},
) {
const payload = { tripId: trip.id, status, driverId: trip.driver_id, ...extra };
this.gateway.tripUpdate(tenantId, trip.rider_id, payload);
if (driverUserId) this.gateway.tripUpdate(tenantId, driverUserId, payload);
const key =
status === 'cancelled' && Number(extra.fee ?? 0) > 0
? 'trip.cancelled_fee'
: `trip.${status}`;
const params = {
fare: trip.final_fare ?? trip.quoted_fare ?? '—',
currency: trip.currency ?? '',
...extra,
};
const data = { type: `trip_${status}`, ...payload };
const targets = [trip.rider_id, ...(driverUserId ? [driverUserId] : [])];
await this.notifications.sendLocalizedToMany(tenantId, targets, key, params, data);
}
private async recordEvent(
@@ -331,3 +519,15 @@ export class TripsService {
);
}
}
/** المسافة بين نقطتين بالكيلومترات (لتصفية الرحلات المتاحة). */
function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
@@ -30,6 +30,10 @@ export class User {
@Column({ default: 'active' })
status: string;
// لغة الواجهة والإشعارات (ar | en). العربية هي الافتراض.
@Column({ default: 'ar' })
language: string;
@CreateDateColumn()
created_at: Date;
@@ -21,7 +21,13 @@ export class UsersController {
}
@Patch('me')
updateMe(@CurrentUser() user: AuthUser, @Body('name') name: string) {
return this.users.updateProfile(user.tenantId, user.userId, { name });
updateMe(
@CurrentUser() user: AuthUser,
@Body() body: { name?: string; language?: string },
) {
return this.users.updateProfile(user.tenantId, user.userId, {
name: body.name,
language: body.language,
});
}
}
+15 -3
View File
@@ -35,14 +35,26 @@ export class UsersService {
async updateProfile(
tenantId: string,
id: string,
data: Partial<Pick<User, 'name'>>,
data: Partial<Pick<User, 'name' | 'language'>>,
): Promise<User | null> {
if (data.name !== undefined) {
await this.userRepository.update({ tenant_id: tenantId, id }, { name: data.name });
const patch: Partial<User> = {};
if (data.name !== undefined) patch.name = data.name;
if (data.language !== undefined) patch.language = data.language;
if (Object.keys(patch).length > 0) {
await this.userRepository.update({ tenant_id: tenantId, id }, patch);
}
return this.findById(tenantId, id);
}
/** لغة الإشعارات للمستخدم — استعلام خفيف (عمود واحد) يُستدعى قبل كل push. */
async getLanguage(tenantId: string, id: string): Promise<string | null> {
const row = await this.userRepository.findOne({
where: { tenant_id: tenantId, id },
select: { language: true },
});
return row?.language ?? null;
}
async setRole(tenantId: string, id: string, role: string): Promise<void> {
await this.userRepository.update(
{ tenant_id: tenantId, id },
@@ -22,6 +22,10 @@ export class WalletTxn {
@Column({ type: 'numeric', precision: 12, scale: 3 })
amount: number;
// الرصيد بعد تطبيق هذه الحركة — يجعل الدفتر قابلاً للتدقيق ومطابقة الرصيد.
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
balance_after: number | null;
@Column()
type: string; // credit | debit
@@ -0,0 +1,132 @@
import { randomUUID } from 'crypto';
import { newDb } from 'pg-mem';
import { DataSource } from 'typeorm';
import { BadRequestException } from '@nestjs/common';
import { Wallet } from './entities/wallet.entity';
import { WalletTxn } from './entities/wallet-txn.entity';
import { WalletService } from './wallet.service';
/**
* يعمل على Postgres في الذاكرة (pg-mem) — يثبت أن SQL المولَّد صحيح وأن دلالة
* الخصم المشروط تعمل. التزامن الحقيقي (عمليات متوازية على نفس الصف) لا يمكن
* إثباته هنا لأن pg-mem أحادي الخيط — لذلك scripts/wallet-race-test.mjs
* يُشغَّل على السيرفر مقابل Postgres حقيقي.
*/
const TENANT = '11111111-1111-1111-1111-111111111111';
const USER = '22222222-2222-2222-2222-222222222222';
describe('WalletService', () => {
let ds: DataSource;
let wallet: WalletService;
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: [Wallet, WalletTxn],
entityPrefix: 'tripz_',
})) as DataSource;
await ds.initialize();
await ds.synchronize();
// شبكة الأمان التي تضيفها الهجرة WalletLedger
await ds.query(
`ALTER TABLE tripz_wallets ADD CONSTRAINT tripz_wallets_balance_non_negative CHECK (balance >= 0)`,
);
wallet = new WalletService(ds.getRepository(Wallet), ds.getRepository(WalletTxn));
});
afterEach(async () => {
if (ds?.isInitialized) await ds.destroy();
});
it('ينشئ المحفظة عند أول إيداع ويضبط الرصيد', async () => {
const w = await wallet.credit(TENANT, USER, 10, 'topup');
expect(w.balance).toBe(10);
});
it('يجمع الإيداعات المتتالية بلا فقدان', async () => {
await wallet.credit(TENANT, USER, 10, 'topup');
await wallet.credit(TENANT, USER, 5.5, 'topup');
const w = await wallet.getOrCreate(TENANT, USER);
expect(Number(w.balance)).toBe(15.5);
});
it('يخصم عند توفّر الرصيد', async () => {
await wallet.credit(TENANT, USER, 20, 'topup');
const w = await wallet.debit(TENANT, USER, 8, 'trip_fare');
expect(w.balance).toBe(12);
});
it('يرفض الخصم عند نقص الرصيد ولا يغيّر شيئاً', async () => {
await wallet.credit(TENANT, USER, 5, 'topup');
await expect(wallet.debit(TENANT, USER, 9, 'trip_fare')).rejects.toThrow(BadRequestException);
const w = await wallet.getOrCreate(TENANT, USER);
expect(Number(w.balance)).toBe(5);
// القيد المرفوض لا يُسجَّل في الدفتر
const txns = await wallet.history(TENANT, w.id);
expect(txns.filter((t) => t.type === 'debit')).toHaveLength(0);
});
it('يرفض الخصم من محفظة غير موجودة', async () => {
await expect(wallet.debit(TENANT, USER, 1, 'trip_fare')).rejects.toThrow(BadRequestException);
});
it('يرفض المبالغ غير الصالحة', async () => {
for (const bad of [0, -5, NaN, Infinity]) {
await expect(wallet.credit(TENANT, USER, bad, 'topup')).rejects.toThrow(BadRequestException);
await expect(wallet.debit(TENANT, USER, bad, 'payout')).rejects.toThrow(BadRequestException);
}
});
it('الدفتر يطابق الرصيد ويسجّل balance_after', async () => {
await wallet.credit(TENANT, USER, 30, 'topup');
await wallet.debit(TENANT, USER, 12, 'trip_fare');
await wallet.credit(TENANT, USER, 2, 'refund');
const w = await wallet.getOrCreate(TENANT, USER);
const txns = await wallet.history(TENANT, w.id);
expect(txns).toHaveLength(3);
const sum = txns.reduce(
(acc, t) => acc + (t.type === 'credit' ? 1 : -1) * Number(t.amount),
0,
);
expect(sum).toBe(Number(w.balance));
expect(sum).toBe(20);
const last = txns.find((t) => t.reason === 'refund')!;
expect(Number(last.balance_after)).toBe(20);
});
it('محافظ مستأجرين مختلفين معزولة رغم نفس user_id', async () => {
const other = '33333333-3333-3333-3333-333333333333';
await wallet.credit(TENANT, USER, 10, 'topup');
await wallet.credit(other, USER, 7, 'topup');
expect(Number((await wallet.getOrCreate(TENANT, USER)).balance)).toBe(10);
expect(Number((await wallet.getOrCreate(other, USER)).balance)).toBe(7);
});
});
+86 -31
View File
@@ -1,9 +1,20 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EntityManager, Repository } from 'typeorm';
import { Wallet } from './entities/wallet.entity';
import { WalletTxn } from './entities/wallet-txn.entity';
type TxnType = 'credit' | 'debit';
/**
* المحفظة — دفتر قيود (wallet_txns) + رصيد مادّي (wallets.balance).
*
* قاعدة صارمة (docs/17 — I1): **لا يُقرأ الرصيد ثم يُكتب**. كل تغيير يمرّ بعبارة
* `UPDATE … SET balance = balance ± :delta` واحدة، والخصم مشروط بـ
* `balance >= :amount` داخل نفس العبارة. القاعدة تسلسل التحديثات على الصف،
* فخصمان متزامنان لا يكتبان فوق بعضهما.
* القيد والرصيد يُكتبان في **معاملة واحدة** — لا خصم بلا قيد ولا قيد بلا خصم.
*/
@Injectable()
export class WalletService {
constructor(
@@ -12,40 +23,16 @@ export class WalletService {
) {}
async getOrCreate(tenantId: string, userId: string, currency = 'JOD'): Promise<Wallet> {
let w = await this.wallets.findOne({ where: { tenant_id: tenantId, user_id: userId } });
if (!w) {
w = await this.wallets.save(
this.wallets.create({ tenant_id: tenantId, user_id: userId, balance: 0, currency }),
);
}
return w;
await this.ensureWallet(this.wallets.manager, tenantId, userId, currency);
return (await this.wallets.findOne({ where: { tenant_id: tenantId, user_id: userId } }))!;
}
private num(v: number | string): number {
return typeof v === 'string' ? Number(v) : v;
credit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
return this.apply(tenantId, userId, amount, 'credit', reason, ref);
}
async credit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
if (amount <= 0) throw new BadRequestException('amount must be > 0');
const w = await this.getOrCreate(tenantId, userId);
w.balance = Number((this.num(w.balance) + amount).toFixed(3));
await this.wallets.save(w);
await this.txns.save(
this.txns.create({ tenant_id: tenantId, wallet_id: w.id, amount, type: 'credit', reason, ref }),
);
return w;
}
async debit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
if (amount <= 0) throw new BadRequestException('amount must be > 0');
const w = await this.getOrCreate(tenantId, userId);
if (this.num(w.balance) < amount) throw new BadRequestException('Insufficient balance');
w.balance = Number((this.num(w.balance) - amount).toFixed(3));
await this.wallets.save(w);
await this.txns.save(
this.txns.create({ tenant_id: tenantId, wallet_id: w.id, amount, type: 'debit', reason, ref }),
);
return w;
debit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
return this.apply(tenantId, userId, amount, 'debit', reason, ref);
}
history(tenantId: string, walletId: string) {
@@ -55,4 +42,72 @@ export class WalletService {
take: 100,
});
}
// ---- داخلي ----
/** إنشاء المحفظة إن غابت. ON CONFLICT DO NOTHING — إنشاءان متزامنان لا يتصادمان. */
private async ensureWallet(
em: EntityManager,
tenantId: string,
userId: string,
currency = 'JOD',
): Promise<void> {
await em
.createQueryBuilder()
.insert()
.into(Wallet)
.values({ tenant_id: tenantId, user_id: userId, balance: 0, currency })
.orIgnore()
.execute();
}
private async apply(
tenantId: string,
userId: string,
amount: number,
type: TxnType,
reason: string,
ref?: string,
): Promise<Wallet> {
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException('amount must be > 0');
}
const delta = type === 'credit' ? amount : -amount;
return this.wallets.manager.transaction(async (em) => {
await this.ensureWallet(em, tenantId, userId);
const qb = em
.createQueryBuilder()
.update(Wallet)
.set({ balance: () => 'balance + :delta' })
.where('tenant_id = :tenantId AND user_id = :userId')
.setParameters({ delta, tenantId, userId });
// شرط الرصيد داخل نفس العبارة: لا فجوة بين الفحص والخصم.
if (type === 'debit') qb.andWhere('balance >= :amount', { amount });
const res = await qb.returning('*').execute();
if (!res.raw?.length) throw new BadRequestException('Insufficient balance');
const wallet = this.hydrate(res.raw[0]);
await em.getRepository(WalletTxn).insert({
tenant_id: tenantId,
wallet_id: wallet.id,
amount,
type,
reason,
ref,
balance_after: wallet.balance,
});
return wallet;
});
}
/** Postgres يرجّع numeric كنص — نوحّده رقماً كما تتوقّعه بقية الخدمات. */
private hydrate(row: any): Wallet {
return { ...row, balance: Number(row.balance) } as Wallet;
}
}
+5
View File
@@ -108,6 +108,11 @@ export class RealtimeGateway implements OnGatewayConnection {
this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer', payload);
}
/** العرض مات (قبِله غيره أو أُلغي) — يزيله من شاشة السائق والـoverlay. */
offerTaken(tenantId: string, driverUserId: string, payload: any) {
this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer_taken', payload);
}
tripUpdate(tenantId: string, userId: string, payload: any) {
this.server.to(`tenant:${tenantId}:user:${userId}`).emit('trip:update', payload);
}