add payment, trust badge, handle race condition, fix booking schema

This commit is contained in:
arifal
2026-04-20 23:57:31 +07:00
parent ba5f64ae0e
commit fcdca34460
33 changed files with 1781 additions and 138 deletions
+292 -53
View File
@@ -1,13 +1,30 @@
import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { tripRepo } from "@/server/repositories/trip.repo";
import { participantRepo } from "@/server/repositories/participant.repo";
import { LIMITS } from "@/lib/limits";
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
const SERIAL_TX_ATTEMPTS = 6;
function isSerializationConflict(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2034"
);
}
interface CreateTripInput {
title: string;
description?: string;
mountain: string;
location: string;
meetingPoint?: string;
itinerary?: string;
whatsIncluded?: string;
whatsExcluded?: string;
date: Date;
endDate?: Date;
maxParticipants: number;
@@ -34,17 +51,6 @@ export const tripService = {
},
async createTrip(input: CreateTripInput) {
const since = utcStartOfDay(new Date());
const todayCount = await tripRepo.countByOrganizerSince(
input.organizerId,
since
);
if (todayCount >= LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY) {
throw new Error(
`Batas harian: maksimal ${LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY} trip per hari (UTC). Coba lagi besok.`
);
}
if (isTripDepartureDayPast(input.date)) {
throw new Error("Tanggal berangkat tidak boleh di masa lalu");
}
@@ -53,68 +59,156 @@ export const tripService = {
throw new Error("Tanggal pulang tidak boleh sebelum tanggal berangkat");
}
const since = utcStartOfDay(new Date());
const images = input.imageUrls?.length
? {
create: input.imageUrls.map((url, i) => ({ url, order: i })),
}
: undefined;
return tripRepo.create({
const tripData = {
title: input.title,
description: input.description,
mountain: input.mountain,
location: input.location,
meetingPoint: input.meetingPoint,
itinerary: input.itinerary,
whatsIncluded: input.whatsIncluded,
whatsExcluded: input.whatsExcluded,
date: input.date,
endDate: input.endDate,
maxParticipants: input.maxParticipants,
price: input.price,
organizer: { connect: { id: input.organizerId } },
images,
});
} satisfies Prisma.TripCreateInput;
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
const todayCount = await tx.trip.count({
where: {
organizerId: input.organizerId,
createdAt: { gte: since },
},
});
if (todayCount >= LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY) {
throw new Error(
`Batas harian: maksimal ${LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY} trip per hari. Coba lagi besok.`
);
}
return tx.trip.create({ data: tripData });
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 15000,
}
);
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
}
}
throw lastErr instanceof Error
? lastErr
: new Error("Gagal membuat trip. Coba lagi sebentar.");
},
async joinTrip(tripId: string, userId: string) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: {
id: true,
status: true,
date: true,
organizerId: true,
maxParticipants: true,
},
});
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.status !== "OPEN") {
throw new Error("Trip tidak tersedia untuk pendaftaran");
}
if (isTripDepartureDayPast(trip.date)) {
throw new Error(
"Trip sudah melewati tanggal berangkat, tidak bisa mendaftar"
);
}
if (trip.organizerId === userId) {
throw new Error("Organizer tidak bisa join trip sendiri");
}
const existing = await tx.tripParticipant.findUnique({
where: { tripId_userId: { tripId, userId } },
});
if (existing && existing.status !== "CANCELLED") {
throw new Error("Kamu sudah terdaftar di trip ini");
}
const participantCount = await tx.tripParticipant.count({
where: { tripId, status: { not: "CANCELLED" } },
});
if (participantCount >= trip.maxParticipants) {
throw new Error("Trip sudah penuh");
}
const participant =
existing?.status === "CANCELLED"
? await tx.tripParticipant.update({
where: { tripId_userId: { tripId, userId } },
data: {
status: "PENDING",
markedPaidAt: null,
paymentConfirmedAt: null,
},
})
: await tx.tripParticipant.create({
data: { tripId, userId, status: "PENDING" },
});
const newCount = await tx.tripParticipant.count({
where: { tripId, status: { not: "CANCELLED" } },
});
if (newCount >= trip.maxParticipants) {
await tx.trip.update({
where: { id: tripId },
data: { status: "FULL" },
});
}
return participant;
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 15000,
}
);
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
}
}
if (trip.status !== "OPEN") {
throw new Error("Trip tidak tersedia untuk pendaftaran");
}
if (isTripDepartureDayPast(trip.date)) {
throw new Error(
"Trip sudah melewati tanggal berangkat, tidak bisa mendaftar"
);
}
if (trip.organizerId === userId) {
throw new Error("Organizer tidak bisa join trip sendiri");
}
const existing = await participantRepo.findByTripAndUser(tripId, userId);
if (existing && existing.status !== "CANCELLED") {
throw new Error("Kamu sudah terdaftar di trip ini");
}
const participantCount = await participantRepo.countByTrip(tripId);
if (participantCount >= trip.maxParticipants) {
await tripRepo.updateStatus(tripId, "FULL");
throw new Error("Trip sudah penuh");
}
const participant =
existing?.status === "CANCELLED"
? await participantRepo.reactivate(tripId, userId)
: await participantRepo.create(tripId, userId);
const newCount = await participantRepo.countByTrip(tripId);
if (newCount >= trip.maxParticipants) {
await tripRepo.updateStatus(tripId, "FULL");
}
return participant;
throw lastErr instanceof Error
? lastErr
: new Error("Pendaftaran sedang ramai. Coba lagi sebentar.");
},
async cancelJoin(tripId: string, userId: string) {
@@ -145,4 +239,149 @@ export const tripService = {
return result;
},
async confirmParticipant(
tripId: string,
participantId: string,
organizerId: string
) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.organizerId !== organizerId) {
throw new Error("Hanya organizer trip ini yang bisa mengonfirmasi peserta");
}
const participant = await participantRepo.findById(participantId);
if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta tidak ditemukan");
}
if (participant.status !== "PENDING") {
throw new Error("Peserta ini tidak dalam status menunggu persetujuan");
}
return participantRepo.setStatus(participantId, "CONFIRMED");
},
async rejectParticipant(
tripId: string,
participantId: string,
organizerId: string
) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.organizerId !== organizerId) {
throw new Error("Hanya organizer trip ini yang bisa menolak permintaan bergabung");
}
const participant = await participantRepo.findById(participantId);
if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta tidak ditemukan");
}
if (participant.status !== "PENDING") {
throw new Error("Hanya permintaan yang masih menunggu yang bisa ditolak");
}
await participantRepo.setStatusAndClearPayment(
participantId,
"CANCELLED"
);
if (trip.status === "FULL") {
const count = await participantRepo.countByTrip(tripId);
if (count < trip.maxParticipants) {
await tripRepo.updateStatus(tripId, "OPEN");
}
}
return { ok: true as const };
},
async markParticipantPayment(tripId: string, userId: string) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (isTripDepartureDayPast(trip.date)) {
throw new Error("Trip sudah lewat — pembayaran tidak perlu ditandai");
}
const p = await participantRepo.findByTripAndUser(tripId, userId);
if (!p || p.status === "CANCELLED") {
throw new Error("Kamu tidak terdaftar di trip ini");
}
if (p.paymentConfirmedAt) {
throw new Error("Pembayaran kamu sudah dikonfirmasi organizer");
}
if (p.markedPaidAt) {
throw new Error("Kamu sudah menandai sudah bayar — tunggu konfirmasi organizer");
}
const updated = await participantRepo.tryMarkPaidByUser(tripId, userId);
if (updated.count === 0) {
const again = await participantRepo.findByTripAndUser(tripId, userId);
if (!again || again.status === "CANCELLED") {
throw new Error("Kamu tidak terdaftar di trip ini");
}
if (again.paymentConfirmedAt) {
throw new Error("Pembayaran kamu sudah dikonfirmasi organizer");
}
if (again.markedPaidAt) {
throw new Error(
"Kamu sudah menandai sudah bayar — tunggu konfirmasi organizer"
);
}
throw new Error("Tidak bisa menandai pembayaran. Coba lagi sebentar.");
}
const row = await participantRepo.findByTripAndUser(tripId, userId);
if (!row) {
throw new Error("Data peserta tidak ditemukan setelah update");
}
return row;
},
async confirmParticipantPayment(
tripId: string,
participantId: string,
organizerId: string
) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.organizerId !== organizerId) {
throw new Error("Hanya organizer yang bisa mengonfirmasi pembayaran");
}
const participant = await participantRepo.findById(participantId);
if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta tidak ditemukan");
}
if (participant.status === "CANCELLED") {
throw new Error("Peserta sudah tidak aktif");
}
if (!participant.markedPaidAt) {
throw new Error("Peserta belum menandai sudah membayar");
}
if (participant.paymentConfirmedAt) {
throw new Error("Pembayaran peserta ini sudah dikonfirmasi");
}
const updated = await participantRepo.tryConfirmPaymentByOrganizer(
participantId
);
if (updated.count === 0) {
throw new Error(
"Konfirmasi tidak diproses — mungkin sudah dikonfirmasi atau pembayaran belum ditandai peserta."
);
}
return participantRepo.findById(participantId);
},
};
+39
View File
@@ -0,0 +1,39 @@
import { prisma } from "@/lib/prisma";
import { TRIP_LEADER_MIN_TRIPS } from "@/lib/trust";
export type OrganizerTrust = {
isVerified: boolean;
tripsCreated: number;
avgRating: number | null;
reviewCount: number;
isTripLeader: boolean;
};
export const trustService = {
async getOrganizerTrust(organizerId: string): Promise<OrganizerTrust> {
const [user, tripsCreated, reviewAgg] = await Promise.all([
prisma.user.findUnique({
where: { id: organizerId },
select: { isVerified: true },
}),
prisma.trip.count({ where: { organizerId } }),
prisma.tripReview.aggregate({
where: {
trip: { organizerId },
},
_avg: { rating: true },
_count: { _all: true },
}),
]);
const avg = reviewAgg._avg.rating;
return {
isVerified: user?.isVerified ?? false,
tripsCreated,
avgRating:
avg != null ? Math.round(Number(avg) * 10) / 10 : null,
reviewCount: reviewAgg._count._all,
isTripLeader: tripsCreated >= TRIP_LEADER_MIN_TRIPS,
};
},
};