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, 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 = {}, data: Record = {}, 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 = {}, data: Record = {}, 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 = {}, 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, opts: PushOptions, ) { const key = this.config.get('fcm.serverKey'); if (!key || rows.length === 0) { this.logger.debug(`push skip (key=${!!key} tokens=${rows.length}) "${title}"`); return; } const endpoint = this.config.get('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): Record { const out: Record = {}; for (const [k, v] of Object.entries(data)) { if (v == null) continue; out[k] = typeof v === 'object' ? JSON.stringify(v) : String(v); } return out; } }