P2: ride types + class-aware matching + round-trip + dispatch + wallet + FCM + WS live location/WebRTC
- ride-types: per-tenant catalog (economy/comfort/electric/family_van/women/scooter), seeded
- matching now class-scoped (geo:drivers:{tenant}:{class}); trips match by requested type
- trips: is_round_trip (doubles distance for fare) + payment_method + wallet settlement on paid
- dispatch: operator creates trip for phone customer (role-guarded)
- wallet: balance + credit/debit + txns + self topup; wallet-paid trips settle rider→driver
- notifications: FCM device tokens + best-effort push (assign event)
- realtime: trip:join, live driver:location broadcast, WebRTC call signaling (offer/answer/ice/end)
- migration InitP2 (ride_types, wallets, wallet_txns, device_tokens, trips.is_round_trip)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,3 +43,7 @@ NABEH_BASE_URL=https://nabeh.intaleqapp.com
|
|||||||
NABEH_EMAIL=
|
NABEH_EMAIL=
|
||||||
NABEH_PASSWORD=
|
NABEH_PASSWORD=
|
||||||
NABEH_OTP_TYPE=text
|
NABEH_OTP_TYPE=text
|
||||||
|
|
||||||
|
# ---- FCM push (اتركه فارغاً لتعطيل الإرسال) ----
|
||||||
|
FCM_SERVER_KEY=
|
||||||
|
FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import { FraudModule } from './modules/fraud/fraud.module';
|
|||||||
import { ChatModule } from './modules/chat/chat.module';
|
import { ChatModule } from './modules/chat/chat.module';
|
||||||
import { RatingsModule } from './modules/ratings/ratings.module';
|
import { RatingsModule } from './modules/ratings/ratings.module';
|
||||||
import { NabehModule } from './integrations/nabeh/nabeh.module';
|
import { NabehModule } from './integrations/nabeh/nabeh.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 { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -45,6 +49,7 @@ import { NabehModule } from './integrations/nabeh/nabeh.module';
|
|||||||
|
|
||||||
RedisModule, // عالمي — عميل Redis للمطابقة و OTP
|
RedisModule, // عالمي — عميل Redis للمطابقة و OTP
|
||||||
NabehModule, // عالمي — إرسال OTP واتساب
|
NabehModule, // عالمي — إرسال OTP واتساب
|
||||||
|
NotificationsModule, // عالمي — FCM
|
||||||
|
|
||||||
HealthModule,
|
HealthModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
@@ -53,10 +58,13 @@ import { NabehModule } from './integrations/nabeh/nabeh.module';
|
|||||||
MapsModule,
|
MapsModule,
|
||||||
MatchingModule,
|
MatchingModule,
|
||||||
TariffModule,
|
TariffModule,
|
||||||
|
RideTypesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
FraudModule,
|
FraudModule,
|
||||||
|
WalletModule,
|
||||||
TripsModule,
|
TripsModule,
|
||||||
|
DispatchModule,
|
||||||
ChatModule,
|
ChatModule,
|
||||||
RatingsModule,
|
RatingsModule,
|
||||||
SeedModule,
|
SeedModule,
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { SeedService } from './seed.service';
|
import { SeedService } from './seed.service';
|
||||||
import { TenantsModule } from '../../modules/tenants/tenants.module';
|
import { TenantsModule } from '../../modules/tenants/tenants.module';
|
||||||
import { TariffModule } from '../../modules/tariff/tariff.module';
|
import { TariffModule } from '../../modules/tariff/tariff.module';
|
||||||
|
import { RideTypesModule } from '../../modules/ride-types/ride-types.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TenantsModule, TariffModule],
|
imports: [TenantsModule, TariffModule, RideTypesModule],
|
||||||
providers: [SeedService],
|
providers: [SeedService],
|
||||||
})
|
})
|
||||||
export class SeedModule {}
|
export class SeedModule {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { TenantsService } from '../../modules/tenants/tenants.service';
|
import { TenantsService } from '../../modules/tenants/tenants.service';
|
||||||
import { TariffService } from '../../modules/tariff/tariff.service';
|
import { TariffService } from '../../modules/tariff/tariff.service';
|
||||||
|
import { RideTypesService } from '../../modules/ride-types/ride-types.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* seed تلقائي عند الإقلاع: مستأجر تجريبي (Tenant Zero) + تعرفة افتراضية،
|
* seed تلقائي عند الإقلاع: مستأجر تجريبي (Tenant Zero) + تعرفة افتراضية،
|
||||||
@@ -13,6 +14,7 @@ export class SeedService implements OnModuleInit {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly tenants: TenantsService,
|
private readonly tenants: TenantsService,
|
||||||
private readonly tariff: TariffService,
|
private readonly tariff: TariffService,
|
||||||
|
private readonly rideTypes: RideTypesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
@@ -60,5 +62,19 @@ export class SeedService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
this.logger.log('seeded default tariff for "siro"');
|
this.logger.log('seeded default tariff for "siro"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// أنواع الرحلات الافتراضية
|
||||||
|
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"');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,4 +65,10 @@ export default () => ({
|
|||||||
|
|
||||||
// رموز الاتصال الدولية لكل country pack (لتنسيق الهاتف قبل الإرسال).
|
// رموز الاتصال الدولية لكل country pack (لتنسيق الهاتف قبل الإرسال).
|
||||||
callingCodes: { jo: '962', sy: '963' } as Record<string, string>,
|
callingCodes: { jo: '962', sy: '963' } as Record<string, string>,
|
||||||
|
|
||||||
|
// إشعارات FCM (اتركه فارغاً لتعطيل الإرسال — يُسجَّل فقط).
|
||||||
|
fcm: {
|
||||||
|
serverKey: process.env.FCM_SERVER_KEY ?? '',
|
||||||
|
endpoint: process.env.FCM_ENDPOINT ?? 'https://fcm.googleapis.com/fcm/send',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,21 @@
|
|||||||
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { DispatchService, DispatchOrderDto } from './dispatch.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@ApiTags('dispatch')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('dispatcher', 'admin')
|
||||||
|
@Controller('dispatch')
|
||||||
|
export class DispatchController {
|
||||||
|
constructor(private readonly dispatch: DispatchService) {}
|
||||||
|
|
||||||
|
@Post('orders')
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() body: DispatchOrderDto) {
|
||||||
|
return this.dispatch.createOrder(user.tenantId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DispatchService } from './dispatch.service';
|
||||||
|
import { DispatchController } from './dispatch.controller';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { TripsModule } from '../trips/trips.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [UsersModule, TripsModule],
|
||||||
|
controllers: [DispatchController],
|
||||||
|
providers: [DispatchService],
|
||||||
|
})
|
||||||
|
export class DispatchModule {}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
import { TripsService, RequestTripDto } from '../trips/trips.service';
|
||||||
|
|
||||||
|
export interface DispatchOrderDto extends RequestTripDto {
|
||||||
|
phone: string; // هاتف الراكب الذي اتصل
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* لوحة المشغّل: إنشاء رحلة نيابةً عن راكب اتصل هاتفياً (docs/02).
|
||||||
|
* يجد/ينشئ مستخدم الراكب بالهاتف ثم يطلب الرحلة باسمه.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DispatchService {
|
||||||
|
constructor(
|
||||||
|
private readonly users: UsersService,
|
||||||
|
private readonly trips: TripsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createOrder(tenantId: string, dto: DispatchOrderDto) {
|
||||||
|
const rider = await this.users.findOrCreate(tenantId, dto.phone, 'rider');
|
||||||
|
return this.trips.request(tenantId, rider.id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,9 +73,15 @@ export class DriversService {
|
|||||||
driver.is_online = online;
|
driver.is_online = online;
|
||||||
await this.repo.save(driver);
|
await this.repo.save(driver);
|
||||||
if (!online) {
|
if (!online) {
|
||||||
await this.matching.removeDriver(tenantId, driver.id);
|
await this.matching.removeDriver(tenantId, driver.service_class, driver.id);
|
||||||
} else if (driver.last_lat != null && driver.last_lng != null) {
|
} else if (driver.last_lat != null && driver.last_lng != null) {
|
||||||
await this.matching.addDriver(tenantId, driver.id, driver.last_lat, driver.last_lng);
|
await this.matching.addDriver(
|
||||||
|
tenantId,
|
||||||
|
driver.service_class,
|
||||||
|
driver.id,
|
||||||
|
driver.last_lat,
|
||||||
|
driver.last_lng,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return driver;
|
return driver;
|
||||||
}
|
}
|
||||||
@@ -93,7 +99,7 @@ export class DriversService {
|
|||||||
driver.last_lng = lng;
|
driver.last_lng = lng;
|
||||||
await this.repo.save(driver);
|
await this.repo.save(driver);
|
||||||
if (driver.is_online) {
|
if (driver.is_online) {
|
||||||
await this.matching.addDriver(tenantId, driver.id, lat, lng);
|
await this.matching.addDriver(tenantId, driver.service_class, driver.id, lat, lng);
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,27 +16,35 @@ export interface NearbyDriver {
|
|||||||
export class MatchingService {
|
export class MatchingService {
|
||||||
constructor(@Inject(REDIS) private readonly redis: Redis) {}
|
constructor(@Inject(REDIS) private readonly redis: Redis) {}
|
||||||
|
|
||||||
private key(tenantId: string): string {
|
// فهرس GEO لكل (مستأجر × فئة خدمة) — المطابقة تحترم نوع الرحلة.
|
||||||
return `geo:drivers:${tenantId}`;
|
private key(tenantId: string, serviceClass: string): string {
|
||||||
|
return `geo:drivers:${tenantId}:${serviceClass}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addDriver(tenantId: string, driverId: string, lat: number, lng: number) {
|
async addDriver(
|
||||||
|
tenantId: string,
|
||||||
|
serviceClass: string,
|
||||||
|
driverId: string,
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
) {
|
||||||
await this.redis.call(
|
await this.redis.call(
|
||||||
'GEOADD',
|
'GEOADD',
|
||||||
this.key(tenantId),
|
this.key(tenantId, serviceClass),
|
||||||
String(lng),
|
String(lng),
|
||||||
String(lat),
|
String(lat),
|
||||||
driverId,
|
driverId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeDriver(tenantId: string, driverId: string) {
|
async removeDriver(tenantId: string, serviceClass: string, driverId: string) {
|
||||||
await this.redis.call('ZREM', this.key(tenantId), driverId);
|
await this.redis.call('ZREM', this.key(tenantId, serviceClass), driverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** أقرب السائقين ضمن نصف قطر (كم)، مرتبين بالأقرب. */
|
/** أقرب السائقين من نفس فئة الخدمة ضمن نصف قطر (كم). */
|
||||||
async findNearby(
|
async findNearby(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
|
serviceClass: string,
|
||||||
lat: number,
|
lat: number,
|
||||||
lng: number,
|
lng: number,
|
||||||
radiusKm = 5,
|
radiusKm = 5,
|
||||||
@@ -44,7 +52,7 @@ export class MatchingService {
|
|||||||
): Promise<NearbyDriver[]> {
|
): Promise<NearbyDriver[]> {
|
||||||
const res = (await this.redis.call(
|
const res = (await this.redis.call(
|
||||||
'GEOSEARCH',
|
'GEOSEARCH',
|
||||||
this.key(tenantId),
|
this.key(tenantId, serviceClass),
|
||||||
'FROMLONLAT',
|
'FROMLONLAT',
|
||||||
String(lng),
|
String(lng),
|
||||||
String(lat),
|
String(lat),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/** توكن جهاز لإشعارات FCM. الجدول: tripz_device_tokens. */
|
||||||
|
@Entity('device_tokens')
|
||||||
|
@Index(['tenant_id', 'user_id'])
|
||||||
|
export class DeviceToken {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
tenant_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
user_id: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@Column({ default: 'android' })
|
||||||
|
platform: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@ApiTags('notifications')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('notifications')
|
||||||
|
export class NotificationsController {
|
||||||
|
constructor(private readonly notifications: NotificationsService) {}
|
||||||
|
|
||||||
|
@Post('token')
|
||||||
|
register(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body('token') token: string,
|
||||||
|
@Body('platform') platform: string,
|
||||||
|
) {
|
||||||
|
return this.notifications.register(user.tenantId, user.userId, token, platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { DeviceToken } from './entities/device-token.entity';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { NotificationsController } from './notifications.controller';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([DeviceToken])],
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
providers: [NotificationsService],
|
||||||
|
exports: [NotificationsService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { DeviceToken } from './entities/device-token.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* إشعارات FCM. تسجيل التوكنات + إرسال أفضل جهد (best-effort).
|
||||||
|
* بلا FCM_SERVER_KEY: يُسجَّل فقط دون إرسال.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsService {
|
||||||
|
private readonly logger = new Logger('Notifications');
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(DeviceToken) private readonly tokens: Repository<DeviceToken>,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(tenantId: string, userId: string, token: string, platform = 'android') {
|
||||||
|
const existing = await this.tokens.findOne({ where: { token } });
|
||||||
|
if (existing) {
|
||||||
|
existing.tenant_id = tenantId;
|
||||||
|
existing.user_id = userId;
|
||||||
|
existing.platform = platform;
|
||||||
|
return this.tokens.save(existing);
|
||||||
|
}
|
||||||
|
return this.tokens.save(
|
||||||
|
this.tokens.create({ tenant_id: tenantId, user_id: userId, token, platform }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendToUser(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
data: Record<string, any> = {},
|
||||||
|
) {
|
||||||
|
const key = this.config.get<string>('fcm.serverKey');
|
||||||
|
const rows = await this.tokens.find({ where: { tenant_id: tenantId, user_id: userId } });
|
||||||
|
if (!key || rows.length === 0) {
|
||||||
|
this.logger.debug(`push skip (key=${!!key} tokens=${rows.length}) "${title}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const endpoint = this.config.get<string>('fcm.endpoint')!;
|
||||||
|
for (const r of rows) {
|
||||||
|
try {
|
||||||
|
await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `key=${key}` },
|
||||||
|
body: JSON.stringify({ to: r.token, notification: { title, body }, data }),
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`push failed: ${e?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* كتالوج أنواع الرحلات/السيارات لكل مستأجر (economy, comfort, electric,
|
||||||
|
* family_van, women, scooter...). الجدول: tripz_ride_types.
|
||||||
|
* كل نوع = service_class يُطابَق مع تعرفة وسائقين من نفس الفئة.
|
||||||
|
*/
|
||||||
|
@Entity('ride_types')
|
||||||
|
@Index(['tenant_id', 'code'], { unique: true })
|
||||||
|
export class RideType {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
tenant_id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
code: string; // economy | comfort | electric | family_van | women | scooter
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name_ar: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
name_en: string;
|
||||||
|
|
||||||
|
@Column({ default: 'car' })
|
||||||
|
vehicle_kind: string; // car | van | scooter | bike
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
women_only: boolean;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
round_trip_supported: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
icon: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
sort: number;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Body, Controller, Get, Post, Headers, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { RideTypesService } from './ride-types.service';
|
||||||
|
import { TenantsService } from '../tenants/tenants.service';
|
||||||
|
|
||||||
|
@ApiTags('ride-types')
|
||||||
|
@Controller('ride-types')
|
||||||
|
export class RideTypesController {
|
||||||
|
constructor(
|
||||||
|
private readonly rideTypes: RideTypesService,
|
||||||
|
private readonly tenants: TenantsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// عام للتطبيق: أنواع الرحلات المتاحة للمستأجر (عبر x-tenant-id slug)
|
||||||
|
@Get()
|
||||||
|
async list(@Headers('x-tenant-id') slug: string) {
|
||||||
|
if (!slug) throw new UnauthorizedException('x-tenant-id required');
|
||||||
|
const tenant = await this.tenants.resolve(slug);
|
||||||
|
if (!tenant) throw new UnauthorizedException('Unknown tenant');
|
||||||
|
return this.rideTypes.listActive(tenant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// للأدمن: إنشاء نوع (يُقيَّد بدور لاحقاً)
|
||||||
|
@Post()
|
||||||
|
async create(@Headers('x-tenant-id') slug: string, @Body() body: any) {
|
||||||
|
const tenant = await this.tenants.resolve(slug);
|
||||||
|
if (!tenant) throw new UnauthorizedException('Unknown tenant');
|
||||||
|
return this.rideTypes.create({ ...body, tenant_id: tenant.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { RideType } from './entities/ride-type.entity';
|
||||||
|
import { RideTypesService } from './ride-types.service';
|
||||||
|
import { RideTypesController } from './ride-types.controller';
|
||||||
|
import { TenantsModule } from '../tenants/tenants.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([RideType]), TenantsModule],
|
||||||
|
controllers: [RideTypesController],
|
||||||
|
providers: [RideTypesService],
|
||||||
|
exports: [RideTypesService],
|
||||||
|
})
|
||||||
|
export class RideTypesModule {}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { RideType } from './entities/ride-type.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RideTypesService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(RideType) private readonly repo: Repository<RideType>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
listActive(tenantId: string): Promise<RideType[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { tenant_id: tenantId, active: true },
|
||||||
|
order: { sort: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findByCode(tenantId: string, code: string): Promise<RideType | null> {
|
||||||
|
return this.repo.findOne({ where: { tenant_id: tenantId, code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(data: Partial<RideType>): Promise<RideType> {
|
||||||
|
return this.repo.save(this.repo.create(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** يُنشئ النوع إن لم يوجد (للـ seed). */
|
||||||
|
async ensure(data: Partial<RideType>): Promise<RideType> {
|
||||||
|
const existing = await this.findByCode(data.tenant_id!, data.code!);
|
||||||
|
return existing ?? this.create(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ export class Trip {
|
|||||||
@Column({ default: 'economy' })
|
@Column({ default: 'economy' })
|
||||||
service_class: string;
|
service_class: string;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
is_round_trip: boolean;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
city: string;
|
city: string;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { MatchingModule } from '../matching/matching.module';
|
|||||||
import { DriversModule } from '../drivers/drivers.module';
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
import { RealtimeModule } from '../../realtime/realtime.module';
|
import { RealtimeModule } from '../../realtime/realtime.module';
|
||||||
import { FraudModule } from '../fraud/fraud.module';
|
import { FraudModule } from '../fraud/fraud.module';
|
||||||
|
import { WalletModule } from '../wallet/wallet.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -20,6 +21,7 @@ import { FraudModule } from '../fraud/fraud.module';
|
|||||||
DriversModule,
|
DriversModule,
|
||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
FraudModule,
|
FraudModule,
|
||||||
|
WalletModule,
|
||||||
],
|
],
|
||||||
controllers: [TripsController],
|
controllers: [TripsController],
|
||||||
providers: [TripsService],
|
providers: [TripsService],
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { MatchingService } from '../matching/matching.service';
|
|||||||
import { DriversService } from '../drivers/drivers.service';
|
import { DriversService } from '../drivers/drivers.service';
|
||||||
import { RealtimeGateway } from '../../realtime/realtime.gateway';
|
import { RealtimeGateway } from '../../realtime/realtime.gateway';
|
||||||
import { FraudService } from '../fraud/fraud.service';
|
import { FraudService } from '../fraud/fraud.service';
|
||||||
|
import { WalletService } from '../wallet/wallet.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
|
||||||
/** رسوم الإلغاء حسب مرحلة الرحلة (docs/04). */
|
/** رسوم الإلغاء حسب مرحلة الرحلة (docs/04). */
|
||||||
const CANCEL_FEE_BY_STAGE: Partial<Record<TripStatus, number>> = {
|
const CANCEL_FEE_BY_STAGE: Partial<Record<TripStatus, number>> = {
|
||||||
@@ -43,6 +45,8 @@ export interface RequestTripDto {
|
|||||||
destination: { lat: number; lng: number };
|
destination: { lat: number; lng: number };
|
||||||
service_class?: string;
|
service_class?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
|
is_round_trip?: boolean;
|
||||||
|
rider_id?: string; // يُستخدم من dispatch لإنشاء رحلة نيابةً عن راكب
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -58,6 +62,8 @@ export class TripsService {
|
|||||||
private readonly drivers: DriversService,
|
private readonly drivers: DriversService,
|
||||||
private readonly gateway: RealtimeGateway,
|
private readonly gateway: RealtimeGateway,
|
||||||
private readonly fraud: FraudService,
|
private readonly fraud: FraudService,
|
||||||
|
private readonly wallet: WalletService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get(tenantId: string, id: string): Promise<Trip | null> {
|
get(tenantId: string, id: string): Promise<Trip | null> {
|
||||||
@@ -85,7 +91,13 @@ export class TripsService {
|
|||||||
}
|
}
|
||||||
const serviceClass = dto.service_class ?? 'economy';
|
const serviceClass = dto.service_class ?? 'economy';
|
||||||
const city = dto.city ?? 'default';
|
const city = dto.city ?? 'default';
|
||||||
const route = await this.maps.route(dto.origin, dto.destination);
|
const isRound = !!dto.is_round_trip;
|
||||||
|
const oneWay = await this.maps.route(dto.origin, dto.destination);
|
||||||
|
// ذهاب وعودة: يُضاعف المسافة والزمن للتسعير
|
||||||
|
const route = {
|
||||||
|
distanceKm: isRound ? Number((oneWay.distanceKm * 2).toFixed(3)) : oneWay.distanceKm,
|
||||||
|
durationMin: isRound ? Number((oneWay.durationMin * 2).toFixed(1)) : oneWay.durationMin,
|
||||||
|
};
|
||||||
|
|
||||||
// تسعير (اختياري — لو ما في تعرفة مفعّلة نكمل بلا سعر مقفول)
|
// تسعير (اختياري — لو ما في تعرفة مفعّلة نكمل بلا سعر مقفول)
|
||||||
let quotedFare: number | null = null;
|
let quotedFare: number | null = null;
|
||||||
@@ -109,6 +121,7 @@ export class TripsService {
|
|||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
rider_id: riderId,
|
rider_id: riderId,
|
||||||
service_class: serviceClass,
|
service_class: serviceClass,
|
||||||
|
is_round_trip: isRound,
|
||||||
city,
|
city,
|
||||||
origin_lat: dto.origin.lat,
|
origin_lat: dto.origin.lat,
|
||||||
origin_lng: dto.origin.lng,
|
origin_lng: dto.origin.lng,
|
||||||
@@ -121,13 +134,15 @@ export class TripsService {
|
|||||||
tariff_version: tariffVersion,
|
tariff_version: tariffVersion,
|
||||||
quoted_fare: quotedFare,
|
quoted_fare: quotedFare,
|
||||||
currency: currency ?? undefined,
|
currency: currency ?? undefined,
|
||||||
|
payment_method: (dto as any).payment_method ?? 'cash',
|
||||||
});
|
});
|
||||||
trip = await this.trips.save(trip);
|
trip = await this.trips.save(trip);
|
||||||
await this.recordEvent(trip, null, 'searching', 'rider');
|
await this.recordEvent(trip, null, 'searching', 'rider');
|
||||||
|
|
||||||
// مطابقة وبث عروض للسائقين القريبين
|
// مطابقة وبث عروض للسائقين القريبين من نفس فئة الخدمة
|
||||||
const nearby = await this.matching.findNearby(
|
const nearby = await this.matching.findNearby(
|
||||||
tenantId,
|
tenantId,
|
||||||
|
serviceClass,
|
||||||
dto.origin.lat,
|
dto.origin.lat,
|
||||||
dto.origin.lng,
|
dto.origin.lng,
|
||||||
);
|
);
|
||||||
@@ -170,6 +185,13 @@ export class TripsService {
|
|||||||
status: 'assigned',
|
status: 'assigned',
|
||||||
driverId: driver.id,
|
driverId: driver.id,
|
||||||
});
|
});
|
||||||
|
await this.notifications.sendToUser(
|
||||||
|
tenantId,
|
||||||
|
trip.rider_id,
|
||||||
|
'تم قبول رحلتك',
|
||||||
|
'سائقك في الطريق إليك',
|
||||||
|
{ tripId: trip.id, type: 'trip_assigned' },
|
||||||
|
);
|
||||||
return trip;
|
return trip;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +235,18 @@ export class TripsService {
|
|||||||
}
|
}
|
||||||
await this.applyTransition(trip, toStatus, actor);
|
await this.applyTransition(trip, toStatus, actor);
|
||||||
|
|
||||||
|
// تسوية المحفظة عند الدفع (payment_method === wallet)
|
||||||
|
if (toStatus === 'paid' && trip.payment_method === 'wallet' && trip.final_fare != null) {
|
||||||
|
const fare = Number(trip.final_fare);
|
||||||
|
const drv = trip.driver_id ? await this.drivers.findById(tenantId, trip.driver_id) : null;
|
||||||
|
try {
|
||||||
|
await this.wallet.debit(tenantId, trip.rider_id, fare, 'trip_fare', trip.id);
|
||||||
|
if (drv) await this.wallet.credit(tenantId, drv.user_id, fare, 'trip_earning', trip.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`wallet settle failed: ${e?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// بث لطرفَي الرحلة
|
// بث لطرفَي الرحلة
|
||||||
this.gateway.tripUpdate(tenantId, trip.rider_id, { tripId: trip.id, status: toStatus });
|
this.gateway.tripUpdate(tenantId, trip.rider_id, { tripId: trip.id, status: toStatus });
|
||||||
if (trip.driver_id) {
|
if (trip.driver_id) {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export class UsersService {
|
|||||||
return this.userRepository.save(user);
|
return this.userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findOrCreate(tenantId: string, phone: string, role: string = 'rider'): Promise<User> {
|
||||||
|
const existing = await this.findByPhone(tenantId, phone);
|
||||||
|
return existing ?? this.create(tenantId, phone, role);
|
||||||
|
}
|
||||||
|
|
||||||
async listByTenant(tenantId: string): Promise<User[]> {
|
async listByTenant(tenantId: string): Promise<User[]> {
|
||||||
return this.userRepository.find({ where: { tenant_id: tenantId } });
|
return this.userRepository.find({ where: { tenant_id: tenantId } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/** حركة محفظة. الجدول: tripz_wallet_txns. */
|
||||||
|
@Entity('wallet_txns')
|
||||||
|
@Index(['tenant_id', 'wallet_id'])
|
||||||
|
export class WalletTxn {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
tenant_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
wallet_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'numeric', precision: 12, scale: 3 })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
type: string; // credit | debit
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
ref: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/** محفظة المستخدم (راكب/سائق). الجدول: tripz_wallets. */
|
||||||
|
@Entity('wallets')
|
||||||
|
@Index(['tenant_id', 'user_id'], { unique: true })
|
||||||
|
export class Wallet {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
tenant_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
user_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
@Column({ default: 'JOD' })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { WalletService } from './wallet.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@ApiTags('wallet')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('wallet')
|
||||||
|
export class WalletController {
|
||||||
|
constructor(private readonly wallet: WalletService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async me(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.wallet.getOrCreate(user.tenantId, user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('transactions')
|
||||||
|
async history(@CurrentUser() user: AuthUser) {
|
||||||
|
const w = await this.wallet.getOrCreate(user.tenantId, user.userId);
|
||||||
|
return this.wallet.history(user.tenantId, w.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// شحن ذاتي تجريبي (كاش-إن). للإنتاج يُربط بمزوّد دفع أو يُقيَّد للأدمن.
|
||||||
|
@Post('topup')
|
||||||
|
topup(@CurrentUser() user: AuthUser, @Body('amount') amount: number) {
|
||||||
|
return this.wallet.credit(user.tenantId, user.userId, Number(amount), 'topup');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Wallet } from './entities/wallet.entity';
|
||||||
|
import { WalletTxn } from './entities/wallet-txn.entity';
|
||||||
|
import { WalletService } from './wallet.service';
|
||||||
|
import { WalletController } from './wallet.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Wallet, WalletTxn])],
|
||||||
|
controllers: [WalletController],
|
||||||
|
providers: [WalletService],
|
||||||
|
exports: [WalletService],
|
||||||
|
})
|
||||||
|
export class WalletModule {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Wallet } from './entities/wallet.entity';
|
||||||
|
import { WalletTxn } from './entities/wallet-txn.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WalletService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Wallet) private readonly wallets: Repository<Wallet>,
|
||||||
|
@InjectRepository(WalletTxn) private readonly txns: Repository<WalletTxn>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getOrCreate(tenantId: string, userId: string, currency = 'JOD'): Promise<Wallet> {
|
||||||
|
let w = await this.wallets.findOne({ where: { tenant_id: tenantId, user_id: userId } });
|
||||||
|
if (!w) {
|
||||||
|
w = await this.wallets.save(
|
||||||
|
this.wallets.create({ tenant_id: tenantId, user_id: userId, balance: 0, currency }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
private num(v: number | string): number {
|
||||||
|
return typeof v === 'string' ? Number(v) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async credit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
|
||||||
|
if (amount <= 0) throw new BadRequestException('amount must be > 0');
|
||||||
|
const w = await this.getOrCreate(tenantId, userId);
|
||||||
|
w.balance = Number((this.num(w.balance) + amount).toFixed(3));
|
||||||
|
await this.wallets.save(w);
|
||||||
|
await this.txns.save(
|
||||||
|
this.txns.create({ tenant_id: tenantId, wallet_id: w.id, amount, type: 'credit', reason, ref }),
|
||||||
|
);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
async debit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
|
||||||
|
if (amount <= 0) throw new BadRequestException('amount must be > 0');
|
||||||
|
const w = await this.getOrCreate(tenantId, userId);
|
||||||
|
if (this.num(w.balance) < amount) throw new BadRequestException('Insufficient balance');
|
||||||
|
w.balance = Number((this.num(w.balance) - amount).toFixed(3));
|
||||||
|
await this.wallets.save(w);
|
||||||
|
await this.txns.save(
|
||||||
|
this.txns.create({ tenant_id: tenantId, wallet_id: w.id, amount, type: 'debit', reason, ref }),
|
||||||
|
);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
history(tenantId: string, walletId: string) {
|
||||||
|
return this.txns.find({
|
||||||
|
where: { tenant_id: tenantId, wallet_id: walletId },
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
|
SubscribeMessage,
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { DriversService } from '../modules/drivers/drivers.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* البوابة الحية (Socket.IO) — docs/09. المصادقة على الاتصال بـ JWT،
|
* البوابة الحية (Socket.IO) — docs/09. مصادقة JWT + غرف مُنطَّقة بالمستأجر،
|
||||||
* والانضمام لغرف مُنطَّقة بالمستأجر (لا تسريب بين المستأجرين).
|
* بث مواقع السائقين لحظياً، وإشارات مكالمات WebRTC (offer/answer/ice) عبر غرفة الرحلة.
|
||||||
* غرف: tenant:{id}:driver:{driverId} · tenant:{id}:trip:{tripId} · tenant:{id}:dispatch
|
|
||||||
*/
|
*/
|
||||||
@WebSocketGateway({ cors: true })
|
@WebSocketGateway({ cors: true })
|
||||||
export class RealtimeGateway implements OnGatewayConnection {
|
export class RealtimeGateway implements OnGatewayConnection {
|
||||||
@@ -19,7 +22,10 @@ export class RealtimeGateway implements OnGatewayConnection {
|
|||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
server: Server;
|
||||||
|
|
||||||
constructor(private readonly jwt: JwtService) {}
|
constructor(
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly drivers: DriversService,
|
||||||
|
) {}
|
||||||
|
|
||||||
handleConnection(client: Socket) {
|
handleConnection(client: Socket) {
|
||||||
try {
|
try {
|
||||||
@@ -28,29 +34,84 @@ export class RealtimeGateway implements OnGatewayConnection {
|
|||||||
(client.handshake.headers?.authorization as string)?.replace('Bearer ', '');
|
(client.handshake.headers?.authorization as string)?.replace('Bearer ', '');
|
||||||
if (!token) throw new Error('no token');
|
if (!token) throw new Error('no token');
|
||||||
const p: any = this.jwt.verify(token);
|
const p: any = this.jwt.verify(token);
|
||||||
const tenantId = p.tenant_id;
|
client.data.user = { userId: p.sub, tenantId: p.tenant_id, role: p.role };
|
||||||
client.data.user = { userId: p.sub, tenantId, role: p.role };
|
client.join(`tenant:${p.tenant_id}:user:${p.sub}`);
|
||||||
client.join(`tenant:${tenantId}:user:${p.sub}`);
|
if (p.role === 'driver') client.join(`tenant:${p.tenant_id}:drivers`);
|
||||||
if (p.role === 'driver') client.join(`tenant:${tenantId}:drivers`);
|
|
||||||
this.logger.debug(`connected user=${p.sub} tenant=${tenantId} role=${p.role}`);
|
|
||||||
} catch {
|
} catch {
|
||||||
client.disconnect(true);
|
client.disconnect(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- مساعدات البث التي تستدعيها الخدمات ----
|
private tripRoom(tenantId: string, tripId: string) {
|
||||||
|
return `tenant:${tenantId}:trip:${tripId}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** عرض رحلة لسائق محدد. */
|
// ---- اشتراكات الويب سوكت ----
|
||||||
|
|
||||||
|
/** الطرفان ينضمّان لغرفة الرحلة لاستقبال التتبع والدردشة والمكالمات. */
|
||||||
|
@SubscribeMessage('trip:join')
|
||||||
|
onTripJoin(@ConnectedSocket() c: Socket, @MessageBody() body: { tripId: string }) {
|
||||||
|
const u = c.data.user;
|
||||||
|
if (!u || !body?.tripId) return;
|
||||||
|
c.join(this.tripRoom(u.tenantId, body.tripId));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** موقع السائق الحي: يحدّث Redis GEO ويبثّ لغرفة الرحلة. */
|
||||||
|
@SubscribeMessage('driver:location')
|
||||||
|
async onDriverLocation(
|
||||||
|
@ConnectedSocket() c: Socket,
|
||||||
|
@MessageBody() body: { lat: number; lng: number; tripId?: string },
|
||||||
|
) {
|
||||||
|
const u = c.data.user;
|
||||||
|
if (!u || u.role !== 'driver' || body?.lat == null || body?.lng == null) return;
|
||||||
|
await this.drivers.updateLocation(u.tenantId, u.userId, body.lat, body.lng);
|
||||||
|
if (body.tripId) {
|
||||||
|
c.to(this.tripRoom(u.tenantId, body.tripId)).emit('driver:location', {
|
||||||
|
tripId: body.tripId,
|
||||||
|
lat: body.lat,
|
||||||
|
lng: body.lng,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- إشارات WebRTC (مكالمة مجانية بين الطرفين عبر غرفة الرحلة) ----
|
||||||
|
@SubscribeMessage('call:offer')
|
||||||
|
onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
||||||
|
this.relay(c, 'call:offer', b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('call:answer')
|
||||||
|
onCallAnswer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
||||||
|
this.relay(c, 'call:answer', b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('call:ice')
|
||||||
|
onCallIce(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
||||||
|
this.relay(c, 'call:ice', b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('call:end')
|
||||||
|
onCallEnd(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
||||||
|
this.relay(c, 'call:end', b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private relay(c: Socket, event: string, b: any) {
|
||||||
|
const u = c.data.user;
|
||||||
|
if (!u || !b?.tripId) return;
|
||||||
|
c.to(this.tripRoom(u.tenantId, b.tripId)).emit(event, { from: u.userId, ...b });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- مساعدات البث التي تستدعيها الخدمات ----
|
||||||
offerToDriver(tenantId: string, driverUserId: string, payload: any) {
|
offerToDriver(tenantId: string, driverUserId: string, payload: any) {
|
||||||
this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer', payload);
|
this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** تحديث حالة الرحلة للراكب أو السائق. */
|
|
||||||
tripUpdate(tenantId: string, userId: string, payload: any) {
|
tripUpdate(tenantId: string, userId: string, payload: any) {
|
||||||
this.server.to(`tenant:${tenantId}:user:${userId}`).emit('trip:update', payload);
|
this.server.to(`tenant:${tenantId}:user:${userId}`).emit('trip:update', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** بث للوحة المشغّل. */
|
|
||||||
dispatch(tenantId: string, event: string, payload: any) {
|
dispatch(tenantId: string, event: string, payload: any) {
|
||||||
this.server.to(`tenant:${tenantId}:dispatch`).emit(event, payload);
|
this.server.to(`tenant:${tenantId}:dispatch`).emit(event, payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { RealtimeGateway } from './realtime.gateway';
|
import { RealtimeGateway } from './realtime.gateway';
|
||||||
|
import { DriversModule } from '../modules/drivers/drivers.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
DriversModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
Reference in New Issue
Block a user