445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
"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<typeof itineraryItemsSchema.parse> = [];
|
|
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<ReturnType<typeof tripService.closeTrip>>;
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
}
|