Files
setrip/server/services/trust.service.ts
T
2026-05-09 00:55:40 +07:00

126 lines
3.6 KiB
TypeScript

import { prisma } from "@/lib/prisma";
import {
COMPLETION_RATE_MIN_SAMPLE,
TRIP_LEADER_MIN_TRIPS,
} from "@/lib/trust";
export type RatingBreakdown = Record<1 | 2 | 3 | 4 | 5, number>;
export type OrganizerTrust = {
isVerified: boolean;
tripsCreated: number;
/// Trip yang sudah lewat tanggal selesai-nya AND tidak dibatalkan (status != CLOSED).
/// Dihitung on-the-fly dari endDate (fallback ke date kalau endDate null) — tidak
/// bergantung pada Trip.status = COMPLETED yang saat ini belum pernah di-set.
tripsCompleted: number;
/// Trip dengan status = CLOSED (dibatalkan organizer).
tripsCancelled: number;
/// Akumulasi peserta CONFIRMED di seluruh trip yang sudah selesai.
totalParticipantsServed: number;
/// tripsCompleted / (tripsCompleted + tripsCancelled). Null kalau sample
/// < COMPLETION_RATE_MIN_SAMPLE — mencegah angka menyesatkan untuk
/// organizer yang masih sedikit history-nya.
completionRate: number | null;
avgRating: number | null;
reviewCount: number;
ratingBreakdown: RatingBreakdown;
isTripLeader: boolean;
};
export const trustService = {
async getOrganizerTrust(organizerId: string): Promise<OrganizerTrust> {
const now = new Date();
// Filter "trip yang sudah lewat": endDate < now, atau (endDate null AND date < now).
// Trip multi-hari pakai endDate; trip 1 hari biasanya endDate null jadi fallback ke date.
const pastTripFilter = {
OR: [
{ endDate: { lt: now } },
{ AND: [{ endDate: null }, { date: { lt: now } }] },
],
};
const [
tripsCreated,
tripsCompleted,
tripsCancelled,
totalParticipantsServed,
reviewAgg,
ratingGroups,
organizerVerification,
] = await Promise.all([
prisma.trip.count({ where: { organizerId } }),
prisma.trip.count({
where: {
organizerId,
status: { not: "CLOSED" },
...pastTripFilter,
},
}),
prisma.trip.count({
where: { organizerId, status: "CLOSED" },
}),
prisma.tripParticipant.count({
where: {
status: "CONFIRMED",
trip: {
organizerId,
status: { not: "CLOSED" },
...pastTripFilter,
},
},
}),
prisma.tripReview.aggregate({
where: { trip: { organizerId } },
_avg: { rating: true },
_count: { _all: true },
}),
prisma.tripReview.groupBy({
by: ["rating"],
where: { trip: { organizerId } },
_count: { _all: true },
}),
prisma.organizerVerification.findUnique({
where: { userId: organizerId },
select: { status: true },
}),
]);
const ratingBreakdown: RatingBreakdown = {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
};
for (const row of ratingGroups) {
const r = row.rating;
if (r >= 1 && r <= 5) {
ratingBreakdown[r as 1 | 2 | 3 | 4 | 5] = row._count._all;
}
}
const completionSample = tripsCompleted + tripsCancelled;
const completionRate =
completionSample >= COMPLETION_RATE_MIN_SAMPLE
? tripsCompleted / completionSample
: null;
const avg = reviewAgg._avg.rating;
return {
isVerified: organizerVerification?.status === "APPROVED",
tripsCreated,
tripsCompleted,
tripsCancelled,
totalParticipantsServed,
completionRate,
avgRating:
avg != null ? Math.round(Number(avg) * 10) / 10 : null,
reviewCount: reviewAgg._count._all,
ratingBreakdown,
isTripLeader: tripsCreated >= TRIP_LEADER_MIN_TRIPS,
};
},
};