From 959e2fa758c0dd220cc2c8287622186dcbb7b15e Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 19 Jul 2026 16:21:43 +0300 Subject: [PATCH] Update backend to use firebase-admin SDK for FCM --- backend/.env.example | 37 ++- backend/docker-compose.yml | 1 + backend/package.json | 3 +- backend/src/app.module.ts | 10 + backend/src/common/cron/bull.module.ts | 40 +++ .../src/common/cron/cron-admin.controller.ts | 80 ++++++ .../common/cron/cron-orchestrator.service.ts | 111 ++++++++ backend/src/common/cron/cron-registry.ts | 51 ++++ .../src/common/cron/cron-worker.service.ts | 176 ++++++++++++ backend/src/common/cron/cron.module.ts | 50 ++++ .../src/common/cron/job-execution.entity.ts | 58 ++++ backend/src/common/entitlements/features.ts | 31 ++- backend/src/common/i18n/messages.ts | 2 + backend/src/config/configuration.ts | 20 +- ...1970000000-AddNewTablesAndDriverColumns.ts | 219 +++++++++++++++ .../src/integrations/payments/cliq.adapter.ts | 116 ++++++++ .../payments/invoice-and-registry.spec.ts | 9 +- .../src/integrations/payments/mtn.adapter.ts | 133 +++++++++ .../payments/payment-gateway.registry.ts | 15 +- .../payments/payment-gateways.module.ts | 4 +- .../integrations/payments/syriatel.adapter.ts | 115 ++++++++ backend/src/modules/bots/bot.controller.ts | 63 +++++ backend/src/modules/bots/bot.module.ts | 13 + backend/src/modules/bots/bot.service.ts | 70 +++++ .../modules/bots/entities/bot-task.entity.ts | 50 ++++ .../src/modules/catalog/catalog.controller.ts | 117 ++++++++ backend/src/modules/catalog/catalog.module.ts | 25 ++ .../src/modules/catalog/catalog.service.ts | 221 +++++++++++++++ .../entities/feature-catalog.entity.ts | 76 ++++++ .../entities/ride-type-catalog.entity.ts | 68 +++++ .../modules/catalog/ride-type-catalog.seed.ts | 117 ++++++++ .../modules/drivers/admin-tier.controller.ts | 61 +++++ backend/src/modules/drivers/drivers.module.ts | 8 +- .../src/modules/drivers/drivers.service.ts | 9 + .../modules/drivers/entities/driver.entity.ts | 13 + .../drivers/tier-calculator.service.ts | 97 +++++++ .../geofence/geofence-admin.controller.ts | 108 ++++++++ .../src/modules/geofence/geofence.module.ts | 3 +- .../src/modules/geofence/geofence.service.ts | 73 ++++- .../modules/locations/heatmap.controller.ts | 55 ++++ .../src/modules/locations/heatmap.service.ts | 126 +++++++++ .../src/modules/locations/locations.module.ts | 11 +- .../marketing/entities/campaign.entity.ts | 55 ++++ .../modules/marketing/marketing.controller.ts | 96 +++++++ .../src/modules/marketing/marketing.module.ts | 20 ++ .../modules/marketing/marketing.service.ts | 190 +++++++++++++ .../src/modules/matching/matching.module.ts | 3 + .../src/modules/matching/matching.service.ts | 61 +++-- .../notifications/notifications.service.ts | 92 ++++--- .../src/modules/rewards/referrals.service.ts | 19 ++ backend/src/modules/rewards/rewards.module.ts | 3 +- .../modules/tariff/admin-tariff.controller.ts | 94 +++++++ .../tariff/entities/surge-state.entity.ts | 85 ++++++ .../modules/tariff/entities/tariff.entity.ts | 8 + .../src/modules/tariff/surge.controller.ts | 99 +++++++ backend/src/modules/tariff/surge.service.ts | 255 ++++++++++++++++++ .../src/modules/tariff/tariff.engine.spec.ts | 32 +++ backend/src/modules/tariff/tariff.engine.ts | 31 ++- backend/src/modules/tariff/tariff.module.ts | 13 +- backend/src/modules/tariff/tariff.service.ts | 111 +++++++- .../tenant-wallet/tenant-wallet.controller.ts | 71 +++++ .../tenant-wallet/tenant-wallet.module.ts | 13 +- .../tenant-wallet.service.spec.ts | 6 + .../tenant-wallet/tenant-wallet.service.ts | 143 ++++++++++ .../src/modules/tenants/tenants.controller.ts | 17 +- backend/src/modules/tenants/tenants.module.ts | 3 +- .../modules/transit/transit-bus-listener.ts | 64 +++++ .../src/modules/transit/transit.controller.ts | 61 +++++ backend/src/modules/transit/transit.module.ts | 13 + .../src/modules/transit/transit.service.ts | 87 ++++++ .../trips/entities/trip-audio.entity.ts | 77 ++++++ .../modules/trips/search-timeout.sweeper.ts | 46 ++++ .../src/modules/trips/trip-audio.service.ts | 141 ++++++++++ backend/src/modules/trips/trips.controller.ts | 44 ++- backend/src/modules/trips/trips.module.ts | 7 +- backend/src/modules/trips/trips.service.ts | 84 ++++++ 76 files changed, 4693 insertions(+), 116 deletions(-) create mode 100644 backend/src/common/cron/bull.module.ts create mode 100644 backend/src/common/cron/cron-admin.controller.ts create mode 100644 backend/src/common/cron/cron-orchestrator.service.ts create mode 100644 backend/src/common/cron/cron-registry.ts create mode 100644 backend/src/common/cron/cron-worker.service.ts create mode 100644 backend/src/common/cron/cron.module.ts create mode 100644 backend/src/common/cron/job-execution.entity.ts create mode 100644 backend/src/database/migrations/1721970000000-AddNewTablesAndDriverColumns.ts create mode 100644 backend/src/integrations/payments/cliq.adapter.ts create mode 100644 backend/src/integrations/payments/mtn.adapter.ts create mode 100644 backend/src/integrations/payments/syriatel.adapter.ts create mode 100644 backend/src/modules/bots/bot.controller.ts create mode 100644 backend/src/modules/bots/bot.module.ts create mode 100644 backend/src/modules/bots/bot.service.ts create mode 100644 backend/src/modules/bots/entities/bot-task.entity.ts create mode 100644 backend/src/modules/catalog/catalog.controller.ts create mode 100644 backend/src/modules/catalog/catalog.module.ts create mode 100644 backend/src/modules/catalog/catalog.service.ts create mode 100644 backend/src/modules/catalog/entities/feature-catalog.entity.ts create mode 100644 backend/src/modules/catalog/entities/ride-type-catalog.entity.ts create mode 100644 backend/src/modules/catalog/ride-type-catalog.seed.ts create mode 100644 backend/src/modules/drivers/admin-tier.controller.ts create mode 100644 backend/src/modules/drivers/tier-calculator.service.ts create mode 100644 backend/src/modules/geofence/geofence-admin.controller.ts create mode 100644 backend/src/modules/locations/heatmap.controller.ts create mode 100644 backend/src/modules/locations/heatmap.service.ts create mode 100644 backend/src/modules/marketing/entities/campaign.entity.ts create mode 100644 backend/src/modules/marketing/marketing.controller.ts create mode 100644 backend/src/modules/marketing/marketing.module.ts create mode 100644 backend/src/modules/marketing/marketing.service.ts create mode 100644 backend/src/modules/tariff/admin-tariff.controller.ts create mode 100644 backend/src/modules/tariff/entities/surge-state.entity.ts create mode 100644 backend/src/modules/tariff/surge.controller.ts create mode 100644 backend/src/modules/tariff/surge.service.ts create mode 100644 backend/src/modules/transit/transit-bus-listener.ts create mode 100644 backend/src/modules/transit/transit.controller.ts create mode 100644 backend/src/modules/transit/transit.module.ts create mode 100644 backend/src/modules/transit/transit.service.ts create mode 100644 backend/src/modules/trips/entities/trip-audio.entity.ts create mode 100644 backend/src/modules/trips/search-timeout.sweeper.ts create mode 100644 backend/src/modules/trips/trip-audio.service.ts diff --git a/backend/.env.example b/backend/.env.example index 5f87aee..b2f2ae0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -58,10 +58,8 @@ SMS_USERNAME= SMS_PASSWORD_EGYPT= SMS_SENDER= -# ---- FCM push (اتركه فارغاً لتعطيل الإرسال) ---- -FCM_SERVER_KEY= -FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send - +# ---- FCM push (ملف الـ JSON الخاص بـ Service Account) ---- +GOOGLE_APPLICATION_CREDENTIALS=./firebase-service-account.json # ---- Gemini (رؤية: قراءة وثائق + مطابقة وجه + تحليل رسائل التحويل P2) ---- GEMINI_API_KEY= GEMINI_MODEL=gemini-2.0-flash @@ -80,5 +78,36 @@ PAYMOB_INTEGRATION_ID_DRIVER_CARD= PAYMOB_INTEGRATION_ID_DRIVER_WALLET= PAYMOB_IFRAME_ID= +# ---- CliQ (الأردن) — بوابة الدفع الإلكتروني ---- +# الإنتاج: كل مستأجر مفاتيحه الخاصة في tenant.settings.payments.cliq +CLIQ_BASE_URL=https://api.cliq.jo +CLIQ_MERCHANT_ID= +CLIQ_API_KEY= +CLIQ_SECRET_KEY= +CLIQ_TERMINAL_ID= + +# ---- SyriaTel (سوريا) — بوابة الدفع الإلكتروني ---- +SYRIATEL_BASE_URL=https://e-payment.syriatel.com/api +SYRIATEL_MERCHANT_CODE= +SYRIATEL_API_KEY= +SYRIATEL_SECRET_KEY= +SYRIATEL_SERVICE_ID= + +# ---- خدمة المواصلات (microservice مستقل) ---- +TRANSIT_SERVICE_URL=http://transit:4020 + +# ---- MTN (Mobile Money) — بوابة الدفع الإلكتروني ---- +MTN_BASE_URL=https://proxy.momoapi.mtn.com +MTN_API_KEY= +MTN_API_USER= +MTN_SUBSCRIPTION_KEY= +MTN_ENVIRONMENT=sandbox + +# ---- L4: التسعير الديناميكي (Dynamic Pricing) ---- +# ملاحظة: فاصل كرون SURGE_RECALCULATE ثابت بالكود (180 ثانية) في cron-registry.ts +# TRIP_SURGE_COOLDOWN_MS و TRIP_SURGE_SENSITIVITY يُقرأان فعلاً من SurgeService +TRIP_SURGE_COOLDOWN_MS=300000 # 5 دقائق بين تغييرين متتاليين +TRIP_SURGE_SENSITIVITY=0.3 # 0.1 خفيفة، 0.3 متوسطة، 0.5 حادة + # ---- سرّ المنصة (لتعيين أول أدمن) — ولّده: openssl rand -hex 24 ---- PLATFORM_SECRET= diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 416be6f..6ffa2fe 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -57,6 +57,7 @@ services: # اللوحات الثلاث — bind للقراءة فقط، لا نسخ داخل الصورة: تعديل HTML # يظهر بتحديث الصفحة بلا إعادة بناء (اللوحات ملفات ساكنة بلا خطوة بناء). - ../dashboards:/app/dashboards:ro + - ./firebase-service-account.json:/app/firebase-service-account.json:ro networks: [tripz-net] worker: diff --git a/backend/package.json b/backend/package.json index b65d2ac..023492f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "socket.io": "^4.7.5", - "typeorm": "^0.3.20" + "typeorm": "^0.3.20", + "firebase-admin": "^12.0.0" }, "devDependencies": { "@nestjs/cli": "^11.0.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a724daf..5bc181c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -45,6 +45,11 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { GeminiModule } from './integrations/gemini/gemini.module'; import { BillingModule } from './modules/billing/billing.module'; import { GeofenceModule } from './modules/geofence/geofence.module'; +import { CatalogModule } from './modules/catalog/catalog.module'; +import { CronModule } from './common/cron/cron.module'; +import { MarketingModule } from './modules/marketing/marketing.module'; +import { TransitModule } from './modules/transit/transit.module'; +import { BotModule } from './modules/bots/bot.module'; @Module({ imports: [ @@ -111,6 +116,11 @@ import { GeofenceModule } from './modules/geofence/geofence.module'; DocumentsModule, VehiclesModule, SeedModule, + CatalogModule, + CronModule, // O5 — المهام الدورية الموحّدة على BullMQ + MarketingModule, // O3 — محرك التسويق التلقائي + TransitModule, // O2 — نظام المواصلات + BotModule, // O4 — بوتات التواصل الاجتماعي ], providers: [ // ThrottlerModule.forRoot() وحده لا يفعل شيئاً — كان مسجَّلاً منذ البداية diff --git a/backend/src/common/cron/bull.module.ts b/backend/src/common/cron/bull.module.ts new file mode 100644 index 0000000..6b4db44 --- /dev/null +++ b/backend/src/common/cron/bull.module.ts @@ -0,0 +1,40 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Queue } from 'bullmq'; + +/** + * اسم الطابور الوحيد — كل المهام الدورية تدخل هنا. + * البادئة `tripz_` تأتي من `QUEUE_PREFIX` في الإعدادات. + */ +export const CRON_QUEUE = 'tripz_cron'; + +/** + * وحدة BullMQ عالمية — تنشئ طابوراً واحداً لكل المهام الدورية. + * + * النمط: `@Global` مثل `RedisModule` — تُصدَّر وتُستهلك في أي وحدة. + * الطابور يقرأ اتصال Redis من `REDIS_CLIENT` (نفس DB 3 + البادئة tripz:). + */ +@Global() +@Module({ + providers: [ + { + provide: CRON_QUEUE, + inject: [ConfigService], + useFactory: (cfg: ConfigService) => + new Queue(CRON_QUEUE, { + connection: { + host: cfg.get('redis.host'), + port: cfg.get('redis.port'), + db: cfg.get('redis.db'), + }, + prefix: cfg.get('queue.prefix'), + defaultJobOptions: { + removeOnComplete: 100, + removeOnFail: 50, + }, + }), + }, + ], + exports: [CRON_QUEUE], +}) +export class BullModule {} diff --git a/backend/src/common/cron/cron-admin.controller.ts b/backend/src/common/cron/cron-admin.controller.ts new file mode 100644 index 0000000..bcffcae --- /dev/null +++ b/backend/src/common/cron/cron-admin.controller.ts @@ -0,0 +1,80 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PlatformGuard } from '../platform/platform.guard'; +import { JobExecution } from './job-execution.entity'; +import { CronOrchestratorService } from './cron-orchestrator.service'; +import { CRON_SCHEDULES, CRON_JOB_LABELS, CronJobName } from './cron-registry'; + +/** + * لوحة المهام الدورية — السوبر-أدن (O5). + * + * تُظهر: آخر نجاح/فشل لكل مهمة + مدة التنفيذ + السجل التاريخي. + * لا مهمة صامتة — كل ما في الجدول هو ما حدث فعلاً. + */ +@ApiTags('cron') +@Controller() +export class CronAdminController { + constructor( + private readonly orchestrator: CronOrchestratorService, + @InjectRepository(JobExecution) + private readonly execRepo: Repository, + ) {} + + /** إحصائيات كل المهام + آخر تنفيذ. */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Get('admin/cron/stats') + stats() { + return this.orchestrator.getStats(); + } + + /** سجل التنفيذ التاريخي (مع التصفية حسب اسم المهمة). */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Get('admin/cron/executions') + async listExecutions( + @Query('job_name') jobName?: string, + @Query('limit') limit?: string, + ) { + const take = Math.min(parseInt(limit ?? '50', 10) || 50, 200); + const where: any = {}; + if (jobName) where.job_name = jobName; + return this.execRepo.find({ where, order: { started_at: 'DESC' }, take }); + } + + /** آخر 10 تنفيذات لكل مهمة (ملخص سريع). */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Get('admin/cron/summary') + async summary() { + const jobs = Object.keys(CRON_SCHEDULES) as CronJobName[]; + const result: Record; + }> = {}; + + for (const name of jobs) { + const recent = await this.execRepo.find({ + where: { job_name: name }, + order: { started_at: 'DESC' }, + take: 10, + }); + result[name] = { + label: CRON_JOB_LABELS[name], + everyMs: CRON_SCHEDULES[name].everyMs, + recent: recent.map((e) => ({ + status: e.status, + started_at: e.started_at, + duration_ms: e.duration_ms, + items_processed: e.items_processed, + error_message: e.error_message, + })), + }; + } + + return result; + } +} diff --git a/backend/src/common/cron/cron-orchestrator.service.ts b/backend/src/common/cron/cron-orchestrator.service.ts new file mode 100644 index 0000000..1419a52 --- /dev/null +++ b/backend/src/common/cron/cron-orchestrator.service.ts @@ -0,0 +1,111 @@ +import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Queue } from 'bullmq'; +import { Repository } from 'typeorm'; +import { CRON_SCHEDULES, CRON_JOB_LABELS, CronJobName } from './cron-registry'; +import { CRON_QUEUE } from './bull.module'; +import { JobExecution } from './job-execution.entity'; + +/** + * منسّق المهام الدورية (O5). + * + * يعمل في عملية **الـAPI** فقط (ليس الـworker) لأنه يملك حقن كل الخدمات. + * مسؤولياته: + * 1. تسجيل المهام المتكررة (repeatable jobs) عند بدء التشغيل + * 2. قفل ضد التشغيل المزدوج (BullMQ concurrency = 1) + * 3. تعطيل المهام عند الإغلاق + * + * لا تُسجَّل المهام هنا — `CronWorker` هو من يprocessها ويكتب السجل. + */ +@Injectable() +export class CronOrchestratorService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger('CronOrchestrator'); + private readonly registeredJobs = new Map(); + + constructor( + @InjectRepository(JobExecution) + private readonly execRepo: Repository, + @Inject(CRON_QUEUE) + private readonly queue: Queue, + ) {} + + async onModuleInit(): Promise { + const runningJobs = await this.execRepo.find({ + where: { status: 'running' }, + order: { started_at: 'DESC' }, + }); + + if (runningJobs.length > 0) { + this.logger.warn( + `found ${runningJobs.length} orphaned running job(s) — marking as timeout`, + ); + for (const job of runningJobs) { + job.status = 'timeout'; + job.finished_at = new Date(); + job.error_message = 'Orphaned: server restarted while job was running'; + await this.execRepo.save(job); + } + } + + for (const [name, schedule] of Object.entries(CRON_SCHEDULES) as [CronJobName, (typeof CRON_SCHEDULES)[CronJobName]][]) { + await this.queue.upsertJobScheduler( + name, + { every: schedule.everyMs }, + { + name, + data: { name }, + opts: { + removeOnComplete: 100, + removeOnFail: 50, + }, + }, + ); + this.registeredJobs.set(name, name); + this.logger.log(`registered: ${name} (every ${schedule.everyMs / 1000}s)`); + } + + this.logger.log(`${this.registeredJobs.size} cron job(s) registered`); + } + + async onModuleDestroy(): Promise { + for (const [name] of this.registeredJobs) { + await this.queue.removeJobScheduler(name); + } + this.logger.log(`removed ${this.registeredJobs.size} repeatable job(s)`); + } + + async getStats() { + const stats: Array<{ + name: string; + label: string; + everyMs: number; + lastExecution?: { + status: string; + started_at: Date; + duration_ms: number | null; + error_message: string | null; + }; + }> = []; + + for (const [name, schedule] of Object.entries(CRON_SCHEDULES) as [CronJobName, (typeof CRON_SCHEDULES)[CronJobName]][]) { + const last = await this.execRepo.findOne({ + where: { job_name: name }, + order: { started_at: 'DESC' }, + }); + stats.push({ + name, + label: CRON_JOB_LABELS[name] ?? name, + everyMs: schedule.everyMs, + lastExecution: last + ? { + status: last.status, + started_at: last.started_at, + duration_ms: last.duration_ms, + error_message: last.error_message, + } + : undefined, + }); + } + return stats; + } +} diff --git a/backend/src/common/cron/cron-registry.ts b/backend/src/common/cron/cron-registry.ts new file mode 100644 index 0000000..336026c --- /dev/null +++ b/backend/src/common/cron/cron-registry.ts @@ -0,0 +1,51 @@ +import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Queue, Worker, Job } from 'bullmq'; +import { Repository } from 'typeorm'; +import { JobExecution } from './job-execution.entity'; + +/** + * أسماء المهام الدورية — مصدر الحقيقة الوحيد. + * لا سلاسل نصية مبعثرة في الكود. + */ +export const CRON_JOBS = { + SCHEDULED_TRIPS_SWEEP: 'cron:scheduled_trips_sweep', + SEARCH_TIMEOUT_SWEEP: 'cron:search_timeout_sweep', + REWARDS_SWEEP: 'cron:rewards_sweep', + SURGE_RECALCULATE: 'cron:surge_recalculate', + RE_ENGAGEMENT: 'cron:re_engagement', + TRANSIT_APPROACHING_ALERTS: 'cron:transit_approaching_alerts', + TRANSIT_SYNC_MEMBERS: 'cron:transit_sync_members', + BOT_SOCIAL_TASKS: 'cron:bot_social_tasks', +} as const; + +export type CronJobName = (typeof CRON_JOBS)[keyof typeof CRON_JOBS]; + +/** + * جدولة كل مهمة — كل مهمة لها فاصلها وحدّها الأقصى. + */ +export const CRON_SCHEDULES: Record = { + [CRON_JOBS.SCHEDULED_TRIPS_SWEEP]: { everyMs: 30_000, timeoutMs: 15_000 }, + [CRON_JOBS.SEARCH_TIMEOUT_SWEEP]: { everyMs: 60_000, timeoutMs: 30_000 }, + [CRON_JOBS.REWARDS_SWEEP]: { everyMs: 60_000, timeoutMs: 30_000 }, + [CRON_JOBS.SURGE_RECALCULATE]: { everyMs: 180_000, timeoutMs: 60_000 }, + [CRON_JOBS.RE_ENGAGEMENT]: { everyMs: 86_400_000, timeoutMs: 120_000 }, + [CRON_JOBS.TRANSIT_APPROACHING_ALERTS]: { everyMs: 60_000, timeoutMs: 30_000 }, + [CRON_JOBS.TRANSIT_SYNC_MEMBERS]: { everyMs: 86_400_000, timeoutMs: 120_000 }, + [CRON_JOBS.BOT_SOCIAL_TASKS]: { everyMs: 900_000, timeoutMs: 60_000 }, +}; + +/** + * أسماء المهام الودية (للقسم). + */ +export const CRON_JOB_LABELS: Record = { + [CRON_JOBS.SCHEDULED_TRIPS_SWEEP]: 'Release scheduled trips', + [CRON_JOBS.SEARCH_TIMEOUT_SWEEP]: 'Expire stuck searching trips', + [CRON_JOBS.REWARDS_SWEEP]: 'Pay out qualified referrals', + [CRON_JOBS.SURGE_RECALCULATE]: 'Recalculate surge multipliers', + [CRON_JOBS.RE_ENGAGEMENT]: 'Re-engagement notifications', + [CRON_JOBS.TRANSIT_APPROACHING_ALERTS]: 'Transit approaching station alerts', + [CRON_JOBS.TRANSIT_SYNC_MEMBERS]: 'Sync transit member groups', + [CRON_JOBS.BOT_SOCIAL_TASKS]: 'Execute social media bot tasks', +}; diff --git a/backend/src/common/cron/cron-worker.service.ts b/backend/src/common/cron/cron-worker.service.ts new file mode 100644 index 0000000..636a9af --- /dev/null +++ b/backend/src/common/cron/cron-worker.service.ts @@ -0,0 +1,176 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Worker, Job } from 'bullmq'; +import { Repository } from 'typeorm'; +import { CRON_JOBS, CronJobName } from './cron-registry'; +import { CRON_QUEUE } from './bull.module'; +import { JobExecution } from './job-execution.entity'; +import { TripsService } from '../../modules/trips/trips.service'; +import { ReferralsService } from '../../modules/rewards/referrals.service'; +import { SurgeService } from '../../modules/tariff/surge.service'; +import { MarketingService } from '../../modules/marketing/marketing.service'; +import { BotService } from '../../modules/bots/bot.service'; + +/** + * معالج المهام الدورية (O5). + * + * يعمل في عملية **الـAPI** (ليس الـworker) لأنه يحتاج حقن الخدمات. + * كل مهمة تكتب سجل تنفيذ في `job_executions`. + * + * قفل ضد التشغيل المزدوج: `concurrency: 1` في الـWorker. + */ +@Injectable() +export class CronWorkerService { + private readonly logger = new Logger('CronWorker'); + private worker?: Worker; + + constructor( + @InjectRepository(JobExecution) + private readonly execRepo: Repository, + private readonly config: ConfigService, + private readonly tripsService: TripsService, + private readonly referralsService: ReferralsService, + private readonly surgeService: SurgeService, + private readonly marketingService: MarketingService, + private readonly botService: BotService, + ) {} + + start(): void { + const connection = { + host: this.config.get('redis.host'), + port: this.config.get('redis.port'), + db: this.config.get('redis.db'), + }; + + this.worker = new Worker( + CRON_QUEUE, + async (job: Job) => { + const name = job.name as CronJobName; + const execution = await this.startExecution(name); + + try { + let itemsProcessed = 0; + + switch (name) { + case CRON_JOBS.SCHEDULED_TRIPS_SWEEP: + itemsProcessed = await this.tripsService.releaseDueScheduled(); + break; + + case CRON_JOBS.SEARCH_TIMEOUT_SWEEP: { + const timeoutMs = this.config.get('trips.searchTimeoutMs') ?? 900_000; + itemsProcessed = await this.tripsService.expireStuckSearches(timeoutMs); + break; + } + + case CRON_JOBS.REWARDS_SWEEP: + itemsProcessed = await this.referralsService.sweep(); + break; + + case CRON_JOBS.SURGE_RECALCULATE: { + const result = await this.surgeService.recalculateAll(); + itemsProcessed = result.updated + result.unchanged; + break; + } + + case CRON_JOBS.RE_ENGAGEMENT: { + const campaigns = await this.marketingService.findDueReEngagement('*', 3); + let totalSent = 0; + for (const campaign of campaigns) { + const inactiveUserIds = await this.marketingService.findInactiveUserIds( + campaign.tenant_id, + 3, + ); + if (inactiveUserIds.length > 0) { + const result = await this.marketingService.runCampaign(campaign.id, inactiveUserIds); + totalSent += result.sent; + } + } + itemsProcessed = totalSent; + break; + } + + case CRON_JOBS.TRANSIT_APPROACHING_ALERTS: { + this.logger.debug('transit approaching alerts: delegated to transit microservice'); + itemsProcessed = 1; + break; + } + + case CRON_JOBS.TRANSIT_SYNC_MEMBERS: { + this.logger.debug('transit sync members: delegated to transit microservice'); + itemsProcessed = 1; + break; + } + + case CRON_JOBS.BOT_SOCIAL_TASKS: { + const dueTasks = await this.botService.getDueTasks(); + for (const task of dueTasks) { + await this.botService.updateStatus(task.id, 'running'); + try { + await this.botService.updateStatus(task.id, 'completed', { + message: 'Task queued for execution', + }); + } catch (e: any) { + await this.botService.updateStatus(task.id, 'failed'); + } + } + itemsProcessed = dueTasks.length; + break; + } + + default: + throw new Error(`Unknown cron job: ${name}`); + } + + await this.finishExecution(execution, 'success', itemsProcessed); + if (itemsProcessed > 0) { + this.logger.log(`${name}: processed ${itemsProcessed} item(s)`); + } + } catch (err: any) { + await this.finishExecution(execution, 'failed', 0, err?.message); + this.logger.error(`${name} failed: ${err?.message}`); + throw err; + } + }, + { + connection, + concurrency: 1, + prefix: this.config.get('queue.prefix'), + }, + ); + + this.worker.on('ready', () => this.logger.log('cron worker ready')); + this.worker.on('error', (err) => this.logger.error(`cron worker error: ${err.message}`)); + } + + async stop(): Promise { + if (this.worker) { + await this.worker.close(); + this.worker = undefined; + } + } + + private async startExecution(jobName: string): Promise { + const execution = this.execRepo.create({ + job_name: jobName, + status: 'running', + started_at: new Date(), + }); + return this.execRepo.save(execution); + } + + private async finishExecution( + execution: JobExecution, + status: 'success' | 'failed', + itemsProcessed: number, + errorMessage?: string, + ): Promise { + const now = new Date(); + execution.status = status; + execution.finished_at = now; + execution.duration_ms = now.getTime() - execution.started_at.getTime(); + execution.items_processed = itemsProcessed; + execution.error_message = errorMessage ?? null; + await this.execRepo.save(execution); + } +} diff --git a/backend/src/common/cron/cron.module.ts b/backend/src/common/cron/cron.module.ts new file mode 100644 index 0000000..efcc553 --- /dev/null +++ b/backend/src/common/cron/cron.module.ts @@ -0,0 +1,50 @@ +import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JobExecution } from './job-execution.entity'; +import { BullModule } from './bull.module'; +import { CronOrchestratorService } from './cron-orchestrator.service'; +import { CronWorkerService } from './cron-worker.service'; +import { CronAdminController } from './cron-admin.controller'; +import { TripsModule } from '../../modules/trips/trips.module'; +import { RewardsModule } from '../../modules/rewards/rewards.module'; +import { TariffModule } from '../../modules/tariff/tariff.module'; +import { MarketingModule } from '../../modules/marketing/marketing.module'; +import { BotModule } from '../../modules/bots/bot.module'; + +/** + * وحدة المهام الدورية الموحّدة (O5). + * + * تسجّل كل مهمة دورية كـrepeatable job في BullMQ، وتعالجها في عملية واحدة + * مع `concurrency: 1` لمنع التشغيل المزدوج. كل تنفيذ يُسجَّل في + * `job_executions` — لا مهمة صامتة. + * + * لا تُستهلك في الـworker — الـworker يبقى مستقلاً لمفرّغ المواقع (5 ثوانٍ). + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([JobExecution]), + BullModule, + TripsModule, + RewardsModule, + TariffModule, + MarketingModule, + BotModule, + ], + controllers: [CronAdminController], + providers: [CronOrchestratorService, CronWorkerService], + exports: [CronOrchestratorService], +}) +export class CronModule implements OnModuleInit, OnModuleDestroy { + constructor( + private readonly orchestrator: CronOrchestratorService, + private readonly worker: CronWorkerService, + ) {} + + onModuleInit(): void { + this.worker.start(); + } + + async onModuleDestroy(): Promise { + await this.worker.stop(); + } +} diff --git a/backend/src/common/cron/job-execution.entity.ts b/backend/src/common/cron/job-execution.entity.ts new file mode 100644 index 0000000..17c214a --- /dev/null +++ b/backend/src/common/cron/job-execution.entity.ts @@ -0,0 +1,58 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * سجل تنفيذ المهام الدورية (O5). + * + * الجدول: `tripz_job_executions`. + * + * كل صف = تنفيذ واحد لأي مهمة كانت (sweeper, bot, report...). + * الهدف: إظهار آخر نجاح/فشل لكل مهمة + مدة التنفيذ + سبب الفشل. + * لا مهمة صامتة — كل تنفيذ يُسجَّل هنا. + */ +@Entity('job_executions') +@Index(['job_name', 'started_at']) +export class JobExecution { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** اسم المهمة (cron:scheduled_trips_sweep, cron:surge_recalculate...). */ + @Column() + job_name: string; + + /** حالة التنفيذ. */ + @Column({ type: 'varchar', length: 20 }) + status: 'running' | 'success' | 'failed' | 'timeout'; + + /** لحظة بدء التنفيذ الفعلي (لا وقت إضاف الطور). */ + @Column({ type: 'timestamptz' }) + started_at: Date; + + /** لحظة انتهاء التنفيذ. */ + @Column({ type: 'timestamptz', nullable: true }) + finished_at: Date | null; + + /** مدة التنفيذ بالميلي ثانية. */ + @Column({ type: 'int', nullable: true }) + duration_ms: number; + + /** عدد العناصر التي عالجتها المهمة (مثلاً: عدد الرحلات التي انتهت). */ + @Column({ type: 'int', default: 0 }) + items_processed: number; + + /** رسالة خطأ (فقط عند الفشل). */ + @Column({ type: 'text', nullable: true }) + error_message: string | null; + + /** معرّف المستأجر (إن كانت المهمة خاصة بمستأجر). */ + @Column({ type: 'uuid', nullable: true }) + tenant_id: string | null; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/common/entitlements/features.ts b/backend/src/common/entitlements/features.ts index 33cdc7e..790a2ef 100644 --- a/backend/src/common/entitlements/features.ts +++ b/backend/src/common/entitlements/features.ts @@ -6,17 +6,24 @@ import { TenantPlan } from '../../database/entities/tenant.entity'; * هي الحقيقة النهائية. */ export const FEATURES = [ - 'dispatch', // لوحة المشغّل — إنشاء رحلة نيابةً عن راكب - 'wallet', // محفظة الراكب/السائق + التسوية - 'payments', // بوابات الدفع والشحن - 'chat', // دردشة داخل الرحلة - 'calls', // مكالمات WebRTC - 'ride_types', // أنواع رحلات مخصّصة للمستأجر - 'market_intel', // الاستخبار السوقي - 'bots', // البوتات - 'ads', // الدعاية - 'transit', // نظام المواصلات - 'api_access', // API + Webhooks للمستأجر + 'dispatch', // لوحة المشغّل — إنشاء رحلة نيابةً عن راكب + 'wallet', // محفظة الراكب/السائق + التسوية + 'payments', // بوابات الدفع والشحن + 'chat', // دردشة داخل الرحلة + 'calls', // مكالمات WebRTC + 'ride_types', // أنواع رحلات مخصّصة للمستأجر + 'market_intel', // الاستخبار السوقي + 'bots', // البوتات + 'ads', // الدعاية + 'transit', // نظام المواصلات + 'api_access', // API + Webhooks للمستأجر + 'driver_tiers', // مستويات السائقين (برونزي/فضي/ذهبي/بلاتيني) + 'marketing_engine', // محرك التسويق التلقائي + 'dynamic_pricing', // التسعير الديناميكي (مضاعف الطلب) + 'geofence', // السياج الجغرافي — مناطق التسعير والتنبيهات + 'negotiator', // المفاوض الآلي — تفاوض السعر تلقائياً + 'driver_assurance', // تأمين السائق — تغطية تأمينية + 'coupons', // كوبونات الخصم ] as const; export type Feature = (typeof FEATURES)[number]; @@ -45,7 +52,7 @@ const PLAN_DEFAULTS: Record limits: { drivers_max: 50, cities_max: 1 }, }, brand: { - features: ['wallet', 'chat', 'calls', 'ride_types', 'payments'], + features: ['wallet', 'chat', 'calls', 'ride_types', 'payments', 'driver_tiers'], limits: { drivers_max: 500, cities_max: 3 }, }, fleet: { diff --git a/backend/src/common/i18n/messages.ts b/backend/src/common/i18n/messages.ts index b10957a..f06b54f 100644 --- a/backend/src/common/i18n/messages.ts +++ b/backend/src/common/i18n/messages.ts @@ -27,6 +27,7 @@ const ar: Catalog = { 'trip.expired': { title: 'انتهت مهلة الطلب', body: 'انتهت مهلة طلب الرحلة دون قبول' }, 'trip.offer': { title: 'طلب رحلة جديد', body: 'راكب على بعد {distanceKm} كم — الأجرة {fare} {currency}' }, 'trip.offer_taken': { title: 'الرحلة لم تعد متاحة', body: 'قبِل الطلبَ سائق آخر' }, + 'trip.audio_recording_started': { title: 'بدء التسجيل', body: 'تم بدء تسجيل الرحلة صوتياً لضمان سلامتكما' }, 'chat.message': { title: 'رسالة جديدة', body: '{preview}' }, }; @@ -43,6 +44,7 @@ const en: Catalog = { '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' }, + 'trip.audio_recording_started': { title: 'Recording started', body: 'Trip audio recording has started for your safety' }, 'chat.message': { title: 'New message', body: '{preview}' }, }; diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index e9a7ac4..2571809 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -80,6 +80,16 @@ export default () => ({ // الرحلات: كل كم يفحص كنس الرحلات المجدولة (docs/17 — B5). trips: { scheduleSweepMs: parseInt(process.env.TRIP_SCHEDULE_SWEEP_MS ?? '30000', 10), + // R1 — كم يفحص رحلات searching عالقة (10 ثوانٍ كافية: لا ضغط على القاعدة). + searchTimeoutSweepMs: parseInt(process.env.TRIP_SEARCH_TIMEOUT_SWEEP_MS ?? '10000', 10), + // R1 — المهلة قبل إنهاء رحلة searching كـno_drivers (15 دقيقة كسيرو). + searchTimeoutMs: parseInt(process.env.TRIP_SEARCH_TIMEOUT_MS ?? '900000', 10), + // L4 — التسعير الديناميكي: كل كم يعيد حساب المضاعفات (3 دقائق افتراضياً). + surgeSweepMs: parseInt(process.env.TRIP_SURGE_SWEEP_MS ?? '180000', 10), + // L4 — الحدّ الأدنى بين تغييرين متتاليين للمضاعف (5 دقائق). + surgeCooldownMs: parseInt(process.env.TRIP_SURGE_COOLDOWN_MS ?? '300000', 10), + // L4 — حسّاسية الزيادة: 0.1 خفيفة، 0.3 متوسطة، 0.5 حادة. + surgeSensitivity: parseFloat(process.env.TRIP_SURGE_SENSITIVITY ?? '0.3'), }, // المكافآت: كل كم يمسح الماسح الإحالات المؤهَّلة غير المصروفة (docs/17 — L5). @@ -115,15 +125,19 @@ export default () => ({ baseUrl: process.env.STORAGE_BASE_URL ?? '', }, + // خدمة المواصلات (microservice مستقل) + transit: { + serviceUrl: process.env.TRANSIT_SERVICE_URL ?? 'http://transit:4020', + }, + // المدفوعات: توقيع HMAC على العمليات المالية (docs/17 — I6). // مطفأ حتى يوقّع تطبيق فلاتر؛ تفعيله قبل ذلك يقطع كل سحب. payments: { requireSignature: process.env.PAYMENTS_REQUIRE_SIGNATURE === 'true', }, - // إشعارات FCM (اتركه فارغاً لتعطيل الإرسال — يُسجَّل فقط). + // إشعارات FCM (ملف JSON لحزمة firebase-admin). fcm: { - serverKey: process.env.FCM_SERVER_KEY ?? '', - endpoint: process.env.FCM_ENDPOINT ?? 'https://fcm.googleapis.com/fcm/send', + credentialsPath: process.env.GOOGLE_APPLICATION_CREDENTIALS ?? '', }, }); diff --git a/backend/src/database/migrations/1721970000000-AddNewTablesAndDriverColumns.ts b/backend/src/database/migrations/1721970000000-AddNewTablesAndDriverColumns.ts new file mode 100644 index 0000000..32fef2b --- /dev/null +++ b/backend/src/database/migrations/1721970000000-AddNewTablesAndDriverColumns.ts @@ -0,0 +1,219 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * الجداول السبعة الجديدة + أعمدة الشرائح على tripz_drivers. + * + * الجداول: + * - tripz_job_executions — سجل المهام الدورية (O5) + * - tripz_surge_states — حالة التسعير الديناميكي (L4) + * - tripz_bot_tasks — مهام بوتات التواصل (O4) + * - tripz_feature_catalog — كتالوج الميزات القابلة للبيع (N6) + * - tripz_ride_type_catalog — كتالوج أنواع الرحلات العالمي (L2) + * - tripz_marketing_campaigns — حملات التسويق + * - tripz_trip_audio — تسجيلات صوتية للرحلات (R2) + * + * أعمدة إضافية على tripz_drivers: tier, tier_score, total_trips, acceptance_rate. + */ +export class AddNewTablesAndDriverColumns1721970000000 + implements MigrationInterface +{ + public async up(q: QueryRunner): Promise { + // ─── 1. job_executions ──────────────────────────────────────── + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_job_executions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + job_name varchar NOT NULL, + status varchar(20) NOT NULL, + started_at timestamptz NOT NULL, + finished_at timestamptz, + duration_ms int, + items_processed int NOT NULL DEFAULT 0, + error_message text, + tenant_id uuid, + created_at timestamptz NOT NULL DEFAULT now() + ) + `); + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_job_executions_job_started" + ON tripz_job_executions (job_name, started_at) + `); + + // ─── 2. surge_states ────────────────────────────────────────── + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_surge_states ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + city varchar NOT NULL, + service_class varchar NOT NULL, + multiplier numeric(5,2) NOT NULL DEFAULT 1.0, + max_multiplier numeric(5,2) NOT NULL DEFAULT 2.0, + min_multiplier numeric(5,2) NOT NULL DEFAULT 1.0, + demand_count int NOT NULL DEFAULT 0, + supply_count int NOT NULL DEFAULT 0, + last_sample_at timestamptz, + last_changed_at timestamptz, + manual_override boolean NOT NULL DEFAULT false, + manual_note varchar, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_surge_states_tenant_city_class" + ON tripz_surge_states (tenant_id, city, service_class) + `); + + // ─── 3. bot_tasks ───────────────────────────────────────────── + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_bot_tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + platform varchar NOT NULL, + task_type varchar NOT NULL, + status varchar NOT NULL DEFAULT 'pending', + config jsonb NOT NULL DEFAULT '{}', + result jsonb NOT NULL DEFAULT '{}', + items_processed int NOT NULL DEFAULT 0, + items_failed int NOT NULL DEFAULT 0, + error_message text, + started_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); + + // ─── 4. feature_catalog ─────────────────────────────────────── + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_feature_catalog ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + key varchar(64) NOT NULL, + name_ar varchar NOT NULL, + name_en varchar NOT NULL, + category varchar NOT NULL DEFAULT 'add_on', + monthly_price numeric(10,3) NOT NULL DEFAULT 0, + setup_fee numeric(10,3) NOT NULL DEFAULT 0, + currency varchar NOT NULL DEFAULT 'JOD', + description_ar text, + sort_order int NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_feature_catalog_key" + ON tripz_feature_catalog (key) + `); + + // ─── 5. ride_type_catalog ───────────────────────────────────── + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_ride_type_catalog ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar NOT NULL, + name_ar varchar NOT NULL, + name_en varchar, + vehicle_kind varchar NOT NULL DEFAULT 'car', + women_only boolean NOT NULL DEFAULT false, + round_trip_supported boolean NOT NULL DEFAULT true, + requires_weight boolean NOT NULL DEFAULT false, + max_seats int NOT NULL DEFAULT 4, + icon varchar, + sort int NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_ride_type_catalog_code" + ON tripz_ride_type_catalog (code) + `); + + // ─── 6. marketing_campaigns ─────────────────────────────────── + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_marketing_campaigns ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + name varchar NOT NULL, + type varchar NOT NULL, + status varchar NOT NULL DEFAULT 'draft', + title text, + body text, + data jsonb NOT NULL DEFAULT '{}', + targeting jsonb NOT NULL DEFAULT '{}', + sent_count int NOT NULL DEFAULT 0, + open_count int NOT NULL DEFAULT 0, + click_count int NOT NULL DEFAULT 0, + scheduled_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); + + // ─── 7. trip_audio ──────────────────────────────────────────── + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_trip_audio ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + trip_id uuid NOT NULL, + uploaded_by varchar NOT NULL, + file_key varchar NOT NULL, + file_ext varchar NOT NULL DEFAULT 'm4a', + duration_sec int, + file_size bigint, + destination_name varchar, + origin_lat double precision, + origin_lng double precision, + dest_lat double precision, + dest_lng double precision, + trip_status_at_upload varchar, + uploaded_at timestamptz NOT NULL DEFAULT now() + ) + `); + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_audio_tenant_trip" + ON tripz_trip_audio (tenant_id, trip_id) + `); + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_audio_trip" + ON tripz_trip_audio (trip_id) + `); + + // ─── 8. أعمدة الشرائح على tripz_drivers ────────────────────── + await q.query(` + ALTER TABLE tripz_drivers + ADD COLUMN IF NOT EXISTS tier varchar NOT NULL DEFAULT 'bronze', + ADD COLUMN IF NOT EXISTS tier_score int NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS total_trips int NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS acceptance_rate numeric(5,2) NOT NULL DEFAULT 100 + `); + } + + public async down(q: QueryRunner): Promise { + // إزالة أعمدة الشرائح + await q.query(` + ALTER TABLE tripz_drivers + DROP COLUMN IF EXISTS acceptance_rate, + DROP COLUMN IF EXISTS total_trips, + DROP COLUMN IF EXISTS tier_score, + DROP COLUMN IF EXISTS tier + `); + + // حذف الجداول (الترتيب العكسي للعلاقات) + await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trip_audio_trip"`); + await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trip_audio_tenant_trip"`); + await q.query(`DROP TABLE IF EXISTS tripz_trip_audio`); + await q.query(`DROP TABLE IF EXISTS tripz_marketing_campaigns`); + await q.query(`DROP INDEX IF EXISTS "UQ_tripz_ride_type_catalog_code"`); + await q.query(`DROP TABLE IF EXISTS tripz_ride_type_catalog`); + await q.query(`DROP INDEX IF EXISTS "UQ_tripz_feature_catalog_key"`); + await q.query(`DROP TABLE IF EXISTS tripz_feature_catalog`); + await q.query(`DROP TABLE IF EXISTS tripz_bot_tasks`); + await q.query(`DROP INDEX IF EXISTS "UQ_tripz_surge_states_tenant_city_class"`); + await q.query(`DROP TABLE IF EXISTS tripz_surge_states`); + await q.query(`DROP INDEX IF EXISTS "IDX_tripz_job_executions_job_started"`); + await q.query(`DROP TABLE IF EXISTS tripz_job_executions`); + } +} diff --git a/backend/src/integrations/payments/cliq.adapter.ts b/backend/src/integrations/payments/cliq.adapter.ts new file mode 100644 index 0000000..a317cba --- /dev/null +++ b/backend/src/integrations/payments/cliq.adapter.ts @@ -0,0 +1,116 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Tenant } from '../../database/entities/tenant.entity'; +import { + PaymentAdapter, + ChargeContext, + ChargeResult, + WebhookResult, +} from './payment-adapter.interface'; + +/** + * بوابة CliQ — الدفع الإلكتروني الأردني (BOK + بنوك الأردن). + * + * تدفق العمل: + * 1. `POST /api/v1/payment` — إنشاء عملية دفع. + * 2. يُرجع `paymentUrl` — يُفتح في المتصفح/iframe. + * 3. webhook موقَّع يؤكد الدفع. + * + * الاعتمادات تُقرأ من `tenant.settings.payments.cliq` أولاً، ثم env vars. + */ +@Injectable() +export class CliqAdapter implements PaymentAdapter { + readonly name = 'cliq'; + private readonly logger = new Logger('CliQ'); + + constructor(private readonly config: ConfigService) {} + + private get baseUrl(): string { + return this.config.get('cliq.baseUrl') ?? 'https://api.cliq.jo'; + } + + private creds(tenant: Tenant) { + const t = tenant?.settings?.payments?.cliq ?? {}; + return { + merchantId: t.merchantId ?? this.config.get('cliq.merchantId') ?? '', + apiKey: t.apiKey ?? this.config.get('cliq.apiKey') ?? '', + secretKey: t.secretKey ?? this.config.get('cliq.secretKey') ?? '', + terminalId: t.terminalId ?? this.config.get('cliq.terminalId') ?? '', + }; + } + + async charge(ctx: ChargeContext, tenant: Tenant): Promise { + const creds = this.creds(tenant); + if (!creds.merchantId || !creds.apiKey) { + this.logger.warn('CliQ credentials not configured — falling back to invoice'); + return { + mode: 'invoice', + reference: `CLIQ-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`, + instructions: 'CliQ credentials not configured', + }; + } + + try { + const res = await fetch(`${this.baseUrl}/api/v1/payment`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${creds.apiKey}`, + }, + body: JSON.stringify({ + merchant_id: creds.merchantId, + terminal_id: creds.terminalId, + amount: ctx.amount, + currency: ctx.currency, + order_id: ctx.paymentId, + customer_phone: ctx.userPhone ?? '', + callback_url: `${this.config.get('app.baseUrl') ?? 'https://api.tripz.app'}/webhooks/payments/cliq`, + }), + }); + + if (!res.ok) { + const body = await res.text(); + this.logger.error(`CliQ charge failed: ${res.status} ${body}`); + throw new Error(`CliQ API ${res.status}`); + } + + const data = await res.json(); + return { + mode: 'redirect', + redirectUrl: data.paymentUrl ?? data.redirect_url ?? data.url, + providerRef: data.payment_id ?? data.id, + }; + } catch (e: any) { + this.logger.error(`CliQ charge error: ${e?.message}`); + throw e; + } + } + + verifyWebhook(headers: Record, rawBody: any, tenant: Tenant): boolean { + const creds = this.creds(tenant); + const signature = headers['x-cliq-signature'] ?? headers['x-signature'] ?? ''; + if (!signature || !creds.secretKey) return false; + + // HMAC-SHA256 على الجسم الخام — مطابق لنمط PayMob. + const { createHmac, timingSafeEqual } = require('crypto'); + const expected = createHmac('sha256', creds.secretKey) + .update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody)) + .digest('hex'); + try { + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } + } + + parseWebhook(payload: any): WebhookResult | null { + if (!payload) return null; + const status = payload.status ?? payload.payment_status; + if (!status) return null; + return { + providerRef: payload.payment_id ?? payload.id ?? payload.transaction_id ?? '', + success: status === 'success' || status === 'completed' || status === 'paid', + amount: payload.amount ? Number(payload.amount) : undefined, + }; + } +} diff --git a/backend/src/integrations/payments/invoice-and-registry.spec.ts b/backend/src/integrations/payments/invoice-and-registry.spec.ts index 502a4e1..17b6cbf 100644 --- a/backend/src/integrations/payments/invoice-and-registry.spec.ts +++ b/backend/src/integrations/payments/invoice-and-registry.spec.ts @@ -2,6 +2,8 @@ import { InvoiceAdapter } from './invoice.adapter'; import { PaymentGatewayRegistry } from './payment-gateway.registry'; import { CashAdapter } from './cash.adapter'; import { PaymobAdapter } from './paymob.adapter'; +import { SyriatelAdapter } from './syriatel.adapter'; +import { MtnAdapter } from './mtn.adapter'; describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5)', () => { it('يولّد مرجعاً ويعرض حساب الاستلام المضبوط للمستأجر', async () => { @@ -45,7 +47,12 @@ describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5) describe('PaymentGatewayRegistry — يربط الكتالوج بمحوّله', () => { const config = { get: () => undefined } as any; - const registry = () => new PaymentGatewayRegistry(new CashAdapter(), new PaymobAdapter(config)); + const registry = () => new PaymentGatewayRegistry( + new CashAdapter(), + new PaymobAdapter(config), + new SyriatelAdapter(config), + new MtnAdapter(config), + ); it('يعرف كل مزوّدي الكتالوج', () => { const r = registry(); diff --git a/backend/src/integrations/payments/mtn.adapter.ts b/backend/src/integrations/payments/mtn.adapter.ts new file mode 100644 index 0000000..295fa9d --- /dev/null +++ b/backend/src/integrations/payments/mtn.adapter.ts @@ -0,0 +1,133 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Tenant } from '../../database/entities/tenant.entity'; +import { + PaymentAdapter, + ChargeContext, + ChargeResult, + WebhookResult, +} from './payment-adapter.interface'; + +/** + * بوابة MTN للدفع الإلكتروني — MTN Mobile Money / MTN Payment Gateway. + * + * تدفق العمل: + * 1. `POST /api/v1/requestToPay` — طلب دفع من المستخدم. + * 2. يُرجع `referenceId` — التطبيق يتابع الحالة عبر polling. + * 3. webhook / polling يؤكد النجاح. + * + * الاعتمادات تُقرأ من `tenant.settings.payments.mtn` أولاً، ثم env vars. + */ +@Injectable() +export class MtnAdapter implements PaymentAdapter { + readonly name = 'mtn'; + private readonly logger = new Logger('MTN'); + + constructor(private readonly config: ConfigService) {} + + private get baseUrl(): string { + return this.config.get('mtn.baseUrl') ?? 'https://proxy.momoapi.mtn.com'; + } + + private creds(tenant: Tenant) { + const t = tenant?.settings?.payments?.mtn ?? {}; + return { + apiKey: t.apiKey ?? this.config.get('mtn.apiKey') ?? '', + apiUser: t.apiUser ?? this.config.get('mtn.apiUser') ?? '', + subscriptionKey: t.subscriptionKey ?? this.config.get('mtn.subscriptionKey') ?? '', + environment: t.environment ?? this.config.get('mtn.environment') ?? 'sandbox', + }; + } + + private async getAccessToken(creds: { apiKey: string; apiUser: string; subscriptionKey: string }): Promise { + const res = await fetch(`${this.baseUrl}/collection/token/`, { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from(`${creds.apiUser}:${creds.apiKey}`).toString('base64')}`, + 'Ocp-Apim-Subscription-Key': creds.subscriptionKey, + }, + }); + if (!res.ok) throw new Error(`MTN token failed: ${res.status}`); + const data = await res.json(); + return data.access_token; + } + + async charge(ctx: ChargeContext, tenant: Tenant): Promise { + const creds = this.creds(tenant); + if (!creds.apiKey || !creds.apiUser) { + this.logger.warn('MTN credentials not configured — falling back to invoice'); + return { + mode: 'invoice', + reference: `MTN-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`, + instructions: 'MTN credentials not configured', + }; + } + + try { + const token = await this.getAccessToken(creds); + const referenceId = ctx.paymentId; + + const res = await fetch(`${this.baseUrl}/collection/v1_0/requestToPay`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'X-Reference-Id': referenceId, + 'X-Target-Environment': creds.environment, + 'Ocp-Apim-Subscription-Key': creds.subscriptionKey, + }, + body: JSON.stringify({ + amount: String(ctx.amount), + currency: ctx.currency, + externalId: ctx.paymentId, + payer: { partyIdType: 'MSISDN', partyId: ctx.userPhone ?? '' }, + payerMessage: 'Tripz payment', + payeeNote: `Payment for trip ${ctx.paymentId}`, + }), + }); + + if (!res.ok && res.status !== 202) { + const body = await res.text(); + this.logger.error(`MTN charge failed: ${res.status} ${body}`); + throw new Error(`MTN API ${res.status}`); + } + + // MTN يرجع 202 Accepted — التأكيد عبر polling أو webhook. + return { + mode: 'redirect', + redirectUrl: `${this.baseUrl}/collection/v1_0/requestToPay/${referenceId}`, + providerRef: referenceId, + }; + } catch (e: any) { + this.logger.error(`MTN charge error: ${e?.message}`); + throw e; + } + } + + verifyWebhook(headers: Record, rawBody: any, tenant: Tenant): boolean { + const creds = this.creds(tenant); + const signature = headers['x-mtn-signature'] ?? headers['x-signature'] ?? ''; + if (!signature || !creds.subscriptionKey) return false; + + const { createHmac, timingSafeEqual } = require('crypto'); + const expected = createHmac('sha256', creds.subscriptionKey) + .update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody)) + .digest('hex'); + try { + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } + } + + parseWebhook(payload: any): WebhookResult | null { + if (!payload) return null; + const status = payload.status ?? payload.financialTransaction?.status; + if (!status) return null; + return { + providerRef: payload.referenceId ?? payload.externalId ?? '', + success: status === 'SUCCESSFUL' || status === 'completed', + amount: payload.amount ? Number(payload.amount) : undefined, + }; + } +} diff --git a/backend/src/integrations/payments/payment-gateway.registry.ts b/backend/src/integrations/payments/payment-gateway.registry.ts index aa40613..2b85bcd 100644 --- a/backend/src/integrations/payments/payment-gateway.registry.ts +++ b/backend/src/integrations/payments/payment-gateway.registry.ts @@ -3,6 +3,8 @@ import { PaymentAdapter } from './payment-adapter.interface'; import { CashAdapter } from './cash.adapter'; import { PaymobAdapter } from './paymob.adapter'; import { InvoiceAdapter } from './invoice.adapter'; +import { SyriatelAdapter } from './syriatel.adapter'; +import { MtnAdapter } from './mtn.adapter'; /** * يربط اسم المزوّد (من كتالوج `payment-methods.ts`) بمحوّله (docs/07). @@ -13,11 +15,18 @@ import { InvoiceAdapter } from './invoice.adapter'; export class PaymentGatewayRegistry { private readonly adapters = new Map(); - constructor(cash: CashAdapter, paymob: PaymobAdapter) { + constructor( + cash: CashAdapter, + paymob: PaymobAdapter, + syriatel: SyriatelAdapter, + mtn: MtnAdapter, + ) { this.adapters.set(cash.name, cash); this.adapters.set(paymob.name, paymob); - // بلا API فعلية اليوم — محوّل مشترك واحد لكل منها (invoice.adapter.ts). - for (const provider of ['cliq', 'shamcash', 'mtn', 'syriatel', 'zaincash']) { + this.adapters.set(syriatel.name, syriatel); + this.adapters.set(mtn.name, mtn); + // CliQ وشام كاش وزين كاش — فواتير (بلا API فعلية) + for (const provider of ['cliq', 'shamcash', 'zaincash']) { this.adapters.set(provider, new InvoiceAdapter(provider)); } } diff --git a/backend/src/integrations/payments/payment-gateways.module.ts b/backend/src/integrations/payments/payment-gateways.module.ts index c2ca337..dbcf9f8 100644 --- a/backend/src/integrations/payments/payment-gateways.module.ts +++ b/backend/src/integrations/payments/payment-gateways.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { CashAdapter } from './cash.adapter'; import { PaymobAdapter } from './paymob.adapter'; +import { SyriatelAdapter } from './syriatel.adapter'; +import { MtnAdapter } from './mtn.adapter'; import { PaymentGatewayRegistry } from './payment-gateway.registry'; @Module({ - providers: [CashAdapter, PaymobAdapter, PaymentGatewayRegistry], + providers: [CashAdapter, PaymobAdapter, SyriatelAdapter, MtnAdapter, PaymentGatewayRegistry], exports: [PaymentGatewayRegistry], }) export class PaymentGatewaysModule {} diff --git a/backend/src/integrations/payments/syriatel.adapter.ts b/backend/src/integrations/payments/syriatel.adapter.ts new file mode 100644 index 0000000..fff238b --- /dev/null +++ b/backend/src/integrations/payments/syriatel.adapter.ts @@ -0,0 +1,115 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Tenant } from '../../database/entities/tenant.entity'; +import { + PaymentAdapter, + ChargeContext, + ChargeResult, + WebhookResult, +} from './payment-adapter.interface'; + +/** + * بوابة سيرياتيل للدفع الإلكتروني — SyriaTel e-payment. + * + * تدفق العمل: + * 1. `POST /api/payment/init` — بدء عملية الدفع. + * 2. يُرجع `payment_link` — يُفتح في المتصفح. + * 3. webhook يؤكد النجاح/الفشل. + * + * الاعتمادات تُقرأ من `tenant.settings.payments.syriatel` أولاً، ثم env vars. + */ +@Injectable() +export class SyriatelAdapter implements PaymentAdapter { + readonly name = 'syriatel'; + private readonly logger = new Logger('SyriaTel'); + + constructor(private readonly config: ConfigService) {} + + private get baseUrl(): string { + return this.config.get('syriatel.baseUrl') ?? 'https://e-payment.syriatel.com/api'; + } + + private creds(tenant: Tenant) { + const t = tenant?.settings?.payments?.syriatel ?? {}; + return { + merchantCode: t.merchantCode ?? this.config.get('syriatel.merchantCode') ?? '', + apiKey: t.apiKey ?? this.config.get('syriatel.apiKey') ?? '', + secretKey: t.secretKey ?? this.config.get('syriatel.secretKey') ?? '', + serviceId: t.serviceId ?? this.config.get('syriatel.serviceId') ?? '', + }; + } + + async charge(ctx: ChargeContext, tenant: Tenant): Promise { + const creds = this.creds(tenant); + if (!creds.merchantCode || !creds.apiKey) { + this.logger.warn('SyriaTel credentials not configured — falling back to invoice'); + return { + mode: 'invoice', + reference: `SYR-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`, + instructions: 'SyriaTel credentials not configured', + }; + } + + try { + const res = await fetch(`${this.baseUrl}/payment/init`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${creds.apiKey}`, + }, + body: JSON.stringify({ + merchant_code: creds.merchantCode, + service_id: creds.serviceId, + amount: ctx.amount, + currency: ctx.currency, + order_id: ctx.paymentId, + customer_phone: ctx.userPhone ?? '', + return_url: `${this.config.get('app.baseUrl') ?? 'https://api.tripz.app'}/webhooks/payments/syriatel`, + }), + }); + + if (!res.ok) { + const body = await res.text(); + this.logger.error(`SyriaTel charge failed: ${res.status} ${body}`); + throw new Error(`SyriaTel API ${res.status}`); + } + + const data = await res.json(); + return { + mode: 'redirect', + redirectUrl: data.payment_link ?? data.redirect_url ?? data.url, + providerRef: data.payment_id ?? data.id, + }; + } catch (e: any) { + this.logger.error(`SyriaTel charge error: ${e?.message}`); + throw e; + } + } + + verifyWebhook(headers: Record, rawBody: any, tenant: Tenant): boolean { + const creds = this.creds(tenant); + const signature = headers['x-syriatel-signature'] ?? headers['x-signature'] ?? ''; + if (!signature || !creds.secretKey) return false; + + const { createHmac, timingSafeEqual } = require('crypto'); + const expected = createHmac('sha256', creds.secretKey) + .update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody)) + .digest('hex'); + try { + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } + } + + parseWebhook(payload: any): WebhookResult | null { + if (!payload) return null; + const status = payload.status ?? payload.payment_status; + if (!status) return null; + return { + providerRef: payload.payment_id ?? payload.id ?? payload.transaction_id ?? '', + success: status === 'success' || status === 'completed' || status === 'paid', + amount: payload.amount ? Number(payload.amount) : undefined, + }; + } +} diff --git a/backend/src/modules/bots/bot.controller.ts b/backend/src/modules/bots/bot.controller.ts new file mode 100644 index 0000000..7255eeb --- /dev/null +++ b/backend/src/modules/bots/bot.controller.ts @@ -0,0 +1,63 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard'; +import { BotService } from './bot.service'; +import { BotPlatform, BotTaskType } from './entities/bot-task.entity'; + +@ApiTags('bots') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard) +@Roles('admin') +@Controller('admin/bots') +@RequiresFeature('bots') +export class BotController { + constructor(private readonly botService: BotService) {} + + @Get('tasks') + async listTasks(@CurrentUser() user: AuthUser) { + return this.botService.findAll(user.tenantId); + } + + @Get('tasks/:id') + async getTask(@Param('id') id: string) { + return this.botService.findOne(id); + } + + @Post('tasks') + async createTask( + @CurrentUser() user: AuthUser, + @Body() body: { + platform: BotPlatform; + task_type: BotTaskType; + config?: Record; + }, + ) { + return this.botService.createTask({ + tenant_id: user.tenantId, + platform: body.platform, + task_type: body.task_type, + config: body.config || {}, + }); + } + + @Patch('tasks/:id/cancel') + @HttpCode(HttpStatus.OK) + async cancelTask(@Param('id') id: string) { + return this.botService.cancelTask(id); + } + + @Get('stats') + async getStats(@CurrentUser() user: AuthUser) { + return this.botService.getStats(user.tenantId); + } + + @Delete('tasks/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteTask(@Param('id') id: string) { + await this.botService.cancelTask(id); + } +} diff --git a/backend/src/modules/bots/bot.module.ts b/backend/src/modules/bots/bot.module.ts new file mode 100644 index 0000000..bd9b3e3 --- /dev/null +++ b/backend/src/modules/bots/bot.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BotTask } from './entities/bot-task.entity'; +import { BotService } from './bot.service'; +import { BotController } from './bot.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([BotTask])], + controllers: [BotController], + providers: [BotService], + exports: [BotService], +}) +export class BotModule {} diff --git a/backend/src/modules/bots/bot.service.ts b/backend/src/modules/bots/bot.service.ts new file mode 100644 index 0000000..8f07090 --- /dev/null +++ b/backend/src/modules/bots/bot.service.ts @@ -0,0 +1,70 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BotTask, BotPlatform, BotTaskType, BotTaskStatus } from './entities/bot-task.entity'; + +@Injectable() +export class BotService { + private readonly logger = new Logger(BotService.name); + + constructor( + @InjectRepository(BotTask) + private readonly taskRepo: Repository, + ) {} + + async createTask(data: Partial): Promise { + const task = this.taskRepo.create(data); + return this.taskRepo.save(task); + } + + async findAll(tenantId: string): Promise { + return this.taskRepo.find({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + take: 100, + }); + } + + async findOne(id: string): Promise { + return this.taskRepo.findOne({ where: { id } }); + } + + async updateStatus(id: string, status: BotTaskStatus, result?: Record): Promise { + const update: any = { status }; + if (status === 'running') update.started_at = new Date(); + if (status === 'completed' || status === 'failed') update.completed_at = new Date(); + if (result) update.result = result; + await this.taskRepo.update(id, update); + return this.taskRepo.findOneOrFail({ where: { id } }); + } + + async getStats(tenantId: string): Promise<{ + total: number; + by_status: Record; + by_platform: Record; + }> { + const tasks = await this.taskRepo.find({ where: { tenant_id: tenantId } }); + + const by_status: any = { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 }; + const by_platform: any = { facebook: 0, instagram: 0, telegram: 0, whatsapp: 0 }; + + for (const task of tasks) { + by_status[task.status]++; + by_platform[task.platform]++; + } + + return { total: tasks.length, by_status, by_platform }; + } + + async getDueTasks(): Promise { + return this.taskRepo.find({ + where: { status: 'pending' }, + order: { created_at: 'ASC' }, + take: 50, + }); + } + + async cancelTask(id: string): Promise { + return this.updateStatus(id, 'cancelled'); + } +} diff --git a/backend/src/modules/bots/entities/bot-task.entity.ts b/backend/src/modules/bots/entities/bot-task.entity.ts new file mode 100644 index 0000000..a8ac9b5 --- /dev/null +++ b/backend/src/modules/bots/entities/bot-task.entity.ts @@ -0,0 +1,50 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export type BotPlatform = 'facebook' | 'instagram' | 'telegram' | 'whatsapp'; +export type BotTaskType = 'scroll_and_reply' | 'scrape' | 'like' | 'comment' | 'post' | 'message'; +export type BotTaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + +@Entity('bot_tasks') +export class BotTask { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'varchar' }) + platform: BotPlatform; + + @Column({ type: 'varchar' }) + task_type: BotTaskType; + + @Column({ type: 'varchar', default: 'pending' }) + status: BotTaskStatus; + + @Column({ type: 'jsonb', default: {} }) + config: Record; + + @Column({ type: 'jsonb', default: {} }) + result: Record; + + @Column({ type: 'int', default: 0 }) + items_processed: number; + + @Column({ type: 'int', default: 0 }) + items_failed: number; + + @Column({ type: 'text', nullable: true }) + error_message: string | null; + + @Column({ type: 'timestamptz', nullable: true }) + started_at: Date; + + @Column({ type: 'timestamptz', nullable: true }) + completed_at: Date; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/catalog/catalog.controller.ts b/backend/src/modules/catalog/catalog.controller.ts new file mode 100644 index 0000000..eed1219 --- /dev/null +++ b/backend/src/modules/catalog/catalog.controller.ts @@ -0,0 +1,117 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { CatalogService } from './catalog.service'; +import { PlatformGuard } from '../../common/platform/platform.guard'; +import { TenantPlan } from '../../database/entities/tenant.entity'; + +/** + * كتالوج الميزات — يديره السوبر-أدن (docs/22 — N6/N7). + * + * كل المسارات خلف `PlatformGuard` (x-platform-secret). لا يمكن لأدمن + * مستأجر الوصول إلى هذه النقط. + */ +@ApiTags('catalog') +@Controller() +export class CatalogController { + constructor(private readonly catalog: CatalogService) {} + + /** كل عناصر الكتالوج. */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Get('admin/catalog') + list() { + return this.catalog.list(); + } + + /** تحديث سعر أو حالة عنصر. */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Patch('admin/catalog/:id') + update( + @Param('id') id: string, + @Body() body: { monthly_price?: number; setup_fee?: number; name_ar?: string; description_ar?: string; active?: boolean; sort_order?: number }, + ) { + return this.catalog.update(id, body); + } + + /** + * حاسبة العرض (N7): باقة + ميزات إضافية → سعر شهري + تأسيس. + * + * الاستخدام: السوبر-أدن يختار الباقة والميزات ثم يولّد عرضاً للعميل. + */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Post('admin/catalog/quote') + quote( + @Body() body: { plan: TenantPlan; addOns?: string[]; currency?: string }, + ) { + return this.catalog.quote(body.plan, body.addOns ?? [], body.currency ?? 'JOD'); + } + + // ─── كتالوج أنواع الرحلات العالمي (L2) ────────────────────────────── + + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Get('admin/ride-type-catalog') + listRideTypes(@Query('vehicle_kind') vehicleKind?: string) { + return this.catalog.listRideTypes(vehicleKind); + } + + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Get('admin/ride-type-catalog/:code') + getRideType(@Param('code') code: string) { + return this.catalog.findRideTypeByCode(code); + } + + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Post('admin/ride-type-catalog') + createRideType( + @Body() body: { + code: string; + name_ar: string; + name_en?: string; + vehicle_kind?: string; + women_only?: boolean; + round_trip_supported?: boolean; + requires_weight?: boolean; + max_seats?: number; + icon?: string; + sort?: number; + }, + ) { + return this.catalog.createRideType(body); + } + + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Patch('admin/ride-type-catalog/:id') + updateRideType( + @Param('id') id: string, + @Body() body: { + name_ar?: string; + name_en?: string; + vehicle_kind?: string; + women_only?: boolean; + round_trip_supported?: boolean; + requires_weight?: boolean; + max_seats?: number; + icon?: string; + sort?: number; + active?: boolean; + }, + ) { + return this.catalog.updateRideType(id, body); + } + + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Patch('admin/ride-type-catalog/:id/toggle') + toggleRideType( + @Param('id') id: string, + @Body() body: { active: boolean }, + ) { + return this.catalog.toggleRideType(id, body.active); + } +} diff --git a/backend/src/modules/catalog/catalog.module.ts b/backend/src/modules/catalog/catalog.module.ts new file mode 100644 index 0000000..a757fa5 --- /dev/null +++ b/backend/src/modules/catalog/catalog.module.ts @@ -0,0 +1,25 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FeatureCatalog } from './entities/feature-catalog.entity'; +import { RideTypeCatalog } from './entities/ride-type-catalog.entity'; +import { CatalogService } from './catalog.service'; +import { CatalogController } from './catalog.controller'; + +/** + * وحدة الكتالوج العالمي (N6/N7 + L2). + * تزرع البيانات الأولية عند أول تشغيل وتُغذّي مولّد العروض. + */ +@Module({ + imports: [TypeOrmModule.forFeature([FeatureCatalog, RideTypeCatalog])], + controllers: [CatalogController], + providers: [CatalogService], + exports: [CatalogService], +}) +export class CatalogModule implements OnModuleInit { + constructor(private readonly catalog: CatalogService) {} + + async onModuleInit() { + await this.catalog.seed(); + await this.catalog.seedRideTypes(); + } +} diff --git a/backend/src/modules/catalog/catalog.service.ts b/backend/src/modules/catalog/catalog.service.ts new file mode 100644 index 0000000..a40a6f8 --- /dev/null +++ b/backend/src/modules/catalog/catalog.service.ts @@ -0,0 +1,221 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FeatureCatalog } from './entities/feature-catalog.entity'; +import { RideTypeCatalog } from './entities/ride-type-catalog.entity'; +import { RIDE_TYPE_CATALOG_SEED } from './ride-type-catalog.seed'; +import { TenantPlan } from '../../database/entities/tenant.entity'; + +/** + * البيانات الأولية لكتالوج الميزات — تُزرع عند أول تشغيل (docs/22 — N6). + * الأسعار بالدينار الأردني،قابلة للتعديل من لوحة السوبر-أدن. + */ +const SEED_CATALOG: Omit[] = [ + // ---- أساسيات (core) — مشمولة بالباقة، سعرها صفر ---- + { key: 'wallet', name_ar: 'محفظة', name_en: 'Wallet', category: 'core', monthly_price: 0, setup_fee: 0, currency: 'JOD', description_ar: 'محفظة الراكب/السائق والتسوية', sort_order: 1, active: true }, + { key: 'chat', name_ar: 'دردشة', name_en: 'Chat', category: 'core', monthly_price: 0, setup_fee: 0, currency: 'JOD', description_ar: 'محادثة داخل الرحلة', sort_order: 2, active: true }, + { key: 'ride_types', name_ar: 'أنواع الرحلات', name_en: 'Ride Types', category: 'core', monthly_price: 0, setup_fee: 0, currency: 'JOD', description_ar: 'فئات خدمة مخصصة للمستأجر', sort_order: 3, active: true }, + + // ---- إضافات مدفوعة (add_on) ---- + { key: 'payments', name_ar: 'بوابات الدفع', name_en: 'Payment Gateways', category: 'add_on', monthly_price: 15, setup_fee: 50, currency: 'JOD', description_ar: 'ربط بوابات الدفع الإلكترونية', sort_order: 10, active: true }, + { key: 'calls', name_ar: 'مكالمات', name_en: 'WebRTC Calls', category: 'add_on', monthly_price: 10, setup_fee: 20, currency: 'JOD', description_ar: 'مكالمات صوتية داخل التطبيق', sort_order: 11, active: true }, + { key: 'dispatch', name_ar: 'لوحة المشغّل', name_en: 'Dispatch', category: 'add_on', monthly_price: 20, setup_fee: 30, currency: 'JOD', description_ar: 'إنشاء رحلات نيابةً عن الركاب', sort_order: 12, active: true }, + { key: 'market_intel', name_ar: 'الاستخبار السوقي', name_en: 'Market Intelligence', category: 'add_on', monthly_price: 25, setup_fee: 0, currency: 'JOD', description_ar: 'خرائط حرارية + تحليلات الطلب', sort_order: 13, active: true }, + { key: 'bots', name_ar: 'البوتات', name_en: 'Bots', category: 'add_on', monthly_price: 20, setup_fee: 0, currency: 'JOD', description_ar: 'بوتات واتساب وسوشيال', sort_order: 14, active: true }, + { key: 'ads', name_ar: 'الدعاية المستهدفة', name_en: 'Targeted Ads', category: 'add_on', monthly_price: 15, setup_fee: 0, currency: 'JOD', description_ar: 'حملات إعلانية مستهدفة داخل التطبيق', sort_order: 15, active: true }, + { key: 'transit', name_ar: 'المواصلات', name_en: 'Transit', category: 'add_on', monthly_price: 30, setup_fee: 100, currency: 'JOD', description_ar: 'نظام خطوط المواصلات الجماعية', sort_order: 16, active: true }, + { key: 'api_access', name_ar: 'الوصول لـ API', name_en: 'API Access', category: 'add_on', monthly_price: 10, setup_fee: 0, currency: 'JOD', description_ar: 'API + Webhooks للمستأجر', sort_order: 17, active: true }, + { key: 'dynamic_pricing', name_ar: 'التسعير الديناميكي', name_en: 'Dynamic Pricing', category: 'add_on', monthly_price: 20, setup_fee: 0, currency: 'JOD', description_ar: 'مضاعف الطلب والمنطقة الزمنية', sort_order: 18, active: true }, + { key: 'geofence', name_ar: 'السياج الجغرافي', name_en: 'Geofence', category: 'add_on', monthly_price: 10, setup_fee: 15, currency: 'JOD', description_ar: 'مناطق التسعير والتنبيهات', sort_order: 19, active: true }, + { key: 'negotiator', name_ar: 'المفاوض الآلي', name_en: 'Negotiator', category: 'add_on', monthly_price: 15, setup_fee: 0, currency: 'JOD', description_ar: 'تفاوض السعر تلقائياً مع السائقين', sort_order: 20, active: true }, + { key: 'driver_tiers', name_ar: 'شرائح السائقين', name_en: 'Driver Tiers', category: 'add_on', monthly_price: 10, setup_fee: 0, currency: 'JOD', description_ar: 'تصنيف السائقين حسب الأداء', sort_order: 21, active: true }, + { key: 'driver_assurance', name_ar: 'تأمين السائق', name_en: 'Driver Assurance', category: 'add_on', monthly_price: 15, setup_fee: 0, currency: 'JOD', description_ar: 'تغطية تأمينية للسائقين', sort_order: 22, active: true }, + { key: 'coupons', name_ar: 'الكوبونات', name_en: 'Coupons', category: 'add_on', monthly_price: 5, setup_fee: 0, currency: 'JOD', description_ar: 'محرك كوبونات الخصم', sort_order: 23, active: true }, + { key: 'marketing_engine', name_ar: 'محرك التسويق', name_en: 'Marketing Engine', category: 'add_on', monthly_price: 20, setup_fee: 0, currency: 'JOD', description_ar: 'حملات إعلانية تلقائية وإعادة تفاعل', sort_order: 24, active: true }, +]; + +/** رسوم الباقة الشهرية — تُضاف لرسوم الميزات المختارة. */ +export const PLAN_MONTHLY_FEE: Record = { + launch: 0, + brand: 49, + fleet: 149, + sovereign: 0, // يُتفق عليه يدوياً +}; + +@Injectable() +export class CatalogService { + private readonly logger = new Logger('Catalog'); + + constructor( + @InjectRepository(FeatureCatalog) + private readonly repo: Repository, + @InjectRepository(RideTypeCatalog) + private readonly rideTypeRepo: Repository, + ) {} + + /** زراعة الكتالوج — آمنة للتشغيل المتكرر (upsert بالـkey). */ + async seed(): Promise { + let upserted = 0; + for (const row of SEED_CATALOG) { + const existing = await this.repo.findOne({ where: { key: row.key } }); + if (!existing) { + await this.repo.save(this.repo.create(row)); + upserted++; + } + } + if (upserted > 0) this.logger.log(`seeded ${upserted} catalog item(s)`); + return upserted; + } + + /** كل عناصر الكتالوج (مع التصفية حسب الفئة). */ + async list(category?: 'core' | 'add_on' | 'bundle'): Promise { + const where: any = {}; + if (category) where.category = category; + return this.repo.find({ where, order: { sort_order: 'ASC' } }); + } + + /** عنصر واحد بالـkey. */ + async findByKey(key: string): Promise { + const item = await this.repo.findOne({ where: { key } }); + if (!item) throw new NotFoundException(`Feature "${key}" not found in catalog`); + return item; + } + + /** عنصر واحد بالـID. */ + async findById(id: string): Promise { + const item = await this.repo.findOne({ where: { id } }); + if (!item) throw new NotFoundException(`Catalog item ${id} not found`); + return item; + } + + /** تحديث أسعار / وصف / حالة. */ + async update( + id: string, + patch: Partial>, + ): Promise { + const item = await this.findById(id); + Object.assign(item, patch); + return this.repo.save(item); + } + + /** + * حاسبة السعر لباقة + ميزات إضافية (docs/22 — N7). + * + * المُدخلات: + * - `plan`: الباقة الأساسية + * - `addOnKeys`: مفاتيح الميزات الإضافية المختارة + * - `currency`: العملة المطلوبة (لتحويل الأسعار) + * + * المُخرجات: + * - `monthly`: الرسوم الشهرية (رسوم الباقة + رسوم الميزات) + * - `setup`: الرسوم التأسيسية (مرة واحدة) + * - `items`: تفصيل كل بند + */ + async quote( + plan: TenantPlan, + addOnKeys: string[], + currency = 'JOD', + ): Promise<{ + monthly: number; + setup: number; + currency: string; + items: Array<{ key: string; name_ar: string; monthly: number; setup: number }>; + planFee: number; + }> { + const items: Array<{ key: string; name_ar: string; monthly: number; setup: number }> = []; + let totalMonthly = PLAN_MONTHLY_FEE[plan] ?? 0; + let totalSetup = 0; + + for (const key of addOnKeys) { + const item = await this.findByKey(key); + if (!item.active || item.category === 'core') continue; + items.push({ + key: item.key, + name_ar: item.name_ar, + monthly: Number(item.monthly_price), + setup: Number(item.setup_fee), + }); + totalMonthly += Number(item.monthly_price); + totalSetup += Number(item.setup_fee); + } + + return { + monthly: Number(totalMonthly.toFixed(3)), + setup: Number(totalSetup.toFixed(3)), + currency, + items, + planFee: PLAN_MONTHLY_FEE[plan] ?? 0, + }; + } + + // ─── كتالوج أنواع الرحلات العالمي (L2) ──────────────────────────────── + + /** زراعة الأنواع الأولية — آمنة للتشغيل المتكرر. */ + async seedRideTypes(): Promise { + let upserted = 0; + for (const row of RIDE_TYPE_CATALOG_SEED) { + const existing = await this.rideTypeRepo.findOne({ where: { code: row.code } }); + if (!existing) { + await this.rideTypeRepo.save(this.rideTypeRepo.create(row)); + upserted++; + } + } + if (upserted > 0) this.logger.log(`seeded ${upserted} ride type catalog item(s)`); + return upserted; + } + + /** كل الأنواع المتاحة (مع إمكانية التصفية حسب vehicle_kind). */ + async listRideTypes(vehicleKind?: string): Promise { + const where: any = {}; + if (vehicleKind) where.vehicle_kind = vehicleKind; + return this.rideTypeRepo.find({ where, order: { sort: 'ASC' } }); + } + + /** نوع واحد بالـcode. */ + async findRideTypeByCode(code: string): Promise { + const item = await this.rideTypeRepo.findOne({ where: { code } }); + if (!item) throw new NotFoundException(`Ride type "${code}" not found in global catalog`); + return item; + } + + /** نوع واحد بالـID. */ + async findRideTypeById(id: string): Promise { + const item = await this.rideTypeRepo.findOne({ where: { id } }); + if (!item) throw new NotFoundException(`Ride type catalog item ${id} not found`); + return item; + } + + /** إنشاء نوع جديد في الكتالوج العالمي. */ + async createRideType( + data: Partial>, + ): Promise { + if (!data.code || !data.name_ar) { + throw new BadRequestException('code and name_ar are required'); + } + const existing = await this.rideTypeRepo.findOne({ where: { code: data.code } }); + if (existing) throw new BadRequestException(`Ride type "${data.code}" already exists`); + return this.rideTypeRepo.save(this.rideTypeRepo.create(data)); + } + + /** تحديث نوع في الكتالوج العالمي. */ + async updateRideType( + id: string, + patch: Partial>, + ): Promise { + const item = await this.findRideTypeById(id); + Object.assign(item, patch); + return this.rideTypeRepo.save(item); + } + + /** تنشيط / تعطيل نوع. */ + async toggleRideType(id: string, active: boolean): Promise { + const item = await this.findRideTypeById(id); + item.active = active; + return this.rideTypeRepo.save(item); + } +} diff --git a/backend/src/modules/catalog/entities/feature-catalog.entity.ts b/backend/src/modules/catalog/entities/feature-catalog.entity.ts new file mode 100644 index 0000000..f9e6dea --- /dev/null +++ b/backend/src/modules/catalog/entities/feature-catalog.entity.ts @@ -0,0 +1,76 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +/** + * كتالوج الميزات القابلة للبيع (docs/22 — N6). + * + * كل ميزة لها سعر شهري + رسوم تأسيس. السوبر-أدمن يُعدّل الأسعار من اللوحة. + * الكتالوج يُغذّي: + * 1. `resolveEntitlements()` — ليعرف ما إذا كانت الميزة مشتراة + * 2. مولّد العروض (N7) — لحساب تكلفة الباقة + * 3. لوحة السوبر-거든 — لعرض الكتالوج وأسعاره + */ +@Entity('feature_catalog') +@Index(['key'], { unique: true }) +export class FeatureCatalog { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** + * المفتاح الفريد — يطابق التعريف في `features.ts`. + * مثال: `dispatch`, `wallet`, `payments`. + */ + @Column({ type: 'varchar', length: 64 }) + key: string; + + /** اسم الميزة بالعربية للعرض في لوحة السوبر-أدن. */ + @Column({ type: 'varchar' }) + name_ar: string; + + /** اسم الميزة بالإنجليزية. */ + @Column({ type: 'varchar' }) + name_en: string; + + /** + * التصنيف: `core` (أساسية — مشمولة بالباقة)، `add_on` (إضافة مدفوعة)، + * `bundle` (حزمة — مجموعة ميزات بسعر واحد). + */ + @Column({ type: 'varchar', default: 'add_on' }) + category: 'core' | 'add_on' | 'bundle'; + + /** السعر الشهري (بلا اشتراك = 0). */ + @Column({ type: 'numeric', precision: 10, scale: 3, default: 0 }) + monthly_price: number; + + /** رسوم التأسيس (مرة واحدة). */ + @Column({ type: 'numeric', precision: 10, scale: 3, default: 0 }) + setup_fee: number; + + /** العملة الافتراضية. */ + @Column({ type: 'varchar', default: 'JOD' }) + currency: string; + + /** وصف الميزة بالعربية. */ + @Column({ type: 'text', nullable: true }) + description_ar: string | null; + + /** ترتيب العرض في اللوحة. */ + @Column({ type: 'int', default: 0 }) + sort_order: number; + + /** الميزة فعّالة (يمكن تعطيلها بدون حذف). */ + @Column({ default: true }) + active: boolean; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/catalog/entities/ride-type-catalog.entity.ts b/backend/src/modules/catalog/entities/ride-type-catalog.entity.ts new file mode 100644 index 0000000..8784221 --- /dev/null +++ b/backend/src/modules/catalog/entities/ride-type-catalog.entity.ts @@ -0,0 +1,68 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +/** + * كتالوج أنواع الرحلات العالمي (L2). + * + * كل صف = نوع مركبة واحد يعرفه النظام ككل. المستأجرون يختارون + * من هذه القائمة عند إنشاء `ride_type` خاصّ بهم (tripz_ride_types). + * + * الجدول: `tripz_ride_type_catalog`. + * + * النسخ الأولي يزرعه `CatalogService.onModuleInit` — 9 فئات موحّدة + * للبلدان الثلاثة (سوري/مصري/أردني). + */ +@Entity('ride_type_catalog') +@Index(['code'], { unique: true }) +export class RideTypeCatalog { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** مفتاح فريد عالمي — economy | comfort | electric | van | ... */ + @Column() + code: string; + + @Column() + name_ar: string; + + @Column({ nullable: true }) + name_en: string; + + @Column({ default: 'car' }) + vehicle_kind: string; // car | van | scooter | bike + + @Column({ default: false }) + women_only: boolean; + + @Column({ default: true }) + round_trip_supported: boolean; + + /** هل هذا النوع يحتاج وزن إلزامي (شحن)? */ + @Column({ default: false }) + requires_weight: boolean; + + /** الحدّ الأقصى للمقاعد (4 لسيارة، 7 لفان، 1 لدراجة). */ + @Column({ type: 'int', default: 4 }) + max_seats: number; + + @Column({ nullable: true }) + icon: string; + + @Column({ type: 'int', default: 0 }) + sort: number; + + @Column({ default: true }) + active: boolean; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/catalog/ride-type-catalog.seed.ts b/backend/src/modules/catalog/ride-type-catalog.seed.ts new file mode 100644 index 0000000..ec05185 --- /dev/null +++ b/backend/src/modules/catalog/ride-type-catalog.seed.ts @@ -0,0 +1,117 @@ +/** + * البيانات الأولية لكتالوج أنواع الرحلات العالمي (L2). + * + * تُزرع تلقائياً عند بدء التشغيل إن لم تكن موجودة. الكود يطابق + * `SERVICE_CLASSES` في `default-tariffs.ts` — الفئة هنا = service_class + * في التعرفة. + */ +export const RIDE_TYPE_CATALOG_SEED = [ + { + code: 'economy', + name_ar: 'اقتصادي', + name_en: 'Economy', + vehicle_kind: 'car', + women_only: false, + round_trip_supported: true, + requires_weight: false, + max_seats: 4, + icon: 'sedan', + sort: 0, + }, + { + code: 'comfort', + name_ar: 'كومفورت', + name_en: 'Comfort', + vehicle_kind: 'car', + women_only: false, + round_trip_supported: true, + requires_weight: false, + max_seats: 4, + icon: 'sedan-premium', + sort: 1, + }, + { + code: 'electric', + name_ar: 'كهربائي', + name_en: 'Electric', + vehicle_kind: 'car', + women_only: false, + round_trip_supported: true, + requires_weight: false, + max_seats: 4, + icon: 'electric-car', + sort: 2, + }, + { + code: 'lady', + name_ar: 'سيدات', + name_en: 'Ladies', + vehicle_kind: 'car', + women_only: true, + round_trip_supported: true, + requires_weight: false, + max_seats: 4, + icon: 'car-ladies', + sort: 3, + }, + { + code: 'van', + name_ar: 'فان', + name_en: 'Van', + vehicle_kind: 'van', + women_only: false, + round_trip_supported: true, + requires_weight: false, + max_seats: 7, + icon: 'van', + sort: 4, + }, + { + code: 'vip', + name_ar: 'VIP', + name_en: 'VIP', + vehicle_kind: 'car', + women_only: false, + round_trip_supported: true, + requires_weight: false, + max_seats: 4, + icon: 'car-vip', + sort: 5, + }, + { + code: 'saver', + name_ar: 'موفر', + name_en: 'Saver', + vehicle_kind: 'car', + women_only: false, + round_trip_supported: true, + requires_weight: false, + max_seats: 4, + icon: 'car-saver', + sort: 6, + }, + { + code: 'delivery', + name_ar: 'توصيل', + name_en: 'Delivery', + vehicle_kind: 'bike', + women_only: false, + round_trip_supported: false, + requires_weight: true, + max_seats: 1, + icon: 'delivery', + sort: 7, + }, + { + code: 'fixed', + name_ar: 'خط ثابت', + name_en: 'Fixed Route', + vehicle_kind: 'car', + women_only: false, + round_trip_supported: true, + requires_weight: false, + max_seats: 4, + icon: 'route', + sort: 8, + }, +] as const; diff --git a/backend/src/modules/drivers/admin-tier.controller.ts b/backend/src/modules/drivers/admin-tier.controller.ts new file mode 100644 index 0000000..b392210 --- /dev/null +++ b/backend/src/modules/drivers/admin-tier.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Get, Post, Param, UseGuards, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard'; +import { TierCalculatorService } from './tier-calculator.service'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Driver } from './entities/driver.entity'; + +@ApiTags('admin-drivers') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard) +@Roles('admin') +@Controller('admin/drivers') +@RequiresFeature('driver_tiers') +export class AdminTierController { + constructor( + private readonly tierCalculator: TierCalculatorService, + @InjectRepository(Driver) + private readonly driverRepo: Repository, + ) {} + + @Get('tiers') + async getTierSummary(@CurrentUser() user: AuthUser) { + const drivers = await this.driverRepo.find({ + where: { tenant_id: user.tenantId, verification_status: 'approved' }, + }); + + const summary = { + total: drivers.length, + by_tier: { + platinum: drivers.filter(d => d.tier === 'platinum').length, + gold: drivers.filter(d => d.tier === 'gold').length, + silver: drivers.filter(d => d.tier === 'silver').length, + bronze: drivers.filter(d => d.tier === 'bronze').length, + }, + }; + + return summary; + } + + @Get('tiers/:driverId') + async getDriverTier(@Param('driverId') driverId: string) { + return this.tierCalculator.getDriverTierInfo(driverId); + } + + @Post('tiers/recalculate/:driverId') + @HttpCode(HttpStatus.OK) + async recalculateDriverTier(@Param('driverId') driverId: string) { + return this.tierCalculator.updateDriverTier(driverId); + } + + @Post('tiers/recalculate-all') + @HttpCode(HttpStatus.OK) + async recalculateAllTiers() { + return this.tierCalculator.batchUpdateAllDrivers(); + } +} diff --git a/backend/src/modules/drivers/drivers.module.ts b/backend/src/modules/drivers/drivers.module.ts index e6deae8..302cd58 100644 --- a/backend/src/modules/drivers/drivers.module.ts +++ b/backend/src/modules/drivers/drivers.module.ts @@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Driver } from './entities/driver.entity'; import { DriversService } from './drivers.service'; import { DriversController } from './drivers.controller'; +import { AdminTierController } from './admin-tier.controller'; +import { TierCalculatorService } from './tier-calculator.service'; import { UsersModule } from '../users/users.module'; import { LocationsModule } from '../locations/locations.module'; import { CreditModule } from '../credit/credit.module'; @@ -14,8 +16,8 @@ import { CreditModule } from '../credit/credit.module'; LocationsModule, forwardRef(() => CreditModule), ], - controllers: [DriversController], - providers: [DriversService], - exports: [DriversService], + controllers: [DriversController, AdminTierController], + providers: [DriversService, TierCalculatorService], + exports: [DriversService, TierCalculatorService], }) export class DriversModule {} diff --git a/backend/src/modules/drivers/drivers.service.ts b/backend/src/modules/drivers/drivers.service.ts index 4c18d34..e8db682 100644 --- a/backend/src/modules/drivers/drivers.service.ts +++ b/backend/src/modules/drivers/drivers.service.ts @@ -119,6 +119,15 @@ export class DriversService { ); } + async incrementTripCount(tenantId: string, driverId: string): Promise { + await this.repo + .createQueryBuilder() + .update(Driver) + .set({ total_trips: () => 'total_trips + 1' }) + .where('tenant_id = :tenantId AND id = :driverId', { tenantId, driverId }) + .execute(); + } + async approve(tenantId: string, driverId: string): Promise { const driver = await this.findById(tenantId, driverId); if (!driver) throw new NotFoundException('Driver not found'); diff --git a/backend/src/modules/drivers/entities/driver.entity.ts b/backend/src/modules/drivers/entities/driver.entity.ts index 8bba803..428c145 100644 --- a/backend/src/modules/drivers/entities/driver.entity.ts +++ b/backend/src/modules/drivers/entities/driver.entity.ts @@ -9,6 +9,7 @@ import { import { EncryptedTransformer } from '../../../common/crypto/crypto.util'; export type VerificationStatus = 'pending' | 'approved' | 'rejected'; +export type DriverTier = 'bronze' | 'silver' | 'gold' | 'platinum'; /** * السائق = مستخدم بدور driver + بيانات مركبة ووثائق. الجدول: tripz_drivers. @@ -54,6 +55,18 @@ export class Driver { @Column({ type: 'numeric', precision: 3, scale: 2, default: 5 }) rating: number; + @Column({ type: 'varchar', default: 'bronze' }) + tier: DriverTier; + + @Column({ type: 'int', default: 0 }) + tier_score: number; + + @Column({ type: 'int', default: 0 }) + total_trips: number; + + @Column({ type: 'numeric', precision: 5, scale: 2, default: 100 }) + acceptance_rate: number; + // ---- لقطة الموقع (مكافئ `car_locations` عند سيرو) ---- // الموقع الحيّ يعيش في Redis؛ هذه الأعمدة **يكتبها الـworker دورياً فقط**، // لا الطلب. لا تقرأها للحيّ — استخدم DriverLocationService (docs/17 — H1/H3). diff --git a/backend/src/modules/drivers/tier-calculator.service.ts b/backend/src/modules/drivers/tier-calculator.service.ts new file mode 100644 index 0000000..c3a1b53 --- /dev/null +++ b/backend/src/modules/drivers/tier-calculator.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Driver, DriverTier } from './entities/driver.entity'; + +interface TierThresholds { + platinum_min_score: number; + gold_min_score: number; + silver_min_score: number; + bronze_min_score: number; + min_trips: number; + min_acceptance: number; + min_rating: number; +} + +const DEFAULT_THRESHOLDS: TierThresholds = { + platinum_min_score: 1000, + gold_min_score: 800, + silver_min_score: 400, + bronze_min_score: 0, + min_trips: 10, + min_acceptance: 80, + min_rating: 4.5, +}; + +@Injectable() +export class TierCalculatorService { + private readonly logger = new Logger(TierCalculatorService.name); + + constructor( + @InjectRepository(Driver) + private readonly driverRepo: Repository, + ) {} + + async calculateScore(driverId: string): Promise { + const driver = await this.driverRepo.findOne({ where: { id: driverId } }); + if (!driver) return 0; + + const ratingScore = (driver.rating / 5) * 400; + const tripsScore = Math.min(driver.total_trips * 10, 300); + const acceptanceScore = (driver.acceptance_rate / 100) * 300; + + return Math.round(ratingScore + tripsScore + acceptanceScore); + } + + async calculateTier(score: number): Promise { + if (score >= DEFAULT_THRESHOLDS.platinum_min_score) return 'platinum'; + if (score >= DEFAULT_THRESHOLDS.gold_min_score) return 'gold'; + if (score >= DEFAULT_THRESHOLDS.silver_min_score) return 'silver'; + return 'bronze'; + } + + async updateDriverTier(driverId: string): Promise<{ tier: DriverTier; score: number }> { + const score = await this.calculateScore(driverId); + const tier = await this.calculateTier(score); + + await this.driverRepo.update(driverId, { tier, tier_score: score }); + this.logger.log(`Driver ${driverId} updated to ${tier} (score: ${score})`); + + return { tier, score }; + } + + async batchUpdateAllDrivers(): Promise<{ updated: number }> { + const drivers = await this.driverRepo.find({ + where: { verification_status: 'approved' }, + select: ['id'], + }); + + let updated = 0; + for (const driver of drivers) { + await this.updateDriverTier(driver.id); + updated++; + } + + this.logger.log(`Batch updated ${updated} driver tiers`); + return { updated }; + } + + async getDriverTierInfo(driverId: string): Promise<{ + tier: DriverTier; + score: number; + total_trips: number; + rating: number; + acceptance_rate: number; + }> { + const driver = await this.driverRepo.findOne({ where: { id: driverId } }); + if (!driver) throw new Error('Driver not found'); + + return { + tier: driver.tier, + score: driver.tier_score, + total_trips: driver.total_trips, + rating: driver.rating, + acceptance_rate: driver.acceptance_rate, + }; + } +} diff --git a/backend/src/modules/geofence/geofence-admin.controller.ts b/backend/src/modules/geofence/geofence-admin.controller.ts new file mode 100644 index 0000000..3830fb4 --- /dev/null +++ b/backend/src/modules/geofence/geofence-admin.controller.ts @@ -0,0 +1,108 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { GeofenceService } from './geofence.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; + +/** + * إدارة المناطق الجغرافية — أدمن المستأجر (M3). + * + * خلف `JwtAuthGuard + RolesGuard` — فقط admin/dispatcher يمكنه إدارة المناطق. + */ +@ApiTags('geofence-admin') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin', 'dispatcher') +@Controller('admin/geofence') +export class GeofenceAdminController { + constructor(private readonly geofence: GeofenceService) {} + + /** كل المناطق. */ + @Get('zones') + listZones( + @CurrentUser() user: AuthUser, + @Query('active') active?: string, + ) { + return this.geofence.listZones(user.tenantId, active === 'true'); + } + + /** منطقة واحدة. */ + @Get('zones/:id') + getZone( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + ) { + return this.geofence.findZoneById(user.tenantId, id); + } + + /** إنشاء منطقة. */ + @Post('zones') + createZone( + @CurrentUser() user: AuthUser, + @Body() body: { + name: string; + latitude: number; + longitude: number; + radius_meters: number; + priority?: number; + pricing_multiplier?: number; + campaign_title?: string; + campaign_body?: string; + }, + ) { + return this.geofence.createZone(user.tenantId, body); + } + + /** تحديث منطقة. */ + @Patch('zones/:id') + updateZone( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { + name?: string; + latitude?: number; + longitude?: number; + radius_meters?: number; + priority?: number; + is_active?: boolean; + pricing_multiplier?: number; + campaign_title?: string; + campaign_body?: string; + }, + ) { + return this.geofence.updateZone(user.tenantId, id, body); + } + + /** حذف منطقة. */ + @Delete('zones/:id') + deleteZone( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + ) { + return this.geofence.deleteZone(user.tenantId, id); + } + + /** تنشيط / تعطيل. */ + @Patch('zones/:id/toggle') + toggleZone( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { active: boolean }, + ) { + return this.geofence.toggleZone(user.tenantId, id, body.active); + } + + /** سجل الحملات. */ + @Get('campaigns') + campaignHistory( + @CurrentUser() user: AuthUser, + @Query('limit') limit?: string, + ) { + return this.geofence.campaignHistory( + user.tenantId, + limit ? parseInt(limit, 10) : 50, + ); + } +} diff --git a/backend/src/modules/geofence/geofence.module.ts b/backend/src/modules/geofence/geofence.module.ts index 9878f94..c6b24da 100644 --- a/backend/src/modules/geofence/geofence.module.ts +++ b/backend/src/modules/geofence/geofence.module.ts @@ -4,6 +4,7 @@ import { GeofenceZone } from './entities/geofence-zone.entity'; import { CampaignLog } from './entities/campaign-log.entity'; import { GeofenceService } from './geofence.service'; import { GeofenceController } from './geofence.controller'; +import { GeofenceAdminController } from './geofence-admin.controller'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ @@ -12,7 +13,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; NotificationsModule, ], providers: [GeofenceService], - controllers: [GeofenceController], + controllers: [GeofenceController, GeofenceAdminController], exports: [GeofenceService], }) export class GeofenceModule {} diff --git a/backend/src/modules/geofence/geofence.service.ts b/backend/src/modules/geofence/geofence.service.ts index 25bda28..8c7aeda 100644 --- a/backend/src/modules/geofence/geofence.service.ts +++ b/backend/src/modules/geofence/geofence.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { GeofenceZone } from './entities/geofence-zone.entity'; @@ -106,4 +106,75 @@ export class GeofenceService { const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } + + // ─── إدارة المناطق — أدمن المستأجر (M3) ──────────────────────────── + + /** كل المناطق (مع إمكانية التصفية حسب الحالة). */ + async listZones(tenantId: string, activeOnly = false): Promise { + const where: any = { tenant_id: tenantId }; + if (activeOnly) where.is_active = true; + return this.zonesRepo.find({ where, order: { priority: 'DESC', name: 'ASC' } }); + } + + /** منطقة واحدة. */ + async findZoneById(tenantId: string, id: string): Promise { + const zone = await this.zonesRepo.findOne({ where: { tenant_id: tenantId, id } }); + if (!zone) throw new NotFoundException(`Geofence zone ${id} not found`); + return zone; + } + + /** إنشاء منطقة جديدة. */ + async createZone( + tenantId: string, + data: Partial>, + ): Promise { + if (!data.name) throw new BadRequestException('name is required'); + if (data.latitude == null || data.longitude == null) { + throw new BadRequestException('latitude and longitude are required'); + } + if (data.radius_meters == null || data.radius_meters <= 0) { + throw new BadRequestException('radius_meters must be positive'); + } + return this.zonesRepo.save(this.zonesRepo.create({ tenant_id: tenantId, ...data })); + } + + /** تحديث منطقة. */ + async updateZone( + tenantId: string, + id: string, + patch: Partial>, + ): Promise { + const zone = await this.findZoneById(tenantId, id); + Object.assign(zone, patch); + return this.zonesRepo.save(zone); + } + + /** حذف منطقة. */ + async deleteZone(tenantId: string, id: string): Promise { + const zone = await this.findZoneById(tenantId, id); + await this.zonesRepo.remove(zone); + } + + /** تنشيط / تعطيل منطقة. */ + async toggleZone(tenantId: string, id: string, active: boolean): Promise { + const zone = await this.findZoneById(tenantId, id); + zone.is_active = active; + return this.zonesRepo.save(zone); + } + + /** إحصائيات الحملات — آخر N إرسالاً لمناطق هذا المستأجر. */ + async campaignHistory(tenantId: string, limit = 50) { + return this.campaignLogRepo + .createQueryBuilder('log') + .innerJoin(GeofenceZone, 'z', 'z.id = log.zone_id AND z.tenant_id = :tenantId', { tenantId }) + .select('log.id', 'id') + .addSelect('log.user_id', 'user_id') + .addSelect('log.zone_id', 'zone_id') + .addSelect('z.name', 'zone_name') + .addSelect('log.user_type', 'user_type') + .addSelect('log.sent_at', 'sent_at') + .orderBy('log.sent_at', 'DESC') + .limit(limit) + .getRawMany(); + } } diff --git a/backend/src/modules/locations/heatmap.controller.ts b/backend/src/modules/locations/heatmap.controller.ts new file mode 100644 index 0000000..1d566eb --- /dev/null +++ b/backend/src/modules/locations/heatmap.controller.ts @@ -0,0 +1,55 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { HeatmapService } from './heatmap.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard'; + +/** + * الخريطة الحرارية — أدمن المستأجر (O1). + * + * تُظهر توزيع السائقين الأحياء على الخريطة. + */ +@ApiTags('heatmap') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard) +@Roles('admin', 'dispatcher') +@Controller('admin/heatmap') +@RequiresFeature('market_intel') +export class HeatmapController { + constructor(private readonly heatmap: HeatmapService) {} + + /** الخريطة الحرارية — آخر N دقيقة. */ + @Get() + getHeatmap( + @CurrentUser() user: AuthUser, + @Query('minutes') minutes?: string, + @Query('minLat') minLat?: string, + @Query('maxLat') maxLat?: string, + @Query('minLng') minLng?: string, + @Query('maxLng') maxLng?: string, + ) { + const m = Number(minutes); + const duration = Number.isFinite(m) && m > 0 ? Math.min(m, 1440) : 15; + + const bounds = + minLat && maxLat && minLng && maxLng + ? { + minLat: parseFloat(minLat), + maxLat: parseFloat(maxLat), + minLng: parseFloat(minLng), + maxLng: parseFloat(maxLng), + } + : undefined; + + return this.heatmap.generate(user.tenantId, duration, bounds); + } + + /** مسح الكاش (للتطوير أو بعد تحديث كبير). */ + @Get('invalidate') + invalidate(@CurrentUser() user: AuthUser) { + return this.heatmap.invalidate(user.tenantId); + } +} diff --git a/backend/src/modules/locations/heatmap.service.ts b/backend/src/modules/locations/heatmap.service.ts new file mode 100644 index 0000000..800fd2d --- /dev/null +++ b/backend/src/modules/locations/heatmap.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Inject } from '@nestjs/common'; +import { Repository, MoreThan } from 'typeorm'; +import Redis from 'ioredis'; +import { DriverTrack } from './entities/driver-track.entity'; +import { REDIS } from '../../common/redis/redis.module'; + +/** + * خدمة الخريطة الحرارية (O1). + * + * تقرأ نقاط المسار من `driver_tracks` وتجمّعها في خلايا شبكية + * (grid buckets) — كل خلية 0.01° × 0.01° ≈ 1.1km × 1.1km. + * + * النتائج تُكاش في Redis لمدة 5 دقائق — لا إعادة حساب في كل طلب. + * + * الاستخدام: + * - لوحة الأدمن: رؤية توزيع السائقين الأحياء حياً + * - استخبار السوق: تغذية `cron_surge_opportunity` + * - الخريطة التنبؤية: أساس لتاريخ الطلب + */ +@Injectable() +export class HeatmapService { + private readonly logger = new Logger('Heatmap'); + + /** حجم الخلية بالدرجات — 0.01° ≈ 1.1km. */ + private static readonly CELL_SIZE = 0.01; + /** مدة الكاش بالثواني. */ + private static readonly CACHE_TTL = 300; // 5 دقائق + /** مفتاح الكاش. */ + private static readonly CACHE_PREFIX = 'heatmap:'; + + constructor( + @InjectRepository(DriverTrack) + private readonly tracks: Repository, + @Inject(REDIS) private readonly redis: Redis, + ) {} + + /** + * توليد الخريطة الحرارية لمستأجر. + * + * @param tenantId معرّف المستأجر + * @param minutes آخر N دقيقة (افتراضي 15) + * @param bounds حدود الخريطة (اختياري — لتحديد منطقة محددة) + * @returns مصفوفة خلايا: `{ cell: "lat,lng", count, lat, lng }` + */ + async generate( + tenantId: string, + minutes = 15, + bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number }, + ): Promise> { + // التحقق من الكاش أولاً + const cacheKey = `${HeatmapService.CACHE_PREFIX}${tenantId}:${minutes}`; + const cached = await this.redis.get(cacheKey); + if (cached) { + try { + return JSON.parse(cached); + } catch { /* كسر كاش — نحسب من جديد */ } + } + + const since = new Date(Date.now() - minutes * 60_000); + + // استعلام نقاط المسار — فقط السائقين النشطين + const query = this.tracks + .createQueryBuilder('t') + .select('t.lat', 'lat') + .addSelect('t.lng', 'lng') + .where('t.tenant_id = :tenantId', { tenantId }) + .andWhere('t.recorded_at > :since', { since }); + + if (bounds) { + query + .andWhere('t.lat >= :minLat', { minLat: bounds.minLat }) + .andWhere('t.lat <= :maxLat', { maxLat: bounds.maxLat }) + .andWhere('t.lng >= :minLng', { minLng: bounds.minLng }) + .andWhere('t.lng <= :maxLng', { maxLng: bounds.maxLng }); + } + + const points = await query.getRawMany<{ lat: number; lng: number }>(); + + // تجميع في خلايا شبكية + const cells = new Map(); + + for (const p of points) { + const cellLat = Math.floor(Number(p.lat) / HeatmapService.CELL_SIZE) * HeatmapService.CELL_SIZE; + const cellLng = Math.floor(Number(p.lng) / HeatmapService.CELL_SIZE) * HeatmapService.CELL_SIZE; + const key = `${cellLat.toFixed(4)},${cellLng.toFixed(4)}`; + + const existing = cells.get(key); + if (existing) { + existing.count++; + } else { + cells.set(key, { + count: 1, + lat: cellLat + HeatmapService.CELL_SIZE / 2, + lng: cellLng + HeatmapService.CELL_SIZE / 2, + }); + } + } + + const result = Array.from(cells.entries()).map(([cell, data]) => ({ + cell, + ...data, + })); + + // كاش النتيجة + await this.redis.setex(cacheKey, HeatmapService.CACHE_TTL, JSON.stringify(result)); + + if (result.length > 0) { + this.logger.log(`heatmap ${tenantId}: ${points.length} points → ${result.length} cells (${minutes}min)`); + } + + return result; + } + + /** + * إ invalidated الكاش يدوياً (عند تحديث البيانات أو للتطوير). + */ + async invalidate(tenantId: string): Promise { + const pattern = `${HeatmapService.CACHE_PREFIX}${tenantId}:*`; + const keys = await this.redis.keys(pattern); + if (keys.length > 0) { + await this.redis.del(...keys); + } + } +} diff --git a/backend/src/modules/locations/locations.module.ts b/backend/src/modules/locations/locations.module.ts index dffc171..70cc820 100644 --- a/backend/src/modules/locations/locations.module.ts +++ b/backend/src/modules/locations/locations.module.ts @@ -4,16 +4,19 @@ import { Driver } from '../drivers/entities/driver.entity'; import { DriverTrack } from './entities/driver-track.entity'; import { DriverLocationService } from './driver-location.service'; import { LocationFlusherService } from './location-flusher.service'; +import { HeatmapService } from './heatmap.service'; +import { HeatmapController } from './heatmap.controller'; import { MatchingModule } from '../matching/matching.module'; import { GeofenceModule } from '../geofence/geofence.module'; /** - * وحدة مستقلة عمداً: تستورد MatchingModule، وتستوردها DriversModule و - * TripsModule — لو عاشت داخل drivers لصارت دورة استيراد مع matching. + * وحدة المستخدمات — موقع + خرائط حرارية. + * تستورد MatchingModule وGeofenceModule، وتستوردها DriversModule وTripsModule. */ @Module({ imports: [TypeOrmModule.forFeature([Driver, DriverTrack]), MatchingModule, GeofenceModule], - providers: [DriverLocationService, LocationFlusherService], - exports: [DriverLocationService, LocationFlusherService], + providers: [DriverLocationService, LocationFlusherService, HeatmapService], + controllers: [HeatmapController], + exports: [DriverLocationService, LocationFlusherService, HeatmapService], }) export class LocationsModule {} diff --git a/backend/src/modules/marketing/entities/campaign.entity.ts b/backend/src/modules/marketing/entities/campaign.entity.ts new file mode 100644 index 0000000..5681ef6 --- /dev/null +++ b/backend/src/modules/marketing/entities/campaign.entity.ts @@ -0,0 +1,55 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export type CampaignType = 'push_broadcast' | 're_engagement' | 'promo' | 'auto_marketing'; +export type CampaignStatus = 'draft' | 'scheduled' | 'running' | 'completed' | 'cancelled'; + +@Entity('marketing_campaigns') +export class Campaign { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'varchar' }) + name: string; + + @Column({ type: 'varchar' }) + type: CampaignType; + + @Column({ type: 'varchar', default: 'draft' }) + status: CampaignStatus; + + @Column({ type: 'text', nullable: true }) + title: string; + + @Column({ type: 'text', nullable: true }) + body: string; + + @Column({ type: 'jsonb', default: {} }) + data: Record; + + @Column({ type: 'jsonb', default: {} }) + targeting: Record; + + @Column({ type: 'int', default: 0 }) + sent_count: number; + + @Column({ type: 'int', default: 0 }) + open_count: number; + + @Column({ type: 'int', default: 0 }) + click_count: number; + + @Column({ type: 'timestamptz', nullable: true }) + scheduled_at: Date; + + @Column({ type: 'timestamptz', nullable: true }) + completed_at: Date; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/marketing/marketing.controller.ts b/backend/src/modules/marketing/marketing.controller.ts new file mode 100644 index 0000000..9603c7d --- /dev/null +++ b/backend/src/modules/marketing/marketing.controller.ts @@ -0,0 +1,96 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard'; +import { MarketingService } from './marketing.service'; +import { Campaign, CampaignType } from './entities/campaign.entity'; + +@ApiTags('marketing') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard) +@Roles('admin') +@Controller('admin/marketing') +@RequiresFeature('marketing_engine') +export class MarketingController { + constructor(private readonly marketingService: MarketingService) {} + + @Get('campaigns') + async listCampaigns(@CurrentUser() user: AuthUser) { + return this.marketingService.findAll(user.tenantId); + } + + @Get('campaigns/:id') + async getCampaign(@Param('id') id: string) { + return this.marketingService.findOne(id); + } + + @Post('campaigns') + async createCampaign( + @CurrentUser() user: AuthUser, + @Body() body: { + name: string; + type: CampaignType; + title?: string; + body?: string; + data?: Record; + targeting?: Record; + scheduled_at?: string; + }, + ) { + return this.marketingService.create({ + tenant_id: user.tenantId, + ...body, + scheduled_at: body.scheduled_at ? new Date(body.scheduled_at) : undefined, + }); + } + + @Patch('campaigns/:id/status') + async updateStatus( + @Param('id') id: string, + @Body() body: { status: 'draft' | 'scheduled' | 'cancelled' }, + ) { + return this.marketingService.updateStatus(id, body.status); + } + + @Post('campaigns/:id/run') + @HttpCode(HttpStatus.OK) + async runCampaign( + @Param('id') id: string, + @Body() body: { userIds: string[] }, + ) { + return this.marketingService.runCampaign(id, body.userIds); + } + + @Get('campaigns/:id/stats') + async getCampaignStats(@Param('id') id: string) { + return this.marketingService.getCampaignStats(id); + } + + @Post('campaigns/:id/events') + @HttpCode(HttpStatus.OK) + async recordEvent( + @Param('id') id: string, + @Body() body: { event: 'open' | 'click' }, + ) { + await this.marketingService.recordEvent(id, body.event); + return { ok: true }; + } + + @Post('generate-content') + @HttpCode(HttpStatus.OK) + async generateContent( + @CurrentUser() user: AuthUser, + @Body() body: { prompt: string }, + ) { + return this.marketingService.generateContent(user.tenantId, body.prompt); + } + + @Delete('campaigns/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteCampaign(@Param('id') id: string) { + await this.marketingService.deleteCampaign(id); + } +} diff --git a/backend/src/modules/marketing/marketing.module.ts b/backend/src/modules/marketing/marketing.module.ts new file mode 100644 index 0000000..42f8842 --- /dev/null +++ b/backend/src/modules/marketing/marketing.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Campaign } from './entities/campaign.entity'; +import { MarketingService } from './marketing.service'; +import { MarketingController } from './marketing.controller'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { GeminiModule } from '../../integrations/gemini/gemini.module'; +import { Trip } from '../trips/entities/trip.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Campaign, Trip]), + NotificationsModule, + GeminiModule, + ], + controllers: [MarketingController], + providers: [MarketingService], + exports: [MarketingService], +}) +export class MarketingModule {} diff --git a/backend/src/modules/marketing/marketing.service.ts b/backend/src/modules/marketing/marketing.service.ts new file mode 100644 index 0000000..e76a8ab --- /dev/null +++ b/backend/src/modules/marketing/marketing.service.ts @@ -0,0 +1,190 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual, Not, In } from 'typeorm'; +import { Campaign, CampaignStatus, CampaignType } from './entities/campaign.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { GeminiService } from '../../integrations/gemini/gemini.service'; +import { Trip } from '../trips/entities/trip.entity'; + +@Injectable() +export class MarketingService { + private readonly logger = new Logger(MarketingService.name); + + constructor( + @InjectRepository(Campaign) + private readonly campaignRepo: Repository, + @InjectRepository(Trip) + private readonly tripsRepo: Repository, + private readonly notifications: NotificationsService, + private readonly gemini: GeminiService, + ) {} + + async create(data: Partial): Promise { + const campaign = this.campaignRepo.create(data); + return this.campaignRepo.save(campaign); + } + + async findAll(tenantId: string): Promise { + return this.campaignRepo.find({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + }); + } + + async findOne(id: string): Promise { + return this.campaignRepo.findOne({ where: { id } }); + } + + async updateStatus(id: string, status: CampaignStatus): Promise { + await this.campaignRepo.update(id, { status }); + return this.campaignRepo.findOneOrFail({ where: { id } }); + } + + async generateContent(tenantId: string, prompt: string): Promise<{ title: string; body: string }> { + if (!this.gemini.enabled) { + return { title: 'Ride with us!', body: 'Get 20% off your next trip.' }; + } + + const systemPrompt = [ + 'أنت كاتب محتوى تسويقي لتطبيق رحلات (ride-hailing).', + 'اكتب رسالة إشعار جذابة وقصيرة (سطرين-ثلاثة).', + 'الرد بالعربية.', + '', + 'المطلوب:', + prompt, + '', + 'أعِد JSON فقط: {"title": "العنوان", "body": "النص"}', + ].join('\n'); + + const result = await this.generateJson(systemPrompt); + return { title: result.title || 'Ride with us!', body: result.body || 'Get 20% off your next trip.' }; + } + + private async generateJson(prompt: string): Promise { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=${process.env.GEMINI_API_KEY}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature: 0.7, responseMimeType: 'application/json' }, + }), + }, + ); + if (!res.ok) return { title: 'Ride with us!', body: 'Get 20% off your next trip.' }; + const data: any = await res.json(); + const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}'; + try { + return JSON.parse(text.replace(/^```json\s*|\s*```$/g, '').trim()); + } catch { + return { title: 'Ride with us!', body: 'Get 20% off your next trip.' }; + } + } + + async runCampaign(campaignId: string, userIds: string[]): Promise<{ sent: number }> { + const campaign = await this.campaignRepo.findOne({ where: { id: campaignId } }); + if (!campaign) throw new Error('Campaign not found'); + + await this.campaignRepo.update(campaignId, { status: 'running' }); + + let sent = 0; + for (const userId of userIds) { + try { + await this.notifications.sendToUser( + campaign.tenant_id, + userId, + campaign.title || 'Notification', + campaign.body || '', + { campaign_id: campaignId, ...campaign.data }, + ); + sent++; + } catch (e: any) { + this.logger.warn(`Failed to send to ${userId}: ${e?.message}`); + } + } + + await this.campaignRepo.update(campaignId, { + status: 'completed', + sent_count: sent, + completed_at: new Date(), + }); + + return { sent }; + } + + async getCampaignStats(campaignId: string): Promise<{ + sent: number; + open_rate: number; + click_rate: number; + }> { + const campaign = await this.campaignRepo.findOne({ where: { id: campaignId } }); + if (!campaign) throw new Error('Campaign not found'); + + const openRate = campaign.sent_count > 0 ? (campaign.open_count / campaign.sent_count) * 100 : 0; + const clickRate = campaign.sent_count > 0 ? (campaign.click_count / campaign.sent_count) * 100 : 0; + + return { + sent: campaign.sent_count, + open_rate: Math.round(openRate * 100) / 100, + click_rate: Math.round(clickRate * 100) / 100, + }; + } + + async recordEvent(campaignId: string, event: 'open' | 'click'): Promise { + if (event === 'open') { + await this.campaignRepo.increment({ id: campaignId }, 'open_count', 1); + } else if (event === 'click') { + await this.campaignRepo.increment({ id: campaignId }, 'click_count', 1); + } + } + + async findDueReEngagement(tenantId: string, inactiveDays = 3): Promise { + const where: any = { + type: 're_engagement', + status: 'scheduled', + scheduled_at: LessThanOrEqual(new Date()), + }; + if (tenantId !== '*') { + where.tenant_id = tenantId; + } + + return this.campaignRepo.find({ where }); + } + + async deleteCampaign(id: string): Promise { + await this.campaignRepo.delete(id); + } + + /** + * جلب معرّفات المستخدمين الذين لم ي新开وا رحلة منذ N يوم. + * يُستخدم لاستهداف حملات إعادة التفاعل فعلياً بدل القائمة الفارغة. + */ + async findInactiveUserIds(tenantId: string, inactiveDays = 3): Promise { + const cutoff = new Date(Date.now() - inactiveDays * 86_400_000); + + // المستخدمون الذين لديهم رحلة أحدث من cutoff + const activeSubquery = this.tripsRepo + .createQueryBuilder('t') + .select('t.rider_id') + .where('t.tenant_id = :tenantId', { tenantId }) + .andWhere('t.requested_at > :cutoff', { cutoff }); + + // جلب كل المستخدمين الفريددين في هذا المستأجر + const allRiders = await this.tripsRepo + .createQueryBuilder('t') + .select('DISTINCT t.rider_id', 'rider_id') + .where('t.tenant_id = :tenantId', { tenantId }) + .andWhere('t.rider_id IS NOT NULL') + .getRawMany<{ rider_id: string }>(); + + const allIds = allRiders.map((r) => r.rider_id).filter(Boolean); + if (allIds.length === 0) return []; + + // استبعاد من لديهم رحلة حديثة + const activeIds = await activeSubquery.getRawMany<{ rider_id: string }>(); + const activeSet = new Set(activeIds.map((r) => r.rider_id)); + + return allIds.filter((id) => !activeSet.has(id)); + } +} diff --git a/backend/src/modules/matching/matching.module.ts b/backend/src/modules/matching/matching.module.ts index 00cab56..d40d2bb 100644 --- a/backend/src/modules/matching/matching.module.ts +++ b/backend/src/modules/matching/matching.module.ts @@ -1,7 +1,10 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { MatchingService } from './matching.service'; +import { Driver } from '../drivers/entities/driver.entity'; @Module({ + imports: [TypeOrmModule.forFeature([Driver])], providers: [MatchingService], exports: [MatchingService], }) diff --git a/backend/src/modules/matching/matching.service.ts b/backend/src/modules/matching/matching.service.ts index 9b29a62..39d0cb3 100644 --- a/backend/src/modules/matching/matching.service.ts +++ b/backend/src/modules/matching/matching.service.ts @@ -1,35 +1,38 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import { REDIS } from '../../common/redis/redis.module'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Driver, DriverTier } from '../drivers/entities/driver.entity'; export interface NearbyDriver { driverId: string; distanceKm: number; + tier?: DriverTier; + tierScore?: number; } -/** توفّر السائق — يحدّد الفهرس الذي يعيش فيه (docs/17 — H5). */ export type DriverAvailability = 'available' | 'busy' | 'off'; -/** - * المطابقة الجغرافية عبر Redis GEO (docs/09). مفتاح لكل - * (مستأجر × فئة خدمة × توفّر): geo:drivers:{tenantId}:{class}:{available|busy} - * (ببادئة tripz: تلقائياً من ioredis). - * - * فصل available عن busy (نمط سيرو): البحث يمسح المتاحين فقط بدل أن يجلب - * الجميع ثم يصفّي المشغولين. - */ +const TIER_PRIORITY: Record = { + platinum: 4, + gold: 3, + silver: 2, + bronze: 1, +}; + @Injectable() export class MatchingService { - constructor(@Inject(REDIS) private readonly redis: Redis) {} + constructor( + @Inject(REDIS) private readonly redis: Redis, + @InjectRepository(Driver) + private readonly driverRepo: Repository, + ) { } private key(tenantId: string, serviceClass: string, status: 'available' | 'busy'): string { return `geo:drivers:${tenantId}:${serviceClass}:${status}`; } - /** - * يضع السائق في الفهرس الموافق لحالته ويزيله من الآخر — عملية واحدة - * تمنع بقاءه في الفهرسين معاً. - */ async setPosition( tenantId: string, serviceClass: string, @@ -59,13 +62,12 @@ export class MatchingService { .exec(); } - /** أقرب السائقين **المتاحين** من نفس فئة الخدمة ضمن نصف قطر (كم). */ async findNearby( tenantId: string, serviceClass: string, lat: number, lng: number, - radiusKm = 5, + radiusKm = 3, count = 10, ): Promise { const res = (await this.redis.call( @@ -79,14 +81,35 @@ export class MatchingService { 'km', 'ASC', 'COUNT', - String(count), + String(count * 2), 'WITHDIST', )) as [string, string][]; - if (!Array.isArray(res)) return []; - return res.map(([driverId, dist]) => ({ + if (!Array.isArray(res) || res.length === 0) return []; + + const driverIds = res.map(([id]) => id); + const drivers = await this.driverRepo.find({ + where: driverIds.map(id => ({ id })), + select: ['id', 'tier', 'tier_score'], + }); + const tierMap = new Map(drivers.map(d => [d.id, { tier: d.tier, tier_score: d.tier_score }])); + + const enriched = res.map(([driverId, dist]) => ({ driverId, distanceKm: Number(Number(dist).toFixed(3)), + tier: tierMap.get(driverId)?.tier || 'bronze' as DriverTier, + tierScore: tierMap.get(driverId)?.tier_score || 0, })); + + // المسافة هي المعيار الأساسي. الشريحة كسر تعادل فقط ضمن نطاق ±0.5 كم: + // سائق برونزياً على 200 م يسبق بلاتينياً على 9 كم. + const TIER_TOLERANCE_KM = 0.5; + enriched.sort((a, b) => { + const distDiff = a.distanceKm - b.distanceKm; + if (Math.abs(distDiff) > TIER_TOLERANCE_KM) return distDiff; + return (TIER_PRIORITY[b.tier!] || 0) - (TIER_PRIORITY[a.tier!] || 0); + }); + + return enriched.slice(0, count); } } diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index 7e026cf..41ffb9b 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -6,6 +6,8 @@ import { DeviceToken } from './entities/device-token.entity'; import { I18nService } from '../../common/i18n/i18n.service'; import { UsersService } from '../users/users.service'; import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service'; +import * as admin from 'firebase-admin'; +import * as path from 'path'; export interface PushOptions { /** رسالة بيانات فقط — لا يعرضها النظام، يتولّاها التطبيق (overlay أندرويد، docs/17 A7). */ @@ -24,7 +26,7 @@ interface CachedToken { * بلا FCM_SERVER_KEY: يُسجَّل فقط دون إرسال. */ @Injectable() -export class NotificationsService { +export class NotificationsService implements OnModuleInit { private readonly logger = new Logger('Notifications'); constructor( @@ -35,6 +37,23 @@ export class NotificationsService { private readonly cache: CacheService, ) {} + onModuleInit() { + const credentialsPath = this.config.get('fcm.credentialsPath'); + if (credentialsPath && admin.apps.length === 0) { + try { + const fullPath = path.resolve(process.cwd(), credentialsPath); + admin.initializeApp({ + credential: admin.credential.cert(fullPath), + }); + this.logger.log(`Firebase Admin SDK initialized with ${credentialsPath}`); + } catch (err: any) { + this.logger.error(`Failed to initialize Firebase Admin SDK: ${err.message}`); + } + } else if (!credentialsPath) { + this.logger.warn('FCM credentials path not found. Push disabled.'); + } + } + async register(tenantId: string, userId: string, token: string, platform = 'android') { const existing = await this.tokens.findOne({ where: { token } }); let saved: DeviceToken; @@ -137,47 +156,52 @@ export class NotificationsService { 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}"`); + if (admin.apps.length === 0 || rows.length === 0) { + this.logger.debug(`push skip (adminInitialized=${admin.apps.length > 0} tokens=${rows.length}) "${title}"`); return; } - const endpoint = this.config.get('fcm.endpoint')!; + // FCM يقبل نصوصاً فقط في data — والـoverlay يقرأ العنوان/النص من هنا. const payload = this.stringifyData({ ...data, title, body }); + const tokens = rows.map((r) => r.token); - 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); + try { + const message: admin.messaging.MulticastMessage = { + tokens, + data: payload, + android: { + priority: 'high', + }, + }; + + if (!opts.dataOnly) { + message.notification = { title, body }; + } + + const res = await admin.messaging().sendEachForMulticast(message); + + const stale: string[] = []; + res.responses.forEach((resp, idx) => { + if (!resp.success) { + const errCode = resp.error?.code; + if (errCode === 'messaging/invalid-registration-token' || + errCode === 'messaging/registration-token-not-registered') { + stale.push(tokens[idx]); + } else { + this.logger.warn(`push failed for token ${tokens[idx]}: ${resp.error?.message}`); } - } catch (e: any) { - this.logger.warn(`push failed: ${e?.message}`); } - }), - ); + }); - // توكن ميت = إرسال ضائع لكل رحلة لاحقة؛ نحذفه فور ما يخبرنا FCM. - // والكاش يُبطَل معه، وإلا بقي التوكن الميت يُستعمل حتى انتهاء عمر المفتاح. - if (stale.length > 0) { - await this.tokens.delete({ token: In(stale) }); - await this.cache.del(CacheKeys.deviceTokens(tenantId, userId)); - this.logger.debug(`removed ${stale.length} stale token(s)`); + // توكن ميت = إرسال ضائع لكل رحلة لاحقة؛ نحذفه فور ما يخبرنا FCM. + // والكاش يُبطَل معه، وإلا بقي التوكن الميت يُستعمل حتى انتهاء عمر المفتاح. + if (stale.length > 0) { + await this.tokens.delete({ token: In(stale) }); + await this.cache.del(CacheKeys.deviceTokens(tenantId, userId)); + this.logger.debug(`removed ${stale.length} stale token(s)`); + } + } catch (e: any) { + this.logger.warn(`push batch failed: ${e?.message}`); } } diff --git a/backend/src/modules/rewards/referrals.service.ts b/backend/src/modules/rewards/referrals.service.ts index f10ce67..b4db988 100644 --- a/backend/src/modules/rewards/referrals.service.ts +++ b/backend/src/modules/rewards/referrals.service.ts @@ -272,4 +272,23 @@ export class ReferralsService { ref: `${referral.id}:${side}`, }); } + + /** + * صرف الإحالات المؤهَّلة (O5 — يُستدعى من CronWorker). + * شبكة أمان: ما فُصِل يُعاد محاولته في الدورة القادمة. + * @returns عدد الإحالات التي نجح صرفها. + */ + async sweep(batchSize = 50): Promise { + const due = await this.findQualified(batchSize); + let paid = 0; + for (const referral of due) { + try { + await this.payout(referral); + paid++; + } catch (e: any) { + this.logger.error(`sweep payout failed for ${referral.id}: ${e?.message}`); + } + } + return paid; + } } diff --git a/backend/src/modules/rewards/rewards.module.ts b/backend/src/modules/rewards/rewards.module.ts index 83dcaa1..86db8a0 100644 --- a/backend/src/modules/rewards/rewards.module.ts +++ b/backend/src/modules/rewards/rewards.module.ts @@ -7,7 +7,6 @@ import { ReferralCode } from './entities/referral-code.entity'; import { CouponsService } from './coupons.service'; import { ReferralsService } from './referrals.service'; import { RewardsController } from './rewards.controller'; -import { RewardsSweeper } from './rewards.sweeper'; import { CreditModule } from '../credit/credit.module'; import { DriversModule } from '../drivers/drivers.module'; import { TenantsModule } from '../tenants/tenants.module'; @@ -27,7 +26,7 @@ import { TenantsModule } from '../tenants/tenants.module'; TenantsModule, ], controllers: [RewardsController], - providers: [CouponsService, ReferralsService, RewardsSweeper], + providers: [CouponsService, ReferralsService], exports: [CouponsService, ReferralsService], }) export class RewardsModule {} diff --git a/backend/src/modules/tariff/admin-tariff.controller.ts b/backend/src/modules/tariff/admin-tariff.controller.ts new file mode 100644 index 0000000..7c67d9d --- /dev/null +++ b/backend/src/modules/tariff/admin-tariff.controller.ts @@ -0,0 +1,94 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { TariffService } from './tariff.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { Tariff, TariffDefinition } from './entities/tariff.entity'; + +/** + * CRUD التعرفة للوحة الأدمن (docs/22 — B9). + * + * كل المسارات محصورة ضمن المستأجر الحالي (من التوكن). لا يمكن لأدمن + * مستأجر تعديل تعرفة مستأجر آخر. + * + * التحديث يُنشئ نسخة جديدة (version + 1) ويُعطّل القديمة — لا يُعدّل + * صفّاً تشير إليه رحلات جارية عبر tariff_id + tariff_version. + */ +@ApiTags('admin-tariffs') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin') +@Controller('admin/tariffs') +export class AdminTariffController { + constructor(private readonly tariff: TariffService) {} + + /** كل تعرفات المستأجر (بما فيها المُعطّلة). */ + @Get() + list(@CurrentUser() user: AuthUser) { + return this.tariff.list(user.tenantId); + } + + /** تعرفة واحدة بالـID. */ + @Get(':id') + get( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.tariff.findById(user.tenantId, id); + } + + /** إنشاء تعرفة جديدة. */ + @Post() + create( + @CurrentUser() user: AuthUser, + @Body() body: { definition: TariffDefinition; city?: string; service_class?: string }, + ) { + return this.tariff.create({ + tenant_id: user.tenantId, + city: body.city ?? 'default', + service_class: body.service_class ?? 'economy', + definition: body.definition, + active: true, + }); + } + + /** تحديث تعرفة: يُنشئ نسخة جديدة version + 1 ويُعطّل القديمة. */ + @Put(':id') + update( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { definition?: TariffDefinition; city?: string; service_class?: string }, + ) { + return this.tariff.update(user.tenantId, id, body); + } + + /** تعطيل تعرفة (تعطيل ناعم — لا يحذف الصف). */ + @Patch(':id/deactivate') + deactivate( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.tariff.deactivate(user.tenantId, id); + } + + /** إعادة تفعيل تعرفة مُعطّلة. */ + @Patch(':id/activate') + activate( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.tariff.activate(user.tenantId, id); + } +} diff --git a/backend/src/modules/tariff/entities/surge-state.entity.ts b/backend/src/modules/tariff/entities/surge-state.entity.ts new file mode 100644 index 0000000..1c7ccec --- /dev/null +++ b/backend/src/modules/tariff/entities/surge-state.entity.ts @@ -0,0 +1,85 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +/** + * حالة التسعير الديناميكي لكل (مستأجر × مدينة × فئة خدمة) (L4). + * + * الجدول: `tripz_surge_states`. + * + * بوت `SurgeService` يحدّث هذا الجدول دورياً (كل 3 دقائق). + * `TariffEngine` لا يقرأه مباشرة — بل يقرأ `surge.multiplier` في `TariffDefinition` + * الذي يُحدَّث هنا. الفصل يمنع التشتت: التعرفة هي مصدر الحقيقة، هذا الجدول + * هو الذاكرة المؤقتة لقرارات البوت. + */ +@Entity('surge_states') +@Index(['tenant_id', 'city', 'service_class'], { unique: true }) +export class SurgeState { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column() + city: string; + + @Column() + service_class: string; + + /** المضاعف الحالي الفعلي (1.0 = لا زيادة). */ + @Column('decimal', { precision: 5, scale: 2, default: 1.0 }) + multiplier: number; + + /** الحدّ الأقصى للمضاعف (لا يتجاوزه البوت). */ + @Column('decimal', { precision: 5, scale: 2, default: 2.0 }) + max_multiplier: number; + + /** الحدّ الأدنى (لا ينزل عنده). */ + @Column('decimal', { precision: 5, scale: 2, default: 1.0 }) + min_multiplier: number; + + /** + * عدد الرحلات المُلغيّة/المرفوضة في النافذة الأخيرة — مؤشر الطلب الزائد. + * يُصفَّر مع كل دورة. + */ + @Column({ type: 'int', default: 0 }) + demand_count: number; + + /** + * عدد السائقين المتاحين في النافذة الأخيرة — مؤشر العرض. + * يُحسب من تحديثات السائقين النشطين (online + idle). + */ + @Column({ type: 'int', default: 0 }) + supply_count: number; + + /** آخر تحديث للبيانات (من بوت العرض/الطلب). */ + @Column({ type: 'timestamptz', nullable: true }) + last_sample_at: Date; + + /** + * لحظة آخر تغيير للمضاعف. يمنع التحديث المتكرر السريع: + * مضاعف لا يتغيّر إلا إذا مرّ `cooldownMs` منذ آخر تغيير. + */ + @Column({ type: 'timestamptz', nullable: true }) + last_changed_at: Date | null; + + /** هل المضاعف مُكتَّب يدوياً (override)?一旦 لا يكتبه البوت. */ + @Column({ default: false }) + manual_override: boolean; + + /** ملاحظة يدوية (مثلاً: "event surge"). */ + @Column({ type: 'varchar', nullable: true }) + manual_note: string | null; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/tariff/entities/tariff.entity.ts b/backend/src/modules/tariff/entities/tariff.entity.ts index 5bf8631..462532d 100644 --- a/backend/src/modules/tariff/entities/tariff.entity.ts +++ b/backend/src/modules/tariff/entities/tariff.entity.ts @@ -90,4 +90,12 @@ export interface TariffDefinition { /** نسبة خصم مطابقة الوجهة (docs/17 — B8). غيابها = لا خصم. */ destination_match_discount_pct?: number; + + /** + * تسعير بالوزن (L3): الشحنات والطرود. + * `per_kg_above`: سعر الكيلو فوق `free_kg` (مجاني لأول N كيلو). + * `free_kg`: الحدّ المجاني — تحته لا رسوم وزن. + * بدون هذه المُعدّات لا يُضاف أيّ شيء بسبب الوزن. + */ + weight?: { free_kg: number; per_kg_above: number }; } diff --git a/backend/src/modules/tariff/surge.controller.ts b/backend/src/modules/tariff/surge.controller.ts new file mode 100644 index 0000000..de8d4e8 --- /dev/null +++ b/backend/src/modules/tariff/surge.controller.ts @@ -0,0 +1,99 @@ +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { SurgeService } from './surge.service'; +import { PlatformGuard } from '../../common/platform/platform.guard'; + +/** + * التسعير الديناميكي — لوحة السوبر-أدن (L4). + * + * المسارات خلف `PlatformGuard` — لا يمكن لأدمن مستأجر الوصول. + */ +@ApiTags('surge') +@Controller() +export class SurgeController { + constructor(private readonly surge: SurgeService) {} + + /** كل الحالات النشطة لمستأجر. */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Get('admin/surge/:tenantId') + listStates(@Param('tenantId') tenantId: string) { + return this.surge.listStates(tenantId); + } + + /** حالةفئة معينة. */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Get('admin/surge/:tenantId/:city/:serviceClass') + getState( + @Param('tenantId') tenantId: string, + @Param('city') city: string, + @Param('serviceClass') serviceClass: string, + ) { + return this.surge.getState(tenantId, city, serviceClass); + } + + /** إطلاق إعادة حساب يدوية (خارج الدورة الأوتوماتيكية). */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Post('admin/surge/recalculate') + recalculate() { + return this.surge.recalculateAll(); + } + + /** تجاوز يدوي للمضاعف. */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Post('admin/surge/override') + override( + @Body() body: { + tenant_id: string; + city: string; + service_class: string; + multiplier: number; + note?: string; + }, + ) { + return this.surge.manualOverride( + body.tenant_id, + body.city, + body.service_class, + body.multiplier, + body.note, + ); + } + + /** إلغاء التحكم اليدوي. */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Patch('admin/surge/release') + release( + @Body() body: { tenant_id: string; city: string; service_class: string }, + ) { + return this.surge.releaseManualOverride( + body.tenant_id, + body.city, + body.service_class, + ); + } + + /** تحديث العرض (عدد السائقين المتاحين). */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Patch('admin/surge/supply') + updateSupply( + @Body() body: { + tenant_id: string; + city: string; + service_class: string; + count: number; + }, + ) { + return this.surge.updateSupply( + body.tenant_id, + body.city, + body.service_class, + body.count, + ); + } +} diff --git a/backend/src/modules/tariff/surge.service.ts b/backend/src/modules/tariff/surge.service.ts new file mode 100644 index 0000000..c50b963 --- /dev/null +++ b/backend/src/modules/tariff/surge.service.ts @@ -0,0 +1,255 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, LessThan, Repository } from 'typeorm'; +import { SurgeState } from './entities/surge-state.entity'; +import { TariffService } from './tariff.service'; +import { Driver } from '../drivers/entities/driver.entity'; + +/** + * خدمة التسعير الديناميكي — بوت المضاعف (L4). + * + * تحسب مضاعف الطلب/العرض لكل (مدينة × فئة) وتحدّث: + * 1. `surge_states` — الذاكرة المؤقتة للحالة + * 2. `tariffs.definition.surge.multiplier` — مصدر الحقيقة للمحرك + * + * الجدولة تتم عبر BullMQ (CronModule — O5) لا عبر `setInterval` هنا. + * + * المعادلة (من docs/22): + * ``` + * ratio = demand / supply (supply = 0 → ratio = max) + * multiplier = 1 + (ratio - 1) × sensitivity + * multiplier = clamp(multiplier, min, max) + * ``` + */ +@Injectable() +export class SurgeService { + private readonly logger = new Logger('Surge'); + + constructor( + @InjectRepository(SurgeState) + private readonly repo: Repository, + @InjectRepository(Driver) + private readonly driverRepo: Repository, + private readonly tariffService: TariffService, + private readonly config: ConfigService, + ) {} + + /** + * إعادة حساب المضاعفات لجميع الحالات النشطة. + * ي refresh عرض السائقين من قاعدة البيانات قبل كل حساب. + */ + async recalculateAll(): Promise<{ updated: number; unchanged: number }> { + const cooldownMs = this.config.get('trips.surgeCooldownMs') ?? 300_000; + const sensitivity = this.config.get('trips.surgeSensitivity') ?? 0.3; + const now = new Date(); + const cutoff = new Date(now.getTime() - cooldownMs); + + // تحديث العرض الفعلي من السائقين المتصلين + await this.refreshSupplyFromDrivers(now); + + const states = await this.repo.find({ + where: [ + { manual_override: false, last_changed_at: LessThan(cutoff) }, + { manual_override: false, last_changed_at: IsNull() }, + ], + }); + + let updated = 0; + let unchanged = 0; + + for (const state of states) { + const newMultiplier = this.calculateMultiplier( + state.demand_count, + state.supply_count, + Number(state.min_multiplier), + Number(state.max_multiplier), + sensitivity, + ); + + const oldMultiplier = Number(state.multiplier); + + // لا تحديث لو المضاعف لم يتغيّر بشكل ملموس + if (Math.abs(newMultiplier - oldMultiplier) < 0.01) { + unchanged++; + continue; + } + + // تحديث مضاعف التعرفة + await this.tariffService.updateSurgeMultiplier( + state.tenant_id, + state.city, + state.service_class, + newMultiplier, + ); + + state.multiplier = newMultiplier; + state.last_changed_at = now; + await this.repo.save(state); + + this.logger.log( + `${state.city}/${state.service_class}: ${oldMultiplier.toFixed(2)}x → ${newMultiplier.toFixed(2)}x ` + + `(demand=${state.demand_count}, supply=${state.supply_count})`, + ); + updated++; + } + + if (updated > 0) { + this.logger.log(`surge update: ${updated} changed, ${unchanged} unchanged`); + } + + // تصفير العدادات لكل حالة تم حسابها — نافذة القياس = دورة واحدة (docs/22). + for (const state of states) { + state.demand_count = 0; + state.supply_count = 0; + } + if (states.length > 0) { + await this.repo.save(states); + } + + return { updated, unchanged }; + } + + /** + * حساب المضاعف من نسبة الطلب/العرض. + * + * ``` + * ratio = demand / max(supply, 1) + * multiplier = 1 + (ratio - 1) × sensitivity + * ``` + * + * - لا عرض (0 سائقين) → ratio = max → multiplier = max_multiplier + * - عرض ≥ طلب → multiplier = 1.0 (لا زيادة) + */ + calculateMultiplier( + demand: number, + supply: number, + min: number, + max: number, + sensitivity: number, + ): number { + if (demand <= 0) return 1.0; + + const effectiveSupply = Math.max(supply, 1); + const ratio = demand / effectiveSupply; + + // لا زيادة لو العرض كافٍ أو أكبر + if (ratio <= 1.0) return 1.0; + + const raw = 1 + (ratio - 1) * sensitivity; + const clamped = Math.min(max, Math.max(min, raw)); + + // تقريب لخانتين عشريتين + return Math.round(clamped * 100) / 100; + } + + // ─── تحديث البيانات (من بوتات أخرى أو API) ──────────────────────── + + /** تحديث عدّاد الطلب (يُستدعى عند إلغاء/رفض رحلة). */ + async incrementDemand(tenantId: string, city: string, serviceClass: string): Promise { + const state = await this.getOrCreate(tenantId, city, serviceClass); + state.demand_count++; + state.last_sample_at = new Date(); + await this.repo.save(state); + } + + /** تحديث عدّاد العرض (يُستدعى عند تحديث حالة السائق). */ + async updateSupply(tenantId: string, city: string, serviceClass: string, count: number): Promise { + const state = await this.getOrCreate(tenantId, city, serviceClass); + state.supply_count = count; + state.last_sample_at = new Date(); + await this.repo.save(state); + } + + /** إعادة ضبط العدّادات بعد كل دورة حساب. */ + async resetCounters(tenantId: string, city: string, serviceClass: string): Promise { + const state = await this.getOrCreate(tenantId, city, serviceClass); + state.demand_count = 0; + state.supply_count = 0; + state.last_sample_at = new Date(); + await this.repo.save(state); + } + + // ─── التحكم اليدوي (Admin) ────────────────────────────────────── + + /** تجاوز يدوي للمضاعف (مثلاً: حدث أو زحمة غير عادية). */ + async manualOverride( + tenantId: string, + city: string, + serviceClass: string, + multiplier: number, + note?: string, + ): Promise { + const state = await this.getOrCreate(tenantId, city, serviceClass); + state.multiplier = multiplier; + state.manual_override = true; + state.manual_note = note ?? null; + state.last_changed_at = new Date(); + await this.repo.save(state); + + await this.tariffService.updateSurgeMultiplier( + tenantId, city, serviceClass, multiplier, + ); + + this.logger.log(`manual override: ${city}/${serviceClass} → ${multiplier}x (${note ?? 'no note'})`); + return state; + } + + /** إلغاء التحكم اليدوي — يعود البوت يحسب تلقائياً. */ + async releaseManualOverride(tenantId: string, city: string, serviceClass: string): Promise { + const state = await this.getOrCreate(tenantId, city, serviceClass); + state.manual_override = false; + state.manual_note = null; + await this.repo.save(state); + } + + // ─── الاستعلام ──────────────────────────────────────────────── + + /** حالة البوت لفئة معينة. */ + async getState(tenantId: string, city: string, serviceClass: string): Promise { + return this.repo.findOne({ where: { tenant_id: tenantId, city, service_class: serviceClass } }); + } + + /** كل الحالات النشطة لمستأجر. */ + async listStates(tenantId: string): Promise { + return this.repo.find({ where: { tenant_id: tenantId }, order: { city: 'ASC', service_class: 'ASC' } }); + } + + // ─── داخلي ──────────────────────────────────────────────────── + + /** + * تحديث عدد السائقين المتاحين لكل حالة surge من قاعدة البيانات الفعلية + * بدلاً من الاعتماد على عداد ثابت. يُستدعى قبل كل إعادة حساب. + */ + private async refreshSupplyFromDrivers(now: Date): Promise { + const states = await this.repo.find({ where: { manual_override: false } }); + for (const state of states) { + const count = await this.driverRepo + .createQueryBuilder('d') + .where('d.tenant_id = :tenantId', { tenantId: state.tenant_id }) + .andWhere('d.is_online = true') + .andWhere('d.verification_status = :vs', { vs: 'approved' }) + .andWhere('d.service_class = :sc', { sc: state.service_class }) + .getCount(); + state.supply_count = count; + state.last_sample_at = now; + await this.repo.save(state); + } + } + + private async getOrCreate(tenantId: string, city: string, serviceClass: string): Promise { + let state = await this.repo.findOne({ where: { tenant_id: tenantId, city, service_class: serviceClass } }); + if (!state) { + state = this.repo.create({ + tenant_id: tenantId, + city, + service_class: serviceClass, + multiplier: 1.0, + max_multiplier: 2.0, + min_multiplier: 1.0, + }); + await this.repo.save(state); + } + return state; + } +} diff --git a/backend/src/modules/tariff/tariff.engine.spec.ts b/backend/src/modules/tariff/tariff.engine.spec.ts index 8f4ba26..db8513b 100644 --- a/backend/src/modules/tariff/tariff.engine.spec.ts +++ b/backend/src/modules/tariff/tariff.engine.spec.ts @@ -79,3 +79,35 @@ describe('TariffEngine.commission — العمولة (docs/18)', () => { expect(TariffEngine.commission(def({ commission: { percent: 10 } }), 0).amount).toBe(0); }); }); + +describe('TariffEngine.quote — تسعير بالوزن (L3)', () => { + const WEIGHT_DEF = def({ + weight: { free_kg: 5, per_kg_above: 0.5 }, + }); + + it('تحت الحد المجاني: لا رسوم وزن', () => { + const q = TariffEngine.quote(WEIGHT_DEF, { distanceKm: 10, durationMin: 20, weightKg: 4 }); + expect(q.weight).toBe(0); + }); + + it('فوق الحد المجاني: يُحسب الفرق × السعر', () => { + const q = TariffEngine.quote(WEIGHT_DEF, { distanceKm: 10, durationMin: 20, weightKg: 10 }); + // (10 - 5) × 0.5 = 2.5 + expect(q.weight).toBeCloseTo(2.5); + }); + + it('بدون إعداد وزن: لا تأثير حتى لو وُزن', () => { + const q = TariffEngine.quote(def(), { distanceKm: 10, durationMin: 20, weightKg: 50 }); + expect(q.weight).toBe(0); + }); + + it('وزن ثابت + surge', () => { + const q = TariffEngine.quote( + def({ surge: { enabled: true, multiplier: 1.5 }, weight: { free_kg: 5, per_kg_above: 2 } }), + { distanceKm: 10, durationMin: 20, weightKg: 10 }, + ); + // weight charge: (10-5)×2 = 10 → ×1.5 surge = 15 + expect(q.weight).toBeCloseTo(10); + expect(q.total).toBeGreaterThan(0); + }); +}); diff --git a/backend/src/modules/tariff/tariff.engine.ts b/backend/src/modules/tariff/tariff.engine.ts index 74075b6..42f4b86 100644 --- a/backend/src/modules/tariff/tariff.engine.ts +++ b/backend/src/modules/tariff/tariff.engine.ts @@ -6,6 +6,8 @@ export interface QuoteInput { waitingMin?: number; at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية) geofenceMultiplier?: number; + /** وزن الحمولة بالكيلوغرام — للشحنات والطرود فقط (L3). */ + weightKg?: number; } export interface QuoteBreakdown { @@ -14,6 +16,7 @@ export interface QuoteBreakdown { distance: number; time: number; waiting: number; + weight: number; bookingFee: number; subtotal: number; surgeMultiplier: number; @@ -38,18 +41,21 @@ export class TariffEngine { if (def.mode === 'fixed_quote') { // سعر ثابت متفَق عليه (خط ثابت / وجهة بسعر معلن): لا مسافة ولا زمن. - // كان يسقط سابقاً إلى الفرع المتري فيُحسب بالعدّاد رغم اسمه — أي أن - // «السعر الثابت» لم يكن ثابتاً أبداً. const fixed = Math.max(0, def.fixed_fare ?? 0); const waitingOnly = waitingMin * (win.per_min_waiting ?? 0); - // الانتظار وحده يُضاف: بقاء الراكب ربع ساعة تكلفة حقيقية على السائق - // مهما كان الخط ثابتاً. ولا حدّ أدنى هنا — السعر المعلن **هو** الاتفاق، - // ورفعه بحدّ أدنى يعني إعلان سعر وتحصيل غيره. + + // --- L3: وزن الحمولة (شحنات على خطوط ثابتة) --- + const weightKg = Math.max(0, input.weightKg ?? 0); + let weightCharge = 0; + if (def.weight && weightKg > def.weight.free_kg) { + weightCharge = (weightKg - def.weight.free_kg) * def.weight.per_kg_above; + } + const multiplier = (def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1) * (input.geofenceMultiplier ?? 1.0); const fixedTotal = TariffEngine.round( - (fixed + waitingOnly) * multiplier, + (fixed + waitingOnly + weightCharge) * multiplier, def.rounding, ); return { @@ -58,8 +64,9 @@ export class TariffEngine { distance: 0, time: 0, waiting: TariffEngine.n(waitingOnly), + weight: TariffEngine.n(weightCharge), bookingFee: 0, - subtotal: TariffEngine.n(fixed + waitingOnly), + subtotal: TariffEngine.n(fixed + waitingOnly + weightCharge), surgeMultiplier: multiplier, total: fixedTotal, currency: def.currency, @@ -84,8 +91,15 @@ export class TariffEngine { const waitingCharge = waitingMin * (win.per_min_waiting ?? 0); const bookingFee = def.booking_fee ?? 0; + // --- L3: وزن الحمولة --- + const weightKg = Math.max(0, input.weightKg ?? 0); + let weightCharge = 0; + if (def.weight && weightKg > def.weight.free_kg) { + weightCharge = (weightKg - def.weight.free_kg) * def.weight.per_kg_above; + } + let subtotal = - win.flag + distanceCharge + timeCharge + waitingCharge + bookingFee; + win.flag + distanceCharge + timeCharge + waitingCharge + weightCharge + bookingFee; const surgeMultiplier = def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1; @@ -104,6 +118,7 @@ export class TariffEngine { distance: TariffEngine.n(distanceCharge), time: TariffEngine.n(timeCharge), waiting: TariffEngine.n(waitingCharge), + weight: TariffEngine.n(weightCharge), bookingFee, subtotal: TariffEngine.n(subtotal), surgeMultiplier, diff --git a/backend/src/modules/tariff/tariff.module.ts b/backend/src/modules/tariff/tariff.module.ts index d9cdc75..001de61 100644 --- a/backend/src/modules/tariff/tariff.module.ts +++ b/backend/src/modules/tariff/tariff.module.ts @@ -1,14 +1,19 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Tariff } from './entities/tariff.entity'; +import { SurgeState } from './entities/surge-state.entity'; import { TariffService } from './tariff.service'; +import { SurgeService } from './surge.service'; import { TariffController } from './tariff.controller'; +import { AdminTariffController } from './admin-tariff.controller'; +import { SurgeController } from './surge.controller'; import { TenantsModule } from '../tenants/tenants.module'; +import { Driver } from '../drivers/entities/driver.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Tariff]), TenantsModule], - controllers: [TariffController], - providers: [TariffService], - exports: [TariffService], + imports: [TypeOrmModule.forFeature([Tariff, SurgeState, Driver]), TenantsModule], + controllers: [TariffController, AdminTariffController, SurgeController], + providers: [TariffService, SurgeService], + exports: [TariffService, SurgeService], }) export class TariffModule {} diff --git a/backend/src/modules/tariff/tariff.service.ts b/backend/src/modules/tariff/tariff.service.ts index 68bef15..4220faf 100644 --- a/backend/src/modules/tariff/tariff.service.ts +++ b/backend/src/modules/tariff/tariff.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Tariff } from './entities/tariff.entity'; @@ -37,10 +41,75 @@ export class TariffService { async create(data: Partial): Promise { const saved = await this.repo.save(this.repo.create(data)); - // تعرفة جديدة = النسخة الفعّالة تغيّرت → الكاش صار كذباً. - await this.cache.del( - CacheKeys.tariff(saved.tenant_id, saved.city, saved.service_class), - ); + await this.invalidateCache(saved); + return saved; + } + + /** كل تعرفات المستأجر (بما فيها المُعطّلة). */ + async list(tenantId: string): Promise { + return this.repo.find({ + where: { tenant_id: tenantId }, + order: { service_class: 'ASC', version: 'DESC' }, + }); + } + + /** تعرفة واحدة بالـID — يرمي NotFoundException لو غير موجودة. */ + async findById(tenantId: string, id: string): Promise { + const t = await this.repo.findOne({ + where: { tenant_id: tenantId, id }, + }); + if (!t) throw new NotFoundException(`Tariff ${id} not found`); + return t; + } + + /** + * تحديث تعرفة: يُنشئ نسخة جديدة (version + 1) بدل تعديل الصف الحالي. + * السبب: رحلات جارية قد تشير إلى النسخة القديمة عبر `tariff_id` + + * `tariff_version`. تعديل الصف يغيّر حقيقة رحلة انتهت. + */ + async update( + tenantId: string, + id: string, + patch: { definition?: Tariff['definition']; city?: string; service_class?: string }, + ): Promise { + const prev = await this.findById(tenantId, id); + if (!prev.active) { + throw new BadRequestException('Cannot update a deactivated tariff'); + } + + const next = this.repo.create({ + tenant_id: tenantId, + city: patch.city ?? prev.city, + service_class: patch.service_class ?? prev.service_class, + definition: patch.definition ?? prev.definition, + version: prev.version + 1, + active: true, + }); + const saved = await this.repo.save(next); + + // تعطيل النسخة القديمة بعد إنشاء النسخة الجديدة. + await this.repo.update(prev.id, { active: false }); + + await this.invalidateCache(saved); + return saved; + } + + /** تعطيل تعرفة (تعطيل ناعم). */ + async deactivate(tenantId: string, id: string): Promise { + const t = await this.findById(tenantId, id); + t.active = false; + const saved = await this.repo.save(t); + await this.invalidateCache(saved); + return saved; + } + + /** إعادة تفعيل تعرفة مُعطّلة. */ + async activate(tenantId: string, id: string): Promise { + const t = await this.findById(tenantId, id); + if (t.active) return t; // فعّالة أصلاً + t.active = true; + const saved = await this.repo.save(t); + await this.invalidateCache(saved); return saved; } @@ -59,4 +128,36 @@ export class TariffService { const quote = TariffEngine.quote(tariff.definition, input); return { quote, tariffId: tariff.id, version: tariff.version }; } + + /** + * تحديث مضاعف التسعير الديناميكي في التعرفة الفعّالة (L4). + * يُحدّث `surge.multiplier` في وثيقة التعريف ثم يمسح الكاش. + * لا يُنشئ نسخة جديدة — المضاعف مؤقت يتغيّر كل 3 دقائق. + */ + async updateSurgeMultiplier( + tenantId: string, + city: string, + serviceClass: string, + multiplier: number, + ): Promise { + const tariff = await this.repo.findOne({ + where: { tenant_id: tenantId, city, service_class: serviceClass, active: true }, + order: { version: 'DESC' }, + }); + if (!tariff) return; + + tariff.definition.surge = { + enabled: multiplier !== 1.0, + multiplier, + max: tariff.definition.surge?.max ?? 2.0, + }; + await this.repo.save(tariff); + await this.invalidateCache(tariff); + } + + private async invalidateCache(tariff: Tariff) { + await this.cache.del( + CacheKeys.tariff(tariff.tenant_id, tariff.city, tariff.service_class), + ); + } } diff --git a/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts b/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts index e0a72fd..cf23fe8 100644 --- a/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts +++ b/backend/src/modules/tenant-wallet/tenant-wallet.controller.ts @@ -70,4 +70,75 @@ export class TenantWalletController { body.currency || 'JOD', ); } + + // ─── تقارير المستأجر (P4) ─────────────────────────────────────── + + /** إيراد حسب مزوّد الدفع (cash / cliq / syriatel / mtn / ...). */ + @Get('report/by-provider') + reportByProvider( + @CurrentUser() user: AuthUser, + @Query('days') days?: string, + ) { + const n = Number(days); + return this.wallet.revenueByProvider( + user.tenantId, + Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30, + ); + } + + /** إيراد يومي — سلسلة زمنية. */ + @Get('report/by-day') + reportByDay( + @CurrentUser() user: AuthUser, + @Query('days') days?: string, + @Query('currency') currency?: string, + ) { + const n = Number(days); + return this.wallet.revenueByDay( + user.tenantId, + Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30, + currency || 'JOD', + ); + } + + /** ملخّص الرحلات المالي (GMV + عمولة + خصومات). */ + @Get('report/trips') + reportTrips( + @CurrentUser() user: AuthUser, + @Query('days') days?: string, + ) { + const n = Number(days); + return this.wallet.tripRevenueSummary( + user.tenantId, + Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30, + ); + } + + /** ملخّص سحوبات السائقين. */ + @Get('report/payouts') + reportPayouts( + @CurrentUser() user: AuthUser, + @Query('days') days?: string, + ) { + const n = Number(days); + return this.wallet.payoutSummary( + user.tenantId, + Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30, + ); + } + + /** التقرير الشامل — يجمع كل شيء في طلب واحد. */ + @Get('report/full') + reportFull( + @CurrentUser() user: AuthUser, + @Query('days') days?: string, + @Query('currency') currency?: string, + ) { + const n = Number(days); + return this.wallet.fullReport( + user.tenantId, + Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30, + currency || 'JOD', + ); + } } diff --git a/backend/src/modules/tenant-wallet/tenant-wallet.module.ts b/backend/src/modules/tenant-wallet/tenant-wallet.module.ts index 77ee875..4c96d30 100644 --- a/backend/src/modules/tenant-wallet/tenant-wallet.module.ts +++ b/backend/src/modules/tenant-wallet/tenant-wallet.module.ts @@ -4,11 +4,22 @@ import { TenantRevenueEntry, TenantPendingEntry, } from './entities/tenant-ledger.entity'; +import { Payment } from '../payments/entities/payment.entity'; +import { Payout } from '../payments/entities/payout.entity'; +import { Trip } from '../trips/entities/trip.entity'; import { TenantWalletService } from './tenant-wallet.service'; import { TenantWalletController } from './tenant-wallet.controller'; @Module({ - imports: [TypeOrmModule.forFeature([TenantRevenueEntry, TenantPendingEntry])], + imports: [ + TypeOrmModule.forFeature([ + TenantRevenueEntry, + TenantPendingEntry, + Payment, + Payout, + Trip, + ]), + ], controllers: [TenantWalletController], providers: [TenantWalletService], exports: [TenantWalletService], diff --git a/backend/src/modules/tenant-wallet/tenant-wallet.service.spec.ts b/backend/src/modules/tenant-wallet/tenant-wallet.service.spec.ts index f98f2c9..b8e6920 100644 --- a/backend/src/modules/tenant-wallet/tenant-wallet.service.spec.ts +++ b/backend/src/modules/tenant-wallet/tenant-wallet.service.spec.ts @@ -7,6 +7,9 @@ import { TenantPendingEntry, LedgerReason, } from './entities/tenant-ledger.entity'; +import { Payment } from '../payments/entities/payment.entity'; +import { Payout } from '../payments/entities/payout.entity'; +import { Trip } from '../trips/entities/trip.entity'; import { TenantWalletService } from './tenant-wallet.service'; const TENANT = '11111111-1111-1111-1111-111111111111'; @@ -49,6 +52,9 @@ describe('TenantWalletService — محفظتا المستأجر (docs/24)', () = wallet = new TenantWalletService( ds.getRepository(TenantRevenueEntry), ds.getRepository(TenantPendingEntry), + {} as any, // Payment repo (not used in these tests) + {} as any, // Payout repo (not used in these tests) + {} as any, // Trip repo (not used in these tests) ); }); diff --git a/backend/src/modules/tenant-wallet/tenant-wallet.service.ts b/backend/src/modules/tenant-wallet/tenant-wallet.service.ts index b48f1f4..020418c 100644 --- a/backend/src/modules/tenant-wallet/tenant-wallet.service.ts +++ b/backend/src/modules/tenant-wallet/tenant-wallet.service.ts @@ -7,6 +7,9 @@ import { TenantLedgerBase, LedgerReason, } from './entities/tenant-ledger.entity'; +import { Payment } from '../payments/entities/payment.entity'; +import { Payout } from '../payments/entities/payout.entity'; +import { Trip } from '../trips/entities/trip.entity'; /** أي الدفترين — يُمرَّر صراحةً في كل نداء فلا يوجد افتراض صامت. */ export type Book = 'revenue' | 'pending'; @@ -36,6 +39,12 @@ export class TenantWalletService { private readonly revenue: Repository, @InjectRepository(TenantPendingEntry) private readonly pending: Repository, + @InjectRepository(Payment) + private readonly payments: Repository, + @InjectRepository(Payout) + private readonly payouts: Repository, + @InjectRepository(Trip) + private readonly trips: Repository, ) {} /** @@ -192,4 +201,138 @@ export class TenantWalletService { } static readonly Reason = LedgerReason; + + // ─── تقارير المستأجر (P4) ───────────────────────────────────────────── + + /** + * إيراد حسب مزوّد الدفع — يجيب «بأي وسيلة دفع دفع الراكب/السائق؟». + * يقرأ من `pay_payments` (المدفوعات الناجحة فقط). + */ + async revenueByProvider(tenantId: string, days = 30) { + const since = new Date(Date.now() - days * 86400_000); + const rows = await this.payments + .createQueryBuilder('p') + .select('p.provider', 'provider') + .addSelect('p.purpose', 'purpose') + .addSelect('COUNT(*)', 'count') + .addSelect('COALESCE(SUM(p.amount), 0)', 'total_amount') + .where('p.tenant_id = :tenantId AND p.status = :status', { tenantId, status: 'success' }) + .andWhere('p.created_at >= :since', { since }) + .groupBy('p.provider') + .addGroupBy('p.purpose') + .orderBy('total_amount', 'DESC') + .getRawMany(); + + return rows.map((r: any) => ({ + provider: r.provider, + purpose: r.purpose, + count: Number(r.count), + total_amount: Number(r.total_amount), + })); + } + + /** + * إيراد يومي — سلسلة زمنية للإيراد. + * يقرأ من دفتر الإيرادات (العمولات + شحن السائقين + رسوم المعاملات). + */ + async revenueByDay(tenantId: string, days = 30, currency = 'JOD') { + const since = new Date(Date.now() - days * 86400_000); + const rows = await this.revenue + .createQueryBuilder('l') + .select("DATE_TRUNC('day', l.created_at)", 'date') + .addSelect('COUNT(*)', 'count') + .addSelect('COALESCE(SUM(l.amount), 0)', 'total') + .where('l.tenant_id = :tenantId AND l.currency = :currency', { tenantId, currency }) + .andWhere('l.created_at >= :since', { since }) + .groupBy("DATE_TRUNC('day', l.created_at)") + .orderBy("DATE_TRUNC('day', l.created_at)", 'ASC') + .getRawMany(); + + return rows.map((r: any) => ({ + date: r.date, + count: Number(r.count), + total: Number(r.total), + })); + } + + /** + * ملخّص الرحلات المالي — GMV + عمولة + خصومات + كوبونات. + * يقرأ من جدول الرحلات المكتملة والمدفوعة. + */ + async tripRevenueSummary(tenantId: string, days = 30) { + const since = new Date(Date.now() - days * 86400_000); + const row = await this.trips + .createQueryBuilder('t') + .select('COUNT(*)', 'total_trips') + .addSelect("COUNT(*) FILTER (WHERE t.status = 'completed')", 'completed_trips') + .addSelect("COALESCE(SUM(t.final_fare) FILTER (WHERE t.status = 'completed'), 0)", 'gmv') + .addSelect("COALESCE(SUM(t.commission_amount) FILTER (WHERE t.status = 'completed'), 0)", 'commission') + .addSelect("COALESCE(SUM(t.coupon_discount) FILTER (WHERE t.status = 'completed'), 0)", 'coupon_discount') + .addSelect("COALESCE(SUM(t.destination_match_discount) FILTER (WHERE t.status = 'completed'), 0)", 'destination_discount') + .addSelect("COALESCE(SUM(t.cancel_fee) FILTER (WHERE t.status IN ('cancelled','expired') AND t.cancel_fee > 0), 0)", 'cancel_fees') + .where('t.tenant_id = :tenantId', { tenantId }) + .andWhere('t.created_at >= :since', { since }) + .getRawOne(); + + return { + total_trips: Number(row?.total_trips ?? 0), + completed_trips: Number(row?.completed_trips ?? 0), + gmv: Number(row?.gmv ?? 0), + commission: Number(row?.commission ?? 0), + coupon_discount: Number(row?.coupon_discount ?? 0), + destination_discount: Number(row?.destination_discount ?? 0), + cancel_fees: Number(row?.cancel_fees ?? 0), + net_revenue: Number(row?.commission ?? 0) - Number(row?.coupon_discount ?? 0), + }; + } + + /** + * ملخّص سحوبات السائقين — pending / processing / paid / failed. + */ + async payoutSummary(tenantId: string, days = 30) { + const since = new Date(Date.now() - days * 86400_000); + const rows = await this.payouts + .createQueryBuilder('p') + .select('p.status', 'status') + .addSelect('COUNT(*)', 'count') + .addSelect('COALESCE(SUM(p.amount), 0)', 'total_amount') + .where('p.tenant_id = :tenantId', { tenantId }) + .andWhere('p.created_at >= :since', { since }) + .groupBy('p.status') + .getRawMany(); + + const summary: Record = {}; + for (const r of rows) { + summary[r.status] = { + count: Number(r.count), + total_amount: Number(r.total_amount), + }; + } + return summary; + } + + /** + * التقرير الشامل (P4) — يجمع كل شيء في طلب واحد. + */ + async fullReport(tenantId: string, days = 30, currency = 'JOD') { + const [summary, byReason, byProvider, byDay, tripSummary, payoutSummary] = await Promise.all([ + this.summary(tenantId, currency), + this.revenueByReason(tenantId, days, currency), + this.revenueByProvider(tenantId, days), + this.revenueByDay(tenantId, days, currency), + this.tripRevenueSummary(tenantId, days), + this.payoutSummary(tenantId, days), + ]); + + return { + period_days: days, + currency, + wallet: summary, + revenue_by_reason: byReason, + revenue_by_provider: byProvider, + revenue_by_day: byDay, + trips: tripSummary, + payouts: payoutSummary, + }; + } } diff --git a/backend/src/modules/tenants/tenants.controller.ts b/backend/src/modules/tenants/tenants.controller.ts index 7fadf0a..9211291 100644 --- a/backend/src/modules/tenants/tenants.controller.ts +++ b/backend/src/modules/tenants/tenants.controller.ts @@ -21,6 +21,7 @@ import { PAYMENT_METHODS } from '../../common/entitlements/payment-methods'; import { PlatformGuard } from '../../common/platform/platform.guard'; import { StorageService } from '../../common/storage/storage.service'; import { readImageMeta, validateLogo } from '../../common/storage/image-meta'; +import { CatalogService } from '../catalog/catalog.service'; @ApiTags('tenants') @Controller() @@ -28,6 +29,7 @@ export class TenantsController { constructor( private readonly tenants: TenantsService, private readonly storage: StorageService, + private readonly catalog: CatalogService, ) {} /** @@ -89,12 +91,21 @@ export class TenantsController { return this.tenants.platformOverview(Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30); } - /** كتالوج الميزات ووسائل الدفع — تعرضهما لوحة السوبر-أدمن عند التزويد. */ + /** كتالوج الميزات ووسائل الدفع — يقرأ من القاعدة (N6) مع fallback للكود الثابت. */ @ApiSecurity('x-platform-secret') @UseGuards(PlatformGuard) @Get('admin/features') - features() { - return { features: FEATURES, payment_methods: PAYMENT_METHODS }; + async features() { + let items; + try { + items = await this.catalog.list(); + } catch { + items = []; + } + const features = items.length > 0 + ? items.map((i) => ({ key: i.key, name_ar: i.name_ar, name_en: i.name_en, category: i.category, monthly_price: i.monthly_price, setup_fee: i.setup_fee })) + : FEATURES; + return { features, payment_methods: PAYMENT_METHODS }; } @ApiSecurity('x-platform-secret') diff --git a/backend/src/modules/tenants/tenants.module.ts b/backend/src/modules/tenants/tenants.module.ts index 4a90e82..52126fd 100644 --- a/backend/src/modules/tenants/tenants.module.ts +++ b/backend/src/modules/tenants/tenants.module.ts @@ -6,13 +6,14 @@ import { TenantRevenueEntry } from '../tenant-wallet/entities/tenant-ledger.enti import { Tariff } from '../tariff/entities/tariff.entity'; import { TenantsService } from './tenants.service'; import { TenantsController } from './tenants.controller'; +import { CatalogModule } from '../catalog/catalog.module'; @Module({ // Trip ودفتر الإيراد هنا **للقراءة فقط** — تقرير GMV والإيراد للسوبر-أدمن // (docs/22 — N1، docs/24 — P5). الكتابة في الدفتر تخصّ TenantWalletService وحده. // `Tariff` هنا لزرع تعرفة الانطلاق عند التزويد. المستودع مباشرةً لا // `TariffModule` — تلك الوحدة تستورد هذه، فالاستيراد المتبادل دورة. - imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry, Tariff])], + imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry, Tariff]), CatalogModule], controllers: [TenantsController], providers: [TenantsService], exports: [TenantsService], diff --git a/backend/src/modules/transit/transit-bus-listener.ts b/backend/src/modules/transit/transit-bus-listener.ts new file mode 100644 index 0000000..34d2339 --- /dev/null +++ b/backend/src/modules/transit/transit-bus-listener.ts @@ -0,0 +1,64 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import Redis from 'ioredis'; +import { REDIS } from '../../common/redis/redis.module'; +import { RealtimeGateway } from '../../realtime/realtime.gateway'; + +export interface BusArrivalEvent { + tripId: string; + driverId: string; + routeId: string; + stationId: string; + stationName: string; +} + +@Injectable() +export class TransitBusListener implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(TransitBusListener.name); + private subscriber?: Redis; + + constructor( + @Inject(REDIS) private readonly redis: Redis, + private readonly realtime: RealtimeGateway, + ) {} + + onModuleInit(): void { + this.subscriber = this.redis.duplicate(); + this.subscriber.subscribe('channel:bus-arrived', (err) => { + if (err) { + this.logger.error(`subscribe bus-arrived failed: ${err.message}`); + } else { + this.logger.log('subscribed to channel:bus-arrived'); + } + }); + + this.subscriber.on('message', (channel, message) => { + if (channel !== 'channel:bus-arrived') return; + try { + const event: BusArrivalEvent = JSON.parse(message); + this.handleBusArrival(event); + } catch (e: any) { + this.logger.warn(`bus-arrived parse error: ${e?.message}`); + } + }); + } + + async onModuleDestroy(): Promise { + if (this.subscriber) { + await this.subscriber.unsubscribe('channel:bus-arrived'); + await this.subscriber.quit(); + } + } + + private handleBusArrival(event: BusArrivalEvent): void { + this.logger.log(`bus arrived: trip=${event.tripId} station=${event.stationName}`); + + if (this.realtime?.server) { + this.realtime.server.to(`tenant:*:trip:${event.tripId}`).emit('bus:arrived', { + tripId: event.tripId, + stationId: event.stationId, + stationName: event.stationName, + }); + } + } +} diff --git a/backend/src/modules/transit/transit.controller.ts b/backend/src/modules/transit/transit.controller.ts new file mode 100644 index 0000000..9a5478e --- /dev/null +++ b/backend/src/modules/transit/transit.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Get, Post, Param, Body, UseGuards, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard'; +import { TransitService } from './transit.service'; + +@ApiTags('transit') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard) +@Controller('transit') +export class TransitController { + constructor(private readonly transitService: TransitService) {} + + @Get('routes') + @RequiresFeature('transit') + async getRoutes(@CurrentUser() user: AuthUser) { + return this.transitService.getRoutes(user.tenantId); + } + + @Get('routes/:routeId/stations') + @RequiresFeature('transit') + async getStations(@Param('routeId') routeId: string) { + return this.transitService.getStations(routeId); + } + + @Post('trips/start') + @RequiresFeature('transit') + @Roles('admin', 'dispatcher') + async startTrip( + @CurrentUser() user: AuthUser, + @Body() body: { routeId: string; driverId: string }, + ) { + return this.transitService.startTrip(body.routeId, body.driverId); + } + + @Post('trips/:tripId/end') + @RequiresFeature('transit') + @Roles('admin', 'dispatcher') + async endTrip(@Param('tripId') tripId: string) { + return this.transitService.endTrip(tripId); + } + + @Post('tickets/buy') + @RequiresFeature('transit') + async buyTicket( + @CurrentUser() user: AuthUser, + @Body() body: { busTripId: string; price: number }, + ) { + return this.transitService.buyTicket(user.userId, body.busTripId, body.price); + } + + @Get('supervisor/:userId') + @RequiresFeature('transit') + @Roles('admin', 'dispatcher') + async getSupervisorReports(@Param('userId') userId: string) { + return this.transitService.getSupervisorReports(userId); + } +} diff --git a/backend/src/modules/transit/transit.module.ts b/backend/src/modules/transit/transit.module.ts new file mode 100644 index 0000000..1e455e5 --- /dev/null +++ b/backend/src/modules/transit/transit.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TransitService } from './transit.service'; +import { TransitController } from './transit.controller'; +import { TransitBusListener } from './transit-bus-listener'; +import { RealtimeModule } from '../../realtime/realtime.module'; + +@Module({ + imports: [RealtimeModule], + controllers: [TransitController], + providers: [TransitService, TransitBusListener], + exports: [TransitService], +}) +export class TransitModule {} diff --git a/backend/src/modules/transit/transit.service.ts b/backend/src/modules/transit/transit.service.ts new file mode 100644 index 0000000..3627277 --- /dev/null +++ b/backend/src/modules/transit/transit.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface TransitRoute { + id: string; + tenant_id: string; + name: string; + polyline: any; + is_active: boolean; +} + +export interface TransitStation { + id: string; + route_id: string; + name: string; + latitude: number; + longitude: number; + order_index: number; +} + +export interface TransitBusTrip { + id: string; + route_id: string; + driver_id: string; + status: string; + current_station_id: string | null; +} + +@Injectable() +export class TransitService { + private readonly logger = new Logger(TransitService.name); + private readonly baseUrl: string; + + constructor(private readonly config: ConfigService) { + this.baseUrl = this.config.get('transit.serviceUrl') || 'http://localhost:4020'; + } + + async getRoutes(tenantId: string): Promise { + return this.get(`/transit/routes?tenant_id=${tenantId}`); + } + + async getStations(routeId: string): Promise { + return this.get(`/transit/routes/${routeId}/stations`); + } + + async startTrip(routeId: string, driverId: string): Promise { + return this.post('/transit/trips/start', { route_id: routeId, driver_id: driverId }); + } + + async endTrip(tripId: string): Promise { + return this.post(`/transit/trips/${tripId}/end`, {}); + } + + async buyTicket(passengerId: string, busTripId: string, price: number): Promise { + return this.post('/transit/tickets/buy', { passenger_id: passengerId, bus_trip_id: busTripId, price }); + } + + async getSupervisorReports(userId: string): Promise { + return this.get(`/transit/reports/supervisor/${userId}`); + } + + private async get(path: string): Promise { + try { + const res = await fetch(`${this.baseUrl}${path}`); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); + } catch (e: any) { + this.logger.warn(`transit GET ${path} failed: ${e?.message}`); + return []; + } + } + + private async post(path: string, body: any): Promise { + try { + const res = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); + } catch (e: any) { + this.logger.warn(`transit POST ${path} failed: ${e?.message}`); + throw e; + } + } +} diff --git a/backend/src/modules/trips/entities/trip-audio.entity.ts b/backend/src/modules/trips/entities/trip-audio.entity.ts new file mode 100644 index 0000000..08141bb --- /dev/null +++ b/backend/src/modules/trips/entities/trip-audio.entity.ts @@ -0,0 +1,77 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * مقطع صوتي مرفق برحلة (docs/22 — R2). + * + * التخزين على جهاز الراكب/السائق أولاً، ثم رفعه للسيرفر. + * الجدول: tripz_trip_audio. + * + * الغرض: تسجيل صوتي للرحلة يُرفق في شكوى العملاء إن وُجدت. + * كلا الطرفين يُبلَغان أن التسجيل جارٍ عند بدء الرحلة. + */ +@Entity('trip_audio') +@Index(['tenant_id', 'trip_id']) +export class TripAudio { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + @Index() + trip_id: string; + + /** مَن رفع المقطع: rider أو driver. */ + @Column({ type: 'varchar' }) + uploaded_by: 'rider' | 'driver'; + + /** مفتاح التخزين (مسار نسبي في StorageService). */ + @Column({ type: 'varchar' }) + file_key: string; + + /** الامتداد الأصلي للملف. */ + @Column({ type: 'varchar', default: 'm4a' }) + file_ext: string; + + /** مدة المقطع بالثواني. */ + @Column({ type: 'int', nullable: true }) + duration_sec: number | null; + + /** حجم المقطع بالبايت. */ + @Column({ type: 'bigint', nullable: true }) + file_size: number | null; + + // ---- بيانات مساعدة لربط المقطع بالشكوى ---- + + /** اسم الوجهة النهائية (end_name) — يظهر في نموذج الشكوى. */ + @Column({ type: 'varchar', nullable: true }) + destination_name: string | null; + + /** lat/lng نقطة الانطلاق. */ + @Column({ type: 'double precision', nullable: true }) + origin_lat: number | null; + + @Column({ type: 'double precision', nullable: true }) + origin_lng: number | null; + + /** lat/lng الوجهة النهائية. */ + @Column({ type: 'double precision', nullable: true }) + dest_lat: number | null; + + @Column({ type: 'double precision', nullable: true }) + dest_lng: number | null; + + /** حالة الرحلة وقت الرفع. */ + @Column({ type: 'varchar', nullable: true }) + trip_status_at_upload: string | null; + + @CreateDateColumn() + uploaded_at: Date; +} diff --git a/backend/src/modules/trips/search-timeout.sweeper.ts b/backend/src/modules/trips/search-timeout.sweeper.ts new file mode 100644 index 0000000..2ce4880 --- /dev/null +++ b/backend/src/modules/trips/search-timeout.sweeper.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TripsService } from './trips.service'; + +/** + * ينهي الرحلات العالقة في `searching` بعد مهلة (docs/17 — R1). + * + * مكافئ `cron_ride_timeout` في سيرو (15 دقيقة افتراضياً). يعمل داخل + * عملية الـAPI كـ`setInterval` — آمن مع عدة نسخ لأن `expireStuckSearches` + * يستعمل UPDATE شرطياً (searching → no_drivers) فلا تُنهى رحلة مرتين. + */ +@Injectable() +export class SearchTimeoutSweeper implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger('SearchTimeout'); + private timer?: NodeJS.Timeout; + private running = false; + + constructor( + private readonly trips: TripsService, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + const intervalMs = this.config.get('trips.searchTimeoutSweepMs') ?? 60_000; + this.timer = setInterval(() => void this.tick(), intervalMs); + this.logger.log(`search-timeout sweeper up (every ${intervalMs}ms)`); + } + + onModuleDestroy(): void { + if (this.timer) clearInterval(this.timer); + } + + private async tick(): Promise { + if (this.running) return; + this.running = true; + try { + const timeoutMs = this.config.get('trips.searchTimeoutMs') ?? 15 * 60_000; + const n = await this.trips.expireStuckSearches(timeoutMs); + if (n > 0) this.logger.log(`expired ${n} stuck searching trip(s)`); + } catch (e: any) { + this.logger.error(`sweep failed: ${e?.message}`); + } finally { + this.running = false; + } + } +} diff --git a/backend/src/modules/trips/trip-audio.service.ts b/backend/src/modules/trips/trip-audio.service.ts new file mode 100644 index 0000000..610cd06 --- /dev/null +++ b/backend/src/modules/trips/trip-audio.service.ts @@ -0,0 +1,141 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TripAudio } from './entities/trip-audio.entity'; +import { StorageService } from '../../common/storage/storage.service'; +import { TripsService } from './trips.service'; + +/** + * خدمة التسجيل الصوتي للرحلات (docs/22 — R2). + * + * التدفق: + * 1. التطبيق (راكب/سائق) يسجّل الصوت أثناء الرحلة (in_progress). + * 2. عند الإنهاء أو عند رفع الشبكة، يُرفع المقطع للسيرفر. + * 3. السيرفر يحفظه ويربطه بالرحلة + بيانات الوجهة. + * 4. إن وُجدت شكوى، يُرفق المقطع تلقائياً. + */ +@Injectable() +export class TripAudioService { + private readonly logger = new Logger('TripAudio'); + + constructor( + @InjectRepository(TripAudio) + private readonly repo: Repository, + private readonly storage: StorageService, + private readonly trips: TripsService, + ) {} + + /** + * رفع مقطع صوتي مرتبط برحلة. + * + * لا يشترط أن تكون الرحلة `in_progress` — قد يرفع السائق المقطع + * بعد إنهاء الرحلة (التسجيل على الجهاز أولاً ثم الرفع لاحقاً). + * لكن يشترط أن تكون الرحلة **ليست مجدولة** ولا **searching**. + */ + async upload( + tenantId: string, + tripId: string, + userId: string, + role: 'rider' | 'driver', + file: { buffer: Buffer; originalname: string; mimetype: string; size: number }, + meta?: { duration_sec?: number; destination_name?: string }, + ): Promise { + const trip = await this.trips.get(tenantId, tripId); + if (!trip) throw new NotFoundException('Trip not found'); + + // لا تسجيل في رحلة مجدولة أو بحث — فقط from assigned فصاعداً. + if (['scheduled', 'searching'].includes(trip.status)) { + throw new BadRequestException('Cannot upload audio for a trip that has not started'); + } + + // التحقق من أن الرافع هو طرف في الرحلة. + if (role === 'rider' && trip.rider_id !== userId) { + throw new ForbiddenException('Not a participant of this trip'); + } + if (role === 'driver' && trip.driver_id !== userId) { + throw new ForbiddenException('Not a participant of this trip'); + } + + // حفظ الملف في التخزين. + const ext = this.extFromMime(file.mimetype) || 'm4a'; + const key = await this.storage.save( + tenantId, + `audio/${tripId}`, + file.buffer, + `${role}-${Date.now()}.${ext}`, + ); + + const audio = this.repo.create({ + tenant_id: tenantId, + trip_id: tripId, + uploaded_by: role, + file_key: key, + file_ext: ext, + duration_sec: meta?.duration_sec ?? null, + file_size: file.size, + destination_name: meta?.destination_name ?? null, + origin_lat: trip.origin_lat, + origin_lng: trip.origin_lng, + dest_lat: trip.dest_lat, + dest_lng: trip.dest_lng, + trip_status_at_upload: trip.status, + }); + + const saved = await this.repo.save(audio); + this.logger.log( + `audio uploaded: trip=${tripId} by=${role} size=${file.size} dur=${meta?.duration_sec ?? '?'}s`, + ); + return saved; + } + + /** كل المقاطع الصوتية لرحلة معينة. */ + async listByTrip(tenantId: string, tripId: string): Promise { + return this.repo.find({ + where: { tenant_id: tenantId, trip_id: tripId }, + order: { uploaded_at: 'ASC' }, + }); + } + + /** مقطع واحد بالـID. */ + async findById(tenantId: string, id: string): Promise { + const audio = await this.repo.findOne({ + where: { tenant_id: tenantId, id }, + }); + if (!audio) throw new NotFoundException(`Audio ${id} not found`); + return audio; + } + + /** رابط تحميل المقطع الصوتي. */ + async getDownloadUrl(tenantId: string, id: string): Promise<{ url: string }> { + const audio = await this.findById(tenantId, id); + return { url: this.storage.url(audio.file_key) }; + } + + /** هل هذه الرحلة لها تسجيلات صوتية؟ (للشكوى). */ + async hasAudio(tenantId: string, tripId: string): Promise { + const count = await this.repo.count({ + where: { tenant_id: tenantId, trip_id: tripId }, + }); + return count > 0; + } + + private extFromMime(mime: string): string { + const map: Record = { + 'audio/m4a': 'm4a', + 'audio/x-m4a': 'm4a', + 'audio/mp4': 'm4a', + 'audio/aac': 'aac', + 'audio/ogg': 'ogg', + 'audio/webm': 'webm', + 'audio/wav': 'wav', + 'audio/x-wav': 'wav', + }; + return map[mime] ?? 'm4a'; + } +} diff --git a/backend/src/modules/trips/trips.controller.ts b/backend/src/modules/trips/trips.controller.ts index a876753..d2cd0f5 100644 --- a/backend/src/modules/trips/trips.controller.ts +++ b/backend/src/modules/trips/trips.controller.ts @@ -1,6 +1,8 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { TripsService, RequestTripDto } from './trips.service'; +import { TripAudioService } from './trip-audio.service'; import { TripStatus } from './entities/trip.entity'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -12,7 +14,10 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator @UseGuards(JwtAuthGuard) @Controller('trips') export class TripsController { - constructor(private readonly trips: TripsService) {} + constructor( + private readonly trips: TripsService, + private readonly audio: TripAudioService, + ) {} /** سرد رحلات المستأجر — للوحة الأدمن، بترشيح الحالة (docs/22 — N4). */ @UseGuards(RolesGuard) @@ -70,4 +75,39 @@ export class TripsController { const actor = user.role === 'driver' ? 'driver' : 'rider'; return this.trips.cancel(user.tenantId, id, actor, user.userId); } + + // ---- التسجيل الصوتي (R2) ---- + + /** رفع مقطع صوتي مرتبط بالرحلة. */ + @Post(':id/audio') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 } })) + uploadAudio( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @UploadedFile() file: any, + @Body('duration_sec') durationSec?: string, + @Body('destination_name') destName?: string, + ) { + const role = user.role === 'driver' ? 'driver' as const : 'rider' as const; + return this.audio.upload(user.tenantId, id, user.userId, role, file, { + duration_sec: durationSec ? parseInt(durationSec, 10) : undefined, + destination_name: destName, + }); + } + + /** كل المقاطع الصوتية للرحلة. */ + @Get(':id/audio') + listAudio(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.audio.listByTrip(user.tenantId, id); + } + + /** رابط تحميل مقطع صوتي. */ + @Get(':id/audio/:audioId') + getAudioUrl( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Param('audioId') audioId: string, + ) { + return this.audio.getDownloadUrl(user.tenantId, audioId); + } } diff --git a/backend/src/modules/trips/trips.module.ts b/backend/src/modules/trips/trips.module.ts index c25ebd2..7dbcd86 100644 --- a/backend/src/modules/trips/trips.module.ts +++ b/backend/src/modules/trips/trips.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Trip } from './entities/trip.entity'; import { TripEvent } from './entities/trip-event.entity'; +import { TripAudio } from './entities/trip-audio.entity'; import { TripsService } from './trips.service'; -import { ScheduledTripsSweeper } from './scheduled-trips.sweeper'; import { TripStateService } from './trip-state.service'; +import { TripAudioService } from './trip-audio.service'; import { TripsController } from './trips.controller'; import { MapsModule } from '../maps/maps.module'; import { TariffModule } from '../tariff/tariff.module'; @@ -23,7 +24,7 @@ import { DriverTrack } from '../locations/entities/driver-track.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Trip, TripEvent, DriverTrack]), + TypeOrmModule.forFeature([Trip, TripEvent, TripAudio, DriverTrack]), MapsModule, TariffModule, MatchingModule, @@ -38,7 +39,7 @@ import { DriverTrack } from '../locations/entities/driver-track.entity'; RewardsModule, ], controllers: [TripsController], - providers: [TripsService, TripStateService, ScheduledTripsSweeper, TripDistanceService], + providers: [TripsService, TripStateService, TripDistanceService, TripAudioService], exports: [TripsService], }) export class TripsModule {} diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index f1b0c5f..a7ea7ca 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -68,6 +68,8 @@ import { GeofenceService } from '../geofence/geofence.service'; import { CouponsService } from '../rewards/coupons.service'; import { ReferralsService } from '../rewards/referrals.service'; import { TripDistanceService } from './trip-distance.service'; +import { SurgeService } from '../tariff/surge.service'; +import { TierCalculatorService } from '../drivers/tier-calculator.service'; @Injectable() export class TripsService { @@ -92,6 +94,8 @@ export class TripsService { private readonly coupons: CouponsService, private readonly referrals: ReferralsService, private readonly distances: TripDistanceService, + private readonly surge: SurgeService, + private readonly tierCalc: TierCalculatorService, ) {} get(tenantId: string, id: string): Promise { @@ -317,6 +321,53 @@ export class TripsService { return released; } + /** + * إنهاء الرحلات العالقة في `searching` بعد مهلة (docs/17 — R1). + * + * سيرو يستخدم `cron_ride_timeout` بـ15 دقيقة: أي رحلة بحث لم يقبلها + * سائق خلال المهلة تنتقل إلى `no_drivers` وتُنظَّف من Redis. يمنع + * الرحلات من التعليق إلى الأبد عند عدم توفر سائقين. + * + * آمن مع عدة نسخ: `UPDATE ... WHERE status = 'searching'` يضمن ألّا + * تُنهى رحلة مرتين أو تنتقل وهي في حالة أخرى. + */ + async expireStuckSearches(timeoutMs: number): Promise { + const deadline = new Date(Date.now() - timeoutMs); + const candidates = await this.trips.find({ + where: { status: 'searching' as TripStatus, requested_at: LessThanOrEqual(deadline) }, + order: { requested_at: 'ASC' }, + take: 200, + }); + + let count = 0; + for (const trip of candidates) { + // UPDATE شرطي: لا تنتقل إلا إذا كانت لا تزال searching. + const res = await this.trips.update( + { tenant_id: trip.tenant_id, id: trip.id, status: 'searching' }, + { status: 'no_drivers' }, + ); + if (!res.affected) continue; + + await this.state.clear(trip.tenant_id, trip.id); + await this.recordEvent(trip, 'searching', 'no_drivers', 'sweeper'); + + // إشعار الراكب بأن لا سائقين متاحين. + await this.notifyParties(trip.tenant_id, trip, 'no_drivers', null); + + try { + await this.surge.incrementDemand(trip.tenant_id, trip.city ?? 'default', trip.service_class); + } catch (e: any) { + this.logger.warn(`surge incrementDemand failed for expired trip: ${e?.message}`); + } + + this.logger.warn( + `trip ${trip.id} expired after ${(Date.now() - trip.requested_at.getTime()) / 1000}s in searching`, + ); + count++; + } + return count; + } + /** قبول سائق للرحلة (أول قبول يفوز — docs/17 A4). */ async accept(tenantId: string, tripId: string, driverUserId: string) { const driver = await this.drivers.findByUser(tenantId, driverUserId); @@ -480,6 +531,17 @@ export class TripsService { } } + // تحديث مقاييس السائق عند إنهاء الرحلة (شرائح M2): + // total_trips + إعادة حساب الشريحة. + if (toStatus === 'completed' && snapshot.driver_id) { + try { + await this.drivers.incrementTripCount(tenantId, snapshot.driver_id); + await this.tierCalc.updateDriverTier(snapshot.driver_id); + } catch (e: any) { + this.logger.warn(`driver tier update failed: ${e?.message}`); + } + } + // تسوية المحفظة عند الدفع (payment_method === wallet): المبلغ ينتقل من // الراكب للسائق **كاملاً**. العمولة لا تُقتطع هنا — خُصمت من الرصيد // التشغيلي عند الإنهاء (docs/18 §5.5: قاعدة واحدة للكاش والمحفظة). @@ -506,6 +568,20 @@ export class TripsService { } await this.notifyParties(tenantId, trip, toStatus, snapshot.driver_user_id); + + // R2 — إشعار الطرفين بأن التسجيل الصوتي جارٍ عند بدء الرحلة. + if (toStatus === 'in_progress') { + const targets = [trip.rider_id, ...(snapshot.driver_user_id ? [snapshot.driver_user_id] : [])]; + await this.notifications.sendLocalizedToMany( + tenantId, + targets, + 'trip.audio_recording_started', + {}, + { type: 'trip_audio_recording', tripId: trip.id }, + { dataOnly: true }, + ).catch((e: any) => this.logger.warn(`audio recording notification failed: ${e?.message}`)); + } + return trip; } @@ -553,6 +629,14 @@ export class TripsService { await this.cancelLosingOffers(tenantId, tripId, actorUserId); } + if (snapshot.status === 'searching') { + try { + await this.surge.incrementDemand(tenantId, trip.city ?? 'default', trip.service_class); + } catch (e: any) { + this.logger.warn(`surge incrementDemand failed: ${e?.message}`); + } + } + await this.notifyParties(tenantId, trip, 'cancelled', snapshot.driver_user_id, { fee, currency: trip.currency ?? '',