import { prisma } from "@/lib/prisma"; import { Prisma } from "@/app/generated/prisma/client"; import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums"; import { utcStartOfDay, utcDayStartFromYmd, utcDayEndFromYmd, maxUtcDate, } from "@/lib/trip-dates"; export type GroupSize = "SMALL" | "MEDIUM" | "LARGE"; export interface TripFilters { q?: string; from?: string; to?: string; category?: ActivityCategory; vibe?: Vibe; groupSize?: GroupSize; } export const tripRepo = { async findAll() { return prisma.trip.findMany({ include: { organizer: { select: { id: true, name: true, image: true, organizerVerification: { select: { status: true } }, }, }, images: { orderBy: { order: "asc" }, take: 1 }, _count: { select: { participants: { where: { status: { not: "CANCELLED" } } }, }, }, }, orderBy: { date: "asc" }, }); }, async findOpen(filters?: TripFilters) { const todayStart = utcStartOfDay(new Date()); const andParts: Prisma.TripWhereInput[] = [{ status: "OPEN" }]; if (filters?.category) { andParts.push({ category: filters.category }); } if (filters?.vibe) { andParts.push({ vibe: filters.vibe }); } if (filters?.groupSize === "SMALL") { andParts.push({ maxParticipants: { lte: 10 } }); } else if (filters?.groupSize === "MEDIUM") { andParts.push({ maxParticipants: { gte: 11, lte: 20 } }); } else if (filters?.groupSize === "LARGE") { andParts.push({ maxParticipants: { gte: 21 } }); } if (!filters?.from && !filters?.to) { andParts.push({ date: { gte: todayStart } }); } else { const userRangeStart = filters.from ? utcDayStartFromYmd(filters.from) : todayStart; const userRangeEnd = filters.to ? utcDayEndFromYmd(filters.to) : utcDayEndFromYmd("2099-12-31"); const rangeStart = maxUtcDate(todayStart, userRangeStart); const rangeEnd = userRangeEnd; andParts.push({ OR: [ { AND: [ { endDate: { not: null } }, { date: { lte: rangeEnd } }, { endDate: { gte: rangeStart } }, ], }, { AND: [ { endDate: null }, { date: { gte: rangeStart } }, { date: { lte: rangeEnd } }, ], }, ], }); } if (filters?.q) { andParts.push({ OR: [ { title: { contains: filters.q, mode: "insensitive" } }, { destination: { contains: filters.q, mode: "insensitive" } }, { location: { contains: filters.q, mode: "insensitive" } }, ], }); } const where: Prisma.TripWhereInput = { AND: andParts }; return prisma.trip.findMany({ where, include: { organizer: { select: { id: true, name: true, image: true, organizerVerification: { select: { status: true } }, }, }, images: { orderBy: { order: "asc" }, take: 1 }, participants: { where: { status: "CONFIRMED" }, take: 10, orderBy: { createdAt: "asc" }, select: { id: true, user: { select: { id: true, name: true, image: true, profile: { select: { interests: true } }, }, }, }, }, _count: { select: { participants: { where: { status: { not: "CANCELLED" } } }, }, }, }, orderBy: { date: "asc" }, }); }, async findById(id: string) { return prisma.trip.findUnique({ where: { id }, include: { organizer: { select: { id: true, name: true, email: true, image: true, organizerVerification: { select: { status: true } }, }, }, images: { orderBy: { order: "asc" } }, itineraryItems: { orderBy: [{ day: "asc" }, { order: "asc" }], }, participants: { include: { user: { select: { id: true, name: true, image: true, profile: { select: { city: true, interests: true } }, }, }, }, }, reviews: { orderBy: { createdAt: "desc" }, include: { user: { select: { id: true, name: true, image: true } }, }, }, }, }); }, async countByOrganizerSince(organizerId: string, since: Date) { return prisma.trip.count({ where: { organizerId, createdAt: { gte: since } }, }); }, /** * Admin search lintas trip. Tidak filter berdasarkan status departure (admin * boleh lihat semua: OPEN/FULL/CLOSED/COMPLETED). Include _count peserta * aktif untuk indikator cepat. */ async searchForAdmin(filters: { q?: string; status?: "OPEN" | "FULL" | "CLOSED" | "COMPLETED"; }) { const where: Prisma.TripWhereInput = {}; if (filters.status) { where.status = filters.status; } if (filters.q) { where.OR = [ { title: { contains: filters.q, mode: "insensitive" } }, { destination: { contains: filters.q, mode: "insensitive" } }, { location: { contains: filters.q, mode: "insensitive" } }, { organizer: { name: { contains: filters.q, mode: "insensitive" } } }, { organizer: { email: { contains: filters.q, mode: "insensitive" } } }, ]; } return prisma.trip.findMany({ where, include: { organizer: { select: { id: true, name: true, email: true } }, images: { orderBy: { order: "asc" }, take: 1 }, _count: { select: { participants: { where: { status: { not: "CANCELLED" } } }, bookings: { where: { status: "PAID" } }, }, }, }, orderBy: { date: "desc" }, take: 100, }); }, /** Semua trip yang dibuat user (semua status), terbaru dulu — untuk profil. */ async findByOrganizerId(organizerId: string) { return prisma.trip.findMany({ where: { organizerId }, include: { images: { orderBy: { order: "asc" }, take: 1 }, _count: { select: { participants: { where: { status: { not: "CANCELLED" } } }, }, }, }, orderBy: { date: "desc" }, }); }, async create(data: Prisma.TripCreateInput) { return prisma.trip.create({ data }); }, async updateStatus(id: string, status: "OPEN" | "FULL" | "CLOSED" | "COMPLETED") { return prisma.trip.update({ where: { id }, data: { status } }); }, /** * Bulk transisi trip yang sudah lewat `cutoff` (start of today UTC) dari * status OPEN/FULL ke COMPLETED. Idempotent — second run tidak akan match * apa-apa karena status sudah berubah. * * Returns daftar id yang ter-update untuk telemetri/log. */ async bulkCompletePastTrips(cutoff: Date) { const trips = await prisma.trip.findMany({ where: { status: { in: ["OPEN", "FULL"] }, OR: [ { endDate: { lt: cutoff } }, { AND: [{ endDate: null }, { date: { lt: cutoff } }] }, ], }, select: { id: true }, }); if (trips.length === 0) { return { count: 0, ids: [] as string[] }; } const ids = trips.map((t) => t.id); const result = await prisma.trip.updateMany({ where: { id: { in: ids }, status: { in: ["OPEN", "FULL"] }, }, data: { status: "COMPLETED" }, }); return { count: result.count, ids }; }, };