Files
tripz-llc/backend/src/modules/notifications/notifications.service.ts
T
Hamza-AyedandClaude Opus 4.8 9d6b752ea8 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>
2026-07-17 02:23:00 +03:00

151 lines
5.3 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/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()
export class NotificationsService {
private readonly logger = new Logger('Notifications');
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') {
const existing = await this.tokens.findOne({ where: { token } });
if (existing) {
existing.tenant_id = tenantId;
existing.user_id = userId;
existing.platform = platform;
return this.tokens.save(existing);
}
return this.tokens.save(
this.tokens.create({ tenant_id: tenantId, user_id: userId, token, platform }),
);
}
/**
* الإرسال المترجَم — الواجهة المفضّلة. `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');
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')!;
// 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;
}
}