chore: أرشفة باك إند NestJS إلى backend-archive
قرار المالك 2026-07-27: الباك إند المعتمد صار باك إند سيرو PHP، ويُنقل إلى هذا المستودع كنسخة جديدة باسم «انطلق» على api.intaleqapp.com. backend/ → backend-archive/ (305 ملفاً، إعادة تسمية بلا تعديل محتوى) + README-ARCHIVE.md يوضّح سبب الأرشفة وسبب عدم الحذف: scripts/e2e-test.mjs هو أدق توثيق سلوكي لدورة الرحلة كاملة، ويبقى مرجعاً عند بناء الوحدات الناقصة في باك إند PHP (المحفظتان · الاستحقاقات · محرّك التسعير · الوحدات). مسارات backend/ في docs/*.md لم تُعدَّل عمداً — سجلّ تاريخي. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
75b80bd299
commit
fff76c949f
@@ -0,0 +1,136 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import configuration from './config/configuration';
|
||||
import { TenantMiddleware } from './common/tenant/tenant.middleware';
|
||||
import { RedisModule } from './common/redis/redis.module';
|
||||
import { I18nModule } from './common/i18n/i18n.module';
|
||||
import { CacheModule } from './common/cache/cache.module';
|
||||
import { EntitlementsModule } from './common/entitlements/entitlements.module';
|
||||
import { PlatformModule } from './common/platform/platform.module';
|
||||
import { AuditModule } from './common/audit/audit.module';
|
||||
import { SigningModule } from './common/signing/signing.module';
|
||||
import { PhoneModule } from './common/phone/phone.module';
|
||||
import { DeviceModule } from './common/device/device.module';
|
||||
import { SeedModule } from './common/seed/seed.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { TenantsModule } from './modules/tenants/tenants.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { DriversModule } from './modules/drivers/drivers.module';
|
||||
import { MatchingModule } from './modules/matching/matching.module';
|
||||
import { LocationsModule } from './modules/locations/locations.module';
|
||||
import { TariffModule } from './modules/tariff/tariff.module';
|
||||
import { MapsModule } from './modules/maps/maps.module';
|
||||
import { TripsModule } from './modules/trips/trips.module';
|
||||
import { RealtimeModule } from './realtime/realtime.module';
|
||||
import { FraudModule } from './modules/fraud/fraud.module';
|
||||
import { ChatModule } from './modules/chat/chat.module';
|
||||
import { RatingsModule } from './modules/ratings/ratings.module';
|
||||
import { NabehModule } from './integrations/nabeh/nabeh.module';
|
||||
import { OtpModule } from './integrations/otp/otp.module';
|
||||
import { RideTypesModule } from './modules/ride-types/ride-types.module';
|
||||
import { DispatchModule } from './modules/dispatch/dispatch.module';
|
||||
import { WalletModule } from './modules/wallet/wallet.module';
|
||||
import { TenantWalletModule } from './modules/tenant-wallet/tenant-wallet.module';
|
||||
import { CreditModule } from './modules/credit/credit.module';
|
||||
import { RewardsModule } from './modules/rewards/rewards.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { PaymentsModule } from './modules/payments/payments.module';
|
||||
import { StorageModule } from './common/storage/storage.module';
|
||||
import { DocumentsModule } from './modules/documents/documents.module';
|
||||
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: [
|
||||
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
|
||||
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (cfg: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: cfg.get<string>('db.host'),
|
||||
port: cfg.get<number>('db.port'),
|
||||
database: cfg.get<string>('db.name'),
|
||||
username: cfg.get<string>('db.user'),
|
||||
password: cfg.get<string>('db.password'),
|
||||
// بركة اتصالات أكبر — يرفع سقف التزامن تحت الحمل العالي
|
||||
extra: { max: parseInt(process.env.DB_POOL_MAX ?? '30', 10) },
|
||||
// بادئة الجداول (tripz_) لعزل السيرفر المشترك — راجع docs/14
|
||||
entityPrefix: cfg.get<string>('db.tablePrefix'),
|
||||
synchronize: cfg.get<boolean>('db.synchronize'),
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
}),
|
||||
|
||||
ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }]),
|
||||
|
||||
RedisModule, // عالمي — عميل Redis للمطابقة و OTP
|
||||
I18nModule, // عالمي — ترجمة نصوص الإشعارات
|
||||
CacheModule, // عالمي — كاش-جانبي فوق Redis (خط أول قبل القاعدة)
|
||||
EntitlementsModule, // عالمي — استحقاقات الاشتراك + FeatureGuard
|
||||
PlatformModule, // عالمي — حارس السوبر-أدمن (x-platform-secret)
|
||||
AuditModule, // عالمي — سجل التدقيق المالي
|
||||
SigningModule, // عالمي — توقيع HMAC للعمليات المالية
|
||||
PhoneModule, // عالمي — تطبيع أرقام الهاتف لكل دولة
|
||||
DeviceModule, // عالمي — ربط الجلسة بالجهاز
|
||||
NabehModule, // عالمي — إرسال OTP واتساب (سوريا/الأردن)
|
||||
OtpModule, // عالمي — توجيه OTP متعدد المزوّدين حسب الدولة + failover
|
||||
NotificationsModule, // عالمي — FCM
|
||||
StorageModule, // عالمي — تخزين ملفات الوثائق
|
||||
GeminiModule, // عالمي — رؤية AI (وثائق + وجه)
|
||||
GeofenceModule,
|
||||
|
||||
HealthModule,
|
||||
TenantsModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
MapsModule,
|
||||
MatchingModule,
|
||||
LocationsModule,
|
||||
TariffModule,
|
||||
RideTypesModule,
|
||||
DriversModule,
|
||||
RealtimeModule,
|
||||
FraudModule,
|
||||
WalletModule,
|
||||
TenantWalletModule, // دفترا المستأجر: إيراد وأمانات (docs/24)
|
||||
CreditModule,
|
||||
RewardsModule, // الإحالات والكوبونات (المجموعة L)
|
||||
PaymentsModule,
|
||||
BillingModule,
|
||||
TripsModule,
|
||||
DispatchModule,
|
||||
ChatModule,
|
||||
RatingsModule,
|
||||
DocumentsModule,
|
||||
VehiclesModule,
|
||||
SeedModule,
|
||||
CatalogModule,
|
||||
CronModule, // O5 — المهام الدورية الموحّدة على BullMQ
|
||||
MarketingModule, // O3 — محرك التسويق التلقائي
|
||||
TransitModule, // O2 — نظام المواصلات
|
||||
BotModule, // O4 — بوتات التواصل الاجتماعي
|
||||
],
|
||||
providers: [
|
||||
// ThrottlerModule.forRoot() وحده لا يفعل شيئاً — كان مسجَّلاً منذ البداية
|
||||
// بلا أي حارس يطبّقه، فتحديد 120/60s لم يكن ساري المفعول إطلاقاً على أي
|
||||
// نقطة. APP_GUARD يُفعّله عالمياً على كل نقطة تلقائياً (docs/17 — D4).
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(TenantMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* سجل التدقيق المالي (docs/17 — I7؛ مكافئ `admin_audit_log` عند سيرو).
|
||||
* الجدول: tripz_audit_log. **append-only** — لا تحديث ولا حذف.
|
||||
*
|
||||
* يجيب عن سؤال واحد عند كل نزاع: **من فعل ماذا ومتى ومن أين؟**
|
||||
*/
|
||||
@Entity('audit_log')
|
||||
@Index(['tenant_id', 'created_at'])
|
||||
@Index(['tenant_id', 'subject_type', 'subject_id'])
|
||||
export class AuditLog {
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
/** من نفّذ الفعل — قد يكون السائق نفسه أو أدمن أو `null` للنظام. */
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
actor_user_id: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
actor_role: string | null;
|
||||
|
||||
/** payout.request · payout.confirm · payout.complete · payout.fail · credit.topup … */
|
||||
@Column()
|
||||
action: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
subject_type: string | null; // payout | credit | wallet
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
subject_id: string | null;
|
||||
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
amount: number | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
currency: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
meta: Record<string, any>;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
ip: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditLog } from './audit-log.entity';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AuditLog])],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuditLog } from './audit-log.entity';
|
||||
|
||||
export interface AuditEntry {
|
||||
tenantId: string;
|
||||
action: string;
|
||||
actorUserId?: string | null;
|
||||
actorRole?: string | null;
|
||||
subjectType?: string;
|
||||
subjectId?: string;
|
||||
amount?: number | null;
|
||||
currency?: string | null;
|
||||
meta?: Record<string, any>;
|
||||
ip?: string | null;
|
||||
}
|
||||
|
||||
/** سجل التدقيق المالي (docs/17 — I7). */
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger('Audit');
|
||||
|
||||
constructor(@InjectRepository(AuditLog) private readonly repo: Repository<AuditLog>) {}
|
||||
|
||||
/**
|
||||
* يسجّل فعلاً مالياً.
|
||||
*
|
||||
* **لا يرمي أبداً**: فشل التدقيق يجب ألّا يُسقط عمليةً ماليةً نجحت — وإلا
|
||||
* صار السجل نقطة فشل بدل أن يكون شبكة أمان. الفشل يُسجَّل في اللوغ ليُرى.
|
||||
*/
|
||||
async record(entry: AuditEntry): Promise<void> {
|
||||
try {
|
||||
await this.repo.insert({
|
||||
tenant_id: entry.tenantId,
|
||||
actor_user_id: entry.actorUserId ?? null,
|
||||
actor_role: entry.actorRole ?? null,
|
||||
action: entry.action,
|
||||
subject_type: entry.subjectType ?? null,
|
||||
subject_id: entry.subjectId ?? null,
|
||||
amount: entry.amount ?? null,
|
||||
currency: entry.currency ?? null,
|
||||
meta: entry.meta ?? {},
|
||||
ip: entry.ip ?? null,
|
||||
});
|
||||
} catch (e: any) {
|
||||
this.logger.error(`audit write failed [${entry.action}]: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** سجلّ موضوع بعينه — للتحقيق في نزاع. */
|
||||
forSubject(tenantId: string, subjectType: string, subjectId: string) {
|
||||
return this.repo.find({
|
||||
where: { tenant_id: tenantId, subject_type: subjectType, subject_id: subjectId },
|
||||
order: { created_at: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CacheService],
|
||||
exports: [CacheService],
|
||||
})
|
||||
export class CacheModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
import RedisMock from 'ioredis-mock';
|
||||
import { CacheService, CacheKeys } from './cache.service';
|
||||
|
||||
describe('CacheService', () => {
|
||||
let redis: any;
|
||||
let cache: CacheService;
|
||||
|
||||
// ioredis-mock يشارك مخزناً واحداً بين النسخ ذات الإعدادات نفسها —
|
||||
// بلا flushall تتسرّب مفاتيح اختبار إلى الذي يليه.
|
||||
beforeEach(async () => {
|
||||
redis = new RedisMock({ keyPrefix: 'tripz:' });
|
||||
await redis.flushall();
|
||||
cache = new CacheService(redis);
|
||||
});
|
||||
|
||||
it('يخزّن ويقرأ قيمة مركّبة', async () => {
|
||||
await cache.set('k', { a: 1, b: ['x'] }, 60);
|
||||
expect(await cache.get<{ a: number; b: string[] }>('k')).toEqual({ a: 1, b: ['x'] });
|
||||
});
|
||||
|
||||
it('يرجع null لمفتاح غائب', async () => {
|
||||
expect(await cache.get('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('wrap: يحمّل من المصدر مرة واحدة ثم يخدم من الكاش', async () => {
|
||||
const loader = jest.fn().mockResolvedValue({ id: 't1' });
|
||||
|
||||
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
|
||||
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('wrap: لا يخزّن null — الغياب المؤقّت لا يُثبَّت', async () => {
|
||||
const loader = jest.fn().mockResolvedValue(null);
|
||||
|
||||
expect(await cache.wrap('k', 60, loader)).toBeNull();
|
||||
expect(await cache.wrap('k', 60, loader)).toBeNull();
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('del يُبطل المفتاح فيعود المصدر', async () => {
|
||||
const loader = jest.fn().mockResolvedValue('v1');
|
||||
await cache.wrap('k', 60, loader);
|
||||
await cache.del('k');
|
||||
await cache.wrap('k', 60, loader);
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('mget يقرأ عدة مفاتيح ويعطي null للغائب', async () => {
|
||||
await cache.set('a', 1, 60);
|
||||
await cache.set('c', 3, 60);
|
||||
expect(await cache.mget<number>(['a', 'b', 'c'])).toEqual([1, null, 3]);
|
||||
});
|
||||
|
||||
it('فشل Redis لا يُسقط الطلب — يرجع للمصدر', async () => {
|
||||
const broken = {
|
||||
get: jest.fn().mockRejectedValue(new Error('redis down')),
|
||||
set: jest.fn().mockRejectedValue(new Error('redis down')),
|
||||
} as any;
|
||||
const c = new CacheService(broken);
|
||||
const loader = jest.fn().mockResolvedValue('from-db');
|
||||
|
||||
expect(await c.wrap('k', 60, loader)).toBe('from-db');
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('المفاتيح معزولة بالمستأجر', () => {
|
||||
expect(CacheKeys.userLang('t1', 'u1')).not.toBe(CacheKeys.userLang('t2', 'u1'));
|
||||
expect(CacheKeys.tariff('t1', 'amman', 'economy')).toBe('tariff:t1:amman:economy');
|
||||
});
|
||||
});
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS } from '../redis/redis.module';
|
||||
|
||||
/** أعمار الكاش بالثواني — مجمّعة هنا ليسهل ضبطها (docs/17 — G). */
|
||||
export const TTL = {
|
||||
/** المستأجر يتغيّر نادراً جداً. */
|
||||
tenant: 3600,
|
||||
/** لغة المستخدم — يرفعها فلاتر عند الفتح. */
|
||||
userLang: 24 * 3600,
|
||||
/** توكنات FCM للجهاز. */
|
||||
deviceTokens: 6 * 3600,
|
||||
/** التعرفة و ride-types — شبه ثابتة، وتُبطَل صراحةً عند تعديل الأدمن. */
|
||||
catalog: 900,
|
||||
/** نتائج الخرائط — عناوين ومسارات لا تتغيّر عملياً. */
|
||||
maps: 24 * 3600,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* كاش-جانبي (cache-aside) فوق Redis — «الريدز خط أول، القاعدة خط احتياط»
|
||||
* (docs/17 — G).
|
||||
*
|
||||
* مبدأ صارم: **فشل Redis لا يُسقط الطلب**. أي خطأ هنا يُسجَّل ويُرجَع للقاعدة،
|
||||
* لأن الكاش تحسين أداء لا مصدر حقيقة.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
private readonly logger = new Logger('Cache');
|
||||
|
||||
constructor(@Inject(REDIS) private readonly redis: Redis) {}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await this.redis.get(key);
|
||||
return raw == null ? null : (JSON.parse(raw) as T);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`get ${key} failed: ${e?.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, value: unknown, ttlSec: number): Promise<void> {
|
||||
try {
|
||||
await this.redis.set(key, JSON.stringify(value), 'EX', ttlSec);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`set ${key} failed: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async del(...keys: string[]): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
try {
|
||||
await this.redis.del(...keys);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`del failed: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* يقرأ من الكاش، وعند الغياب يحمّل من المصدر ويخزّن.
|
||||
* `null` من المصدر لا يُخزَّن — لئلا يُثبَّت غياب مؤقّت لمدة TTL كاملة.
|
||||
*/
|
||||
async wrap<T>(key: string, ttlSec: number, loader: () => Promise<T | null>): Promise<T | null> {
|
||||
const hit = await this.get<T>(key);
|
||||
if (hit !== null) return hit;
|
||||
|
||||
const fresh = await loader();
|
||||
if (fresh !== null && fresh !== undefined) await this.set(key, fresh, ttlSec);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** قراءة عدة مفاتيح بجولة واحدة — للبث الجماعي (docs/17 — G3). */
|
||||
async mget<T>(keys: string[]): Promise<(T | null)[]> {
|
||||
if (keys.length === 0) return [];
|
||||
try {
|
||||
const raws = await this.redis.mget(...keys);
|
||||
return raws.map((raw) => {
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`mget failed: ${e?.message}`);
|
||||
return keys.map(() => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** مفاتيح الكاش — مجمّعة لتفادي التضارب ولتسهيل الإبطال. */
|
||||
export const CacheKeys = {
|
||||
tenant: (slugOrId: string) => `tenant:${slugOrId}`,
|
||||
userLang: (tenantId: string, userId: string) => `user:lang:${tenantId}:${userId}`,
|
||||
deviceTokens: (tenantId: string, userId: string) => `user:fcm:${tenantId}:${userId}`,
|
||||
tariff: (tenantId: string, city: string, serviceClass: string) =>
|
||||
`tariff:${tenantId}:${city}:${serviceClass}`,
|
||||
rideTypes: (tenantId: string) => `ridetypes:${tenantId}`,
|
||||
mapsRoute: (from: string, to: string) => `maps:route:${from}:${to}`,
|
||||
mapsReverse: (lat: string, lng: string) => `maps:rev:${lat}:${lng}`,
|
||||
mapsGeocode: (country: string, q: string) => `maps:geo:${country}:${q}`,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
} from 'crypto';
|
||||
|
||||
/**
|
||||
* تشفير الحقول الحساسة عند الراحة (at-rest) بـ AES-256-GCM مع IV عشوائي لكل قيمة
|
||||
* (تشفير موثَّق authenticated — يمنع العبث). صيغة التخزين: v1:base64(iv|tag|ct).
|
||||
* IV عشوائي 96-بت لكل عملية — لا IV ثابت إطلاقاً (راجع docs/16).
|
||||
*
|
||||
* للحقول القابلة للبحث بالتساوي (مثل الهاتف): تشفير GCM عشوائي لا يسمح بالمطابقة،
|
||||
* فنستخدم blindIndex (HMAC حتمي) كعمود بحث منفصل — مخطّط لاحقاً.
|
||||
*/
|
||||
const VERSION = 'v1';
|
||||
|
||||
function getKey(): Buffer {
|
||||
const raw = process.env.ENCRYPTION_KEY || '';
|
||||
if (/^[0-9a-f]{64}$/i.test(raw)) return Buffer.from(raw, 'hex'); // 32 بايت hex
|
||||
if (!raw) return createHash('sha256').update('tripz-dev-insecure-key').digest();
|
||||
return createHash('sha256').update(raw).digest(); // أي سر → 32 بايت
|
||||
}
|
||||
|
||||
export function encrypt(plain: string | null | undefined): string | null {
|
||||
if (plain === null || plain === undefined) return plain as any;
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', getKey(), iv);
|
||||
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${VERSION}:${Buffer.concat([iv, tag, ct]).toString('base64')}`;
|
||||
}
|
||||
|
||||
export function decrypt(value: string | null | undefined): string | null {
|
||||
if (value === null || value === undefined) return value as any;
|
||||
if (typeof value !== 'string' || !value.startsWith(`${VERSION}:`)) {
|
||||
return value as any; // نص قديم غير مشفّر — تسامح أثناء الانتقال
|
||||
}
|
||||
try {
|
||||
const raw = Buffer.from(value.slice(VERSION.length + 1), 'base64');
|
||||
const iv = raw.subarray(0, 12);
|
||||
const tag = raw.subarray(12, 28);
|
||||
const ct = raw.subarray(28);
|
||||
const d = createDecipheriv('aes-256-gcm', getKey(), iv);
|
||||
d.setAuthTag(tag);
|
||||
return Buffer.concat([d.update(ct), d.final()]).toString('utf8');
|
||||
} catch {
|
||||
return value as any; // لا نُسقط القراءة عند قيمة تالفة/قديمة
|
||||
}
|
||||
}
|
||||
|
||||
/** فهرس أعمى حتمي للحقول القابلة للبحث (مثل الهاتف) — HMAC-SHA256. */
|
||||
export function blindIndex(value: string): string {
|
||||
const key = process.env.ENCRYPTION_KEY || 'tripz-dev-insecure-key';
|
||||
return createHmac('sha256', key).update(value.trim().toLowerCase()).digest('hex');
|
||||
}
|
||||
|
||||
/** محوّل TypeORM: يشفّر عند الكتابة ويفكّ عند القراءة تلقائياً. */
|
||||
export const EncryptedTransformer = {
|
||||
to: (v: string | null) => encrypt(v),
|
||||
from: (v: string | null) => decrypt(v),
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { DeviceService } from './device.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DeviceService],
|
||||
exports: [DeviceService],
|
||||
})
|
||||
export class DeviceModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/**
|
||||
* ربط الجلسة بالجهاز (docs/17 — D2؛ نمط سيرو).
|
||||
*
|
||||
* فلاتر يولّد بصمة جهاز محلية (device_info_plus) ويرسلها **مرة عند الدخول**
|
||||
* ثم في ترويسة `x-device-id` مع كل طلب لاحق. البصمة الخام لا تُخزَّن — فقط
|
||||
* بصمتها (hash) داخل التوكن، بنفس منطق عدم تخزين أسرار خام.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DeviceService {
|
||||
hash(rawDeviceId: string): string {
|
||||
return createHash('sha256').update(rawDeviceId ?? '').digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import { EntitlementsService } from './entitlements.service';
|
||||
import { FeatureGuard } from './feature.guard';
|
||||
|
||||
/**
|
||||
* عالمي: `FeatureGuard` يُستعمل في وحدات كثيرة، وإجبار كلٍّ منها على استيراد
|
||||
* الوحدة يعني نقطةً منسيّة يوماً ما — والنقطة المنسيّة ميزةٌ مجانية للجميع.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Tenant])],
|
||||
providers: [EntitlementsService, FeatureGuard],
|
||||
exports: [EntitlementsService, FeatureGuard],
|
||||
})
|
||||
export class EntitlementsModule {}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import { CacheService, TTL } from '../cache/cache.service';
|
||||
import { Entitlements, Feature, resolveEntitlements } from './features';
|
||||
|
||||
const key = (tenantId: string) => `entitlements:${tenantId}`;
|
||||
|
||||
/**
|
||||
* استحقاقات المستأجر — Redis خط أول والقاعدة احتياط (docs/19 — K1).
|
||||
* تُقرأ على كل نقطة محميّة، لذا لا يجوز أن تكون استعلام قاعدة في كل طلب.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EntitlementsService {
|
||||
private readonly logger = new Logger('Entitlements');
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Tenant) private readonly tenants: Repository<Tenant>,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async forTenant(tenantId: string): Promise<Entitlements | null> {
|
||||
return this.cache.wrap<Entitlements>(key(tenantId), TTL.tenant, async () => {
|
||||
const t = await this.tenants.findOne({
|
||||
where: { id: tenantId },
|
||||
select: { id: true, plan: true, features: true, status: true },
|
||||
});
|
||||
if (!t) return null;
|
||||
return resolveEntitlements(t.plan, t.features ?? {});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* هل يملك المستأجر هذه الميزة؟
|
||||
* **مستأجر مجهول = ممنوع** — لا يُفترض السماح عند غياب المعلومة.
|
||||
*/
|
||||
async has(tenantId: string, feature: Feature): Promise<boolean> {
|
||||
const e = await this.forTenant(tenantId);
|
||||
return e?.features?.[feature] === true;
|
||||
}
|
||||
|
||||
/** حدّ عددي؛ `undefined`/`null` = بلا حدّ. */
|
||||
async limit(tenantId: string, name: 'drivers_max' | 'cities_max'): Promise<number | null> {
|
||||
const e = await this.forTenant(tenantId);
|
||||
const v = e?.limits?.[name];
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
|
||||
/** يُنادى بعد أي تغيير على الباقة/الميزات — وإلا سرى التغيير بعد ساعة. */
|
||||
async invalidate(tenantId: string): Promise<void> {
|
||||
await this.cache.del(key(tenantId));
|
||||
this.logger.debug(`entitlements invalidated for ${tenantId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { FeatureGuard } from './feature.guard';
|
||||
import { EntitlementsService } from './entitlements.service';
|
||||
|
||||
const TENANT_WITH = 'tenant-with-bots';
|
||||
const TENANT_WITHOUT = 'tenant-without-bots';
|
||||
|
||||
/** سياق تنفيذ مزيّف — `user` هو ما يحقنه JwtAuthGuard من التوكن الموقَّع. */
|
||||
function ctx(user: any, headers: Record<string, string> = {}): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => ({ user, headers }) }),
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('FeatureGuard — الحدّ الأمني الحقيقي (docs/19 K1/K6)', () => {
|
||||
let guard: FeatureGuard;
|
||||
let reflector: Reflector;
|
||||
let entitlements: EntitlementsService;
|
||||
|
||||
beforeEach(() => {
|
||||
reflector = { getAllAndOverride: jest.fn() } as any;
|
||||
entitlements = {
|
||||
has: jest.fn(async (tenantId: string) => tenantId === TENANT_WITH),
|
||||
} as any;
|
||||
guard = new FeatureGuard(reflector, entitlements);
|
||||
});
|
||||
|
||||
it('نقطة بلا @RequiresFeature تمرّ', async () => {
|
||||
(reflector.getAllAndOverride as jest.Mock).mockReturnValue(undefined);
|
||||
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).resolves.toBe(true);
|
||||
expect(entitlements.has).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('مستأجر يملك الميزة يمرّ', async () => {
|
||||
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
|
||||
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITH }))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('مستأجر لا يملكها يُرفض بـ403', async () => {
|
||||
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
|
||||
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('الرسالة ثابتة ولا تكشف شيئاً عن مستأجرين آخرين', async () => {
|
||||
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
|
||||
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).rejects.toThrow(
|
||||
'feature_not_in_plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('بلا مستخدم موثَّق = منع (الافتراض ليس السماح)', async () => {
|
||||
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
|
||||
await expect(guard.canActivate(ctx(undefined))).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('لا يمكن تزوير المستأجر بترويسة — الحارس يقرأ التوكن وحده', async () => {
|
||||
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
|
||||
// المهاجم يدّعي أنه المستأجر المالك للميزة عبر الترويسة…
|
||||
const forged = ctx({ tenantId: TENANT_WITHOUT }, { 'x-tenant-id': TENANT_WITH });
|
||||
|
||||
await expect(guard.canActivate(forged)).rejects.toThrow(ForbiddenException);
|
||||
// …والحارس سأل عن المستأجر القادم من التوكن لا من الترويسة.
|
||||
expect(entitlements.has).toHaveBeenCalledWith(TENANT_WITHOUT, 'bots');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { EntitlementsService } from './entitlements.service';
|
||||
import { Feature } from './features';
|
||||
|
||||
export const FEATURE_KEY = 'required_feature';
|
||||
|
||||
/**
|
||||
* يحرس نقطةً بميزة مشتراة (docs/19 — K1).
|
||||
*
|
||||
* **هذا هو الحدّ الأمني الحقيقي.** علم الميزة في التطبيق قرار *عرض* فقط؛ من
|
||||
* يفكّك التطبيق ويفعّل الأعلام يرى الشاشة ثم يصطدم بـ403 من هنا.
|
||||
*
|
||||
* يجب أن يُستعمل **بعد** `JwtAuthGuard` — يعتمد على `tenant_id` القادم من
|
||||
* التوكن الموقَّع، لا من ترويسة يكتبها العميل.
|
||||
*/
|
||||
export const RequiresFeature = (feature: Feature) => SetMetadata(FEATURE_KEY, feature);
|
||||
|
||||
@Injectable()
|
||||
export class FeatureGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly entitlements: EntitlementsService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const feature = this.reflector.getAllAndOverride<Feature>(FEATURE_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!feature) return true; // النقطة غير محروسة بميزة
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
const tenantId = user?.tenantId;
|
||||
// بلا مستأجر موثَّق لا نستطيع التحقق — والافتراض هو المنع.
|
||||
if (!tenantId) throw new ForbiddenException('feature_not_in_plan');
|
||||
|
||||
if (!(await this.entitlements.has(tenantId, feature))) {
|
||||
// رسالة ثابتة يعرضها التطبيق بلطف («هذه الميزة غير مشمولة باشتراكك»)،
|
||||
// ولا تكشف للمهاجم أي ميزات يملكها المستأجرون الآخرون.
|
||||
throw new ForbiddenException('feature_not_in_plan');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { FEATURES, resolveEntitlements } from './features';
|
||||
|
||||
describe('resolveEntitlements — كتالوج الاستحقاقات (docs/19 K2)', () => {
|
||||
it('الافتراض هو المنع: كل ميزة خارج الباقة محجوبة', () => {
|
||||
const e = resolveEntitlements('launch');
|
||||
expect(e.features.bots).toBe(false);
|
||||
expect(e.features.market_intel).toBe(false);
|
||||
expect(e.features.dispatch).toBe(false);
|
||||
});
|
||||
|
||||
it('كل ميزة في الكتالوج لها قيمة صريحة — لا undefined يُقرأ كسماح', () => {
|
||||
const e = resolveEntitlements('launch');
|
||||
for (const f of FEATURES) {
|
||||
expect(typeof e.features[f]).toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('الباقة تعطي افتراضاتها', () => {
|
||||
expect(resolveEntitlements('launch').features.wallet).toBe(true);
|
||||
expect(resolveEntitlements('fleet').features.dispatch).toBe(true);
|
||||
expect(resolveEntitlements('brand').features.dispatch).toBe(false);
|
||||
});
|
||||
|
||||
it('السيادة تشمل كل شيء — قرار مقصود (docs/19 §6)', () => {
|
||||
const e = resolveEntitlements('sovereign');
|
||||
for (const f of FEATURES) expect(e.features[f]).toBe(true);
|
||||
});
|
||||
|
||||
it('ميزة مشتراة منفردة فوق الباقة', () => {
|
||||
const e = resolveEntitlements('brand', { bots: true });
|
||||
expect(e.features.bots).toBe(true);
|
||||
expect(e.features.wallet).toBe(true); // افتراض الباقة باقٍ
|
||||
});
|
||||
|
||||
it('سحب ميزة من الباقة صراحةً', () => {
|
||||
expect(resolveEntitlements('fleet', { dispatch: false }).features.dispatch).toBe(false);
|
||||
});
|
||||
|
||||
it('مفاتيح مجهولة تُتجاهل — لا تفتح شيئاً', () => {
|
||||
const e = resolveEntitlements('launch', { not_a_feature: true } as any);
|
||||
expect((e.features as any).not_a_feature).toBeUndefined();
|
||||
expect(e.features.bots).toBe(false);
|
||||
});
|
||||
|
||||
it('الحدود العددية من الباقة، وتُتجاوز صراحةً', () => {
|
||||
expect(resolveEntitlements('launch').limits.drivers_max).toBe(50);
|
||||
expect(resolveEntitlements('launch', { limits: { drivers_max: 200 } }).limits.drivers_max).toBe(200);
|
||||
});
|
||||
|
||||
it('باقة مجهولة تسقط لأضيق باقة لا لأوسعها', () => {
|
||||
const e = resolveEntitlements('nonsense' as any);
|
||||
expect(e.features.bots).toBe(false);
|
||||
expect(e.limits.drivers_max).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TenantPlan } from '../../database/entities/tenant.entity';
|
||||
|
||||
/**
|
||||
* كتالوج الميزات القابلة للبيع (docs/19 — K2).
|
||||
* الميزة تُشترى منفردة فوق الباقة، فـ`plan` يعطي الافتراضات و`tenants.features`
|
||||
* هي الحقيقة النهائية.
|
||||
*/
|
||||
export const FEATURES = [
|
||||
'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];
|
||||
|
||||
/** الحدود العددية — تُفرض على السيرفر أيضاً (docs/19 — K4). */
|
||||
export interface Limits {
|
||||
drivers_max?: number;
|
||||
cities_max?: number;
|
||||
}
|
||||
|
||||
export interface Entitlements {
|
||||
features: Record<Feature, boolean>;
|
||||
limits: Limits;
|
||||
}
|
||||
|
||||
/** `null` = بلا حدّ. */
|
||||
const UNLIMITED = null;
|
||||
|
||||
/**
|
||||
* افتراضات كل باقة (docs/05 — انطلاقة/علامة/أسطول+/سيادة).
|
||||
* ما لا يُذكر هنا = **ممنوع**؛ الافتراض هو المنع لا السماح.
|
||||
*/
|
||||
const PLAN_DEFAULTS: Record<TenantPlan, { features: Feature[]; limits: Limits }> = {
|
||||
launch: {
|
||||
features: ['wallet', 'chat', 'ride_types'],
|
||||
limits: { drivers_max: 50, cities_max: 1 },
|
||||
},
|
||||
brand: {
|
||||
features: ['wallet', 'chat', 'calls', 'ride_types', 'payments', 'driver_tiers'],
|
||||
limits: { drivers_max: 500, cities_max: 3 },
|
||||
},
|
||||
fleet: {
|
||||
features: ['wallet', 'chat', 'calls', 'ride_types', 'payments', 'dispatch', 'api_access'],
|
||||
limits: { drivers_max: 5000, cities_max: 20 },
|
||||
},
|
||||
// السيادة: كل شيء مشمول — قرار مقصود (docs/19 §6). المستأجر يشغّل النسخة على
|
||||
// خوادمه، فلا يوجد حارس يحرس ضدّه؛ الحجب هناك وهمٌ لا حماية. الحدّ تعاقدي.
|
||||
sovereign: {
|
||||
features: [...FEATURES],
|
||||
limits: { drivers_max: UNLIMITED as any, cities_max: UNLIMITED as any },
|
||||
},
|
||||
};
|
||||
|
||||
/** خريطة «كل الميزات ممنوعة» — نقطة البداية دائماً (default-deny). */
|
||||
function noFeatures(): Record<Feature, boolean> {
|
||||
return Object.fromEntries(FEATURES.map((f) => [f, false])) as Record<Feature, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* يحسب استحقاقات المستأجر: افتراضات الباقة، ثم تجاوزات `tenants.features`
|
||||
* الصريحة (شراء ميزة منفردة أو سحبها).
|
||||
*
|
||||
* **مفتاح غير موجود = ممنوع.** قائمة بيضاء لا سوداء — ميزة جديدة تُضاف للكتالوج
|
||||
* تبقى محجوبة عن الجميع حتى تُمنح صراحةً، لا العكس.
|
||||
*/
|
||||
export function resolveEntitlements(
|
||||
plan: TenantPlan,
|
||||
overrides: Record<string, any> = {},
|
||||
): Entitlements {
|
||||
const base = PLAN_DEFAULTS[plan] ?? PLAN_DEFAULTS.launch;
|
||||
|
||||
const features = noFeatures();
|
||||
for (const f of base.features) features[f] = true;
|
||||
|
||||
// تجاوزات صريحة — تقبل المنح والسحب معاً.
|
||||
for (const f of FEATURES) {
|
||||
if (typeof overrides?.[f] === 'boolean') features[f] = overrides[f];
|
||||
}
|
||||
|
||||
const limits: Limits = { ...base.limits, ...(overrides?.limits ?? {}) };
|
||||
return { features, limits };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* كتالوج بوابات الدفع المتاحة (docs/22 — N) والدول التي تخدمها.
|
||||
* السوبر-أدمن يفعّل ما يناسب دولة المستأجر عند التزويد.
|
||||
*/
|
||||
export const PAYMENT_METHODS = [
|
||||
{ code: 'cash', name_ar: 'نقداً', countries: ['jo', 'sy', 'eg'] },
|
||||
{ code: 'wallet', name_ar: 'المحفظة', countries: ['jo', 'sy', 'eg'] },
|
||||
{ code: 'cliq', name_ar: 'كليك', countries: ['jo'] },
|
||||
{ code: 'zaincash', name_ar: 'زين كاش', countries: ['jo'] },
|
||||
{ code: 'paymob', name_ar: 'باي موب', countries: ['eg'] },
|
||||
{ code: 'mtn', name_ar: 'MTN', countries: ['sy'] },
|
||||
{ code: 'syriatel', name_ar: 'سيرياتيل كاش', countries: ['sy'] },
|
||||
{ code: 'shamcash', name_ar: 'شام كاش', countries: ['sy'] },
|
||||
] as const;
|
||||
|
||||
export type PaymentMethodCode = (typeof PAYMENT_METHODS)[number]['code'];
|
||||
|
||||
/** الوسائل الافتراضية لدولة (نقد + محفظة + بوابات الدولة) — نقطة بداية التزويد. */
|
||||
export function defaultPaymentMethods(countryPack: string): string[] {
|
||||
return PAYMENT_METHODS.filter((m) => (m.countries as readonly string[]).includes(countryPack)).map(
|
||||
(m) => m.code,
|
||||
);
|
||||
}
|
||||
|
||||
/** يتحقق أن الوسائل المطلوبة كلها في الكتالوج (لا وسيلة مخترعة). */
|
||||
export function validPaymentMethods(codes: string[]): string[] {
|
||||
const known = new Set<string>(PAYMENT_METHODS.map((m) => m.code));
|
||||
return (codes ?? []).filter((c) => known.has(c));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { I18nService } from './i18n.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [I18nService],
|
||||
exports: [I18nService],
|
||||
})
|
||||
export class I18nModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { I18nService } from './i18n.service';
|
||||
|
||||
describe('I18nService', () => {
|
||||
const i18n = new I18nService();
|
||||
|
||||
it('يترجم للعربية افتراضياً ويستبدل المتغيّرات', () => {
|
||||
const m = i18n.t(null, 'trip.assigned', { driverName: 'أحمد' });
|
||||
expect(m.title).toBe('تم قبول رحلتك');
|
||||
expect(m.body).toBe('سائقك أحمد في الطريق إليك');
|
||||
});
|
||||
|
||||
it('يحترم لغة المستخدم الإنجليزية', () => {
|
||||
const m = i18n.t('en', 'trip.driver_arrived');
|
||||
expect(m.title).toBe('Driver has arrived');
|
||||
});
|
||||
|
||||
it('يطبّع وسوم اللغة المركّبة (ar-JO, EN_us)', () => {
|
||||
expect(i18n.normalize('ar-JO')).toBe('ar');
|
||||
expect(i18n.normalize('EN_us')).toBe('en');
|
||||
expect(i18n.normalize('fr')).toBe('ar'); // غير مدعومة → الافتراضية
|
||||
expect(i18n.normalize(undefined)).toBe('ar');
|
||||
});
|
||||
|
||||
it('يترك المتغيّر كما هو إذا لم تُمرَّر قيمته', () => {
|
||||
const m = i18n.t('ar', 'trip.completed', { currency: 'JOD' });
|
||||
expect(m.body).toBe('وصلت إلى وجهتك. الأجرة {fare} JOD');
|
||||
});
|
||||
|
||||
it('لا يرمي عند مفتاح مفقود', () => {
|
||||
expect(() => i18n.t('ar', 'does.not.exist')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CATALOGS, DEFAULT_LANG, Lang, Message, SUPPORTED_LANGS } from './messages';
|
||||
|
||||
/** ترجمة نصوص الإشعارات (docs/17 — A2). العربية هي الافتراض. */
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
private readonly logger = new Logger('I18n');
|
||||
|
||||
/** يطبّع أي وسم لغة (ar-JO, AR, en_US) إلى لغة مدعومة، وإلا الافتراضية. */
|
||||
normalize(lang?: string | null): Lang {
|
||||
const base = (lang ?? '').toLowerCase().split(/[-_]/)[0];
|
||||
return (SUPPORTED_LANGS as string[]).includes(base) ? (base as Lang) : DEFAULT_LANG;
|
||||
}
|
||||
|
||||
/** يترجم مفتاحاً مع استبدال المتغيّرات. المفتاح المفقود يرجع كما هو (لا يكسر الإرسال). */
|
||||
t(lang: string | null | undefined, key: string, params: Record<string, any> = {}): Message {
|
||||
const l = this.normalize(lang);
|
||||
const msg = CATALOGS[l][key] ?? CATALOGS[DEFAULT_LANG][key];
|
||||
if (!msg) {
|
||||
this.logger.warn(`missing i18n key "${key}"`);
|
||||
return { title: key, body: '' };
|
||||
}
|
||||
return {
|
||||
title: this.interpolate(msg.title, params),
|
||||
body: this.interpolate(msg.body, params),
|
||||
};
|
||||
}
|
||||
|
||||
private interpolate(tpl: string, params: Record<string, any>): string {
|
||||
return tpl.replace(/\{(\w+)\}/g, (match, name) =>
|
||||
params[name] == null ? match : String(params[name]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* نصوص الإشعارات لكل لغة. المفتاح يُشتق من نوع الحدث (راجع docs/17 — A2).
|
||||
* كل مدخل: عنوان + نص. المتغيّرات بصيغة {name}.
|
||||
*/
|
||||
export type Lang = 'ar' | 'en';
|
||||
|
||||
export const DEFAULT_LANG: Lang = 'ar';
|
||||
export const SUPPORTED_LANGS: Lang[] = ['ar', 'en'];
|
||||
|
||||
export interface Message {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
type Catalog = Record<string, Message>;
|
||||
|
||||
const ar: Catalog = {
|
||||
'trip.assigned': { title: 'تم قبول رحلتك', body: 'سائقك {driverName} في الطريق إليك' },
|
||||
'trip.driver_arriving': { title: 'السائق في الطريق', body: 'سائقك متوجّه إلى نقطة الانطلاق' },
|
||||
'trip.driver_arrived': { title: 'وصل السائق', body: 'سائقك ينتظرك في نقطة الانطلاق' },
|
||||
'trip.in_progress': { title: 'بدأت الرحلة', body: 'رحلتك جارية الآن — رحلة موفقة' },
|
||||
'trip.completed': { title: 'انتهت الرحلة', body: 'وصلت إلى وجهتك. الأجرة {fare} {currency}' },
|
||||
'trip.paid': { title: 'تم الدفع', body: 'تم استلام مبلغ {fare} {currency}' },
|
||||
'trip.cancelled': { title: 'أُلغيت الرحلة', body: 'تم إلغاء الرحلة' },
|
||||
'trip.cancelled_fee': { title: 'أُلغيت الرحلة', body: 'تم إلغاء الرحلة ورسم الإلغاء {fee} {currency}' },
|
||||
'trip.no_drivers': { title: 'لا يوجد سائقون', body: 'لم نجد سائقاً متاحاً قريباً منك. حاول مجدداً' },
|
||||
'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}' },
|
||||
};
|
||||
|
||||
const en: Catalog = {
|
||||
'trip.assigned': { title: 'Your ride is accepted', body: 'Your driver {driverName} is on the way' },
|
||||
'trip.driver_arriving': { title: 'Driver on the way', body: 'Your driver is heading to the pickup point' },
|
||||
'trip.driver_arrived': { title: 'Driver has arrived', body: 'Your driver is waiting at the pickup point' },
|
||||
'trip.in_progress': { title: 'Trip started', body: 'Your trip is now in progress — have a good ride' },
|
||||
'trip.completed': { title: 'Trip finished', body: 'You have arrived. Fare {fare} {currency}' },
|
||||
'trip.paid': { title: 'Payment received', body: 'Payment of {fare} {currency} received' },
|
||||
'trip.cancelled': { title: 'Trip cancelled', body: 'The trip was cancelled' },
|
||||
'trip.cancelled_fee': { title: 'Trip cancelled', body: 'The trip was cancelled with a {fee} {currency} cancellation fee' },
|
||||
'trip.no_drivers': { title: 'No drivers', body: 'No driver available nearby. Please try again' },
|
||||
'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}' },
|
||||
};
|
||||
|
||||
export const CATALOGS: Record<Lang, Catalog> = { ar, en };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PhoneService } from './phone.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PhoneService],
|
||||
exports: [PhoneService],
|
||||
})
|
||||
export class PhoneModule {}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PhoneService } from './phone.service';
|
||||
|
||||
describe('PhoneService — تطبيع أرقام الهاتف (docs/17 D1)', () => {
|
||||
let phones: PhoneService;
|
||||
|
||||
beforeEach(() => {
|
||||
phones = new PhoneService();
|
||||
});
|
||||
|
||||
describe('الأردن (jo)', () => {
|
||||
it('الصيغة المحلية بالصفر', () => {
|
||||
expect(phones.normalize('0790000000', 'jo')).toBe('962790000000');
|
||||
});
|
||||
|
||||
it('الصيغة الدولية بالفعل', () => {
|
||||
expect(phones.normalize('962790000000', 'jo')).toBe('962790000000');
|
||||
});
|
||||
|
||||
it('بادئة + دولية', () => {
|
||||
expect(phones.normalize('+962790000000', 'jo')).toBe('962790000000');
|
||||
});
|
||||
|
||||
it('بادئة 00 بدل +', () => {
|
||||
expect(phones.normalize('00962790000000', 'jo')).toBe('962790000000');
|
||||
});
|
||||
|
||||
it('فراغات وشرطات لا تؤثر', () => {
|
||||
expect(phones.normalize('+962 79-000-0000', 'jo')).toBe('962790000000');
|
||||
});
|
||||
|
||||
it('محلي بلا صفر بادئ', () => {
|
||||
expect(phones.normalize('790000000', 'jo')).toBe('962790000000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('مصر (eg) — لبس المالك: "01" يُظنّ مفتاحاً وهو ليس كذلك', () => {
|
||||
it('الصيغة المحلية بالصفر (11 رقماً) تُحسب بمفتاح 20 الصحيح لا "2"', () => {
|
||||
const r = phones.normalize('01012345678', 'eg');
|
||||
expect(r).toBe('201012345678');
|
||||
expect(r.startsWith('20')).toBe(true); // لا "2" فقط
|
||||
});
|
||||
|
||||
it('الصيغة الدولية بالفعل', () => {
|
||||
expect(phones.normalize('201012345678', 'eg')).toBe('201012345678');
|
||||
});
|
||||
|
||||
it('بادئة +', () => {
|
||||
expect(phones.normalize('+201012345678', 'eg')).toBe('201012345678');
|
||||
});
|
||||
});
|
||||
|
||||
describe('سوريا (sy)', () => {
|
||||
it('الصيغة المحلية بالصفر', () => {
|
||||
expect(phones.normalize('0944000000', 'sy')).toBe('963944000000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('نفس الرقم الحقيقي بصيغ مختلفة → مفتاح واحد فقط', () => {
|
||||
it('لا يتعدد الحساب لنفس الرقم', () => {
|
||||
const variants = ['0790000000', '962790000000', '+962790000000', '00962790000000'];
|
||||
const results = new Set(variants.map((v) => phones.normalize(v, 'jo')));
|
||||
expect(results.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('مدخلات غير صالحة', () => {
|
||||
it('طول خاطئ يُرفض لا يُخمَّن', () => {
|
||||
expect(() => phones.normalize('12345', 'jo')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('نص فارغ يُرفض', () => {
|
||||
expect(() => phones.normalize('', 'jo')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('حروف بلا أرقام تُرفض', () => {
|
||||
expect(() => phones.normalize('abc-def', 'jo')).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
it('دولة غير معروفة تسقط لخطة افتراضية بلا انهيار', () => {
|
||||
expect(() => phones.normalize('0790000000', 'xx')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* قاعدة ترقيم كل دولة (docs/17 — D1).
|
||||
* `trunkPrefix`: الصفر المحلي الذي يُكتب فيُحذف قبل إضافة مفتاح الدولة.
|
||||
* `nationalLength`: طول الرقم المحلي **بعد** حذف الصفر.
|
||||
*/
|
||||
interface DialPlan {
|
||||
callingCode: string; // بلا +
|
||||
nationalLength: number;
|
||||
}
|
||||
|
||||
const DIAL_PLANS: Record<string, DialPlan> = {
|
||||
// 07XXXXXXXX (10 أرقام بالصفر) → مفتاح 962 + 7XXXXXXXX (9 أرقام)
|
||||
jo: { callingCode: '962', nationalLength: 9 },
|
||||
// 09XXXXXXXX (10 أرقام بالصفر) → مفتاح 963 + 9XXXXXXXX (9 أرقام)
|
||||
sy: { callingCode: '963', nationalLength: 9 },
|
||||
// 01XXXXXXXXX (11 رقماً بالصفر) → مفتاح 20 + 1XXXXXXXXX (10 أرقام).
|
||||
// هذا بالضبط اللبس الذي ذكره المالك: الناس تظن المفتاح "2" لا "20"، لأن
|
||||
// الرقم المحلي نفسه يبدأ بـ1 فيبدو للوهلة الأولى أن "01" بأكملها مفتاح.
|
||||
eg: { callingCode: '20', nationalLength: 10 },
|
||||
};
|
||||
|
||||
/**
|
||||
* تطبيع رقم الهاتف لصيغة دولية قانونية موحّدة (docs/17 — D1).
|
||||
*
|
||||
* **بلا هذا**: نفس الرقم الحقيقي يُكتب "0790000000" مرة و"962790000000"
|
||||
* مرة و"+962790000000" مرة ثالثة — فيصير له 3 حسابات مختلفة. المفتاح
|
||||
* المخزَّن في القاعدة يجب أن يكون **دالة واحدة فقط** لكل رقم حقيقي.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhoneService {
|
||||
/**
|
||||
* يرجع الصيغة الدولية بلا "+" (مثل "962790000000") — هذا هو المفتاح
|
||||
* القانوني الوحيد المخزَّن والمُقارَن به في كل مكان.
|
||||
*/
|
||||
normalize(raw: string, countryPack: string): string {
|
||||
const plan = DIAL_PLANS[countryPack] ?? DIAL_PLANS.jo;
|
||||
const digits = PhoneService.stripToDigits(raw);
|
||||
if (!digits) throw new BadRequestException('invalid phone number');
|
||||
|
||||
// "00" بادئة دولية بديلة عن "+" — إزالتها تعيدنا لحالة "مفتاح كامل".
|
||||
const noIntlPrefix = digits.startsWith('00') ? digits.slice(2) : digits;
|
||||
|
||||
// 1) مكتوب بمفتاح الدولة كاملاً (مع أو بلا +/00 — أُزيلا أعلاه).
|
||||
if (
|
||||
noIntlPrefix.startsWith(plan.callingCode) &&
|
||||
noIntlPrefix.length === plan.callingCode.length + plan.nationalLength
|
||||
) {
|
||||
return noIntlPrefix;
|
||||
}
|
||||
|
||||
// 2) الصيغة المحلية المعتادة: صفر بادئ ثم الرقم المحلي.
|
||||
if (noIntlPrefix.startsWith('0') && noIntlPrefix.length === plan.nationalLength + 1) {
|
||||
return plan.callingCode + noIntlPrefix.slice(1);
|
||||
}
|
||||
|
||||
// 3) رقم محلي بلا صفر بادئ (بعض المستخدمين يحذفونه بأنفسهم).
|
||||
if (noIntlPrefix.length === plan.nationalLength) {
|
||||
return plan.callingCode + noIntlPrefix;
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
`invalid phone number for country "${countryPack}"`,
|
||||
);
|
||||
}
|
||||
|
||||
/** للعرض/الإرسال فقط — Nabeh والتطبيق يريدان الصيغة الدولية بلا +. */
|
||||
toWhatsApp(normalized: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static stripToDigits(raw: string): string {
|
||||
return (raw ?? '').replace(/[^\d]/g, '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { PlatformGuard } from './platform.guard';
|
||||
|
||||
const SECRET = 'super-secret-value';
|
||||
|
||||
function ctx(headers: Record<string, any> = {}): ExecutionContext {
|
||||
return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as any;
|
||||
}
|
||||
|
||||
const guardWith = (secret?: string) =>
|
||||
new PlatformGuard({ get: () => secret } as any);
|
||||
|
||||
describe('PlatformGuard — حارس السوبر-أدمن', () => {
|
||||
it('السرّ الصحيح يمرّ', () => {
|
||||
expect(guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': SECRET }))).toBe(true);
|
||||
});
|
||||
|
||||
it('سرّ خاطئ يُرفض', () => {
|
||||
expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': 'nope' }))).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('بلا ترويسة يُرفض', () => {
|
||||
expect(() => guardWith(SECRET).canActivate(ctx())).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('سرّ غير مضبوط = منع الجميع، لا فتح الباب', () => {
|
||||
// الفخّ الخطير: إعداد ناقص يفتح نقاط المنصة للعالم.
|
||||
expect(() => guardWith(undefined).canActivate(ctx({ 'x-platform-secret': 'anything' }))).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(() => guardWith('').canActivate(ctx({ 'x-platform-secret': '' }))).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('لا ينهار على ترويسة غير نصّية', () => {
|
||||
expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': ['a', 'b'] }))).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('طول مختلف يُرفض بلا انهيار (timingSafeEqual يشترط تطابق الطول)', () => {
|
||||
expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': 'short' }))).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
/**
|
||||
* حارس السوبر-أدمن (مالك المنصة) — يحرس `admin/*` و`platform/*`.
|
||||
*
|
||||
* السوبر-أدمن يدير **كل** المستأجرين، فهو خارج نموذج أدوار المستأجر تماماً
|
||||
* (rider/driver/dispatcher/admin): أدمن المستأجر «سيرو» يجب ألّا يرقّي اشتراكه
|
||||
* بنفسه. لذلك المصادقة بسرّ المنصة لا بـJWT مستأجر.
|
||||
*
|
||||
* مؤقّت حتى يوجد نموذج مستخدم منصة حقيقي؛ يوثَّق في docs/19.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PlatformGuard implements CanActivate {
|
||||
private readonly logger = new Logger('PlatformGuard');
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const provided = req.headers?.['x-platform-secret'];
|
||||
const expected = this.config.get<string>('platform.secret');
|
||||
|
||||
// سرّ غير مضبوط = **منع الجميع**، لا سماح للجميع. تركه فارغاً في الإنتاج
|
||||
// خطأ تشغيلي شائع، وفتحُ الباب عنده أسوأ من إغلاقه.
|
||||
if (!expected) {
|
||||
this.logger.error('PLATFORM_SECRET is not set — refusing all platform access');
|
||||
throw new UnauthorizedException('platform access disabled');
|
||||
}
|
||||
if (typeof provided !== 'string' || !PlatformGuard.safeEqual(provided, expected)) {
|
||||
throw new UnauthorizedException('invalid platform secret');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** مقارنة ثابتة الزمن — المقارنة العادية تسرّب السرّ حرفاً حرفاً بالتوقيت. */
|
||||
private static safeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PlatformGuard } from './platform.guard';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PlatformGuard],
|
||||
exports: [PlatformGuard],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
export const REDIS = 'REDIS_CLIENT';
|
||||
|
||||
/**
|
||||
* عميل Redis عالمي — DB رقم 3 وبادئة مفاتيح tripz: للعزل على السيرفر المشترك
|
||||
* (راجع docs/14). يُستخدم لتخزين OTP والحضور والمطابقة لاحقاً.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: REDIS,
|
||||
inject: [ConfigService],
|
||||
useFactory: (cfg: ConfigService) =>
|
||||
new Redis({
|
||||
host: cfg.get<string>('redis.host'),
|
||||
port: cfg.get<number>('redis.port'),
|
||||
db: cfg.get<number>('redis.db'),
|
||||
keyPrefix: cfg.get<string>('redis.keyPrefix'),
|
||||
maxRetriesPerRequest: null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
exports: [REDIS],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SeedService } from './seed.service';
|
||||
import { TenantsModule } from '../../modules/tenants/tenants.module';
|
||||
import { TariffModule } from '../../modules/tariff/tariff.module';
|
||||
import { RideTypesModule } from '../../modules/ride-types/ride-types.module';
|
||||
|
||||
@Module({
|
||||
imports: [TenantsModule, TariffModule, RideTypesModule],
|
||||
providers: [SeedService],
|
||||
})
|
||||
export class SeedModule {}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { TenantsService } from '../../modules/tenants/tenants.service';
|
||||
import { TariffService } from '../../modules/tariff/tariff.service';
|
||||
import { RideTypesService } from '../../modules/ride-types/ride-types.service';
|
||||
import {
|
||||
buildDefinition,
|
||||
countryTariff,
|
||||
SEEDED_CLASSES,
|
||||
} from '../../modules/tariff/default-tariffs';
|
||||
|
||||
/**
|
||||
* seed تلقائي عند الإقلاع: مستأجر تجريبي (Tenant Zero) + تعرفة افتراضية،
|
||||
* حتى لا نعيد إنشاءهما يدوياً بعد كل `docker compose down -v`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SeedService implements OnModuleInit {
|
||||
private readonly logger = new Logger('Seed');
|
||||
|
||||
constructor(
|
||||
private readonly tenants: TenantsService,
|
||||
private readonly tariff: TariffService,
|
||||
private readonly rideTypes: RideTypesService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.run();
|
||||
} catch (e: any) {
|
||||
// على قاعدة جديدة قد تسبق onModuleInit تشغيلَ الهجرات — لا نُسقط الإقلاع.
|
||||
this.logger.warn(`seed skipped (run migrations then restart api): ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async run() {
|
||||
let tenant = await this.tenants.findBySlug('siro');
|
||||
if (!tenant) {
|
||||
tenant = await this.tenants.create({
|
||||
name: 'Siro (Demo — Tenant Zero)',
|
||||
slug: 'siro',
|
||||
countryPack: 'jo',
|
||||
// سيرو = المشترك الأول بالباقة الكاملة (قرار المالك)، وسيادةً تعني كل
|
||||
// الميزات بلا حدود عددية (docs/19 §6). عملياً أيضاً: أي باقة أدنى كانت
|
||||
// ستحجب dispatch وتُسقط سكربتات التحقق بـ403 وحدّ السائقين.
|
||||
plan: 'sovereign',
|
||||
});
|
||||
this.logger.log(`seeded demo tenant "siro" (${tenant.id})`);
|
||||
}
|
||||
|
||||
// تعرفة الانطلاق **لكل فئة خدمة** من مصدر الحقيقة الواحد
|
||||
// (`default-tariffs.ts`)، لا الاقتصادي وحده: طلب فئة بلا تعرفة كان
|
||||
// يمرّ بلا سعر ثم يُسوّى بصفر — رحلة مجانية وعمولة صفر.
|
||||
// البلد من `country_pack` للمستأجر، والمدينة `'default'` لأنها القيمة
|
||||
// التي يبحث بها `trips.service` فعلاً.
|
||||
const country = countryTariff(tenant.countryPack);
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
for (const serviceClass of SEEDED_CLASSES) {
|
||||
const existing = await this.tariff.getActive(tenant.id, 'default', serviceClass);
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
created++;
|
||||
await this.tariff.create({
|
||||
tenant_id: tenant.id,
|
||||
city: 'default',
|
||||
service_class: serviceClass,
|
||||
version: 1,
|
||||
active: true,
|
||||
definition: buildDefinition(country, serviceClass),
|
||||
});
|
||||
}
|
||||
// العدد الحقيقي لا ثابت: الرسالة السابقة طبعت 9 دائماً حتى حين تُتخطّى
|
||||
// كل الفئات، فلم تكن تُميّز «زُرعت» من «كانت موجودة» — وهو الفرق الوحيد
|
||||
// الذي يُقرأ من هذا السطر أصلاً.
|
||||
this.logger.log(
|
||||
`tariffs for "${tenant.slug}" (${country.currency}): ${created} seeded, ${skipped} already active`,
|
||||
);
|
||||
|
||||
// أنواع الرحلات الافتراضية
|
||||
const types: any[] = [
|
||||
{ code: 'economy', name_ar: 'اقتصادي', name_en: 'Economy', vehicle_kind: 'car', sort: 1 },
|
||||
{ code: 'comfort', name_ar: 'مريح', name_en: 'Comfort', vehicle_kind: 'car', sort: 2 },
|
||||
{ code: 'electric', name_ar: 'كهربائية', name_en: 'Electric', vehicle_kind: 'car', sort: 3 },
|
||||
{ code: 'family_van', name_ar: 'باص عائلي', name_en: 'Family Van', vehicle_kind: 'van', sort: 4 },
|
||||
{ code: 'women', name_ar: 'سيدات', name_en: 'Women', vehicle_kind: 'car', women_only: true, sort: 5 },
|
||||
{ code: 'scooter', name_ar: 'سكوتر توصيل', name_en: 'Scooter', vehicle_kind: 'scooter', round_trip_supported: false, sort: 6 },
|
||||
];
|
||||
for (const t of types) {
|
||||
await this.rideTypes.ensure({ ...t, tenant_id: tenant.id });
|
||||
}
|
||||
this.logger.log('seeded ride types for "siro"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SigningService } from './signing.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
|
||||
/**
|
||||
* يحرس العمليات المالية بتوقيع HMAC (docs/17 — I6).
|
||||
*
|
||||
* **مطفأ افتراضياً** (`PAYMENTS_REQUIRE_SIGNATURE=false`): التوقيع يحتاج فلاتر
|
||||
* أن يوقّع أولاً، وتفعيله قبل ذلك يقطع كل سحب. يُفعَّل بمتغيّر بيئة فور جاهزية
|
||||
* التطبيق — بلا نشر كود.
|
||||
*
|
||||
* ما يقدّمه فعلاً: الـAPI يعمل على **http** بلا TLS، فمن يلتقط الشبكة يسرق
|
||||
* التوكن. التوقيع بمفتاح جلسة (ليس في التوكن ولا في الـAPK) يجعل التوكن
|
||||
* المسروق وحده غير كافٍ لتوقيع سحب، ويمنع تبديل المبلغ في الطريق.
|
||||
* **هذا ترقيع لغياب TLS لا بديل عنه** — راجع docs/17.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SignatureGuard implements CanActivate {
|
||||
private readonly logger = new Logger('SignatureGuard');
|
||||
|
||||
constructor(
|
||||
private readonly signing: SigningService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (!this.config.get<boolean>('payments.requireSignature')) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user;
|
||||
if (!user?.userId || !user?.tenantId) throw new UnauthorizedException('unsigned_request');
|
||||
|
||||
const signature = req.headers?.['x-signature'];
|
||||
const timestamp = req.headers?.['x-timestamp'];
|
||||
if (typeof signature !== 'string' || typeof timestamp !== 'string') {
|
||||
throw new UnauthorizedException('unsigned_request');
|
||||
}
|
||||
|
||||
const reason = await this.signing.verify(
|
||||
user.tenantId,
|
||||
user.userId,
|
||||
signature,
|
||||
timestamp,
|
||||
req.method,
|
||||
req.originalUrl?.split('?')[0] ?? req.url,
|
||||
// الجسم الخام كما وصل؛ إعادة تسلسله قد تُغيّر ترتيب المفاتيح فيفشل توقيع سليم.
|
||||
req.rawBody?.toString() ?? JSON.stringify(req.body ?? {}),
|
||||
);
|
||||
if (reason) {
|
||||
await this.audit.record({
|
||||
tenantId: user.tenantId,
|
||||
actorUserId: user.userId,
|
||||
action: 'signature.rejected',
|
||||
ip: req.ip ?? null,
|
||||
meta: { reason, path: req.url },
|
||||
});
|
||||
// رسالة واحدة لكل الأسباب — لا نُعلّم المهاجم أين أخطأ.
|
||||
throw new UnauthorizedException('unsigned_request');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { SigningService } from './signing.service';
|
||||
import { SignatureGuard } from './signature.guard';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SigningService, SignatureGuard],
|
||||
exports: [SigningService, SignatureGuard],
|
||||
})
|
||||
export class SigningModule {}
|
||||
@@ -0,0 +1,119 @@
|
||||
import RedisMock from 'ioredis-mock';
|
||||
import { SigningService } from './signing.service';
|
||||
|
||||
const TENANT = 't1';
|
||||
const USER = 'u1';
|
||||
const BODY = '{"amount":50,"channel":"cliq"}';
|
||||
|
||||
describe('SigningService — توقيع العمليات المالية (docs/17 I6)', () => {
|
||||
let redis: any;
|
||||
let signing: SigningService;
|
||||
|
||||
const sign = (secret: string, ts: string, method = 'POST', path = '/api/payouts/request', body = BODY) =>
|
||||
SigningService.sign(secret, SigningService.payload(ts, method, path, body));
|
||||
|
||||
const now = () => String(Math.floor(Date.now() / 1000));
|
||||
|
||||
beforeEach(async () => {
|
||||
redis = new RedisMock({ keyPrefix: 'tripz:' });
|
||||
await redis.flushall();
|
||||
signing = new SigningService(redis);
|
||||
});
|
||||
|
||||
it('توقيع صحيح يمرّ', async () => {
|
||||
const secret = await signing.issue(TENANT, USER);
|
||||
const ts = now();
|
||||
const sig = sign(secret, ts);
|
||||
|
||||
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/request', BODY)).toBeNull();
|
||||
});
|
||||
|
||||
it('تبديل المبلغ في الطريق يُكشف', async () => {
|
||||
const secret = await signing.issue(TENANT, USER);
|
||||
const ts = now();
|
||||
const sig = sign(secret, ts); // وُقّع على 50
|
||||
|
||||
const tampered = '{"amount":5000,"channel":"cliq"}';
|
||||
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/request', tampered)).toBe(
|
||||
'bad_signature',
|
||||
);
|
||||
});
|
||||
|
||||
it('توقيع نقطةٍ لا يصلح لنقطة أخرى', async () => {
|
||||
const secret = await signing.issue(TENANT, USER);
|
||||
const ts = now();
|
||||
const sig = sign(secret, ts, 'POST', '/api/payouts/request');
|
||||
|
||||
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/x/confirm', BODY)).toBe(
|
||||
'bad_signature',
|
||||
);
|
||||
});
|
||||
|
||||
it('طابع زمني قديم يُرفض (منع إعادة البثّ)', async () => {
|
||||
const secret = await signing.issue(TENANT, USER);
|
||||
const old = String(Math.floor(Date.now() / 1000) - 3600);
|
||||
const sig = sign(secret, old);
|
||||
|
||||
expect(await signing.verify(TENANT, USER, sig, old, 'POST', '/api/payouts/request', BODY)).toBe(
|
||||
'stale_timestamp',
|
||||
);
|
||||
});
|
||||
|
||||
it('طابع زمني غير رقمي يُرفض بلا انهيار', async () => {
|
||||
const secret = await signing.issue(TENANT, USER);
|
||||
expect(await signing.verify(TENANT, USER, sign(secret, 'x'), 'x', 'POST', '/p', BODY)).toBe(
|
||||
'bad_timestamp',
|
||||
);
|
||||
});
|
||||
|
||||
it('مفتاح مستخدم آخر لا يوقّع نيابةً عنه', async () => {
|
||||
const secret = await signing.issue(TENANT, USER);
|
||||
await signing.issue(TENANT, 'attacker');
|
||||
const ts = now();
|
||||
const sig = sign(secret, ts);
|
||||
|
||||
expect(await signing.verify(TENANT, 'attacker', sig, ts, 'POST', '/api/payouts/request', BODY)).toBe(
|
||||
'bad_signature',
|
||||
);
|
||||
});
|
||||
|
||||
it('المستأجرون معزولون', async () => {
|
||||
const secret = await signing.issue(TENANT, USER);
|
||||
const ts = now();
|
||||
expect(await signing.verify('t2', USER, sign(secret, ts), ts, 'POST', '/p', BODY)).toBe(
|
||||
'no_signing_key',
|
||||
);
|
||||
});
|
||||
|
||||
it('بلا مفتاح (لم يسجّل دخوله بعد) يُرفض لا يمرّ', async () => {
|
||||
expect(await signing.verify(TENANT, 'ghost', 'a'.repeat(64), now(), 'POST', '/p', BODY)).toBe(
|
||||
'no_signing_key',
|
||||
);
|
||||
});
|
||||
|
||||
it('دخول جديد يُبطل مفتاح الجلسة السابقة', async () => {
|
||||
const first = await signing.issue(TENANT, USER);
|
||||
const second = await signing.issue(TENANT, USER);
|
||||
expect(first).not.toBe(second);
|
||||
|
||||
const ts = now();
|
||||
expect(await signing.verify(TENANT, USER, sign(first, ts), ts, 'POST', '/p', BODY)).toBe(
|
||||
'bad_signature',
|
||||
);
|
||||
});
|
||||
|
||||
it('السحب يُبطل التوقيع', async () => {
|
||||
const secret = await signing.issue(TENANT, USER);
|
||||
await signing.revoke(TENANT, USER);
|
||||
const ts = now();
|
||||
expect(await signing.verify(TENANT, USER, sign(secret, ts), ts, 'POST', '/p', BODY)).toBe(
|
||||
'no_signing_key',
|
||||
);
|
||||
});
|
||||
|
||||
it('توقيع بطول مختلف يُرفض بلا انهيار (timingSafeEqual يشترط الطول)', async () => {
|
||||
const secret = await signing.issue(TENANT, USER);
|
||||
const ts = now();
|
||||
expect(await signing.verify(TENANT, USER, 'short', ts, 'POST', '/p', BODY)).toBe('bad_signature');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS } from '../redis/redis.module';
|
||||
|
||||
/** عمر مفتاح التوقيع — يطابق عمر توكن التحديث. */
|
||||
const KEY_TTL_SEC = 30 * 24 * 3600;
|
||||
/** نافذة انحراف الساعة المسموحة للطابع الزمني. */
|
||||
export const SIGNATURE_SKEW_SEC = 300;
|
||||
|
||||
/**
|
||||
* توقيع العمليات المالية (docs/17 — I6/D3).
|
||||
*
|
||||
* مفتاح **لكل جلسة** يُولَّد على السيرفر عند الدخول — لا سرّ ثابت في التطبيق
|
||||
* (السرّ الثابت يُستخرج بالهندسة العكسية فيصير التوقيع بلا معنى).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SigningService {
|
||||
constructor(@Inject(REDIS) private readonly redis: Redis) {}
|
||||
|
||||
private key(tenantId: string, userId: string) {
|
||||
return `sign:${tenantId}:${userId}`;
|
||||
}
|
||||
|
||||
/** يُصدر مفتاحاً جديداً — الدخول من جهاز جديد يُبطل توقيع السابق. */
|
||||
async issue(tenantId: string, userId: string): Promise<string> {
|
||||
const secret = randomBytes(32).toString('hex');
|
||||
await this.redis.set(this.key(tenantId, userId), secret, 'EX', KEY_TTL_SEC);
|
||||
return secret;
|
||||
}
|
||||
|
||||
async revoke(tenantId: string, userId: string): Promise<void> {
|
||||
await this.redis.del(this.key(tenantId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* النصّ الموقَّع: `timestamp.METHOD.path.body`.
|
||||
* تضمين المسار والطريقة يمنع إعادة استعمال توقيعٍ صالح على نقطة أخرى؛
|
||||
* والجسم يمنع تبديل المبلغ في الطريق (الـAPI على http حالياً).
|
||||
*/
|
||||
static payload(timestamp: string, method: string, path: string, rawBody: string): string {
|
||||
return `${timestamp}.${method.toUpperCase()}.${path}.${rawBody ?? ''}`;
|
||||
}
|
||||
|
||||
static sign(secret: string, payload: string): string {
|
||||
return createHmac('sha256', secret).update(payload).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* يتحقق من التوقيع. يرجع سبب الفشل نصّاً (أو `null` عند النجاح) ليُسجَّل
|
||||
* في التدقيق — لا ليُعرض للمهاجم.
|
||||
*/
|
||||
async verify(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
signature: string,
|
||||
timestamp: string,
|
||||
method: string,
|
||||
path: string,
|
||||
rawBody: string,
|
||||
): Promise<string | null> {
|
||||
const ts = Number(timestamp);
|
||||
if (!Number.isFinite(ts)) return 'bad_timestamp';
|
||||
// نافذة ضيقة = حماية من إعادة البثّ (replay) بلا تخزين حالة.
|
||||
if (Math.abs(Date.now() / 1000 - ts) > SIGNATURE_SKEW_SEC) return 'stale_timestamp';
|
||||
|
||||
const secret = await this.redis.get(this.key(tenantId, userId));
|
||||
if (!secret) return 'no_signing_key'; // لم يُسجّل دخوله بعد هذه الميزة
|
||||
|
||||
const expected = SigningService.sign(secret, SigningService.payload(timestamp, method, path, rawBody));
|
||||
return SigningService.safeEqual(signature, expected) ? null : 'bad_signature';
|
||||
}
|
||||
|
||||
/** مقارنة ثابتة الزمن — العادية تسرّب التوقيع الصحيح بالتوقيت. */
|
||||
private static safeEqual(a: string, b: string): boolean {
|
||||
if (typeof a !== 'string' || a.length !== b.length) return false;
|
||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readImageMeta, validateLogo, LOGO_MIN_SIDE } from './image-meta';
|
||||
|
||||
/** يبني ترويسة PNG صالحة بأبعاد محدّدة (لا نحتاج بقية الملف للقراءة). */
|
||||
function png(width: number, height: number): Buffer {
|
||||
const buf = Buffer.alloc(24);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
|
||||
buf.write('IHDR', 12, 'ascii');
|
||||
buf.writeUInt32BE(width, 16);
|
||||
buf.writeUInt32BE(height, 20);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** JPEG بجزء APP0 قبل SOF0 — يثبت أننا نتنقّل بالأطوال لا بموضع ثابت. */
|
||||
function jpeg(width: number, height: number): Buffer {
|
||||
const app0 = Buffer.alloc(4 + 14);
|
||||
app0.writeUInt16BE(0xffe0, 0);
|
||||
app0.writeUInt16BE(16, 2); // طول APP0
|
||||
|
||||
const sof = Buffer.alloc(11);
|
||||
sof.writeUInt16BE(0xffc0, 0);
|
||||
sof.writeUInt16BE(8, 2);
|
||||
sof.writeUInt8(8, 4);
|
||||
sof.writeUInt16BE(height, 5);
|
||||
sof.writeUInt16BE(width, 7);
|
||||
|
||||
return Buffer.concat([Buffer.from([0xff, 0xd8]), app0, sof]);
|
||||
}
|
||||
|
||||
describe('readImageMeta — قراءة الأبعاد بلا اعتمادية', () => {
|
||||
it('يقرأ أبعاد PNG', () => {
|
||||
expect(readImageMeta(png(1024, 1024))).toEqual({ format: 'png', width: 1024, height: 1024 });
|
||||
});
|
||||
|
||||
it('يقرأ أبعاد JPEG متجاوزاً الأجزاء التي تسبق SOF', () => {
|
||||
expect(readImageMeta(jpeg(800, 600))).toEqual({ format: 'jpeg', width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('يرفض ما ليس صورة', () => {
|
||||
expect(readImageMeta(Buffer.from('هذا ملف نصي لا صورة'))).toBeNull();
|
||||
});
|
||||
|
||||
it('لا يدور بلا نهاية على JPEG بطول معطوب', () => {
|
||||
// طول = 0 كان سيُبقي المؤشّر مكانه إلى الأبد.
|
||||
const bad = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x00]), Buffer.alloc(40)]);
|
||||
expect(readImageMeta(bad)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLogo — بوابة جودة الأيقونة (docs/22 N2)', () => {
|
||||
it('يقبل لوغو مربّعاً كبيراً', () => {
|
||||
expect(validateLogo(png(1024, 1024))).toBeNull();
|
||||
});
|
||||
|
||||
it('يرفض الصغير — الأيقونة ستخرج مشوّشة', () => {
|
||||
expect(validateLogo(png(LOGO_MIN_SIDE - 1, LOGO_MIN_SIDE - 1))).toContain('صغير');
|
||||
});
|
||||
|
||||
it('يرفض المستطيل — الأيقونة ستُمطّ', () => {
|
||||
expect(validateLogo(png(1024, 512))).toContain('غير مربّع');
|
||||
});
|
||||
|
||||
it('يتسامح مع انحراف بسيط عن التربيع', () => {
|
||||
expect(validateLogo(png(1024, 1000))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* قراءة أبعاد الصورة ونوعها من ترويسة الملف — بلا أي اعتمادية (docs/22 — N2).
|
||||
*
|
||||
* لم نُدخل `sharp`: توليد الأيقونات نفسه شغل `flutter_launcher_icons` في
|
||||
* سكربت N3، والباك إند لا يحتاج إلا **التحقّق** قبل القبول. إدخال مكتبة
|
||||
* صور أصيلة (libvips) في صورة alpine ثمنٌ كبير مقابل قراءة ٢٤ بايت.
|
||||
*
|
||||
* السبب في وجود هذا أصلاً: لوغو صغير أو غير مربّع يُنتج أيقونة ممطوطة أو
|
||||
* مشوّشة في كل تطبيقات المستأجر — ويُكتشف بعد البناء والنشر لا قبله.
|
||||
*/
|
||||
|
||||
export interface ImageMeta {
|
||||
format: 'png' | 'jpeg';
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** يعيد null إن لم يكن الملف PNG أو JPEG صالحاً. */
|
||||
export function readImageMeta(buf: Buffer): ImageMeta | null {
|
||||
return readPng(buf) ?? readJpeg(buf);
|
||||
}
|
||||
|
||||
function readPng(buf: Buffer): ImageMeta | null {
|
||||
// توقيع PNG ثم قطعة IHDR: العرض والارتفاع عند 16 و20 (big-endian).
|
||||
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (buf.length < 24 || !buf.subarray(0, 8).equals(SIG)) return null;
|
||||
if (buf.subarray(12, 16).toString('ascii') !== 'IHDR') return null;
|
||||
return { format: 'png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
function readJpeg(buf: Buffer): ImageMeta | null {
|
||||
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
|
||||
|
||||
// JPEG سلسلة أجزاء متغيّرة الطول؛ الأبعاد في جزء SOF ولا موضع ثابت لها،
|
||||
// فنتنقّل بالأطوال المعلنة حتى نصل SOFn.
|
||||
let i = 2;
|
||||
while (i + 9 < buf.length) {
|
||||
if (buf[i] !== 0xff) return null; // خرجنا عن المحاذاة = ملف تالف
|
||||
const marker = buf[i + 1];
|
||||
const len = buf.readUInt16BE(i + 2);
|
||||
if (len < 2) return null; // طول معطوب: بلا هذا الفحص تصير الحلقة لانهائية
|
||||
|
||||
// SOF0..SOF15 عدا DHT(c4) وJPG(c8) وDAC(cc) — هذه ليست أجزاء إطار.
|
||||
const isSof = marker >= 0xc0 && marker <= 0xcf &&
|
||||
marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
||||
if (isSof) {
|
||||
return { format: 'jpeg', height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** الحدّ الأدنى لأيقونة تطبيق: أندرويد يحتاج 512 لمتجر Play، وiOS 1024. */
|
||||
export const LOGO_MIN_SIDE = 512;
|
||||
|
||||
/**
|
||||
* يتحقّق من صلاحية اللوغو كمصدر للأيقونات ويعيد رسالة الخطأ أو null.
|
||||
* نسمح بانحراف بسيط عن التربيع (٥٪) لأن التصدير اليدوي نادراً ما يكون مضبوطاً
|
||||
* بالبكسل، لكن شعاراً مستطيلاً فعلاً يجب أن يُرفض لا أن يُمطّ.
|
||||
*/
|
||||
export function validateLogo(buf: Buffer): string | null {
|
||||
const meta = readImageMeta(buf);
|
||||
if (!meta) return 'الملف ليس PNG أو JPEG صالحاً';
|
||||
if (meta.width < LOGO_MIN_SIDE || meta.height < LOGO_MIN_SIDE) {
|
||||
return `اللوغو صغير: ${meta.width}×${meta.height} — الحدّ الأدنى ${LOGO_MIN_SIDE}×${LOGO_MIN_SIDE}`;
|
||||
}
|
||||
const ratio = meta.width / meta.height;
|
||||
if (ratio < 0.95 || ratio > 1.05) {
|
||||
return `اللوغو غير مربّع: ${meta.width}×${meta.height} — الأيقونة ستُمطّ`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { promises as fs } from 'fs';
|
||||
import { join, dirname, extname } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* مُحوّل تخزين الملفات. محلياً الآن (قرص/فوليوم)، وقابل للتحويل لسيرفر تخزين
|
||||
* داخل البلد (السيادة الرقمية) بتغيير STORAGE_BASE_URL + STORAGE_DIR (docs/16، docs/06).
|
||||
* الملفات لكل مستأجر معزولة تحت مجلده.
|
||||
*/
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private get dir(): string {
|
||||
return this.config.get<string>('storage.dir') ?? '/app/storage';
|
||||
}
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('storage.baseUrl') ?? '';
|
||||
}
|
||||
|
||||
/** يحفظ ملفاً ويعيد مفتاحه (مسار نسبي معزول بالمستأجر). */
|
||||
async save(
|
||||
tenantId: string,
|
||||
scope: string,
|
||||
buffer: Buffer,
|
||||
originalName = '',
|
||||
): Promise<string> {
|
||||
const ext = extname(originalName) || '.bin';
|
||||
const key = `${tenantId}/${scope}/${randomUUID()}${ext}`;
|
||||
const full = join(this.dir, key);
|
||||
await fs.mkdir(dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, buffer);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** رابط الوصول (يشير لسيرفر التوطين حين يُضبط STORAGE_BASE_URL). */
|
||||
url(key: string): string {
|
||||
return this.baseUrl ? `${this.baseUrl}/${key}` : `/storage/${key}`;
|
||||
}
|
||||
|
||||
async read(key: string): Promise<Buffer> {
|
||||
return fs.readFile(join(this.dir, key));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
export interface TenantStore {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* سياق المستأجر لكل طلب — يُملأ من TenantMiddleware ويُقرأ في المستودعات
|
||||
* لفرض النطاق على tenant_id تلقائياً (راجع docs/06).
|
||||
*/
|
||||
export const tenantContext = new AsyncLocalStorage<TenantStore>();
|
||||
|
||||
export function currentTenantId(): string | undefined {
|
||||
return tenantContext.getStore()?.tenantId;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { currentTenantId } from './tenant.context';
|
||||
|
||||
/**
|
||||
* @Tenant() — يحقن معرّف المستأجر الحالي في معاملات المتحكّم.
|
||||
*/
|
||||
export const Tenant = createParamDecorator(
|
||||
(_data: unknown, _ctx: ExecutionContext): string | undefined =>
|
||||
currentTenantId(),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { tenantContext } from './tenant.context';
|
||||
|
||||
/**
|
||||
* يستخرج معرّف المستأجر من الترويسة (أو الـ subdomain لاحقاً) ويضعه في السياق
|
||||
* لبقية دورة حياة الطلب. لا استعلام يمر بلا tenantId (راجع docs/06).
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenantMiddleware implements NestMiddleware {
|
||||
use(req: Request, _res: Response, next: NextFunction) {
|
||||
const headerTenant =
|
||||
(req.headers['x-tenant-id'] as string) ||
|
||||
(req.headers['x-tenant'] as string) ||
|
||||
'';
|
||||
|
||||
// السوبر-آدمن قد لا يحمل مستأجراً محدداً — يُعالَج بحارس منفصل لاحقاً.
|
||||
tenantContext.run({ tenantId: headerTenant }, () => next());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { currentTenantId } from '../tenant/tenant.context';
|
||||
|
||||
/**
|
||||
* يقيس الاستخدام لكل مستأجر (أساس الفوترة الشهرية — راجع docs/05).
|
||||
* حالياً يسجّل فقط؛ لاحقاً يكتب في جدول usage / يدفع لطابور BullMQ.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UsageInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('Usage');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const tenantId = currentTenantId() ?? 'none';
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
// TODO(P1): عدّ الرحلات المكتملة و GMV بدل تسجيل كل طلب.
|
||||
this.logger.debug(`tenant=${tenantId} ${req.method} ${req.url}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* إعداد مركزي يقرأ متغيرات البيئة مع افتراضات العزل (راجع docs/14).
|
||||
*/
|
||||
export default () => ({
|
||||
env: process.env.NODE_ENV ?? 'development',
|
||||
apiPort: parseInt(process.env.API_PORT ?? '4010', 10),
|
||||
|
||||
db: {
|
||||
host: process.env.DB_HOST ?? 'postgres',
|
||||
port: parseInt(process.env.DB_PORT ?? '5432', 10),
|
||||
name: process.env.DB_NAME ?? 'tripz',
|
||||
user: process.env.DB_USER ?? 'tripz',
|
||||
password: process.env.DB_PASSWORD ?? 'change_me_strong',
|
||||
// بادئة الجداول لعزل Tripz عن باقي البرامج على نفس السيرفر
|
||||
tablePrefix: process.env.DB_TABLE_PREFIX ?? 'tripz_',
|
||||
synchronize: process.env.DB_SYNC === 'true',
|
||||
},
|
||||
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST ?? 'redis',
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||
// DB رقم غير الافتراضي 0 لعزل Tripz عن باقي البرامج
|
||||
db: parseInt(process.env.REDIS_DB ?? '3', 10),
|
||||
keyPrefix: process.env.REDIS_KEY_PREFIX ?? 'tripz:',
|
||||
},
|
||||
|
||||
queue: {
|
||||
prefix: process.env.QUEUE_PREFIX ?? 'tripz_',
|
||||
},
|
||||
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET ?? 'change_me_jwt_secret',
|
||||
expires: process.env.JWT_EXPIRES ?? '15m',
|
||||
refreshExpires: process.env.JWT_REFRESH_EXPIRES ?? '30d',
|
||||
},
|
||||
|
||||
auth: {
|
||||
/**
|
||||
* وضع تطوير: رمز OTP ثابت `1234` يُطبع باللوغ بلا مناداة مزوّد (docs/07).
|
||||
*
|
||||
* **يفشل مُغلقاً عمداً**: لا يُفتح إلا بـ`OTP_DEV_MODE=true` حرفياً. كان
|
||||
* `!== 'false'` أي أن غياب المتغيّر — أو خطأً مطبعياً فيه، أو `OTP_DEV_MODE=0`
|
||||
* (ونصّ `'0'` ليس `'false'`) — يترك الإنتاج برمز ثابت يفتح **كل حساب في
|
||||
* المنصة**. الافتراض الآمن أن نرسل رسالة حقيقية، لا أن نفتح الباب.
|
||||
*/
|
||||
otpDevMode: process.env.OTP_DEV_MODE === 'true',
|
||||
otpTtl: parseInt(process.env.OTP_TTL ?? '300', 10),
|
||||
otpLength: parseInt(process.env.OTP_LENGTH ?? '4', 10),
|
||||
// ربط الجلسة بالجهاز (docs/17 — D2). مطفأ حتى يرسل فلاتر x-device-id؛
|
||||
// تفعيله قبل ذلك يقطع كل طلب مصادَق (نفس نمط PAYMENTS_REQUIRE_SIGNATURE).
|
||||
requireDeviceBinding: process.env.AUTH_REQUIRE_DEVICE_BINDING === 'true',
|
||||
},
|
||||
|
||||
maps: {
|
||||
// بلاطات انطلق تأتي من خوادم انطلق نفسها — لا نستضيفها (docs/25).
|
||||
tilesUrl: process.env.MAPS_TILES_URL ?? 'https://maps.intaleqapp.com',
|
||||
provider: process.env.MAPS_PROVIDER ?? 'antlaq',
|
||||
// خرائط انطلق (map-saas). مفتاح افتراضي + مفاتيح لكل دولة.
|
||||
baseUrl: process.env.MAPS_BASE_URL ?? 'https://map-saas.intaleqapp.com',
|
||||
apiKey: process.env.MAPS_API_KEY ?? '',
|
||||
apiKeyByCountry: {
|
||||
jo: process.env.MAPS_API_KEY_JO ?? process.env.MAPS_API_KEY ?? '',
|
||||
sy: process.env.MAPS_API_KEY_SY ?? process.env.MAPS_API_KEY ?? '',
|
||||
} as Record<string, string>,
|
||||
placesApiKey: process.env.MAPS_PLACES_API_KEY ?? process.env.MAPS_API_KEY ?? '',
|
||||
},
|
||||
|
||||
// Nabeh — إرسال OTP عبر واتساب.
|
||||
// توجيه OTP متعدد المزوّدين حسب الدولة (docs/17 — D5).
|
||||
otp: {
|
||||
// نصّ رسالة SMS (Kazumi/مصر). {code} يُستبدل بالرمز.
|
||||
smsTemplate: process.env.OTP_SMS_TEMPLATE ?? 'Your Tripz code is {code}',
|
||||
kazumi: {
|
||||
username: process.env.SMS_USERNAME ?? '',
|
||||
password: process.env.SMS_PASSWORD_EGYPT ?? '',
|
||||
sender: process.env.SMS_SENDER ?? '',
|
||||
},
|
||||
},
|
||||
|
||||
// الرحلات: كل كم يفحص كنس الرحلات المجدولة (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).
|
||||
// أبطأ من كنس الرحلات عمداً: هذا شبكة أمان لا مسار أساسي.
|
||||
rewards: {
|
||||
sweepMs: parseInt(process.env.REWARDS_SWEEP_MS ?? '60000', 10),
|
||||
},
|
||||
|
||||
nabeh: {
|
||||
baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com',
|
||||
email: process.env.NABEH_EMAIL ?? '',
|
||||
password: process.env.NABEH_PASSWORD ?? '',
|
||||
otpType: process.env.NABEH_OTP_TYPE ?? 'text',
|
||||
},
|
||||
|
||||
// ملاحظة: خرائط الاتصال الدولية صارت في common/phone/phone.service.ts
|
||||
// (docs/17 — D1) — مصدر واحد للحقيقة بدل نسختين قد تتباعدان.
|
||||
|
||||
// Gemini (رؤية) — قراءة الوثائق ومطابقة الوجه.
|
||||
gemini: {
|
||||
apiKey: process.env.GEMINI_API_KEY ?? '',
|
||||
model: process.env.GEMINI_MODEL ?? 'gemini-2.0-flash',
|
||||
},
|
||||
|
||||
// سرّ المنصة (super-admin) — لتعيين أول أدمن قبل وجود أدمن.
|
||||
platform: {
|
||||
secret: process.env.PLATFORM_SECRET ?? '',
|
||||
},
|
||||
|
||||
// تخزين ملفات الوثائق. baseUrl فارغ = محلي؛ يُضبط لسيرفر التوطين لاحقاً (docs/06).
|
||||
storage: {
|
||||
dir: process.env.STORAGE_DIR ?? '/app/storage',
|
||||
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 (ملف JSON لحزمة firebase-admin).
|
||||
fcm: {
|
||||
credentialsPath: process.env.GOOGLE_APPLICATION_CREDENTIALS ?? '',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
|
||||
loadEnv();
|
||||
|
||||
/**
|
||||
* مصدر بيانات TypeORM — يُستخدم للهجرات و runtime.
|
||||
* entityPrefix يضمن أن كل جداول Tripz تبدأ بـ tripz_ لعزلها على السيرفر المشترك.
|
||||
*/
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST ?? 'postgres',
|
||||
port: parseInt(process.env.DB_PORT ?? '5432', 10),
|
||||
database: process.env.DB_NAME ?? 'tripz',
|
||||
username: process.env.DB_USER ?? 'tripz',
|
||||
password: process.env.DB_PASSWORD ?? 'change_me_strong',
|
||||
entityPrefix: process.env.DB_TABLE_PREFIX ?? 'tripz_',
|
||||
synchronize: false,
|
||||
entities: [__dirname + '/../database/entities/*.entity.{ts,js}'],
|
||||
migrations: [__dirname + '/../database/migrations/*.{ts,js}'],
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type TenantMode = 'shared' | 'sovereign';
|
||||
export type TenantPlan = 'launch' | 'brand' | 'fleet' | 'sovereign';
|
||||
|
||||
/**
|
||||
* المستأجر = مكتب تكسي / أسطول / مشغّل. الجدول الفعلي: tripz_tenants
|
||||
* (البادئة من entityPrefix). راجع docs/06 و docs/08.
|
||||
*/
|
||||
@Entity('tenants')
|
||||
export class Tenant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
slug: string;
|
||||
|
||||
@Column({ name: 'country_pack', default: 'jo' })
|
||||
countryPack: string;
|
||||
|
||||
@Column({ type: 'varchar', default: 'launch' })
|
||||
plan: TenantPlan;
|
||||
|
||||
@Column({ type: 'varchar', default: 'shared' })
|
||||
mode: TenantMode;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
branding: Record<string, any>;
|
||||
|
||||
// استحقاقات مشتراة (ماذا يملك) — يحكمها السوبر-أدمن. راجع docs/19.
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
features: Record<string, any>;
|
||||
|
||||
/**
|
||||
* سياسات تشغيل المستأجر (كيف يدير عمله) — يحكمها **المستأجر نفسه**، لا
|
||||
* السوبر-أدمن. منفصلة عن `features` عمداً: تلك «ماذا اشتريت»، وهذه «كيف
|
||||
* تشتغل». راجع docs/18 §5.2 (سياسة الدين).
|
||||
*/
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
settings: Record<string, any>;
|
||||
|
||||
@Column({ default: 'active' })
|
||||
status: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* أول هجرة: جدول المستأجرين + تفعيل PostGIS.
|
||||
* اسم الجدول الفعلي tripz_tenants (البادئة تُضاف عبر entityPrefix، لكن الهجرة
|
||||
* تكتب الاسم صراحةً لأنها SQL خام).
|
||||
*/
|
||||
export class InitTenants1721145600000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`CREATE EXTENSION IF NOT EXISTS postgis`);
|
||||
await q.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_tenants (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name varchar NOT NULL,
|
||||
slug varchar NOT NULL UNIQUE,
|
||||
country_pack varchar NOT NULL DEFAULT 'jo',
|
||||
plan varchar NOT NULL DEFAULT 'launch',
|
||||
mode varchar NOT NULL DEFAULT 'shared',
|
||||
branding jsonb NOT NULL DEFAULT '{}',
|
||||
features jsonb NOT NULL DEFAULT '{}',
|
||||
status varchar NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_tenants`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* جدول المستخدمين tripz_users + فهرس فريد (tenant_id, phone).
|
||||
*/
|
||||
export class InitUsers1721232000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_users (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
phone varchar NOT NULL,
|
||||
name varchar,
|
||||
role varchar NOT NULL DEFAULT 'rider',
|
||||
status varchar NOT NULL DEFAULT 'active',
|
||||
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_users_tenant_phone"
|
||||
ON tripz_users (tenant_id, phone)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_users`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* جداول P1: drivers, tariffs, trips, trip_events (كلها ببادئة tripz_).
|
||||
*/
|
||||
export class InitP11721300000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
// drivers
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_drivers (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
service_class varchar NOT NULL DEFAULT 'economy',
|
||||
vehicle_make varchar, vehicle_model varchar,
|
||||
vehicle_plate varchar, vehicle_color varchar,
|
||||
docs jsonb NOT NULL DEFAULT '{}',
|
||||
verification_status varchar NOT NULL DEFAULT 'pending',
|
||||
is_online boolean NOT NULL DEFAULT false,
|
||||
rating numeric(3,2) NOT NULL DEFAULT 5,
|
||||
last_lat double precision, last_lng double precision,
|
||||
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_drivers_tenant_user" ON tripz_drivers (tenant_id, user_id)`);
|
||||
|
||||
// tariffs
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_tariffs (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
city varchar NOT NULL,
|
||||
service_class varchar NOT NULL,
|
||||
definition jsonb NOT NULL,
|
||||
version int NOT NULL DEFAULT 1,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_tariffs_lookup" ON tripz_tariffs (tenant_id, city, service_class, active)`);
|
||||
|
||||
// trips
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_trips (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
rider_id uuid NOT NULL,
|
||||
driver_id uuid,
|
||||
service_class varchar NOT NULL DEFAULT 'economy',
|
||||
city varchar,
|
||||
origin_lat double precision NOT NULL, origin_lng double precision NOT NULL,
|
||||
dest_lat double precision NOT NULL, dest_lng double precision NOT NULL,
|
||||
status varchar NOT NULL DEFAULT 'searching',
|
||||
distance_km numeric(8,3), duration_min numeric(8,1),
|
||||
tariff_id uuid, tariff_version int,
|
||||
quoted_fare numeric(12,3), final_fare numeric(12,3),
|
||||
currency varchar,
|
||||
payment_method varchar NOT NULL DEFAULT 'cash',
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
assigned_at timestamptz, completed_at timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_status" ON tripz_trips (tenant_id, status)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_rider" ON tripz_trips (tenant_id, rider_id)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_driver" ON tripz_trips (tenant_id, driver_id)`);
|
||||
|
||||
// trip_events
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_trip_events (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
trip_id uuid NOT NULL,
|
||||
from_status varchar,
|
||||
to_status varchar NOT NULL,
|
||||
source varchar NOT NULL DEFAULT 'system',
|
||||
payload jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_events_trip" ON tripz_trip_events (tenant_id, trip_id)`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_trip_events`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_trips`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_tariffs`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_drivers`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* P1b: chat_messages, ratings, fraud_flags + أعمدة إلغاء على trips.
|
||||
*/
|
||||
export class InitP1b1721400000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS cancelled_by varchar`);
|
||||
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS cancel_fee numeric(12,3)`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_chat_messages (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
trip_id uuid NOT NULL,
|
||||
sender_id uuid NOT NULL,
|
||||
sender_role varchar NOT NULL,
|
||||
body text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_chat_trip" ON tripz_chat_messages (tenant_id, trip_id)`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_ratings (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
trip_id uuid NOT NULL,
|
||||
by_user_id uuid NOT NULL,
|
||||
by_role varchar NOT NULL,
|
||||
target_driver_id uuid,
|
||||
stars int NOT NULL,
|
||||
comment text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_trip" ON tripz_ratings (tenant_id, trip_id)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_driver" ON tripz_ratings (tenant_id, target_driver_id)`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_fraud_flags (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
subject_type varchar NOT NULL,
|
||||
subject_id uuid NOT NULL,
|
||||
trip_id uuid,
|
||||
reason varchar NOT NULL,
|
||||
meta jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_fraud_subject" ON tripz_fraud_flags (tenant_id, subject_type, subject_id)`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_fraud_flags`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_ratings`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_chat_messages`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS cancel_fee`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS cancelled_by`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* P2: ride_types, wallets, wallet_txns, device_tokens + trips.is_round_trip.
|
||||
*/
|
||||
export class InitP21721500000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS is_round_trip boolean NOT NULL DEFAULT false`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_ride_types (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
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,
|
||||
icon varchar,
|
||||
sort int NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_ride_types_code" ON tripz_ride_types (tenant_id, code)`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_wallets (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
balance numeric(12,3) NOT NULL DEFAULT 0,
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
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_wallets_user" ON tripz_wallets (tenant_id, user_id)`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_wallet_txns (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
wallet_id uuid NOT NULL,
|
||||
amount numeric(12,3) NOT NULL,
|
||||
type varchar NOT NULL,
|
||||
reason varchar NOT NULL,
|
||||
ref varchar,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_wallet_txns_wallet" ON tripz_wallet_txns (tenant_id, wallet_id)`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_device_tokens (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
token varchar NOT NULL UNIQUE,
|
||||
platform varchar NOT NULL DEFAULT 'android',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_device_tokens_user" ON tripz_device_tokens (tenant_id, user_id)`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_device_tokens`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_wallet_txns`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_wallets`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_ride_types`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS is_round_trip`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* جداول الدفع والسحب (schema الدفع منطقياً — بادئة tripz_pay_، قابلة للفصل).
|
||||
*/
|
||||
export class InitPayments1721600000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_pay_payments (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
trip_id uuid,
|
||||
purpose varchar NOT NULL DEFAULT 'topup',
|
||||
provider varchar NOT NULL,
|
||||
method varchar,
|
||||
amount numeric(12,3) NOT NULL,
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
status varchar NOT NULL DEFAULT 'pending',
|
||||
tx_ref varchar,
|
||||
redirect_url varchar,
|
||||
meta jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payments_user" ON tripz_pay_payments (tenant_id, user_id)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payments_status" ON tripz_pay_payments (tenant_id, status)`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_pay_payouts (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
driver_user_id uuid NOT NULL,
|
||||
amount numeric(12,3) NOT NULL,
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
channel varchar NOT NULL,
|
||||
destination jsonb NOT NULL DEFAULT '{}',
|
||||
status varchar NOT NULL DEFAULT 'requested',
|
||||
ref varchar,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payouts_driver" ON tripz_pay_payouts (tenant_id, driver_user_id)`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_pay_payouts`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_pay_payments`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** وثائق السائق. الجدول: tripz_driver_documents. */
|
||||
export class InitDocuments1721700000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_driver_documents (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
driver_id uuid NOT NULL,
|
||||
type varchar NOT NULL,
|
||||
file_key varchar NOT NULL,
|
||||
number varchar,
|
||||
expiry_date date,
|
||||
status varchar NOT NULL DEFAULT 'pending',
|
||||
review_note varchar,
|
||||
reviewed_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_documents_driver" ON tripz_driver_documents (tenant_id, driver_id)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_documents_status" ON tripz_driver_documents (tenant_id, status)`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_driver_documents`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** حقول إضافية للوثائق: الوجه، اسم الحامل، تاريخ الإصدار، حقول إضافية. */
|
||||
export class DocumentsFields1721750000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS side varchar NOT NULL DEFAULT 'single'`);
|
||||
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS holder_name varchar`);
|
||||
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS issue_date date`);
|
||||
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS extra jsonb NOT NULL DEFAULT '{}'`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS extra`);
|
||||
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS issue_date`);
|
||||
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS holder_name`);
|
||||
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS side`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** لغة المستخدم — تُستخدم لترجمة الإشعارات (docs/17 — A2). */
|
||||
export class UserLanguage1721800000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS language varchar NOT NULL DEFAULT 'ar'`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS language`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* دفتر المحفظة (docs/17 — I1):
|
||||
* - `balance_after` لتدقيق كل حركة ومطابقتها بالرصيد.
|
||||
* - قيد `balance >= 0` — شبكة أمان أخيرة على مستوى القاعدة: أي مسار كتابة
|
||||
* يتجاوز الخصم الذرّي سيفشل بدل أن يُنتج رصيداً سالباً بصمت.
|
||||
*/
|
||||
export class WalletLedger1721810000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(
|
||||
`ALTER TABLE tripz_wallet_txns ADD COLUMN IF NOT EXISTS balance_after numeric(12,3)`,
|
||||
);
|
||||
await q.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE tripz_wallets ADD CONSTRAINT tripz_wallets_balance_non_negative CHECK (balance >= 0);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(
|
||||
`ALTER TABLE tripz_wallets DROP CONSTRAINT IF EXISTS tripz_wallets_balance_non_negative`,
|
||||
);
|
||||
await q.query(`ALTER TABLE tripz_wallet_txns DROP COLUMN IF EXISTS balance_after`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* تقييم الطرفين (docs/17 — G6):
|
||||
* - `ratings.target_user_id` — الراكب المُقيَّم عندما يقيّمه السائق. بدونه كان
|
||||
* تقييم السائق للراكب يُخزَّن بلا هدف فلا يُجمَّع أبداً.
|
||||
* - `users.rating` — التقييم الظاهر للراكب، يُكتب مرة واحدة يومياً.
|
||||
*/
|
||||
export class RatingsBothParties1721820000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_ratings ADD COLUMN IF NOT EXISTS target_user_id uuid`);
|
||||
await q.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_tenant_target_user" ON tripz_ratings (tenant_id, target_user_id)`,
|
||||
);
|
||||
await q.query(
|
||||
`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS rating numeric(3,2) NOT NULL DEFAULT 5`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS rating`);
|
||||
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_ratings_tenant_target_user"`);
|
||||
await q.query(`ALTER TABLE tripz_ratings DROP COLUMN IF EXISTS target_user_id`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* المواقع (docs/17 — H3/H4):
|
||||
* - أعمدة لقطة الموقع على `drivers` — يكتبها الـworker دورياً، لا كل نبضة.
|
||||
* - `driver_tracks` — نقاط المسار التاريخية، إدراج مجمّع من الـworker.
|
||||
*/
|
||||
export class DriverLocations1721830000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS heading real`);
|
||||
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS speed real`);
|
||||
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS loc_status varchar`);
|
||||
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS loc_updated_at timestamptz`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_driver_tracks (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
driver_id uuid NOT NULL,
|
||||
lat double precision NOT NULL,
|
||||
lng double precision NOT NULL,
|
||||
heading real,
|
||||
speed real,
|
||||
status varchar,
|
||||
recorded_at timestamptz NOT NULL
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_tracks_driver_time"
|
||||
ON tripz_driver_tracks (tenant_id, driver_id, recorded_at)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_driver_tracks`);
|
||||
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS loc_updated_at`);
|
||||
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS loc_status`);
|
||||
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS speed`);
|
||||
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS heading`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* نموذج الرحلة (docs/17 — B2/B3/B6/B7):
|
||||
* - فصل السعر: ما يدفعه الراكب مقابل ما يقبضه السائق + العمولة.
|
||||
* - الانتظار: الدقائق ورسمها.
|
||||
* - مشوار وصول السائق للراكب: مسافته وزمنه ورسمه.
|
||||
* - طوابع دقيقة: انطلاق نحو الراكب · الوصول · بدء الرحلة.
|
||||
*/
|
||||
export class TripPricingAndTimestamps1721840000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
const add = (col: string, type: string) =>
|
||||
q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS ${col} ${type}`);
|
||||
|
||||
await add('price_for_passenger', 'numeric(12,3)');
|
||||
await add('price_for_driver', 'numeric(12,3)');
|
||||
await add('commission_amount', 'numeric(12,3)');
|
||||
await add('commission_rate', 'numeric(5,2)');
|
||||
|
||||
await add('waiting_min', 'numeric(6,1)');
|
||||
await add('waiting_charge', 'numeric(12,3)');
|
||||
|
||||
await add('pickup_distance_km', 'numeric(8,3)');
|
||||
await add('pickup_duration_min', 'numeric(8,1)');
|
||||
await add('pickup_charge', 'numeric(12,3)');
|
||||
|
||||
await add('driver_going_at', 'timestamptz');
|
||||
await add('arrived_at', 'timestamptz');
|
||||
await add('started_at', 'timestamptz');
|
||||
|
||||
// رحلات قائمة: الراكب دفع = السائق قبض (لم تكن هناك عمولة أصلاً).
|
||||
await q.query(`
|
||||
UPDATE tripz_trips
|
||||
SET price_for_passenger = COALESCE(price_for_passenger, final_fare, quoted_fare),
|
||||
price_for_driver = COALESCE(price_for_driver, final_fare, quoted_fare)
|
||||
WHERE quoted_fare IS NOT NULL OR final_fare IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
const drop = (col: string) =>
|
||||
q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS ${col}`);
|
||||
for (const c of [
|
||||
'started_at', 'arrived_at', 'driver_going_at',
|
||||
'pickup_charge', 'pickup_duration_min', 'pickup_distance_km',
|
||||
'waiting_charge', 'waiting_min',
|
||||
'commission_rate', 'commission_amount', 'price_for_driver', 'price_for_passenger',
|
||||
]) {
|
||||
await drop(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* الرصيد التشغيلي للسائق (docs/18 — المجموعة J).
|
||||
* عمولة مدفوعة سلفاً، منفصلة تماماً عن محفظة أرباحه.
|
||||
*
|
||||
* **بلا قيد `balance >= 0`** — بخلاف `tripz_wallets`: الدين مسموح عمداً
|
||||
* (قرار المالك: الرحلة لا تُقطع أبداً ولو صار الرصيد سالباً).
|
||||
*/
|
||||
export class DriverCredit1721850000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_driver_credits (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
driver_id uuid NOT NULL,
|
||||
balance numeric(12,3) NOT NULL DEFAULT 0,
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_driver_credits_tenant_driver"
|
||||
ON tripz_driver_credits (tenant_id, driver_id)
|
||||
`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_credit_txns (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
driver_id uuid NOT NULL,
|
||||
amount numeric(12,3) NOT NULL,
|
||||
type varchar NOT NULL,
|
||||
balance_after numeric(12,3),
|
||||
trip_id uuid,
|
||||
ref varchar,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_driver_time"
|
||||
ON tripz_credit_txns (tenant_id, driver_id, created_at)
|
||||
`);
|
||||
|
||||
// مكافأة التسجيل مرة واحدة لكل سائق — تُفرض على القاعدة لا بفحص تطبيقي،
|
||||
// فتسجيلان متزامنان لا يمنحانها مرتين (docs/18 — J6).
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_signup_once"
|
||||
ON tripz_credit_txns (tenant_id, driver_id)
|
||||
WHERE type = 'signup_bonus'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_credit_txns`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_driver_credits`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* سياسات تشغيل المستأجر (docs/18 §5.2): سياسة الدين ومكافأة التسجيل لكل عملة.
|
||||
*
|
||||
* منفصلة عن `features` عمداً: تلك استحقاقات مشتراة يحكمها السوبر-أدمن، وهذه
|
||||
* سياسات يحكمها المستأجر نفسه.
|
||||
*/
|
||||
export class TenantSettings1721860000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(
|
||||
`ALTER TABLE tripz_tenants ADD COLUMN IF NOT EXISTS settings jsonb NOT NULL DEFAULT '{}'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_tenants DROP COLUMN IF EXISTS settings`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* أمان السحب + سجل التدقيق (docs/17 — I4/I5/I7).
|
||||
* - أعمدة إثبات هوية السحب على `pay_payouts`.
|
||||
* - `audit_log` — من فعل ماذا ومتى ومن أين (append-only).
|
||||
*/
|
||||
export class PayoutSecurityAndAudit1721870000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
const add = (col: string, type: string) =>
|
||||
q.query(`ALTER TABLE tripz_pay_payouts ADD COLUMN IF NOT EXISTS ${col} ${type}`);
|
||||
|
||||
await add('otp_verified_at', 'timestamptz');
|
||||
await add('biometric_method', 'varchar');
|
||||
await add('biometric_at', 'timestamptz');
|
||||
await add('device_id', 'varchar');
|
||||
await add('request_ip', 'varchar');
|
||||
|
||||
// الافتراض الجديد للطلبات الجديدة: لا شيء يتحرك قبل التأكيد.
|
||||
await q.query(`ALTER TABLE tripz_pay_payouts ALTER COLUMN status SET DEFAULT 'pending_otp'`);
|
||||
|
||||
// السحوبات القائمة أُنشئت قبل وجود OTP وقد حُجز مالها فعلاً — تُعتبر
|
||||
// مُتحقَّقة، وإلا رفض `complete` صرفها إلى الأبد.
|
||||
await q.query(`
|
||||
UPDATE tripz_pay_payouts
|
||||
SET otp_verified_at = COALESCE(otp_verified_at, created_at)
|
||||
WHERE status IN ('requested', 'processing', 'paid')
|
||||
`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
actor_user_id uuid,
|
||||
actor_role varchar,
|
||||
action varchar NOT NULL,
|
||||
subject_type varchar,
|
||||
subject_id varchar,
|
||||
amount numeric(12,3),
|
||||
currency varchar,
|
||||
meta jsonb NOT NULL DEFAULT '{}',
|
||||
ip varchar,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_audit_log_tenant_time"
|
||||
ON tripz_audit_log (tenant_id, created_at)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_audit_log_subject"
|
||||
ON tripz_audit_log (tenant_id, subject_type, subject_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_audit_log`);
|
||||
await q.query(`ALTER TABLE tripz_pay_payouts ALTER COLUMN status SET DEFAULT 'requested'`);
|
||||
for (const c of ['request_ip', 'device_id', 'biometric_at', 'biometric_method', 'otp_verified_at']) {
|
||||
await q.query(`ALTER TABLE tripz_pay_payouts DROP COLUMN IF EXISTS ${c}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* تطبيع أرقام الهاتف المخزَّنة مسبقاً (docs/17 — D1).
|
||||
*
|
||||
* قبل هذه الهجرة كانت `sendOtp`/`verifyOtp` تخزّنان الرقم **كما كُتب حرفياً**
|
||||
* — فحسابات اختبار الحمل (`0797…`) وأي دخول سابق مخزَّنة بصيغة محلية لا
|
||||
* دولية. تطبّق هذه الهجرة نفس منطق `PhoneService` بـSQL خام لكل مستأجر حسب
|
||||
* `country_pack`، وتتخطّى أي صفٍّ يتعارض ناتجه مع صفٍّ آخر (لا تكسر القيد
|
||||
* الفريد `(tenant_id, phone)`؛ التعارض النادر يُترك يدوياً).
|
||||
*/
|
||||
export class NormalizePhones1721880000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
// jo/sy: صفر محلي + 9 أرقام → مفتاح الدولة + 9 أرقام.
|
||||
await q.query(`
|
||||
UPDATE tripz_users u
|
||||
SET phone = t.country_pack_cc || substring(u.phone from 2)
|
||||
FROM (
|
||||
SELECT id, CASE country_pack WHEN 'jo' THEN '962' WHEN 'sy' THEN '963' END AS country_pack_cc
|
||||
FROM tripz_tenants WHERE country_pack IN ('jo', 'sy')
|
||||
) t
|
||||
WHERE u.tenant_id = t.id
|
||||
AND u.phone ~ '^0[0-9]{9}$'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tripz_users u2
|
||||
WHERE u2.tenant_id = u.tenant_id
|
||||
AND u2.phone = t.country_pack_cc || substring(u.phone from 2)
|
||||
)
|
||||
`);
|
||||
|
||||
// eg: صفر محلي + 10 أرقام → "20" + 10 أرقام.
|
||||
await q.query(`
|
||||
UPDATE tripz_users u
|
||||
SET phone = '20' || substring(u.phone from 2)
|
||||
FROM tripz_tenants t
|
||||
WHERE u.tenant_id = t.id
|
||||
AND t.country_pack = 'eg'
|
||||
AND u.phone ~ '^0[0-9]{10}$'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tripz_users u2
|
||||
WHERE u2.tenant_id = u.tenant_id AND u2.phone = '20' || substring(u.phone from 2)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// لا رجوع — لا سبيل لمعرفة الصيغة الأصلية بعد التطبيع (وقد تكون كانت
|
||||
// بصيغ مختلفة أصلاً). التطبيع أُحادي الاتجاه بطبيعته.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* إشارة المكالمة على الرحلة (docs/17 — E1/E2؛ مكافئ isDriverCallPassenger
|
||||
* عند سيرو). تُخزَّن دائماً على الرحلة نفسها — لا في Redis فقط — لأنها
|
||||
* حقيقة تفيد التحقيق حتى بعد زوال حالة الرحلة من الكاش.
|
||||
*/
|
||||
export class TripCallSignal1721890000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(
|
||||
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS driver_called_passenger boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_at timestamptz`);
|
||||
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_by varchar`);
|
||||
await q.query(
|
||||
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS call_count integer NOT NULL DEFAULT 0`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS call_count`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_by`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_at`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS driver_called_passenger`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { encrypt, blindIndex } from '../../common/crypto/crypto.util';
|
||||
|
||||
/**
|
||||
* تشفير الهاتف at-rest + فهرس أعمى للبحث (docs/16، docs/17 — D1 إضافة).
|
||||
*
|
||||
* **لماذا فهرس منفصل**: `phone` بعد التشفير غير قابل للبحث بالمساواة إطلاقاً
|
||||
* — AES-256-GCM يستعمل IV عشوائياً لكل عملية، فنفس الرقم الحقيقي ينتج شيفرة
|
||||
* مختلفة كل مرة. القيد الفريد ونقاط `WHERE phone = …` (تسجيل الدخول بأكمله)
|
||||
* ينتقلان إلى `phone_bidx` — بصمة حتمية (HMAC) لا تكشف الرقم لكنها تكرّر
|
||||
* نفسها لنفس الرقم دائماً.
|
||||
*
|
||||
* تُشفَّر الصفوف القائمة في نفس الهجرة (لا تُترك نصّاً صافياً) — الحماية
|
||||
* التلقائية عبر `decrypt()` المتسامح كانت ستُبقيها صافية للأبد بلا هذا.
|
||||
*/
|
||||
export class EncryptPhone1721900000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS phone_bidx varchar`);
|
||||
|
||||
const rows: { id: string; phone: string }[] = await q.query(
|
||||
`SELECT id, phone FROM tripz_users`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
// تسامح إعادة التشغيل الجزئي: لا نُشفّر قيمة مشفّرة أصلاً مرة أخرى.
|
||||
const alreadyEncrypted = row.phone?.startsWith('v1:');
|
||||
const canonicalPhone = alreadyEncrypted ? null : row.phone;
|
||||
const bidx = blindIndex(canonicalPhone ?? row.phone);
|
||||
|
||||
if (alreadyEncrypted) {
|
||||
await q.query(`UPDATE tripz_users SET phone_bidx = $1 WHERE id = $2`, [bidx, row.id]);
|
||||
} else {
|
||||
await q.query(`UPDATE tripz_users SET phone = $1, phone_bidx = $2 WHERE id = $3`, [
|
||||
encrypt(row.phone),
|
||||
bidx,
|
||||
row.id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx SET NOT NULL`);
|
||||
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone"`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx"
|
||||
ON tripz_users (tenant_id, phone_bidx)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
// لا رجوع لفكّ التشفير جماعياً هنا عمداً — decrypt() يبقى متاحاً عند
|
||||
// القراءة عبر الـORM؛ التراجع عن هذه الهجرة يعني قبول عدم قابلية البحث
|
||||
// إن أُعيد phone فهرساً فريداً وحده، فلا نعيد بناء ذلك القيد تلقائياً.
|
||||
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`);
|
||||
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx DROP NOT NULL`);
|
||||
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS phone_bidx`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* بيانات السائق والمركبة (docs/17 — المجموعة C):
|
||||
* - جدول `vehicles` (مكافئ CarRegistration عند سيرو) — سائق قد يملك أكثر من مركبة.
|
||||
* - حقول الملف الشخصي للسائق: الرقم الوطني (مشفّر + فهرس أعمى)، الرخصة،
|
||||
* ai_data/user_input، سبب الرفض.
|
||||
*/
|
||||
export class VehiclesAndDriverProfile1721910000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_vehicles (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
driver_id uuid NOT NULL,
|
||||
plate varchar,
|
||||
vin varchar,
|
||||
make varchar,
|
||||
model varchar,
|
||||
year integer,
|
||||
color varchar,
|
||||
color_hex varchar,
|
||||
fuel varchar,
|
||||
owner varchar,
|
||||
registration_expiry date,
|
||||
category varchar,
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
status varchar NOT NULL DEFAULT 'pending',
|
||||
ai_data jsonb NOT NULL DEFAULT '{}',
|
||||
user_input jsonb NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await q.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_tripz_vehicles_tenant_driver" ON tripz_vehicles (tenant_id, driver_id)`,
|
||||
);
|
||||
|
||||
const add = (col: string, type: string) =>
|
||||
q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS ${col} ${type}`);
|
||||
|
||||
await add('gender', 'varchar');
|
||||
await add('national_number', 'varchar');
|
||||
await add('national_number_bidx', 'varchar');
|
||||
await add('name_arabic', 'varchar');
|
||||
await add('birthdate', 'date');
|
||||
await add('address', 'varchar');
|
||||
await add('license_type', 'varchar');
|
||||
await add('license_categories', 'varchar');
|
||||
await add('license_issue', 'date');
|
||||
await add('license_expiry', 'date');
|
||||
await add('rejected_reason', 'varchar');
|
||||
await add(`ai_data`, `jsonb NOT NULL DEFAULT '{}'`);
|
||||
await add(`user_input`, `jsonb NOT NULL DEFAULT '{}'`);
|
||||
|
||||
// فهرس فريد على الرقم الوطني لكل مستأجر (عبر الفهرس الأعمى، لا العمود المشفّر
|
||||
// — العشوائي لا يصلح للتفرّد). جزئي: يتجاهل السائقين بلا رقم وطني بعد.
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_drivers_tenant_national"
|
||||
ON tripz_drivers (tenant_id, national_number_bidx)
|
||||
WHERE national_number_bidx IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_drivers_tenant_national"`);
|
||||
for (const c of [
|
||||
'user_input', 'ai_data', 'rejected_reason', 'license_expiry', 'license_issue',
|
||||
'license_categories', 'license_type', 'address', 'birthdate', 'name_arabic',
|
||||
'national_number_bidx', 'national_number', 'gender',
|
||||
]) {
|
||||
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS ${c}`);
|
||||
}
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_vehicles`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* شريحة B الثانية (docs/17 — B4/B5/B8):
|
||||
* - نقاط التوقف (`stops` jsonb).
|
||||
* - الحجز المسبق (`scheduled_at` + حالة `scheduled`).
|
||||
* - مطابقة الوجهة (`is_destination_match` + `destination_match_discount`).
|
||||
*/
|
||||
export class TripStopsSchedulingMatch1721920000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS stops jsonb NOT NULL DEFAULT '[]'`);
|
||||
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS scheduled_at timestamptz`);
|
||||
await q.query(
|
||||
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS is_destination_match boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
await q.query(
|
||||
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS destination_match_discount numeric(12,3)`,
|
||||
);
|
||||
// الكنس يبحث عن scheduled المستحقة زمنياً — فهرس جزئي عليها فقط.
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_scheduled_due"
|
||||
ON tripz_trips (scheduled_at)
|
||||
WHERE status = 'scheduled'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trips_scheduled_due"`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS destination_match_discount`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS is_destination_match`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS scheduled_at`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS stops`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* دفترا مال المستأجر (docs/24 — P0): الإيراد والأمانات.
|
||||
*
|
||||
* **جدولان منفصلان لا جدول واحد بعمود نوع**: استعلامُ سحبٍ أو تقريرٍ ينسى شرط
|
||||
* النوع كان سيخلط مال المستأجر بأمانات الركّاب. مع جدولين لا يوجد صفٌّ خطأ
|
||||
* ليُقرأ أصلاً.
|
||||
*
|
||||
* الفهرس الفريد على (tenant_id, ref) هو **حارس منع التسوية المزدوجة**: رسالة
|
||||
* تأكيد من كليك/شام كاش قد تصل مرتين، فيرفض الثانية. القيم الفارغة متعدّدة
|
||||
* مسموحة في postgres، فالحركات بلا مرجع خارجي لا تتصادم.
|
||||
*/
|
||||
export class TenantWalletLedgers1721930000000 implements MigrationInterface {
|
||||
private readonly tables = ['tripz_tenant_revenue_ledger', 'tripz_tenant_pending_ledger'];
|
||||
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
for (const t of this.tables) {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${t} (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
amount numeric(14,3) NOT NULL,
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
reason varchar NOT NULL,
|
||||
ref varchar,
|
||||
meta jsonb NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_${t}_tenant_created"
|
||||
ON ${t} (tenant_id, created_at)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_${t}_tenant_ref"
|
||||
ON ${t} (tenant_id, ref)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
for (const t of this.tables) {
|
||||
await q.query(`DROP TABLE IF EXISTS ${t}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* سجلّ رسائل المزوّدين الخام (docs/24 §5 — P2): تسوية كليك/شام كاش بلا API.
|
||||
*
|
||||
* الفهرس الفريد على (tenant_id, fingerprint) هو حارس التسوية المزدوجة على
|
||||
* مستوى الرسالة: الجهاز قد يعيد الإرسال بعد انقطاع شبكة، فتصل الرسالة ذاتها
|
||||
* مرتين — وبلا هذا الفهرس تُسوَّى الفاتورة مرتين ويُشحن الرصيد ضعفين.
|
||||
*/
|
||||
export class RawSmsSettlement1721940000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_pay_raw_sms (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
provider varchar NOT NULL,
|
||||
sender varchar,
|
||||
body text NOT NULL,
|
||||
fingerprint varchar NOT NULL,
|
||||
device_id varchar,
|
||||
status varchar NOT NULL DEFAULT 'received',
|
||||
parsed jsonb NOT NULL DEFAULT '{}',
|
||||
payment_id uuid,
|
||||
note varchar,
|
||||
sent_at timestamptz,
|
||||
received_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_pay_raw_sms_fingerprint"
|
||||
ON tripz_pay_raw_sms (tenant_id, fingerprint)
|
||||
`);
|
||||
// طابور المراجعة يُقرأ بالحالة، والتقارير بالزمن.
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_raw_sms_status"
|
||||
ON tripz_pay_raw_sms (tenant_id, status)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_raw_sms_received"
|
||||
ON tripz_pay_raw_sms (tenant_id, received_at)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_pay_raw_sms`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* الإحالات والكوبونات (المجموعة L — قرار المالك 2026-07-18).
|
||||
*
|
||||
* مكافأة الراكب كوبون لا رصيد محفظة: محفظة الراكب لا تُسحب أبداً (قانون
|
||||
* ثابت)، فإنزال مكافأة تسويقية فيها يخلق التزاماً نقدياً بلا مقابل.
|
||||
* والحدود هنا مفروضة على القاعدة لا بفحص تطبيقي وحده — الفحص التطبيقي
|
||||
* يسقط تحت التزامن، والقيد لا يسقط.
|
||||
*/
|
||||
export class ReferralsAndCoupons1721950000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_coupons (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
code varchar NOT NULL,
|
||||
user_id uuid,
|
||||
discount_type varchar NOT NULL DEFAULT 'percent',
|
||||
discount_value numeric(12,3) NOT NULL,
|
||||
max_discount numeric(12,3),
|
||||
min_fare numeric(12,3),
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
valid_from TIMESTAMP,
|
||||
valid_until TIMESTAMP,
|
||||
max_redemptions int,
|
||||
max_per_user int NOT NULL DEFAULT 1,
|
||||
redeemed_count int NOT NULL DEFAULT 0,
|
||||
status varchar NOT NULL DEFAULT 'active',
|
||||
source varchar NOT NULL DEFAULT 'campaign',
|
||||
ref varchar,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
-- خصم سالب كان سيرفع الأجرة؛ ونسبة فوق 100 تجعلها سالبة.
|
||||
CONSTRAINT chk_tripz_coupons_value CHECK (
|
||||
discount_value >= 0
|
||||
AND (discount_type <> 'percent' OR discount_value <= 100)
|
||||
),
|
||||
CONSTRAINT chk_tripz_coupons_cap CHECK (max_discount IS NULL OR max_discount >= 0)
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupons_tenant_code"
|
||||
ON tripz_coupons (tenant_id, code)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_coupons_tenant_user"
|
||||
ON tripz_coupons (tenant_id, user_id)
|
||||
`);
|
||||
|
||||
// كوبون واحد لكل سبب منح — يمنع مكافأة إحالة مزدوجة إذا التقى الصرف
|
||||
// الفوري بالماسح على نفس الإحالة.
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupons_ref_once"
|
||||
ON tripz_coupons (tenant_id, ref)
|
||||
WHERE ref IS NOT NULL
|
||||
`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_coupon_redemptions (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
coupon_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
trip_id uuid NOT NULL,
|
||||
discount_amount numeric(12,3) NOT NULL,
|
||||
fare_before numeric(12,3),
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
// كوبون واحد لكل رحلة — لا تكديس أكواد على أجرة واحدة.
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_trip_once"
|
||||
ON tripz_coupon_redemptions (tenant_id, trip_id)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_coupon"
|
||||
ON tripz_coupon_redemptions (tenant_id, coupon_id)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_user"
|
||||
ON tripz_coupon_redemptions (tenant_id, user_id)
|
||||
`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_referral_codes (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
code varchar NOT NULL,
|
||||
role varchar NOT NULL,
|
||||
status varchar NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referral_codes_tenant_code"
|
||||
ON tripz_referral_codes (tenant_id, code)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referral_codes_tenant_user"
|
||||
ON tripz_referral_codes (tenant_id, user_id)
|
||||
`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_referrals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
referrer_user_id uuid NOT NULL,
|
||||
referee_user_id uuid NOT NULL,
|
||||
referrer_role varchar NOT NULL,
|
||||
referee_role varchar NOT NULL,
|
||||
code varchar NOT NULL,
|
||||
status varchar NOT NULL DEFAULT 'pending',
|
||||
qualifying_trips int NOT NULL DEFAULT 0,
|
||||
qualified_trip_id uuid,
|
||||
qualified_at TIMESTAMP,
|
||||
paid_at TIMESTAMP,
|
||||
rejected_reason varchar,
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
-- إحالة النفس: أوضح صور الاحتيال وأرخصها منعاً.
|
||||
CONSTRAINT chk_tripz_referrals_not_self CHECK (referrer_user_id <> referee_user_id)
|
||||
)
|
||||
`);
|
||||
// الحارس الأساسي: الشخص يُحال **مرة واحدة** لا مرة لكل داعٍ.
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referrals_referee_once"
|
||||
ON tripz_referrals (tenant_id, referee_user_id)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_referrals_referrer"
|
||||
ON tripz_referrals (tenant_id, referrer_user_id)
|
||||
`);
|
||||
// الماسح يقرأ بالحالة كل دورة — بلا فهرس يمسح الجدول كله مع نموّه.
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_referrals_status"
|
||||
ON tripz_referrals (tenant_id, status)
|
||||
`);
|
||||
|
||||
// أثر الكوبون على الرحلة — نصاً لا مفتاحاً أجنبياً: الرحلة سجلّ تاريخي،
|
||||
// وحذف حملة قديمة يجب ألّا يمحو سبب الخصم من رحلة مضت.
|
||||
await q.query(`
|
||||
ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS coupon_code varchar
|
||||
`);
|
||||
await q.query(`
|
||||
ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS coupon_discount numeric(12,3)
|
||||
`);
|
||||
|
||||
// مكافأة الإحالة مرة واحدة لكل سائق لكل إحالة — نفس حارس J6 على القاعدة.
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_referral_once"
|
||||
ON tripz_credit_txns (tenant_id, ref)
|
||||
WHERE type = 'referral_bonus'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_credit_txns_referral_once"`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS coupon_discount`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS coupon_code`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_referrals`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_referral_codes`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_coupon_redemptions`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_coupons`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* المسافة المقاسة مقابل المقدَّرة (docs/17 — B12).
|
||||
*
|
||||
* الأجرة تُحسب على ما قُطع فعلاً من نبضات GPS لا على تقدير وقت الطلب:
|
||||
* تحويلة حقيقية يقبضها السائق، وجولة مخترَعة يقصّها السقف. الأعمدة الثلاثة
|
||||
* تُخزَّن دائماً — حتى حين لا يُحاسَب عليها — لأن النسبة هي ما يُقرأ لاحقاً
|
||||
* لكشف النمط: سائق نسبته 1.6 في كل رحلاته ليس ضحية ازدحام.
|
||||
*/
|
||||
export class ActualDistanceBilling1721960000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
ALTER TABLE tripz_trips
|
||||
ADD COLUMN IF NOT EXISTS actual_distance_km numeric(10,3),
|
||||
ADD COLUMN IF NOT EXISTS billed_distance_km numeric(10,3),
|
||||
ADD COLUMN IF NOT EXISTS distance_ratio numeric(6,3)
|
||||
`);
|
||||
|
||||
// الاستعلام الحاكم في التسوية: نبضات سائق بين لحظتين. بلا هذا الفهرس
|
||||
// يمسح الجدول كاملاً — وهو أسرع الجداول نموّاً عندنا (نبضة كل ثوانٍ لكل
|
||||
// سائق متصل)، فالمسح يزداد كلفةً كل يوم.
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_tracks_driver_time"
|
||||
ON tripz_driver_tracks (tenant_id, driver_id, recorded_at)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_driver_tracks_driver_time"`);
|
||||
await q.query(`
|
||||
ALTER TABLE tripz_trips
|
||||
DROP COLUMN IF EXISTS distance_ratio,
|
||||
DROP COLUMN IF EXISTS billed_distance_km,
|
||||
DROP COLUMN IF EXISTS actual_distance_km
|
||||
`);
|
||||
}
|
||||
}
|
||||
+219
@@ -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,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* فصل هوية الراكب عن السائق (قرار المالك 2026-07-20 — الخيار ب).
|
||||
*
|
||||
* قبل: القيد الفريد `(tenant_id, phone_bidx)` يعني مستخدماً واحداً لكل رقم في
|
||||
* كل مستأجر — فتسجيل الرقم كسائق (`setRole('driver')`) يقلب سجلّه للأبد ويكسر
|
||||
* وضع الراكب لنفس الرقم. بعد: الرقم الواحد يملك سجلّاً للراكب وآخر للسائق
|
||||
* (ومحفظتين منفصلتين تلقائياً لأنها مربوطة بـ `user_id`).
|
||||
*
|
||||
* التغيير: القيد الفريد يصبح `(tenant_id, phone_bidx, role)`. لا فقدان بيانات —
|
||||
* السجلات القائمة تبقى كما هي؛ الجديد فقط أنه يُسمح بسجلٍّ ثانٍ بدور مختلف.
|
||||
*/
|
||||
export class SeparateRiderDriverIdentity1721980000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx_role"
|
||||
ON tripz_users (tenant_id, phone_bidx, role)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx_role"`);
|
||||
// العودة قد تفشل لو وُجد رقم بدورين — لا يمكن استعادة قيد أضيق مع بيانات تخالفه.
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx"
|
||||
ON tripz_users (tenant_id, phone_bidx)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
buildDefinition,
|
||||
COUNTRY_TARIFFS,
|
||||
SEEDED_CLASSES,
|
||||
} from '../../modules/tariff/default-tariffs';
|
||||
|
||||
/**
|
||||
* يولّد `tariffs.sql` لتطبيقها على قاعدة **قائمة** — للمستأجرين الموجودين
|
||||
* قبل أن يصير الزرع تلقائياً.
|
||||
*
|
||||
* التشغيل: `npx ts-node src/database/seeds/generate-tariff-sql.ts`
|
||||
*
|
||||
* مستأجر جديد لا يحتاج هذا الملف إطلاقاً: `SeedService` يزرع له التعرفة من
|
||||
* **نفس** `default-tariffs.ts` عند الإقلاع. هذا المولّد للحاق بالقائم فقط،
|
||||
* ولذلك يقرأ من مصدر الحقيقة نفسه — نسختان من أرقام التسعير كانتا ستتباعدان.
|
||||
*
|
||||
* الناتج **آمن للتكرار**: كل تشغيل يطفئ الفعّالة ويُدرج `max(version)+1`،
|
||||
* فلا يبقى صفّان فعّالان لنفس (مستأجر، مدينة، فئة) — و`getActive` ترتّب
|
||||
* `version DESC` فكان الاختيار بينهما غير محدَّد.
|
||||
*/
|
||||
function generateSql(): string {
|
||||
let sql = `-- Tripz — تعرفة الانطلاق الافتراضية لكل بلد.\n`;
|
||||
sql += `-- مولَّد من src/database/seeds/generate-tariff-sql.ts — لا يُحرَّر يدوياً.\n`;
|
||||
sql += `-- المصدر: src/modules/tariff/default-tariffs.ts\n`;
|
||||
sql += `-- آمن للتكرار: كل تشغيل يطفئ الفعّالة ويُدرج نسخة أعلى.\n\n`;
|
||||
sql += `BEGIN;\n\n`;
|
||||
|
||||
for (const country of COUNTRY_TARIFFS) {
|
||||
sql += `-- ============ ${country.countryPack.toUpperCase()} (${country.currency}) ============\n`;
|
||||
|
||||
for (const serviceClass of SEEDED_CLASSES) {
|
||||
const definition = buildDefinition(country, serviceClass);
|
||||
const defJson = JSON.stringify(definition).replace(/'/g, "''");
|
||||
|
||||
// يطال **كل** مستأجر في هذا البلد لا سيرو وحده: تعرفة الانطلاق عامة،
|
||||
// وربطها بمستأجر واحد كان يترك كل مستأجر آخر بلا تسعير.
|
||||
sql += `-- ${serviceClass}\n`;
|
||||
sql += `UPDATE tripz_tariffs SET active = false\n`;
|
||||
sql += ` WHERE city = 'default' AND service_class = '${serviceClass}' AND active = true\n`;
|
||||
sql += ` AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = '${country.countryPack}');\n`;
|
||||
sql += `INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)\n`;
|
||||
sql += `SELECT uuid_generate_v4(), t.id, 'default', '${serviceClass}',\n`;
|
||||
sql += ` COALESCE((SELECT MAX(version) FROM tripz_tariffs x\n`;
|
||||
sql += ` WHERE x.tenant_id = t.id AND x.city = 'default'\n`;
|
||||
sql += ` AND x.service_class = '${serviceClass}'), 0) + 1,\n`;
|
||||
sql += ` true, '${defJson}'::jsonb\n`;
|
||||
sql += ` FROM tripz_tenants t WHERE t.country_pack = '${country.countryPack}';\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
sql += `COMMIT;\n`;
|
||||
return sql;
|
||||
}
|
||||
|
||||
const out = join(__dirname, 'tariffs.sql');
|
||||
writeFileSync(out, generateSql());
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`✅ generated ${out}`);
|
||||
@@ -0,0 +1,299 @@
|
||||
-- Tripz — تعرفة الانطلاق الافتراضية لكل بلد.
|
||||
-- مولَّد من src/database/seeds/generate-tariff-sql.ts — لا يُحرَّر يدوياً.
|
||||
-- المصدر: src/modules/tariff/default-tariffs.ts
|
||||
-- آمن للتكرار: كل تشغيل يطفئ الفعّالة ويُدرج نسخة أعلى.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============ JO (JOD) ============
|
||||
-- economy
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'economy' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'economy',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'economy'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":0.184,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.184,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.184,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.184,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.184,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- comfort
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'comfort' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'comfort',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'comfort'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":0.239,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.239,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.239,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.239,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.239,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- lady
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'lady' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'lady',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'lady'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":0.201,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.201,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.201,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.201,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.201,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- electric
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'electric' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'electric',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'electric'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":0.223,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.223,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.223,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.223,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.223,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- van
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'van' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'van',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'van'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":0.278,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.278,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.278,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.278,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.278,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- delivery
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'delivery' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'delivery',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'delivery'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":0.167,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.167,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.167,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.167,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.167,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- vip
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'vip' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'vip',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'vip'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":0.258,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.258,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.258,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.258,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.258,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- saver
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'saver' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'saver',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'saver'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":0.158,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":0.158,"per_min":0.043,"per_min_waiting":0.065},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":0.158,"per_min":0.05,"per_min_waiting":0.074},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":0.158,"per_min":0.043,"per_min_waiting":0.065},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":0.158,"per_min":0.043,"per_min_waiting":0.065}],"booking_fee":0,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- ============ EG (EGP) ============
|
||||
-- economy
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'economy' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'economy',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'economy'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":4,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":4,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":4,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":4,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":4,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- comfort
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'comfort' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'comfort',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'comfort'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":5.2,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":5.2,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":5.2,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":5.2,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":5.2,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- lady
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'lady' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'lady',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'lady'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":4.36,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":4.36,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":4.36,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":4.36,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":4.36,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- electric
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'electric' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'electric',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'electric'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":4.84,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":4.84,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":4.84,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":4.84,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":4.84,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- van
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'van' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'van',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'van'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":6.04,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":6.04,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":6.04,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":6.04,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":6.04,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- delivery
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'delivery' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'delivery',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'delivery'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":3.64,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":3.64,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":3.64,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":3.64,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":3.64,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- vip
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'vip' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'vip',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'vip'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":5.6,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":5.6,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":5.6,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":5.6,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":5.6,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- saver
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'saver' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'saver',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'saver'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":3.44,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":3.44,"per_min":0.8,"per_min_waiting":1.2},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":3.44,"per_min":1.4,"per_min_waiting":2.1},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":3.44,"per_min":0.8,"per_min_waiting":1.2},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":3.44,"per_min":1.5,"per_min_waiting":2.25}],"booking_fee":0,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- ============ SY (SYP) ============
|
||||
-- economy
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'economy' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'economy',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'economy'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":40,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":40,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":40,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":40,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":40,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- comfort
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'comfort' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'comfort',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'comfort'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":52,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":52,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":52,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":52,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":52,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- lady
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'lady' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'lady',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'lady'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":44,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":44,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":44,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":44,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":44,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- electric
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'electric' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'electric',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'electric'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":48,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":48,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":48,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":48,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":48,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- van
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'van' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'van',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'van'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":60,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":60,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":60,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":60,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":60,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- delivery
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'delivery' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'delivery',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'delivery'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":36,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":36,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":36,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":36,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":36,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- vip
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'vip' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'vip',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'vip'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":56,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":56,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":56,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":56,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":56,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- saver
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'saver' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'saver',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'saver'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0,"per_km":34,"per_min":10,"per_min_waiting":15},{"name":"normal_day","from":"09:30","to":"16:00","flag":0,"per_km":34,"per_min":9,"per_min_waiting":14},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0,"per_km":34,"per_min":10,"per_min_waiting":15},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0,"per_km":34,"per_min":9,"per_min_waiting":14},{"name":"late_night","from":"23:00","to":"07:00","flag":0,"per_km":34,"per_min":11,"per_min_waiting":17}],"booking_fee":0,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { GeminiService } from './gemini.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class GeminiModule {}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface GeminiImage {
|
||||
buffer: Buffer;
|
||||
mime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* محوّل Gemini (رؤية) — قراءة الوثائق (استخراج الحقول) ومطابقة الوجه.
|
||||
* يُستخدم في توثيق السائق كذكاء اصطناعي (نمط سيرو). بلا GEMINI_API_KEY:
|
||||
* يرجّع { enabled:false } دون فشل.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GeminiService {
|
||||
private readonly logger = new Logger('Gemini');
|
||||
|
||||
constructor(private readonly config: ConfigService) { }
|
||||
|
||||
get enabled(): boolean {
|
||||
return !!this.config.get<string>('gemini.apiKey');
|
||||
}
|
||||
|
||||
private endpoint(): string {
|
||||
const model = this.config.get<string>('gemini.model') ?? 'gemini-flash-lite-latest';
|
||||
const key = this.config.get<string>('gemini.apiKey');
|
||||
return `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`;
|
||||
}
|
||||
|
||||
/** يرسل نصاً + صوراً ويعيد JSON مُحلَّلاً من رد Gemini. */
|
||||
private async generateJson(prompt: string, images: GeminiImage[]): Promise<any> {
|
||||
const parts: any[] = [{ text: prompt }];
|
||||
for (const img of images) {
|
||||
parts.push({
|
||||
inline_data: { mime_type: img.mime ?? 'image/jpeg', data: img.buffer.toString('base64') },
|
||||
});
|
||||
}
|
||||
const res = await fetch(this.endpoint(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts }],
|
||||
generationConfig: { temperature: 0, responseMimeType: 'application/json' },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Gemini ${res.status}: ${body.slice(0, 300)}`);
|
||||
}
|
||||
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 { raw: text };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* يستخرج حقول تحويل مالي من نصّ رسالة المزوّد (docs/24 §5 — P2).
|
||||
*
|
||||
* `temperature: 0` وتعليماتٌ صريحة بإرجاع `null` عند عدم اليقين: النموذج
|
||||
* الذي «يخمّن» مبلغاً غير مذكور يسوّي فاتورة بمال لم يصل. عدم المطابقة
|
||||
* ومراجعةٌ بشرية أهون بكثير من تسويةٍ خاطئة.
|
||||
*/
|
||||
async extractTransferSms(body: string, provider: string): Promise<any> {
|
||||
const prompt = [
|
||||
`أنت محلّل رسائل تحويل مالي من مزوّد الدفع "${provider}".`,
|
||||
'استخرج من النصّ التالي JSON بالحقول:',
|
||||
'{ "amount": number|null, "currency": string|null, "reference": string|null,',
|
||||
' "sender_name": string|null, "sender_phone": string|null, "is_incoming": boolean }',
|
||||
'',
|
||||
'قواعد صارمة:',
|
||||
'- `amount` المبلغ المُستلَم فقط. إن لم يُذكر صراحةً فأعد null — لا تحسبه ولا تخمّنه.',
|
||||
'- `reference` رقم العملية/الحوالة كما ورد حرفياً.',
|
||||
'- `is_incoming` = true فقط إن كانت الرسالة تؤكّد **استلام** مبلغ.',
|
||||
'- أي حقل غير مذكور صراحةً = null. لا تخترع قيمة أبداً.',
|
||||
'',
|
||||
'النصّ:',
|
||||
body,
|
||||
].join('\n');
|
||||
|
||||
return this.generateJson(prompt, []);
|
||||
}
|
||||
|
||||
/** يستخرج حقول وثيقة من صورتها. */
|
||||
async extractDocument(image: GeminiImage, docType: string): Promise<any> {
|
||||
if (!this.enabled) return { enabled: false };
|
||||
const prompt =
|
||||
`استخرج بيانات وثيقة من نوع "${docType}" من الصورة. ` +
|
||||
`أعِد JSON فقط بالمفاتيح: number, holder_name, issue_date (YYYY-MM-DD), expiry_date (YYYY-MM-DD). ` +
|
||||
`استخدم null لأي حقل غير موجود.`;
|
||||
return this.generateJson(prompt, [image]);
|
||||
}
|
||||
|
||||
/** يقارن وجه السيلفي بصورة الهوية. */
|
||||
async faceMatch(selfie: GeminiImage, id: GeminiImage): Promise<any> {
|
||||
if (!this.enabled) return { enabled: false };
|
||||
const prompt =
|
||||
`الصورة الأولى سيلفي شخص، والثانية صورة هوية. قارن الوجهين. ` +
|
||||
`أعِد JSON فقط: {"match": true/false, "score": رقم 0..1, "reason": "سبب مختصر"}.`;
|
||||
return this.generateJson(prompt, [selfie, id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { NabehService } from './nabeh.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [NabehService],
|
||||
exports: [NabehService],
|
||||
})
|
||||
export class NabehModule {}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* محوّل Nabeh لإرسال OTP عبر واتساب.
|
||||
* التدفق: login (يُخزَّن التوكن) ثم otp/send برمزنا نحن.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NabehService {
|
||||
private readonly logger = new Logger('Nabeh');
|
||||
private token: string | null = null;
|
||||
private tokenExp = 0;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private get base() {
|
||||
return this.config.get<string>('nabeh.baseUrl');
|
||||
}
|
||||
|
||||
private async ensureToken(): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (this.token && now < this.tokenExp - 60) return this.token;
|
||||
|
||||
const res = await fetch(`${this.base}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: this.config.get<string>('nabeh.email'),
|
||||
password: this.config.get<string>('nabeh.password'),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Nabeh login failed: ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
const token = data.token || data.access_token || data.data?.token;
|
||||
if (!token) throw new Error('Nabeh login: no token in response');
|
||||
this.token = token;
|
||||
// exp من JWT إن وُجد، وإلا افتراض ساعة
|
||||
this.tokenExp = NabehService.jwtExp(token) ?? now + 3600;
|
||||
return token;
|
||||
}
|
||||
|
||||
/** يرسل الرمز عبر واتساب. phone بصيغة دولية بلا + (مثل 9627xxxxxxx). */
|
||||
async sendOtp(phone: string, code: string): Promise<void> {
|
||||
const token = await this.ensureToken();
|
||||
const res = await fetch(`${this.base}/api/otp/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
phone,
|
||||
type: this.config.get<string>('nabeh.otpType') ?? 'text',
|
||||
code,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Nabeh otp/send failed: ${res.status} ${body}`);
|
||||
}
|
||||
this.logger.log(`OTP sent via WhatsApp to ${phone}`);
|
||||
}
|
||||
|
||||
private static jwtExp(token: string): number | null {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(token.split('.')[1], 'base64').toString('utf8'),
|
||||
);
|
||||
return typeof payload.exp === 'number' ? payload.exp : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* تعليمات ومساعد الذكاء الاصطناعي لمنصة Tripz (تريبز)
|
||||
* لتأجير وإطلاق تطبيقات النقل الذكي وإدارة الأساطيل.
|
||||
* تُنسخ وتوضع في لوحة تحكم نبيه (Nabeh Hub) تحت قسم روبوت الذكاء الاصطناعي.
|
||||
*/
|
||||
export const TRIPZ_AI_BOT_SYSTEM_PROMPT = `أنت مساعد الذكاء الاصطناعي الرسمي ومسؤول المبيعات وخدمة العملاء لمنصة "Tripz" (تريبز) المتخصصة في إطلاق وتأجير تطبيقات نقل الركاب وإدارة أساطيل التكسي والنقل الذكي (White-label SaaS). مهمتك هي الإجابة عن جميع استفسارات العملاء وأصحاب أساطيل النقل والتاكسي بأسلوب ترحيبي، مهني، ومقنع جداً.
|
||||
|
||||
التزم بالحقائق والمعلومات الحاكمة التالية بدقة عند الإجابة:
|
||||
|
||||
1. التعريف بالخدمة:
|
||||
- Tripz تقدم نظاماً متكاملاً يشمل: تطبيق الراكب (Rider App)، تطبيق السائق (Driver App)، ولوحة التحكم والتوجيه (Dispatch & Admin Hub).
|
||||
- يتم إطلاق التطبيقات كاملة باسمك وشعارك وألوانك الخاصة خلال 30 يوماً فقط من توقيع العقد على متجري آبل (App Store) وجوجل (Google Play).
|
||||
|
||||
2. الملكية وحفظ الحقوق:
|
||||
- التطبيق باسمك وشعارك 100%.
|
||||
- جميع بيانات أسطولك وسائقيك وعملائك هي ملك خالص لك، مع توفير عقد تصدير كود وبيانات مكتوب يضمن حقك كلياً.
|
||||
|
||||
3. الشفافية والتسعير وحاسبة التوفير:
|
||||
- أسعارنا معلنة بالكامل دون أي أسرار أو رسوم خفية أو مفاجآت في الفاتورة.
|
||||
- يمكنك حساب تكلفة الإطلاق وتوفيرك السنوي المباشر عبر حاسبة التوفير الرقمية: https://tripz-egypt.com/apps/
|
||||
|
||||
4. الخبرة والموثوقية:
|
||||
- محرك تقني مجرب وثابت ومستقر كلياً منذ عام 2019.
|
||||
- يخدم بنجاح أكثر من 6,000 سائق نشط وآلاف الرحلات اليومية.
|
||||
|
||||
5. الأمان وتقنيات الذكاء الاصطناعي:
|
||||
- نظام أمان متطور للتحقق التلقائي وتدقيق رخص القيادة والوثائق بالذكاء الاصطناعي.
|
||||
- بصمة وجه ومطابقة رقمية للهوية لمنع الحسابات الوهمية.
|
||||
- تتبع جغرافي حي (GPS Live Tracking) لمسارات الرحلات وتنبيهات الطوارئ.
|
||||
|
||||
6. طرق الدفع والعمولات:
|
||||
- تدعم المنصة طرق دفع متعددة: الدفع نقداً، شام كاش، سيريتل كاش، إم تي إن كاش (MTN Cash)، والبطاقات البنكية.
|
||||
- العمولة والتعرفة تُحدد بالكامل حسب رغبة صاحب الأسطول مع خيارات مرنة جداً.
|
||||
|
||||
7. للتواصل المباشر وحساب التكلفة:
|
||||
- رابط حاسبة التوفير والتكلفة: https://tripz-egypt.com/apps/
|
||||
- واتساب المبيعات المباشر: https://wa.me/201023248456 (رقم التواصل: 201023248456 / @tripzeg).
|
||||
- ساعات الدعم المباشر: يومياً من 11 صباحاً حتى 5 مساءً، والذكاء الاصطناعي متوفر للرد 24/7.
|
||||
|
||||
8. أسلوب التحدث:
|
||||
- تحدث بأسلوب ترحيبي ودود، محترم، ومهني جداً.
|
||||
- استخدم عبارات ترحيبية مثل: "يا هلا بك في Tripz"، "أهلين فيك"، "تكرم عينك"، "كيف بقدر أساعدك اليوم بإطلاق تطبيقك الخاص؟".
|
||||
- كُن مختصراً ومباشراً ومفيداً وركّز على إبراز مزايا التملك والسرعة والشفافية.`;
|
||||
|
||||
export async function generateTripzBotResponse(userMessage: string, apiKey: string): Promise<string> {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=${apiKey}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: TRIPZ_AI_BOT_SYSTEM_PROMPT },
|
||||
{ text: `سؤال العميل: ${userMessage}` }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const data: any = await res.json();
|
||||
const reply = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
return reply ? reply.trim() : 'أهلين فيك في Tripz! تكرم عينك، بنطلق لك تطبيقك الخاص باسمك وشعارك خلال 30 يوم بس. بتقدر تحسب توفيرك عبر الحاسبة: https://tripz-egypt.com/apps/ أو تتواصل معنا على واتساب: 201023248456';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { OtpDispatcher } from './otp-dispatcher.service';
|
||||
import { OtpProvider } from './otp-provider.interface';
|
||||
|
||||
function provider(name: string, result: boolean | (() => boolean)): OtpProvider {
|
||||
return {
|
||||
name,
|
||||
supports: () => true,
|
||||
send: jest.fn(async () => (typeof result === 'function' ? result() : result)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('OtpDispatcher — توجيه حسب الدولة + failover (docs/17 D5)', () => {
|
||||
it('مصر تجرّب Kazumi أولاً', async () => {
|
||||
const kazumi = provider('kazumi', true);
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||
|
||||
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
|
||||
expect(kazumi.send).toHaveBeenCalledTimes(1);
|
||||
expect(nabeh.send).not.toHaveBeenCalled(); // نجح الأول فلا failover
|
||||
});
|
||||
|
||||
it('مصر: فشل Kazumi → يتحوّل إلى Nabeh', async () => {
|
||||
const kazumi = provider('kazumi', false);
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||
|
||||
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
|
||||
expect(kazumi.send).toHaveBeenCalledTimes(1);
|
||||
expect(nabeh.send).toHaveBeenCalledTimes(1); // جرّب البديل
|
||||
});
|
||||
|
||||
it('الأردن يستعمل Nabeh فقط — لا يلمس Kazumi', async () => {
|
||||
const kazumi = provider('kazumi', true);
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||
|
||||
expect(await d.send('962790000000', '1234', { countryPack: 'jo' })).toBe(true);
|
||||
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||
expect(kazumi.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('سوريا تستعمل Nabeh', async () => {
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([nabeh]);
|
||||
expect(await d.send('963944000000', '1234', { countryPack: 'sy' })).toBe(true);
|
||||
});
|
||||
|
||||
it('فشل السلسلة كاملة يرجع false — المُستدعي يُفشل الطلب', async () => {
|
||||
const kazumi = provider('kazumi', false);
|
||||
const nabeh = provider('nabeh', false);
|
||||
const d = new OtpDispatcher([kazumi, nabeh]);
|
||||
|
||||
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(false);
|
||||
expect(kazumi.send).toHaveBeenCalledTimes(1);
|
||||
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('دولة غير معروفة تسقط لـNabeh (السلسلة الافتراضية)', async () => {
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([nabeh]);
|
||||
expect(await d.send('491234567', '1234', { countryPack: 'de' })).toBe(true);
|
||||
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('مزوّد مفقود من التسجيل لا يُسقط السلسلة — يتخطّاه', async () => {
|
||||
// Kazumi غير مسجَّل، فمصر تسقط لـNabeh وحده.
|
||||
const nabeh = provider('nabeh', true);
|
||||
const d = new OtpDispatcher([nabeh]);
|
||||
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
|
||||
expect(nabeh.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { OTP_PROVIDERS, OtpProvider, OtpSendContext } from './otp-provider.interface';
|
||||
|
||||
/**
|
||||
* سلسلة الـfailover لكل دولة (docs/17 — D5؛ نمط سيرو auth/otp/request.php).
|
||||
* الأول أساسي، والبقية بدائل تُجرَّب بالترتيب. مصر: Kazumi SMS ثم Nabeh واتساب
|
||||
* (نفس منطق سيرو). الأردن/سوريا: Nabeh فقط.
|
||||
*/
|
||||
const ROUTING: Record<string, string[]> = {
|
||||
eg: ['kazumi', 'nabeh'],
|
||||
jo: ['nabeh'],
|
||||
sy: ['nabeh'],
|
||||
};
|
||||
const DEFAULT_CHAIN = ['nabeh'];
|
||||
|
||||
/**
|
||||
* يوجّه إرسال OTP للمزوّد المناسب حسب دولة المستأجر، مع failover.
|
||||
*
|
||||
* السبب في وجوده: خدمة واحدة تُستدعى من كل مكان يحتاج إرسال رمز (تسجيل دخول،
|
||||
* تحقّق سحب لاحقاً) بلا أن يعرف المُستدعي أي مزوّد لأي دولة — تُضاف دولة أو
|
||||
* مزوّد جديد هنا وحده.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OtpDispatcher {
|
||||
private readonly logger = new Logger('OtpDispatcher');
|
||||
private readonly byName = new Map<string, OtpProvider>();
|
||||
|
||||
constructor(@Inject(OTP_PROVIDERS) providers: OtpProvider[]) {
|
||||
for (const p of providers) this.byName.set(p.name, p);
|
||||
}
|
||||
|
||||
/**
|
||||
* يرسل الرمز عبر أول مزوّد ينجح في سلسلة الدولة.
|
||||
* يرجع `true` إن نجح أي مزوّد؛ `false` إن فشلت السلسلة كاملة (يقرّر المُستدعي
|
||||
* حينها إفشال الطلب — طلبٌ بلا رمز يصل = مستخدم عالق).
|
||||
*/
|
||||
async send(phone: string, code: string, ctx: OtpSendContext): Promise<boolean> {
|
||||
const chain = ROUTING[ctx.countryPack] ?? DEFAULT_CHAIN;
|
||||
for (const name of chain) {
|
||||
const provider = this.byName.get(name);
|
||||
if (!provider) {
|
||||
this.logger.warn(`provider "${name}" not registered — skipping`);
|
||||
continue;
|
||||
}
|
||||
const ok = await provider.send(phone, code, ctx);
|
||||
if (ok) {
|
||||
this.logger.log(`sent via ${name} (country=${ctx.countryPack})`);
|
||||
return true;
|
||||
}
|
||||
this.logger.warn(`${name} failed for ${ctx.countryPack} — trying next in chain`);
|
||||
}
|
||||
this.logger.error(`all providers failed for country=${ctx.countryPack}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* مزوّد إرسال OTP واحد (docs/17 — D5؛ نمط سيرو auth/otp/providers.php).
|
||||
*
|
||||
* كل مزوّد يرسل رمزاً **نولّده نحن** (لا يولّده المزوّد) — فالرمز يبقى مصدره
|
||||
* السيرفر ومخزَّناً في Redis، والمزوّد قناة توصيل فقط. هذا يختلف عن مزوّد
|
||||
* Intaleq عند سيرو الذي يولّد الرمز بنفسه ويرجعه — استبعدناه عمداً حفاظاً على
|
||||
* مصدر واحد للرمز.
|
||||
*/
|
||||
export interface OtpSendContext {
|
||||
/** دولة المستأجر (jo | sy | eg) — تحدّد المزوّد. */
|
||||
countryPack: string;
|
||||
/** passenger | driver | admin | service — لتخصيص نصّ الرسالة. */
|
||||
userType?: string;
|
||||
}
|
||||
|
||||
export interface OtpProvider {
|
||||
/** اسم قصير للتشخيص واللوغ. */
|
||||
readonly name: string;
|
||||
|
||||
/** الدول التي يخدمها هذا المزوّد (رموز countryPack). */
|
||||
supports(countryPack: string): boolean;
|
||||
|
||||
/**
|
||||
* يرسل الرمز. يرجع `true` عند النجاح المؤكَّد فقط — أي فشل أو استجابة
|
||||
* غامضة = `false` كي يجرّب المُوزِّع المزوّد التالي في السلسلة.
|
||||
* **لا يرمي**: الرمي يقطع سلسلة الـfailover.
|
||||
*/
|
||||
send(phone: string, code: string, ctx: OtpSendContext): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const OTP_PROVIDERS = Symbol('OTP_PROVIDERS');
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { NabehModule } from '../nabeh/nabeh.module';
|
||||
import { OtpDispatcher } from './otp-dispatcher.service';
|
||||
import { OTP_PROVIDERS } from './otp-provider.interface';
|
||||
import { NabehOtpProvider } from './providers/nabeh.provider';
|
||||
import { KazumiSmsProvider } from './providers/kazumi.provider';
|
||||
|
||||
/**
|
||||
* توجيه OTP متعدد المزوّدين (docs/17 — D5). عالمي: `OtpDispatcher` يُستدعى من
|
||||
* كل مسار يرسل رمزاً. لإضافة مزوّد: أضِف موفّره هنا وسجّله في مصفوفة
|
||||
* `OTP_PROVIDERS` وأضِف اسمه لسلسلة دولته في `otp-dispatcher.service.ts`.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [NabehModule],
|
||||
providers: [
|
||||
NabehOtpProvider,
|
||||
KazumiSmsProvider,
|
||||
{
|
||||
provide: OTP_PROVIDERS,
|
||||
useFactory: (nabeh: NabehOtpProvider, kazumi: KazumiSmsProvider) => [nabeh, kazumi],
|
||||
inject: [NabehOtpProvider, KazumiSmsProvider],
|
||||
},
|
||||
OtpDispatcher,
|
||||
],
|
||||
exports: [OtpDispatcher],
|
||||
})
|
||||
export class OtpModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OtpProvider, OtpSendContext } from '../otp-provider.interface';
|
||||
|
||||
/**
|
||||
* مزوّد Kazumi (SMS) — مصر (docs/17 — D5؛ من سيرو auth/otp/providers.php).
|
||||
*
|
||||
* لماذا SMS لا واتساب في مصر: انتشار واتساب أقل هناك، وSMS أضمن وصولاً.
|
||||
* لو فشل، يتولّى المُوزِّع الرجوع لمزوّد آخر (Nabeh واتساب) في السلسلة.
|
||||
*/
|
||||
@Injectable()
|
||||
export class KazumiSmsProvider implements OtpProvider {
|
||||
readonly name = 'kazumi';
|
||||
private readonly logger = new Logger('KazumiSms');
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
supports(countryPack: string): boolean {
|
||||
return countryPack === 'eg';
|
||||
}
|
||||
|
||||
async send(phone: string, code: string, _ctx: OtpSendContext): Promise<boolean> {
|
||||
const username = this.config.get<string>('otp.kazumi.username');
|
||||
const password = this.config.get<string>('otp.kazumi.password');
|
||||
const sender = this.config.get<string>('otp.kazumi.sender');
|
||||
if (!username || !password || !sender) {
|
||||
this.logger.warn('missing Kazumi credentials — skipping (failover will handle)');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('https://sms.kazumi.me/api/sms/send-sms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
language: 'e',
|
||||
sender,
|
||||
receiver: phone,
|
||||
message: this.config.get<string>('otp.smsTemplate')?.replace('{code}', code)
|
||||
?? `Your Tripz code is ${code}`,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`HTTP ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
const json: any = await res.json().catch(() => null);
|
||||
// Kazumi يرجع { message: 'Success' } عند النجاح.
|
||||
return json?.message === 'Success';
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`send failed: ${e?.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NabehService } from '../../nabeh/nabeh.service';
|
||||
import { OtpProvider, OtpSendContext } from '../otp-provider.interface';
|
||||
|
||||
/**
|
||||
* مزوّد Nabeh (واتساب) — سوريا والأردن (docs/17 — D5).
|
||||
* غلاف رقيق حول `NabehService` القائم يحوّل الرمي إلى `false` ليعمل ضمن سلسلة
|
||||
* الـfailover.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NabehOtpProvider implements OtpProvider {
|
||||
readonly name = 'nabeh';
|
||||
private readonly logger = new Logger('NabehOtp');
|
||||
|
||||
constructor(private readonly nabeh: NabehService) {}
|
||||
|
||||
supports(countryPack: string): boolean {
|
||||
return countryPack === 'jo' || countryPack === 'sy';
|
||||
}
|
||||
|
||||
async send(phone: string, code: string, _ctx: OtpSendContext): Promise<boolean> {
|
||||
try {
|
||||
await this.nabeh.sendOtp(phone, code);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`send failed: ${e?.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PaymentAdapter, ChargeResult, WebhookResult } from './payment-adapter.interface';
|
||||
|
||||
/** كاش — لا مزوّد خارجي، ينجح فوراً عند الاستلام اليدوي. لا webhook له إطلاقاً. */
|
||||
@Injectable()
|
||||
export class CashAdapter implements PaymentAdapter {
|
||||
readonly name = 'cash';
|
||||
|
||||
async charge(): Promise<ChargeResult> {
|
||||
return { mode: 'instant' };
|
||||
}
|
||||
|
||||
verifyWebhook(): boolean {
|
||||
return false; // كاش ليس له مزوّد يبعث webhook — أي طلب هنا مزوَّر بالتعريف
|
||||
}
|
||||
|
||||
parseWebhook(): WebhookResult | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 () => {
|
||||
const adapter = new InvoiceAdapter('cliq');
|
||||
const tenant = { settings: { payments: { transfer_targets: { cliq: '+962790000000' } } } } as any;
|
||||
const result = await adapter.charge({ paymentId: 'abcdef12', amount: 25, currency: 'JOD' }, tenant);
|
||||
|
||||
expect(result.mode).toBe('invoice');
|
||||
if (result.mode === 'invoice') {
|
||||
expect(result.reference.startsWith('CLIQ-')).toBe(true);
|
||||
expect(result.transferTarget).toBe('+962790000000');
|
||||
expect(result.instructions).toContain('+962790000000');
|
||||
}
|
||||
});
|
||||
|
||||
it('بلا حساب استلام مضبوط يوضّح ذلك بدل رابط فارغ', async () => {
|
||||
const adapter = new InvoiceAdapter('mtn');
|
||||
const result = await adapter.charge({ paymentId: 'x', amount: 10, currency: 'SYP' }, { settings: {} } as any);
|
||||
if (result.mode === 'invoice') {
|
||||
expect(result.transferTarget).toBeUndefined();
|
||||
expect(result.instructions).toContain('لم يُضبط');
|
||||
}
|
||||
});
|
||||
|
||||
it('لا webhook حقيقياً — التسوية عبر SMS فقط', () => {
|
||||
const adapter = new InvoiceAdapter('shamcash');
|
||||
expect(adapter.verifyWebhook({}, {}, {} as any)).toBe(false);
|
||||
expect(adapter.parseWebhook({})).toBeNull();
|
||||
});
|
||||
|
||||
it('كل نداء يولّد مرجعاً مختلفاً — لا تصادم بين فاتورتين', async () => {
|
||||
const adapter = new InvoiceAdapter('cliq');
|
||||
const tenant = { settings: {} } as any;
|
||||
const a = await adapter.charge({ paymentId: 'p1', amount: 5, currency: 'JOD' }, tenant);
|
||||
const b = await adapter.charge({ paymentId: 'p2', amount: 5, currency: 'JOD' }, tenant);
|
||||
if (a.mode === 'invoice' && b.mode === 'invoice') {
|
||||
expect(a.reference).not.toBe(b.reference);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaymentGatewayRegistry — يربط الكتالوج بمحوّله', () => {
|
||||
const config = { get: () => undefined } as any;
|
||||
const registry = () => new PaymentGatewayRegistry(
|
||||
new CashAdapter(),
|
||||
new PaymobAdapter(config),
|
||||
new SyriatelAdapter(config),
|
||||
new MtnAdapter(config),
|
||||
);
|
||||
|
||||
it('يعرف كل مزوّدي الكتالوج', () => {
|
||||
const r = registry();
|
||||
for (const p of ['cash', 'paymob', 'cliq', 'shamcash', 'mtn', 'syriatel', 'zaincash']) {
|
||||
expect(r.has(p)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('يرفض مزوّداً مخترعاً', () => {
|
||||
const r = registry();
|
||||
expect(r.has('made_up_gateway')).toBe(false);
|
||||
expect(() => r.get('made_up_gateway')).toThrow();
|
||||
});
|
||||
|
||||
it('cliq وmtn محوّلان مستقلّان لا نفس الكائن — مراجعهما لا تختلط', () => {
|
||||
const r = registry();
|
||||
expect(r.get('cliq')).not.toBe(r.get('mtn'));
|
||||
expect(r.get('cliq').name).toBe('cliq');
|
||||
expect(r.get('mtn').name).toBe('mtn');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import {
|
||||
PaymentAdapter,
|
||||
ChargeContext,
|
||||
ChargeResult,
|
||||
WebhookResult,
|
||||
} from './payment-adapter.interface';
|
||||
|
||||
/**
|
||||
* كليك · شام كاش · MTN · سيرياتيل · زين كاش — **بلا API فعلية اليوم**
|
||||
* (مطابق لما أكّده المالك ولما هو مطبَّق فعلاً في `payment_server` لدى سيرو:
|
||||
* `create_cliq_invoice.php` / `create_mtn_invoice.php` تُنشئ فاتورة فقط،
|
||||
* لا نداء حيّاً لبوابة تحويل).
|
||||
*
|
||||
* المحوّل الواحد يخدم كل هذه المزوّدات لأن سلوكها متطابق حرفياً: يولّد مرجع
|
||||
* فاتورة ويعرض للمستخدم أين يحوّل (رقم/حساب المستأجر المسجَّل عند ذاك
|
||||
* المزوّد)، والتسوية تصل **لاحقاً عبر رسالة SMS** (docs/24 §5 — P2) لا عبر
|
||||
* webhook من المزوّد — فلا `verifyWebhook` هنا يعيد `true` أبداً.
|
||||
*
|
||||
* ⚠️ MTN وسيرياتيل يملكان API حقيقية موثَّقة في سيرو (توكن + OTP داخل
|
||||
* التطبيق) لكنها تتطلّب بيانات اعتماد إنتاجية حيّة (terminal ID · مفتاح
|
||||
* خاص · حساب تاجر) لا نملكها هنا لاختبارها فعلياً. البنية جاهزة لاستقبالها
|
||||
* (`docs/24` بند مفتوح) — لا نبني تكاملاً لا يمكن التحقّق منه.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InvoiceAdapter implements PaymentAdapter {
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
|
||||
const targets = tenant?.settings?.payments?.transfer_targets ?? {};
|
||||
const reference = `${this.name.toUpperCase()}-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`;
|
||||
|
||||
return {
|
||||
mode: 'invoice',
|
||||
reference,
|
||||
transferTarget: targets[this.name] ?? undefined,
|
||||
instructions: targets[this.name]
|
||||
? `حوّل ${ctx.amount} ${ctx.currency} إلى ${targets[this.name]} عبر ${this.name}، واذكر المرجع ${reference}`
|
||||
: `حوّل ${ctx.amount} ${ctx.currency} عبر ${this.name} — لم يُضبط حساب استلام لهذا المستأجر بعد`,
|
||||
};
|
||||
}
|
||||
|
||||
/** لا webhook حقيقياً من هذه البوابات — التسوية عبر SMS فقط (P2). */
|
||||
verifyWebhook(_headers?: Record<string, any>, _rawBody?: any, _tenant?: Tenant): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
parseWebhook(_payload?: any): WebhookResult | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
|
||||
/** ما يحتاجه المحوّل لبدء عملية دفع. المبلغ بالوحدة الكبرى (دينار/جنيه لا قروش). */
|
||||
export interface ChargeContext {
|
||||
paymentId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
userPhone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* نتيجة بدء الدفع — ثلاثة أنماط لا نمط واحد، لأن بوابات المنطقة تختلف جذرياً:
|
||||
*
|
||||
* - `instant`: ينجح فوراً (كاش) — لا انتظار.
|
||||
* - `redirect`: بوابة حقيقية بواجهة دفع (PayMob) — رابط iframe يفتحه التطبيق،
|
||||
* وتأكيد لاحق عبر webhook **موقَّع** من المزوّد.
|
||||
* - `invoice`: **لا API فعلية** (كليك/شام كاش/MTN كما هي اليوم) — نولّد مرجعاً
|
||||
* ونعرض للمستخدم أين يحوّل، والتأكيد عبر **رسالة SMS** (docs/24 §5 — P2)
|
||||
* لا عبر webhook من المزوّد نفسه.
|
||||
*/
|
||||
export type ChargeResult =
|
||||
| { mode: 'instant' }
|
||||
| { mode: 'redirect'; redirectUrl: string; providerRef?: string }
|
||||
| { mode: 'invoice'; reference: string; transferTarget?: string; instructions?: string };
|
||||
|
||||
/** نتيجة تحليل حمولة webhook بعد التحقق من توقيعها. */
|
||||
export interface WebhookResult {
|
||||
providerRef: string;
|
||||
success: boolean;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface PaymentAdapter {
|
||||
readonly name: string;
|
||||
|
||||
charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult>;
|
||||
|
||||
/**
|
||||
* يتحقّق من توقيع الطلب الوارد. **يجب أن يُنادى قبل أي قراءة للحمولة** —
|
||||
* القيمة غير الموثَّقة لا تُستهلَك أبداً حتى لو بدت معقولة.
|
||||
*/
|
||||
verifyWebhook(headers: Record<string, any>, rawBody: any, tenant: Tenant): boolean;
|
||||
|
||||
/** يُنادى فقط بعد `verifyWebhook() === true`. */
|
||||
parseWebhook(payload: any): WebhookResult | null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
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).
|
||||
* إضافة مزوّد جديد بواجهة API فعلية = محوّل جديد + سطر هنا، بلا مساس بمنطق
|
||||
* الدفع في `PaymentsService`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentGatewayRegistry {
|
||||
private readonly adapters = new Map<string, PaymentAdapter>();
|
||||
|
||||
constructor(
|
||||
cash: CashAdapter,
|
||||
paymob: PaymobAdapter,
|
||||
syriatel: SyriatelAdapter,
|
||||
mtn: MtnAdapter,
|
||||
) {
|
||||
this.adapters.set(cash.name, cash);
|
||||
this.adapters.set(paymob.name, paymob);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
get(provider: string): PaymentAdapter {
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (!adapter) throw new NotFoundException(`no payment adapter for provider "${provider}"`);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
has(provider: string): boolean {
|
||||
return this.adapters.has(provider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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, SyriatelAdapter, MtnAdapter, PaymentGatewayRegistry],
|
||||
exports: [PaymentGatewayRegistry],
|
||||
})
|
||||
export class PaymentGatewaysModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user