P1 complete: real انطلق maps + Nabeh WhatsApp OTP + chat + ratings + cancel(both) + fraud detection

- maps: real map-saas route/geocode/reverse/places (x-api-key per country) + straight-line fallback
- auth: Redis-backed OTP + Nabeh WhatsApp send (dev mode keeps fixed 1234 bypass)
- chat: per-trip messages (participant-guarded) + socket broadcast
- ratings: post-trip rating + driver avg recompute + no double-rate
- cancel: from rider or driver + stage-based cancel fee + notify both
- fraud: cancel-abuse (soft/hard block via Redis), arrived-far-from-pickup, completed-too-fast → fraud_flags
- migration InitP1b (chat_messages, ratings, fraud_flags, trips.cancelled_by/cancel_fee)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 17:12:38 +03:00
co-authored by Claude Opus 4.8
parent 00870a8cc3
commit 65a2e5e275
25 changed files with 948 additions and 53 deletions
@@ -0,0 +1,40 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/** تقييم بعد الرحلة (من الراكب أو السائق). الجدول: tripz_ratings. */
@Entity('ratings')
@Index(['tenant_id', 'trip_id'])
@Index(['tenant_id', 'target_driver_id'])
export class Rating {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
trip_id: string;
@Column({ type: 'uuid' })
by_user_id: string;
@Column()
by_role: string; // rider | driver
@Column({ type: 'uuid', nullable: true })
target_driver_id: string | null;
@Column({ type: 'int' })
stars: number;
@Column({ type: 'text', nullable: true })
comment: string | null;
@CreateDateColumn()
created_at: Date;
}
@@ -0,0 +1,23 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RatingsService } from './ratings.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
@ApiTags('ratings')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('trips')
export class RatingsController {
constructor(private readonly ratings: RatingsService) {}
@Post(':id/rate')
rate(
@CurrentUser() user: AuthUser,
@Param('id') tripId: string,
@Body('stars') stars: number,
@Body('comment') comment?: string,
) {
return this.ratings.rate(user.tenantId, tripId, user.userId, Number(stars), comment);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Rating } from './entities/rating.entity';
import { RatingsService } from './ratings.service';
import { RatingsController } from './ratings.controller';
import { TripsModule } from '../trips/trips.module';
import { DriversModule } from '../drivers/drivers.module';
@Module({
imports: [TypeOrmModule.forFeature([Rating]), TripsModule, DriversModule],
controllers: [RatingsController],
providers: [RatingsService],
})
export class RatingsModule {}
@@ -0,0 +1,81 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Rating } from './entities/rating.entity';
import { TripsService } from '../trips/trips.service';
import { DriversService } from '../drivers/drivers.service';
@Injectable()
export class RatingsService {
constructor(
@InjectRepository(Rating) private readonly repo: Repository<Rating>,
private readonly trips: TripsService,
private readonly drivers: DriversService,
) {}
async rate(
tenantId: string,
tripId: string,
byUserId: string,
stars: number,
comment?: string,
) {
if (!(stars >= 1 && stars <= 5)) {
throw new BadRequestException('stars must be 1..5');
}
const trip = await this.trips.get(tenantId, tripId);
if (!trip) throw new NotFoundException('Trip not found');
if (!['completed', 'paid'].includes(trip.status)) {
throw new BadRequestException('Can only rate a finished trip');
}
// تحديد دور المقيِّم
let role: 'rider' | 'driver' | null = null;
if (trip.rider_id === byUserId) role = 'rider';
else if (trip.driver_id) {
const drv = await this.drivers.findById(tenantId, trip.driver_id);
if (drv && drv.user_id === byUserId) role = 'driver';
}
if (!role) throw new ForbiddenException('Not a participant of this trip');
// منع التقييم المزدوج من نفس الطرف
const dup = await this.repo.findOne({
where: { tenant_id: tenantId, trip_id: tripId, by_user_id: byUserId },
});
if (dup) throw new BadRequestException('Already rated');
const targetDriverId = role === 'rider' ? trip.driver_id : null;
const rating = await this.repo.save(
this.repo.create({
tenant_id: tenantId,
trip_id: tripId,
by_user_id: byUserId,
by_role: role,
target_driver_id: targetDriverId,
stars,
comment: comment ?? null,
}),
);
// تحديث متوسط تقييم السائق عند تقييم الراكب له
if (role === 'rider' && targetDriverId) {
const raw = await this.repo
.createQueryBuilder('r')
.select('AVG(r.stars)', 'avg')
.where('r.tenant_id = :t AND r.target_driver_id = :d', {
t: tenantId,
d: targetDriverId,
})
.getRawOne<{ avg: string }>();
if (raw?.avg) {
await this.drivers.setRating(tenantId, targetDriverId, Number(raw.avg));
}
}
return rating;
}
}