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:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user