feat: نقطة الرحلة المعلّقة للتقييم + اختبار E2E شامل

نقطة التقييم عند الفتح (طلب المالك): GET /trips/rating/pending يرجع أحدث
رحلة منتهية لم يقيّمها المستخدم (سائقاً أو راكباً) + العدد. التطبيق يناديها
عند كل فتح ليفرض شاشة التقييم قبل رحلة جديدة. TripsService.
finishedForParticipant يجلب المنتهية، و RatingsService.pendingRating يطرح
المقيَّمة باستعلام واحد.

scripts/e2e-test.mjs: اختبار شامل متكامل لكل وظائف الباك إند —
- تسجيل الدخول/الحساب + تطبيع الهاتف (نفس الرقم بصيغتين = حساب واحد)
- إعداد السائق: تقديم + ملف (رقم وطني مشفّر) + مركبة + اعتماد + إعادة دخول
  للحصول على دور driver في التوكن
- الرصيد التشغيلي (مكافأة التسجيل) + عدم الحجب
- اتصال السائق ورفع الموقع (Redis) + المطابقة + /trips/available
- طلب رحلة (تسعير + عرض) + كل الحالات (assigned→...→completed→paid)
- WebSocket: اتصال JWT + رصد trip:update (اختياري، يتخطّى إن غاب
  socket.io-client)
- خصم العمولة من الرصيد التشغيلي عند الإنهاء
- تسوية المحفظة عند paid (خصم الأجرة من الراكب)
- التقييم: رحلة معلّقة → تقييم الطرفين → تُمسح المعلّقة + منع التكرار
- السحب بـOTP خطوتين (طلب بلا خصم → تأكيد بالرمز → حجز)

أوامر التشغيل في docs/15 (مع/بلا WebSocket). يتطلّب OTP_DEV_MODE=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 20:05:29 +03:00
co-authored by Claude Opus 4.8
parent a58f611063
commit feb2a9663d
6 changed files with 387 additions and 2 deletions
@@ -1,4 +1,4 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, 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';
@@ -11,6 +11,15 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator
export class RatingsController {
constructor(private readonly ratings: RatingsService) {}
/**
* رحلة منتهية تنتظر تقييم هذا المستخدم (docs/17 — التقييم). يناديها التطبيق
* عند كل فتح ليفرض شاشة التقييم قبل رحلة جديدة.
*/
@Get('rating/pending')
pending(@CurrentUser() user: AuthUser) {
return this.ratings.pendingRating(user.tenantId, user.userId);
}
@Post(':id/rate')
rate(
@CurrentUser() user: AuthUser,
+41 -1
View File
@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { Rating } from './entities/rating.entity';
import { TripsService } from '../trips/trips.service';
import { DriversService } from '../drivers/drivers.service';
@@ -22,6 +22,46 @@ export class RatingsService {
private readonly aggregate: RatingAggregateService,
) {}
/**
* الرحلة المنتهية غير المقيَّمة من هذا المستخدم (docs/17 — التقييم).
*
* يستدعيها التطبيق عند كل فتح (سائقاً أو راكباً): إن رجعت رحلة، يفتح شاشة
* التقييم إجبارياً قبل السماح بطلب/قبول رحلة جديدة. يرجع الأحدث + العدد
* الكلّي للمعلَّقات.
*/
async pendingRating(tenantId: string, userId: string) {
const driver = await this.drivers.findByUser(tenantId, userId);
const finished = await this.trips.finishedForParticipant(tenantId, userId, driver?.id ?? null);
if (finished.length === 0) return { pending: null, count: 0 };
// ما قيّمه المستخدم من هذه الرحلات — استعلام واحد بدل واحد لكل رحلة.
const rated = await this.repo.find({
where: {
tenant_id: tenantId,
by_user_id: userId,
trip_id: In(finished.map((t) => t.id)),
},
select: { trip_id: true },
});
const ratedIds = new Set(rated.map((r) => r.trip_id));
const unrated = finished.filter((t) => !ratedIds.has(t.id));
if (unrated.length === 0) return { pending: null, count: 0 };
const trip = unrated[0]; // الأحدث (مرتَّبة تنازلياً)
const asDriver = !!driver && trip.driver_id === driver.id && trip.rider_id !== userId;
return {
count: unrated.length,
pending: {
tripId: trip.id,
role: asDriver ? 'driver' : 'rider', // مَن يقيّم مَن
fare: trip.final_fare == null ? null : Number(trip.final_fare),
currency: trip.currency ?? null,
completedAt: trip.completed_at,
distanceKm: trip.distance_km == null ? null : Number(trip.distance_km),
},
};
}
async rate(
tenantId: string,
tripId: string,
@@ -93,6 +93,28 @@ export class TripsService {
});
}
/**
* الرحلات المنتهية (completed/paid) التي شارك فيها المستخدم راكباً أو سائقاً
* — أساس كشف الرحلة غير المقيَّمة عند فتح التطبيق (docs/17 — G6/التقييم).
*/
finishedForParticipant(
tenantId: string,
riderId: string,
driverId: string | null,
limit = 20,
): Promise<Trip[]> {
const qb = this.trips
.createQueryBuilder('t')
.where('t.tenant_id = :tenantId', { tenantId })
.andWhere('t.status IN (:...done)', { done: ['completed', 'paid'] });
if (driverId) {
qb.andWhere('(t.rider_id = :riderId OR t.driver_id = :driverId)', { riderId, driverId });
} else {
qb.andWhere('t.rider_id = :riderId', { riderId });
}
return qb.orderBy('t.completed_at', 'DESC').take(limit).getMany();
}
listForDriver(tenantId: string, driverId: string): Promise<Trip[]> {
return this.trips.find({
where: { tenant_id: tenantId, driver_id: driverId },