"use server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { createTripSchema, itineraryItemsSchema, tripImageUrlsSchema, } from "./schemas"; import { tripService } from "@/server/services/trip.service"; import { organizerService } from "@/server/services/organizer.service"; import { revalidatePath } from "next/cache"; import { tripStoredInstantFromYmd } from "@/lib/trip-dates"; import { requireActiveUser } from "@/lib/auth-guards"; import { auditLog } from "@/server/services/audit-log.service"; import { emailService } from "@/lib/email/send"; import { prisma } from "@/lib/prisma"; export async function createTripAction(formData: FormData) { const session = await getServerSession(authOptions); if (!session?.user) { return { error: "Kamu harus login terlebih dahulu" }; } try { await requireActiveUser(session.user.id); } catch (err) { return { error: (err as Error).message }; } const raw = { category: formData.get("category") as string, title: formData.get("title") as string, description: formData.get("description") as string, destination: formData.get("destination") as string, location: formData.get("location") as string, meetingPoint: formData.get("meetingPoint") as string, whatsIncluded: formData.get("whatsIncluded") as string, whatsExcluded: formData.get("whatsExcluded") as string, date: formData.get("date") as string, endDate: (formData.get("endDate") as string) || undefined, maxParticipants: formData.get("maxParticipants") as string, price: formData.get("price") as string, vibe: formData.get("vibe"), }; const result = createTripSchema.safeParse(raw); if (!result.success) { return { error: result.error.issues[0].message }; } const itineraryJson = formData.get("itineraryItems"); let itineraryItems: ReturnType = []; if (typeof itineraryJson === "string" && itineraryJson.trim().length > 0) { let parsed: unknown; try { parsed = JSON.parse(itineraryJson); } catch { return { error: "Format itinerary tidak valid" }; } const itineraryParsed = itineraryItemsSchema.safeParse(parsed); if (!itineraryParsed.success) { return { error: itineraryParsed.error.issues[0].message }; } itineraryItems = itineraryParsed.data; } if (result.data.price > 0) { const approved = await organizerService.isApproved(session.user.id); if (!approved) { return { error: "Untuk membuat trip berbayar, akun kamu perlu diverifikasi. Silakan lengkapi verifikasi di /verify.", }; } } const imageUrlsRaw = formData .getAll("imageUrls") .map((v) => (v as string).trim()) .filter(Boolean); const imagesParsed = tripImageUrlsSchema.safeParse(imageUrlsRaw); if (!imagesParsed.success) { return { error: imagesParsed.error.issues[0].message }; } const imageUrls = imagesParsed.data; const date = tripStoredInstantFromYmd(result.data.date); let endDate = result.data.endDate ? tripStoredInstantFromYmd(result.data.endDate) : undefined; if (endDate && endDate.getTime() === date.getTime()) { endDate = undefined; } try { const { meetingPoint, whatsIncluded, whatsExcluded, ...tripCore } = result.data; const trip = await tripService.createTrip({ ...tripCore, meetingPoint, whatsIncluded, whatsExcluded, date, endDate, organizerId: session.user.id, imageUrls: imageUrls.length > 0 ? imageUrls : undefined, itineraryItems: itineraryItems.length > 0 ? itineraryItems : undefined, }); revalidatePath("/trips"); revalidatePath("/"); revalidatePath("/profile"); return { success: true, tripId: trip.id }; } catch (err) { return { error: (err as Error).message }; } } export async function joinTripAction(tripId: string) { const session = await getServerSession(authOptions); if (!session?.user) { return { error: "Kamu harus login terlebih dahulu" }; } try { await requireActiveUser(session.user.id); await tripService.joinTrip(tripId, session.user.id); void notifyJoinRequest(tripId, session.user.id); revalidatePath(`/trips/${tripId}`); revalidatePath("/trips"); revalidatePath("/"); revalidatePath("/profile"); return { success: true }; } catch (err) { return { error: (err as Error).message }; } } export async function cancelJoinAction(tripId: string) { const session = await getServerSession(authOptions); if (!session?.user) { return { error: "Kamu harus login terlebih dahulu" }; } try { await tripService.cancelJoin(tripId, session.user.id); revalidatePath(`/trips/${tripId}`); revalidatePath("/trips"); revalidatePath("/"); revalidatePath("/profile"); return { success: true }; } catch (err) { return { error: (err as Error).message }; } } export async function confirmParticipantAction( tripId: string, participantId: string ) { const session = await getServerSession(authOptions); if (!session?.user) { return { error: "Kamu harus login terlebih dahulu" }; } try { await tripService.confirmParticipant( tripId, participantId, session.user.id ); void notifyBookingApproved(participantId); revalidatePath(`/trips/${tripId}`); revalidatePath("/trips"); revalidatePath("/"); revalidatePath("/profile"); return { success: true }; } catch (err) { return { error: (err as Error).message }; } } async function notifyBookingApproved(participantId: string) { const participant = await prisma.tripParticipant.findUnique({ where: { id: participantId }, include: { user: { select: { email: true, name: true } }, trip: { select: { id: true, title: true } }, booking: { select: { id: true, amount: true } }, }, }); if (!participant || !participant.booking) return; await emailService.send({ to: participant.user.email, idempotencyKey: `booking_approved-${participant.booking.id}`, template: { template: "booking_approved", data: { userName: participant.user.name, tripTitle: participant.trip.title, tripId: participant.trip.id, amount: participant.booking.amount, }, }, }); } /** E3.1 — kabari organizer ada peserta baru yang mengajukan join. */ async function notifyJoinRequest(tripId: string, joinerId: string) { const [trip, joiner] = await Promise.all([ prisma.trip.findUnique({ where: { id: tripId }, select: { title: true, organizer: { select: { email: true, name: true } }, }, }), prisma.user.findUnique({ where: { id: joinerId }, select: { name: true }, }), ]); if (!trip || !joiner) return; await emailService.send({ to: trip.organizer.email, idempotencyKey: `join_request-${tripId}-${joinerId}`, template: { template: "join_request", data: { organizerName: trip.organizer.name, joinerName: joiner.name, tripTitle: trip.title, tripId, }, }, }); } /** E3.2 — kabari peserta kalau permintaan join-nya ditolak organizer. */ async function notifyJoinRejected(participantId: string) { const participant = await prisma.tripParticipant.findUnique({ where: { id: participantId }, select: { user: { select: { email: true, name: true } }, trip: { select: { title: true } }, }, }); if (!participant) return; await emailService.send({ to: participant.user.email, idempotencyKey: `join_rejected-${participantId}`, template: { template: "join_rejected", data: { userName: participant.user.name, tripTitle: participant.trip.title, }, }, }); } export async function rejectParticipantAction( tripId: string, participantId: string ) { const session = await getServerSession(authOptions); if (!session?.user) { return { error: "Kamu harus login terlebih dahulu" }; } try { await tripService.rejectParticipant( tripId, participantId, session.user.id ); void notifyJoinRejected(participantId); revalidatePath(`/trips/${tripId}`); revalidatePath("/trips"); revalidatePath("/"); revalidatePath("/profile"); return { success: true }; } catch (err) { return { error: (err as Error).message }; } } type CloseTripResult = Awaited>; /** * E3.4 / E3.5 (+ E2.4) — kabari semua peserta aktif kalau trip dibatalkan. * Email organizer-cancel & admin-cancel beda template; admin-cancel juga * mengabari organizer. Refund block ikut di email (nominal dari `notify`). */ function notifyTripCancelled( tripId: string, notify: CloseTripResult["notify"], actor: { type: "ORGANIZER" } | { type: "ADMIN"; reason: string } ) { for (const p of notify.participants) { if (actor.type === "ORGANIZER") { void emailService.send({ to: p.email, idempotencyKey: `trip_cancelled_organizer-${tripId}-${p.userId}`, template: { template: "trip_cancelled_organizer", data: { userName: p.name, tripTitle: notify.tripTitle, refundAmount: p.refundAmount, }, }, }); } else { void emailService.send({ to: p.email, idempotencyKey: `trip_cancelled_admin-${tripId}-${p.userId}`, template: { template: "trip_cancelled_admin", data: { userName: p.name, tripTitle: notify.tripTitle, reason: actor.reason, refundAmount: p.refundAmount, }, }, }); } } // Admin force-cancel → organizer juga dikabari (E3.5). if (actor.type === "ADMIN") { void emailService.send({ to: notify.organizer.email, idempotencyKey: `trip_cancelled_admin-${tripId}-organizer`, template: { template: "trip_cancelled_admin", data: { userName: notify.organizer.name, tripTitle: notify.tripTitle, reason: actor.reason, refundAmount: 0, }, }, }); } } export async function cancelTripAction(tripId: string) { const session = await getServerSession(authOptions); if (!session?.user) { return { error: "Kamu harus login terlebih dahulu" }; } try { const result = await tripService.closeTrip(tripId, { type: "ORGANIZER", userId: session.user.id, }); notifyTripCancelled(tripId, result.notify, { type: "ORGANIZER" }); revalidatePath(`/trips/${tripId}`); revalidatePath("/trips"); revalidatePath("/"); revalidatePath("/profile"); revalidatePath("/admin/refunds"); return { success: true as const, refundCount: result.refundsCreated.length, cancelledCount: result.cancelledBookings.length, skippedCount: result.skippedBookings.length, }; } catch (err) { return { error: (err as Error).message }; } } /** * Admin force-cancel trip dari panel admin (intervensi saat organizer * unreachable / safety issue / dispute). Pakai actor ADMIN — bypass cek * organizer match, record `cancelledByAdminId` + `cancelledReason`. */ export async function adminCancelTripAction(tripId: string, reason: string) { const session = await getServerSession(authOptions); if (!session?.user) { return { error: "Kamu harus login terlebih dahulu" }; } const { isAdminEmail } = await import("@/lib/admin"); if (!isAdminEmail(session.user.email)) { return { error: "Hanya admin yang bisa melakukan aksi ini" }; } const trimmedReason = reason.trim(); if (trimmedReason.length < 10) { return { error: "Alasan cancel wajib diisi (minimal 10 karakter untuk audit)", }; } if (trimmedReason.length > 500) { return { error: "Alasan cancel maksimal 500 karakter" }; } try { const result = await tripService.closeTrip(tripId, { type: "ADMIN", adminId: session.user.id, reason: trimmedReason, }); notifyTripCancelled(tripId, result.notify, { type: "ADMIN", reason: trimmedReason, }); await auditLog.record({ admin: { id: session.user.id, email: session.user.email }, action: "TRIP_ADMIN_CANCEL", entityType: "Trip", entityId: tripId, payload: { reason: trimmedReason, refundsCreated: result.refundsCreated.length, cancelledBookings: result.cancelledBookings.length, skippedBookings: result.skippedBookings.length, }, }); revalidatePath(`/trips/${tripId}`); revalidatePath(`/admin/trips/${tripId}`); revalidatePath("/admin/trips"); revalidatePath("/admin/refunds"); revalidatePath("/trips"); return { success: true as const, refundCount: result.refundsCreated.length, cancelledCount: result.cancelledBookings.length, skippedCount: result.skippedBookings.length, }; } catch (err) { return { error: (err as Error).message }; } }