add payment, trust badge, handle race condition, fix booking schema
This commit is contained in:
+292
-53
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user