import { Prisma } from "@/app/generated/prisma/client"; import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums"; import { prisma } from "@/lib/prisma"; import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo"; import { participantRepo } from "@/server/repositories/participant.repo"; import { bookingRepo } from "@/server/repositories/booking.repo"; import { bookingService } from "@/server/services/booking.service"; import { LIMITS } from "@/lib/limits"; import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates"; import { isFreeTrip } from "@/lib/trip-pricing"; 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 { category: ActivityCategory; title: string; description?: string; destination: string; location: string; meetingPoint?: string; itinerary?: string; whatsIncluded?: string; whatsExcluded?: string; date: Date; endDate?: Date; maxParticipants: number; price: number; vibe?: Vibe; organizerId: string; imageUrls?: string[]; } export const tripService = { async getOpenTrips(filters?: TripFilters) { return tripRepo.findOpen(filters); }, async getAllTrips() { return tripRepo.findAll(); }, async getTripById(id: string) { const trip = await tripRepo.findById(id); if (!trip) { throw new Error("Trip tidak ditemukan"); } return trip; }, async createTrip(input: CreateTripInput) { if (isTripDepartureDayPast(input.date)) { throw new Error("Tanggal berangkat tidak boleh di masa lalu"); } if (input.endDate && input.endDate.getTime() < input.date.getTime()) { 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; const tripData = { category: input.category, title: input.title, description: input.description, destination: input.destination, 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, vibe: input.vibe, 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) { 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, price: 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" }, }); // Booking 1-1 ke participant. Upsert untuk handle re-join setelah CANCELLED. await tx.booking.upsert({ where: { participantId: participant.id }, create: { tripId, userId, participantId: participant.id, amount: trip.price, status: "PENDING", }, update: { status: "PENDING", amount: trip.price, }, }); 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; } } throw lastErr instanceof Error ? lastErr : new Error("Pendaftaran sedang ramai. Coba lagi sebentar."); }, async cancelJoin(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( "Tanggal berangkat trip sudah lewat — pendaftaran tidak bisa dibatalkan. Jika trip sudah selesai, gunakan bagian ulasan." ); } const existing = await participantRepo.findByTripAndUser(tripId, userId); if (!existing || existing.status === "CANCELLED") { throw new Error("Kamu tidak terdaftar di trip ini"); } const result = await prisma.$transaction(async (tx) => { const cancelled = await tx.tripParticipant.update({ where: { tripId_userId: { tripId, userId } }, data: { status: "CANCELLED", markedPaidAt: null, paymentConfirmedAt: null, }, }); await tx.booking.updateMany({ where: { participantId: existing.id }, data: { status: "CANCELLED" }, }); return cancelled; }); if (trip.status === "FULL") { const count = await participantRepo.countByTrip(tripId); if (count < trip.maxParticipants) { await tripRepo.updateStatus(tripId, "OPEN"); } } 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"); } // Trip gratis: Booking langsung PAID. Trip berbayar: AWAITING_PAY (tinggal bayar). const nextBookingStatus = isFreeTrip(trip) ? "PAID" : "AWAITING_PAY"; return prisma.$transaction(async (tx) => { await tx.booking.updateMany({ where: { participantId }, data: { status: nextBookingStatus }, }); return tx.tripParticipant.update({ where: { id: participantId }, data: { status: "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 prisma.$transaction(async (tx) => { await tx.tripParticipant.update({ where: { id: participantId }, data: { status: "CANCELLED", markedPaidAt: null, paymentConfirmedAt: null, }, }); await tx.booking.updateMany({ where: { participantId }, data: { status: "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 (isFreeTrip(trip)) { throw new Error( "Trip ini gratis — tidak ada pembayaran yang perlu ditandai" ); } if (isTripDepartureDayPast(trip.date)) { throw new Error("Trip sudah lewat — pembayaran tidak perlu ditandai"); } const booking = await bookingRepo.findByTripAndUser(tripId, userId); if (!booking || booking.status === "CANCELLED") { throw new Error("Kamu tidak terdaftar di trip ini"); } return bookingService.markPaidManual(booking.id, userId); }, /** * Auto-complete trip yang sudah lewat. Dipakai cron harian. * * Cutoff = start of today UTC. Trip dengan endDate < cutoff (atau, kalau * endDate null, date < cutoff) di-set status COMPLETED — selama statusnya * masih OPEN/FULL. CLOSED trip tidak disentuh (organizer eksplisit batalkan). * * Idempotent: dua kali run di hari sama, run kedua nge-match 0 row. */ async autoCompletePastTrips() { const cutoff = utcStartOfDay(new Date()); return tripRepo.bulkCompletePastTrips(cutoff); }, 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"); } if (isFreeTrip(trip)) { throw new Error("Trip ini gratis — tidak ada pembayaran yang perlu dikonfirmasi"); } const booking = await bookingRepo.findByParticipantId(participantId); if (!booking || booking.tripId !== tripId) { throw new Error("Booking tidak ditemukan"); } return bookingService.confirmPaidManual(booking.id, organizerId); }, };