Update backend to use firebase-admin SDK for FCM

This commit is contained in:
Hamza-Ayed
2026-07-19 16:21:43 +03:00
parent 978a7489e6
commit 959e2fa758
76 changed files with 4693 additions and 116 deletions
+33 -4
View File
@@ -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=
+1
View File
@@ -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:
+2 -1
View File
@@ -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",
+10
View File
@@ -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() وحده لا يفعل شيئاً — كان مسجَّلاً منذ البداية
+40
View File
@@ -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<string>('redis.host'),
port: cfg.get<number>('redis.port'),
db: cfg.get<number>('redis.db'),
},
prefix: cfg.get<string>('queue.prefix'),
defaultJobOptions: {
removeOnComplete: 100,
removeOnFail: 50,
},
}),
},
],
exports: [CRON_QUEUE],
})
export class BullModule {}
@@ -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<JobExecution>,
) {}
/** إحصائيات كل المهام + آخر تنفيذ. */
@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<string, {
label: string;
everyMs: number;
recent: Array<{ status: string; started_at: Date; duration_ms: number | null; items_processed: number; error_message: string | null }>;
}> = {};
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;
}
}
@@ -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<CronJobName, string>();
constructor(
@InjectRepository(JobExecution)
private readonly execRepo: Repository<JobExecution>,
@Inject(CRON_QUEUE)
private readonly queue: Queue,
) {}
async onModuleInit(): Promise<void> {
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<void> {
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;
}
}
+51
View File
@@ -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<CronJobName, { everyMs: number; timeoutMs: number }> = {
[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<CronJobName, string> = {
[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',
};
@@ -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<JobExecution>,
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<string>('redis.host'),
port: this.config.get<number>('redis.port'),
db: this.config.get<number>('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<number>('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<string>('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<void> {
if (this.worker) {
await this.worker.close();
this.worker = undefined;
}
}
private async startExecution(jobName: string): Promise<JobExecution> {
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<void> {
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);
}
}
+50
View File
@@ -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<void> {
await this.worker.stop();
}
}
@@ -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;
}
+19 -12
View File
@@ -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<TenantPlan, { features: Feature[]; limits: Limits }>
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: {
+2
View File
@@ -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}' },
};
+17 -3
View File
@@ -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 ?? '',
},
});
@@ -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<void> {
// ─── 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<void> {
// إزالة أعمدة الشرائح
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`);
}
}
@@ -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<string>('cliq.baseUrl') ?? 'https://api.cliq.jo';
}
private creds(tenant: Tenant) {
const t = tenant?.settings?.payments?.cliq ?? {};
return {
merchantId: t.merchantId ?? this.config.get<string>('cliq.merchantId') ?? '',
apiKey: t.apiKey ?? this.config.get<string>('cliq.apiKey') ?? '',
secretKey: t.secretKey ?? this.config.get<string>('cliq.secretKey') ?? '',
terminalId: t.terminalId ?? this.config.get<string>('cliq.terminalId') ?? '',
};
}
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
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<string>('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<string, any>, 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,
};
}
}
@@ -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();
@@ -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<string>('mtn.baseUrl') ?? 'https://proxy.momoapi.mtn.com';
}
private creds(tenant: Tenant) {
const t = tenant?.settings?.payments?.mtn ?? {};
return {
apiKey: t.apiKey ?? this.config.get<string>('mtn.apiKey') ?? '',
apiUser: t.apiUser ?? this.config.get<string>('mtn.apiUser') ?? '',
subscriptionKey: t.subscriptionKey ?? this.config.get<string>('mtn.subscriptionKey') ?? '',
environment: t.environment ?? this.config.get<string>('mtn.environment') ?? 'sandbox',
};
}
private async getAccessToken(creds: { apiKey: string; apiUser: string; subscriptionKey: string }): Promise<string> {
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<ChargeResult> {
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<string, any>, 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,
};
}
}
@@ -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<string, PaymentAdapter>();
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));
}
}
@@ -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 {}
@@ -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<string>('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<string>('syriatel.merchantCode') ?? '',
apiKey: t.apiKey ?? this.config.get<string>('syriatel.apiKey') ?? '',
secretKey: t.secretKey ?? this.config.get<string>('syriatel.secretKey') ?? '',
serviceId: t.serviceId ?? this.config.get<string>('syriatel.serviceId') ?? '',
};
}
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
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<string>('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<string, any>, 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,
};
}
}
@@ -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<string, any>;
},
) {
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);
}
}
+13
View File
@@ -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 {}
+70
View File
@@ -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<BotTask>,
) {}
async createTask(data: Partial<BotTask>): Promise<BotTask> {
const task = this.taskRepo.create(data);
return this.taskRepo.save(task);
}
async findAll(tenantId: string): Promise<BotTask[]> {
return this.taskRepo.find({
where: { tenant_id: tenantId },
order: { created_at: 'DESC' },
take: 100,
});
}
async findOne(id: string): Promise<BotTask | null> {
return this.taskRepo.findOne({ where: { id } });
}
async updateStatus(id: string, status: BotTaskStatus, result?: Record<string, any>): Promise<BotTask> {
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<BotTaskStatus, number>;
by_platform: Record<BotPlatform, number>;
}> {
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<BotTask[]> {
return this.taskRepo.find({
where: { status: 'pending' },
order: { created_at: 'ASC' },
take: 50,
});
}
async cancelTask(id: string): Promise<BotTask> {
return this.updateStatus(id, 'cancelled');
}
}
@@ -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<string, any>;
@Column({ type: 'jsonb', default: {} })
result: Record<string, any>;
@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;
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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<FeatureCatalog, 'id' | 'created_at' | 'updated_at'>[] = [
// ---- أساسيات (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<TenantPlan, number> = {
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<FeatureCatalog>,
@InjectRepository(RideTypeCatalog)
private readonly rideTypeRepo: Repository<RideTypeCatalog>,
) {}
/** زراعة الكتالوج — آمنة للتشغيل المتكرر (upsert بالـkey). */
async seed(): Promise<number> {
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<FeatureCatalog[]> {
const where: any = {};
if (category) where.category = category;
return this.repo.find({ where, order: { sort_order: 'ASC' } });
}
/** عنصر واحد بالـkey. */
async findByKey(key: string): Promise<FeatureCatalog> {
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<FeatureCatalog> {
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<Pick<FeatureCatalog, 'monthly_price' | 'setup_fee' | 'currency' | 'description_ar' | 'name_ar' | 'active' | 'sort_order'>>,
): Promise<FeatureCatalog> {
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<number> {
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<RideTypeCatalog[]> {
const where: any = {};
if (vehicleKind) where.vehicle_kind = vehicleKind;
return this.rideTypeRepo.find({ where, order: { sort: 'ASC' } });
}
/** نوع واحد بالـcode. */
async findRideTypeByCode(code: string): Promise<RideTypeCatalog> {
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<RideTypeCatalog> {
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<Pick<RideTypeCatalog, 'code' | 'name_ar' | 'name_en' | 'vehicle_kind' | 'women_only' | 'round_trip_supported' | 'requires_weight' | 'max_seats' | 'icon' | 'sort'>>,
): Promise<RideTypeCatalog> {
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<Pick<RideTypeCatalog, 'name_ar' | 'name_en' | 'vehicle_kind' | 'women_only' | 'round_trip_supported' | 'requires_weight' | 'max_seats' | 'icon' | 'sort' | 'active'>>,
): Promise<RideTypeCatalog> {
const item = await this.findRideTypeById(id);
Object.assign(item, patch);
return this.rideTypeRepo.save(item);
}
/** تنشيط / تعطيل نوع. */
async toggleRideType(id: string, active: boolean): Promise<RideTypeCatalog> {
const item = await this.findRideTypeById(id);
item.active = active;
return this.rideTypeRepo.save(item);
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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<Driver>,
) {}
@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();
}
}
@@ -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 {}
@@ -119,6 +119,15 @@ export class DriversService {
);
}
async incrementTripCount(tenantId: string, driverId: string): Promise<void> {
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<Driver> {
const driver = await this.findById(tenantId, driverId);
if (!driver) throw new NotFoundException('Driver not found');
@@ -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).
@@ -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<Driver>,
) {}
async calculateScore(driverId: string): Promise<number> {
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<DriverTier> {
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,
};
}
}
@@ -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,
);
}
}
@@ -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 {}
@@ -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<GeofenceZone[]> {
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<GeofenceZone> {
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<Pick<GeofenceZone, 'name' | 'latitude' | 'longitude' | 'radius_meters' | 'priority' | 'pricing_multiplier' | 'campaign_title' | 'campaign_body'>>,
): Promise<GeofenceZone> {
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<Pick<GeofenceZone, 'name' | 'latitude' | 'longitude' | 'radius_meters' | 'priority' | 'is_active' | 'pricing_multiplier' | 'campaign_title' | 'campaign_body'>>,
): Promise<GeofenceZone> {
const zone = await this.findZoneById(tenantId, id);
Object.assign(zone, patch);
return this.zonesRepo.save(zone);
}
/** حذف منطقة. */
async deleteZone(tenantId: string, id: string): Promise<void> {
const zone = await this.findZoneById(tenantId, id);
await this.zonesRepo.remove(zone);
}
/** تنشيط / تعطيل منطقة. */
async toggleZone(tenantId: string, id: string, active: boolean): Promise<GeofenceZone> {
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();
}
}
@@ -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);
}
}
@@ -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<DriverTrack>,
@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<Array<{ cell: string; count: number; lat: number; lng: number }>> {
// التحقق من الكاش أولاً
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<string, { count: number; lat: number; lng: number }>();
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<void> {
const pattern = `${HeatmapService.CACHE_PREFIX}${tenantId}:*`;
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
}
@@ -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 {}
@@ -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<string, any>;
@Column({ type: 'jsonb', default: {} })
targeting: Record<string, any>;
@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;
}
@@ -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<string, any>;
targeting?: Record<string, any>;
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);
}
}
@@ -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 {}
@@ -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<Campaign>,
@InjectRepository(Trip)
private readonly tripsRepo: Repository<Trip>,
private readonly notifications: NotificationsService,
private readonly gemini: GeminiService,
) {}
async create(data: Partial<Campaign>): Promise<Campaign> {
const campaign = this.campaignRepo.create(data);
return this.campaignRepo.save(campaign);
}
async findAll(tenantId: string): Promise<Campaign[]> {
return this.campaignRepo.find({
where: { tenant_id: tenantId },
order: { created_at: 'DESC' },
});
}
async findOne(id: string): Promise<Campaign | null> {
return this.campaignRepo.findOne({ where: { id } });
}
async updateStatus(id: string, status: CampaignStatus): Promise<Campaign> {
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<any> {
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<void> {
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<Campaign[]> {
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<void> {
await this.campaignRepo.delete(id);
}
/**
* جلب معرّفات المستخدمين الذين لم ي新开وا رحلة منذ N يوم.
* يُستخدم لاستهداف حملات إعادة التفاعل فعلياً بدل القائمة الفارغة.
*/
async findInactiveUserIds(tenantId: string, inactiveDays = 3): Promise<string[]> {
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));
}
}
@@ -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],
})
@@ -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<DriverTier, number> = {
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<Driver>,
) { }
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<NearbyDriver[]> {
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);
}
}
@@ -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<string>('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<string, any>,
opts: PushOptions,
) {
const key = this.config.get<string>('fcm.serverKey');
if (!key || rows.length === 0) {
this.logger.debug(`push skip (key=${!!key} tokens=${rows.length}) "${title}"`);
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<string>('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}`);
}
}
@@ -272,4 +272,23 @@ export class ReferralsService {
ref: `${referral.id}:${side}`,
});
}
/**
* صرف الإحالات المؤهَّلة (O5 — يُستدعى من CronWorker).
* شبكة أمان: ما فُصِل يُعاد محاولته في الدورة القادمة.
* @returns عدد الإحالات التي نجح صرفها.
*/
async sweep(batchSize = 50): Promise<number> {
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;
}
}
@@ -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 {}
@@ -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);
}
}
@@ -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;
}
@@ -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 };
}
@@ -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,
);
}
}
+255
View File
@@ -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<SurgeState>,
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
private readonly tariffService: TariffService,
private readonly config: ConfigService,
) {}
/**
* إعادة حساب المضاعفات لجميع الحالات النشطة.
* ي refresh عرض السائقين من قاعدة البيانات قبل كل حساب.
*/
async recalculateAll(): Promise<{ updated: number; unchanged: number }> {
const cooldownMs = this.config.get<number>('trips.surgeCooldownMs') ?? 300_000;
const sensitivity = this.config.get<number>('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<void> {
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<void> {
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<void> {
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<SurgeState> {
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<void> {
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<SurgeState | null> {
return this.repo.findOne({ where: { tenant_id: tenantId, city, service_class: serviceClass } });
}
/** كل الحالات النشطة لمستأجر. */
async listStates(tenantId: string): Promise<SurgeState[]> {
return this.repo.find({ where: { tenant_id: tenantId }, order: { city: 'ASC', service_class: 'ASC' } });
}
// ─── داخلي ────────────────────────────────────────────────────
/**
* تحديث عدد السائقين المتاحين لكل حالة surge من قاعدة البيانات الفعلية
* بدلاً من الاعتماد على عداد ثابت. يُستدعى قبل كل إعادة حساب.
*/
private async refreshSupplyFromDrivers(now: Date): Promise<void> {
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<SurgeState> {
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;
}
}
@@ -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);
});
});
+23 -8
View File
@@ -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,
+9 -4
View File
@@ -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 {}
+106 -5
View File
@@ -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<Tariff>): Promise<Tariff> {
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<Tariff[]> {
return this.repo.find({
where: { tenant_id: tenantId },
order: { service_class: 'ASC', version: 'DESC' },
});
}
/** تعرفة واحدة بالـID — يرمي NotFoundException لو غير موجودة. */
async findById(tenantId: string, id: string): Promise<Tariff> {
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<Tariff> {
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<Tariff> {
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<Tariff> {
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<void> {
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),
);
}
}
@@ -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',
);
}
}
@@ -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],
@@ -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)
);
});
@@ -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<TenantRevenueEntry>,
@InjectRepository(TenantPendingEntry)
private readonly pending: Repository<TenantPendingEntry>,
@InjectRepository(Payment)
private readonly payments: Repository<Payment>,
@InjectRepository(Payout)
private readonly payouts: Repository<Payout>,
@InjectRepository(Trip)
private readonly trips: Repository<Trip>,
) {}
/**
@@ -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<string, { count: number; total_amount: number }> = {};
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,
};
}
}
@@ -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')
@@ -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],
@@ -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<void> {
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,
});
}
}
}
@@ -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);
}
}
@@ -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 {}
@@ -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<string>('transit.serviceUrl') || 'http://localhost:4020';
}
async getRoutes(tenantId: string): Promise<TransitRoute[]> {
return this.get(`/transit/routes?tenant_id=${tenantId}`);
}
async getStations(routeId: string): Promise<TransitStation[]> {
return this.get(`/transit/routes/${routeId}/stations`);
}
async startTrip(routeId: string, driverId: string): Promise<TransitBusTrip> {
return this.post('/transit/trips/start', { route_id: routeId, driver_id: driverId });
}
async endTrip(tripId: string): Promise<TransitBusTrip> {
return this.post(`/transit/trips/${tripId}/end`, {});
}
async buyTicket(passengerId: string, busTripId: string, price: number): Promise<any> {
return this.post('/transit/tickets/buy', { passenger_id: passengerId, bus_trip_id: busTripId, price });
}
async getSupervisorReports(userId: string): Promise<any> {
return this.get(`/transit/reports/supervisor/${userId}`);
}
private async get(path: string): Promise<any> {
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<any> {
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;
}
}
}
@@ -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;
}
@@ -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<number>('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<void> {
if (this.running) return;
this.running = true;
try {
const timeoutMs = this.config.get<number>('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;
}
}
}
@@ -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<TripAudio>,
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<TripAudio> {
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<TripAudio[]> {
return this.repo.find({
where: { tenant_id: tenantId, trip_id: tripId },
order: { uploaded_at: 'ASC' },
});
}
/** مقطع واحد بالـID. */
async findById(tenantId: string, id: string): Promise<TripAudio> {
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<boolean> {
const count = await this.repo.count({
where: { tenant_id: tenantId, trip_id: tripId },
});
return count > 0;
}
private extFromMime(mime: string): string {
const map: Record<string, string> = {
'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';
}
}
+42 -2
View File
@@ -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);
}
}
+4 -3
View File
@@ -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 {}
@@ -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<Trip | null> {
@@ -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<number> {
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 ?? '',