المجموعة 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>
151 lines
5.3 KiB
TypeScript
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;
|
|
}
|
|
}
|