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:
co-authored by
Claude Opus 4.8
parent
a58f611063
commit
feb2a9663d
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user