diff --git a/backend/.env.example b/backend/.env.example index 573bca0..562b68c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -43,3 +43,7 @@ NABEH_BASE_URL=https://nabeh.intaleqapp.com NABEH_EMAIL= NABEH_PASSWORD= NABEH_OTP_TYPE=text + +# ---- FCM push (اتركه فارغاً لتعطيل الإرسال) ---- +FCM_SERVER_KEY= +FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6287a6e..9c1acbf 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -20,6 +20,10 @@ 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 { 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({ imports: [ @@ -45,6 +49,7 @@ import { NabehModule } from './integrations/nabeh/nabeh.module'; RedisModule, // عالمي — عميل Redis للمطابقة و OTP NabehModule, // عالمي — إرسال OTP واتساب + NotificationsModule, // عالمي — FCM HealthModule, TenantsModule, @@ -53,10 +58,13 @@ import { NabehModule } from './integrations/nabeh/nabeh.module'; MapsModule, MatchingModule, TariffModule, + RideTypesModule, DriversModule, RealtimeModule, FraudModule, + WalletModule, TripsModule, + DispatchModule, ChatModule, RatingsModule, SeedModule, diff --git a/backend/src/common/seed/seed.module.ts b/backend/src/common/seed/seed.module.ts index 03ae904..2f499af 100644 --- a/backend/src/common/seed/seed.module.ts +++ b/backend/src/common/seed/seed.module.ts @@ -2,9 +2,10 @@ 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], + imports: [TenantsModule, TariffModule, RideTypesModule], providers: [SeedService], }) export class SeedModule {} diff --git a/backend/src/common/seed/seed.service.ts b/backend/src/common/seed/seed.service.ts index 8a7a76d..9327e9e 100644 --- a/backend/src/common/seed/seed.service.ts +++ b/backend/src/common/seed/seed.service.ts @@ -1,6 +1,7 @@ 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'; /** * seed تلقائي عند الإقلاع: مستأجر تجريبي (Tenant Zero) + تعرفة افتراضية، @@ -13,6 +14,7 @@ export class SeedService implements OnModuleInit { constructor( private readonly tenants: TenantsService, private readonly tariff: TariffService, + private readonly rideTypes: RideTypesService, ) {} async onModuleInit() { @@ -60,5 +62,19 @@ export class SeedService implements OnModuleInit { }); 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"'); } } diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 79418f5..f89224b 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -65,4 +65,10 @@ export default () => ({ // رموز الاتصال الدولية لكل country pack (لتنسيق الهاتف قبل الإرسال). callingCodes: { jo: '962', sy: '963' } as Record, + + // إشعارات FCM (اتركه فارغاً لتعطيل الإرسال — يُسجَّل فقط). + fcm: { + serverKey: process.env.FCM_SERVER_KEY ?? '', + endpoint: process.env.FCM_ENDPOINT ?? 'https://fcm.googleapis.com/fcm/send', + }, }); diff --git a/backend/src/database/migrations/1721500000000-InitP2.ts b/backend/src/database/migrations/1721500000000-InitP2.ts new file mode 100644 index 0000000..a35a7f4 --- /dev/null +++ b/backend/src/database/migrations/1721500000000-InitP2.ts @@ -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 { + 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 { + 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`); + } +} diff --git a/backend/src/modules/dispatch/dispatch.controller.ts b/backend/src/modules/dispatch/dispatch.controller.ts new file mode 100644 index 0000000..d41b3a2 --- /dev/null +++ b/backend/src/modules/dispatch/dispatch.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/dispatch/dispatch.module.ts b/backend/src/modules/dispatch/dispatch.module.ts new file mode 100644 index 0000000..13bc881 --- /dev/null +++ b/backend/src/modules/dispatch/dispatch.module.ts @@ -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 {} diff --git a/backend/src/modules/dispatch/dispatch.service.ts b/backend/src/modules/dispatch/dispatch.service.ts new file mode 100644 index 0000000..6eaee67 --- /dev/null +++ b/backend/src/modules/dispatch/dispatch.service.ts @@ -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); + } +} diff --git a/backend/src/modules/drivers/drivers.service.ts b/backend/src/modules/drivers/drivers.service.ts index f2f40c5..e1ff14a 100644 --- a/backend/src/modules/drivers/drivers.service.ts +++ b/backend/src/modules/drivers/drivers.service.ts @@ -73,9 +73,15 @@ export class DriversService { driver.is_online = online; await this.repo.save(driver); 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) { - 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; } @@ -93,7 +99,7 @@ export class DriversService { driver.last_lng = lng; await this.repo.save(driver); 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 }; } diff --git a/backend/src/modules/matching/matching.service.ts b/backend/src/modules/matching/matching.service.ts index b35c421..79bec18 100644 --- a/backend/src/modules/matching/matching.service.ts +++ b/backend/src/modules/matching/matching.service.ts @@ -16,27 +16,35 @@ export interface NearbyDriver { export class MatchingService { constructor(@Inject(REDIS) private readonly redis: Redis) {} - private key(tenantId: string): string { - return `geo:drivers:${tenantId}`; + // فهرس GEO لكل (مستأجر × فئة خدمة) — المطابقة تحترم نوع الرحلة. + 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( 'GEOADD', - this.key(tenantId), + this.key(tenantId, serviceClass), String(lng), String(lat), driverId, ); } - async removeDriver(tenantId: string, driverId: string) { - await this.redis.call('ZREM', this.key(tenantId), driverId); + async removeDriver(tenantId: string, serviceClass: string, driverId: string) { + await this.redis.call('ZREM', this.key(tenantId, serviceClass), driverId); } - /** أقرب السائقين ضمن نصف قطر (كم)، مرتبين بالأقرب. */ + /** أقرب السائقين من نفس فئة الخدمة ضمن نصف قطر (كم). */ async findNearby( tenantId: string, + serviceClass: string, lat: number, lng: number, radiusKm = 5, @@ -44,7 +52,7 @@ export class MatchingService { ): Promise { const res = (await this.redis.call( 'GEOSEARCH', - this.key(tenantId), + this.key(tenantId, serviceClass), 'FROMLONLAT', String(lng), String(lat), diff --git a/backend/src/modules/notifications/entities/device-token.entity.ts b/backend/src/modules/notifications/entities/device-token.entity.ts new file mode 100644 index 0000000..80efd53 --- /dev/null +++ b/backend/src/modules/notifications/entities/device-token.entity.ts @@ -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; +} diff --git a/backend/src/modules/notifications/notifications.controller.ts b/backend/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..117accc --- /dev/null +++ b/backend/src/modules/notifications/notifications.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/notifications/notifications.module.ts b/backend/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..c7b83a9 --- /dev/null +++ b/backend/src/modules/notifications/notifications.module.ts @@ -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 {} diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..9bd8b1b --- /dev/null +++ b/backend/src/modules/notifications/notifications.service.ts @@ -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, + 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 = {}, + ) { + const key = this.config.get('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('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}`); + } + } + } +} diff --git a/backend/src/modules/ride-types/entities/ride-type.entity.ts b/backend/src/modules/ride-types/entities/ride-type.entity.ts new file mode 100644 index 0000000..53f438c --- /dev/null +++ b/backend/src/modules/ride-types/entities/ride-type.entity.ts @@ -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; +} diff --git a/backend/src/modules/ride-types/ride-types.controller.ts b/backend/src/modules/ride-types/ride-types.controller.ts new file mode 100644 index 0000000..cd376f3 --- /dev/null +++ b/backend/src/modules/ride-types/ride-types.controller.ts @@ -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 }); + } +} diff --git a/backend/src/modules/ride-types/ride-types.module.ts b/backend/src/modules/ride-types/ride-types.module.ts new file mode 100644 index 0000000..48a3866 --- /dev/null +++ b/backend/src/modules/ride-types/ride-types.module.ts @@ -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 {} diff --git a/backend/src/modules/ride-types/ride-types.service.ts b/backend/src/modules/ride-types/ride-types.service.ts new file mode 100644 index 0000000..a0d62d5 --- /dev/null +++ b/backend/src/modules/ride-types/ride-types.service.ts @@ -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, + ) {} + + listActive(tenantId: string): Promise { + return this.repo.find({ + where: { tenant_id: tenantId, active: true }, + order: { sort: 'ASC' }, + }); + } + + findByCode(tenantId: string, code: string): Promise { + return this.repo.findOne({ where: { tenant_id: tenantId, code } }); + } + + create(data: Partial): Promise { + return this.repo.save(this.repo.create(data)); + } + + /** يُنشئ النوع إن لم يوجد (للـ seed). */ + async ensure(data: Partial): Promise { + const existing = await this.findByCode(data.tenant_id!, data.code!); + return existing ?? this.create(data); + } +} diff --git a/backend/src/modules/trips/entities/trip.entity.ts b/backend/src/modules/trips/entities/trip.entity.ts index c4a959a..7ed31f0 100644 --- a/backend/src/modules/trips/entities/trip.entity.ts +++ b/backend/src/modules/trips/entities/trip.entity.ts @@ -40,6 +40,9 @@ export class Trip { @Column({ default: 'economy' }) service_class: string; + @Column({ default: false }) + is_round_trip: boolean; + @Column({ nullable: true }) city: string; diff --git a/backend/src/modules/trips/trips.module.ts b/backend/src/modules/trips/trips.module.ts index b88740d..d540e31 100644 --- a/backend/src/modules/trips/trips.module.ts +++ b/backend/src/modules/trips/trips.module.ts @@ -10,6 +10,7 @@ import { MatchingModule } from '../matching/matching.module'; import { DriversModule } from '../drivers/drivers.module'; import { RealtimeModule } from '../../realtime/realtime.module'; import { FraudModule } from '../fraud/fraud.module'; +import { WalletModule } from '../wallet/wallet.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { FraudModule } from '../fraud/fraud.module'; DriversModule, RealtimeModule, FraudModule, + WalletModule, ], controllers: [TripsController], providers: [TripsService], diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index d303cef..36f480d 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -15,6 +15,8 @@ import { MatchingService } from '../matching/matching.service'; import { DriversService } from '../drivers/drivers.service'; import { RealtimeGateway } from '../../realtime/realtime.gateway'; import { FraudService } from '../fraud/fraud.service'; +import { WalletService } from '../wallet/wallet.service'; +import { NotificationsService } from '../notifications/notifications.service'; /** رسوم الإلغاء حسب مرحلة الرحلة (docs/04). */ const CANCEL_FEE_BY_STAGE: Partial> = { @@ -43,6 +45,8 @@ export interface RequestTripDto { destination: { lat: number; lng: number }; service_class?: string; city?: string; + is_round_trip?: boolean; + rider_id?: string; // يُستخدم من dispatch لإنشاء رحلة نيابةً عن راكب } @Injectable() @@ -58,6 +62,8 @@ export class TripsService { private readonly drivers: DriversService, private readonly gateway: RealtimeGateway, private readonly fraud: FraudService, + private readonly wallet: WalletService, + private readonly notifications: NotificationsService, ) {} get(tenantId: string, id: string): Promise { @@ -85,7 +91,13 @@ export class TripsService { } const serviceClass = dto.service_class ?? 'economy'; 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; @@ -109,6 +121,7 @@ export class TripsService { tenant_id: tenantId, rider_id: riderId, service_class: serviceClass, + is_round_trip: isRound, city, origin_lat: dto.origin.lat, origin_lng: dto.origin.lng, @@ -121,13 +134,15 @@ export class TripsService { tariff_version: tariffVersion, quoted_fare: quotedFare, currency: currency ?? undefined, + payment_method: (dto as any).payment_method ?? 'cash', }); trip = await this.trips.save(trip); await this.recordEvent(trip, null, 'searching', 'rider'); - // مطابقة وبث عروض للسائقين القريبين + // مطابقة وبث عروض للسائقين القريبين من نفس فئة الخدمة const nearby = await this.matching.findNearby( tenantId, + serviceClass, dto.origin.lat, dto.origin.lng, ); @@ -170,6 +185,13 @@ export class TripsService { status: 'assigned', driverId: driver.id, }); + await this.notifications.sendToUser( + tenantId, + trip.rider_id, + 'تم قبول رحلتك', + 'سائقك في الطريق إليك', + { tripId: trip.id, type: 'trip_assigned' }, + ); return trip; } @@ -213,6 +235,18 @@ export class TripsService { } 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 }); if (trip.driver_id) { diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index bfbe673..7d07f33 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -23,6 +23,11 @@ export class UsersService { return this.userRepository.save(user); } + async findOrCreate(tenantId: string, phone: string, role: string = 'rider'): Promise { + const existing = await this.findByPhone(tenantId, phone); + return existing ?? this.create(tenantId, phone, role); + } + async listByTenant(tenantId: string): Promise { return this.userRepository.find({ where: { tenant_id: tenantId } }); } diff --git a/backend/src/modules/wallet/entities/wallet-txn.entity.ts b/backend/src/modules/wallet/entities/wallet-txn.entity.ts new file mode 100644 index 0000000..5a25a30 --- /dev/null +++ b/backend/src/modules/wallet/entities/wallet-txn.entity.ts @@ -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; +} diff --git a/backend/src/modules/wallet/entities/wallet.entity.ts b/backend/src/modules/wallet/entities/wallet.entity.ts new file mode 100644 index 0000000..bce7ad2 --- /dev/null +++ b/backend/src/modules/wallet/entities/wallet.entity.ts @@ -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; +} diff --git a/backend/src/modules/wallet/wallet.controller.ts b/backend/src/modules/wallet/wallet.controller.ts new file mode 100644 index 0000000..31fc3b2 --- /dev/null +++ b/backend/src/modules/wallet/wallet.controller.ts @@ -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'); + } +} diff --git a/backend/src/modules/wallet/wallet.module.ts b/backend/src/modules/wallet/wallet.module.ts new file mode 100644 index 0000000..3a56880 --- /dev/null +++ b/backend/src/modules/wallet/wallet.module.ts @@ -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 {} diff --git a/backend/src/modules/wallet/wallet.service.ts b/backend/src/modules/wallet/wallet.service.ts new file mode 100644 index 0000000..d28cff7 --- /dev/null +++ b/backend/src/modules/wallet/wallet.service.ts @@ -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, + @InjectRepository(WalletTxn) private readonly txns: Repository, + ) {} + + async getOrCreate(tenantId: string, userId: string, currency = 'JOD'): Promise { + 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, + }); + } +} diff --git a/backend/src/realtime/realtime.gateway.ts b/backend/src/realtime/realtime.gateway.ts index df1d1f0..9012579 100644 --- a/backend/src/realtime/realtime.gateway.ts +++ b/backend/src/realtime/realtime.gateway.ts @@ -1,16 +1,19 @@ import { + ConnectedSocket, + MessageBody, OnGatewayConnection, + SubscribeMessage, WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; import { Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Server, Socket } from 'socket.io'; +import { DriversService } from '../modules/drivers/drivers.service'; /** - * البوابة الحية (Socket.IO) — docs/09. المصادقة على الاتصال بـ JWT، - * والانضمام لغرف مُنطَّقة بالمستأجر (لا تسريب بين المستأجرين). - * غرف: tenant:{id}:driver:{driverId} · tenant:{id}:trip:{tripId} · tenant:{id}:dispatch + * البوابة الحية (Socket.IO) — docs/09. مصادقة JWT + غرف مُنطَّقة بالمستأجر، + * بث مواقع السائقين لحظياً، وإشارات مكالمات WebRTC (offer/answer/ice) عبر غرفة الرحلة. */ @WebSocketGateway({ cors: true }) export class RealtimeGateway implements OnGatewayConnection { @@ -19,7 +22,10 @@ export class RealtimeGateway implements OnGatewayConnection { @WebSocketServer() server: Server; - constructor(private readonly jwt: JwtService) {} + constructor( + private readonly jwt: JwtService, + private readonly drivers: DriversService, + ) {} handleConnection(client: Socket) { try { @@ -28,29 +34,84 @@ export class RealtimeGateway implements OnGatewayConnection { (client.handshake.headers?.authorization as string)?.replace('Bearer ', ''); if (!token) throw new Error('no token'); const p: any = this.jwt.verify(token); - const tenantId = p.tenant_id; - client.data.user = { userId: p.sub, tenantId, role: p.role }; - client.join(`tenant:${tenantId}:user:${p.sub}`); - if (p.role === 'driver') client.join(`tenant:${tenantId}:drivers`); - this.logger.debug(`connected user=${p.sub} tenant=${tenantId} role=${p.role}`); + client.data.user = { userId: p.sub, tenantId: p.tenant_id, role: p.role }; + client.join(`tenant:${p.tenant_id}:user:${p.sub}`); + if (p.role === 'driver') client.join(`tenant:${p.tenant_id}:drivers`); } catch { 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) { this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer', payload); } - /** تحديث حالة الرحلة للراكب أو السائق. */ tripUpdate(tenantId: string, userId: string, payload: any) { this.server.to(`tenant:${tenantId}:user:${userId}`).emit('trip:update', payload); } - /** بث للوحة المشغّل. */ dispatch(tenantId: string, event: string, payload: any) { this.server.to(`tenant:${tenantId}:dispatch`).emit(event, payload); } diff --git a/backend/src/realtime/realtime.module.ts b/backend/src/realtime/realtime.module.ts index a96bcca..2b0efc0 100644 --- a/backend/src/realtime/realtime.module.ts +++ b/backend/src/realtime/realtime.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RealtimeGateway } from './realtime.gateway'; +import { DriversModule } from '../modules/drivers/drivers.module'; @Module({ imports: [ + DriversModule, JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService],