246 lines
6.8 KiB
TypeScript
246 lines
6.8 KiB
TypeScript
import { z } from "zod/v4";
|
|
import { LIMITS } from "@/lib/limits";
|
|
import { ACTIVITY_CATEGORIES } from "@/lib/activity-category";
|
|
import { VIBES } from "@/lib/vibe";
|
|
import type { ActivityCategory } from "@/app/generated/prisma/enums";
|
|
import {
|
|
isTripDepartureDayPast,
|
|
tripStoredInstantFromYmd,
|
|
} from "@/lib/trip-dates";
|
|
import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary";
|
|
|
|
/**
|
|
* Foto trip sekarang adalah file yang diunggah ke server sendiri, bukan URL
|
|
* eksternal. Nilai yang valid hanya path terkelola `/api/trip-images/<hex>.webp`
|
|
* yang dihasilkan route upload — regex ini sengaja ketat supaya URL arbitrary
|
|
* (yang dulu sering tidak reachable dari server) tidak bisa lolos lagi.
|
|
*/
|
|
export const tripImageUrlsSchema = z
|
|
.array(
|
|
z
|
|
.string()
|
|
.trim()
|
|
.regex(
|
|
/^\/api\/trip-images\/[a-f0-9]{32}\.webp$/,
|
|
"Foto trip tidak valid — silakan unggah ulang fotonya"
|
|
)
|
|
)
|
|
.max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`);
|
|
|
|
export const itineraryItemSchema = z
|
|
.object({
|
|
day: z.coerce
|
|
.number()
|
|
.int("Nomor hari tidak valid")
|
|
.min(1, "Nomor hari minimal 1")
|
|
.max(
|
|
LIMITS.MAX_ITINERARY_DAYS,
|
|
`Maksimal ${LIMITS.MAX_ITINERARY_DAYS} hari`
|
|
),
|
|
startTime: z
|
|
.string()
|
|
.trim()
|
|
.refine(isValidTimeFormat, "Format jam mulai harus HH:mm"),
|
|
endTime: z.preprocess(
|
|
(val) => {
|
|
if (val == null) return undefined;
|
|
const s = String(val).trim();
|
|
return s === "" ? undefined : s;
|
|
},
|
|
z
|
|
.string()
|
|
.refine(isValidTimeFormat, "Format jam selesai harus HH:mm")
|
|
.optional()
|
|
),
|
|
activity: z
|
|
.string()
|
|
.trim()
|
|
.min(1, "Deskripsi aktivitas harus diisi")
|
|
.max(
|
|
LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH,
|
|
`Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter`
|
|
),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
if (
|
|
data.endTime &&
|
|
timeToMinutes(data.endTime) < timeToMinutes(data.startTime)
|
|
) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "Jam selesai tidak boleh sebelum jam mulai",
|
|
path: ["endTime"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const itineraryItemsSchema = z
|
|
.array(itineraryItemSchema)
|
|
.max(
|
|
LIMITS.MAX_ITINERARY_ITEMS,
|
|
`Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total`
|
|
)
|
|
.superRefine((items, ctx) => {
|
|
if (items.length === 0) return;
|
|
const days = [...new Set(items.map((i) => i.day))].sort((a, b) => a - b);
|
|
for (let i = 0; i < days.length; i++) {
|
|
if (days[i] !== i + 1) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message:
|
|
"Nomor hari harus berurutan mulai dari Hari 1 (tidak boleh lompat)",
|
|
path: [0, "day"],
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
export const createTripSchema = z
|
|
.object({
|
|
category: z.enum(
|
|
ACTIVITY_CATEGORIES as [ActivityCategory, ...ActivityCategory[]],
|
|
{ message: "Kategori aktivitas tidak valid" }
|
|
),
|
|
title: z
|
|
.string()
|
|
.trim()
|
|
.min(3, "Judul minimal 3 karakter")
|
|
.max(
|
|
LIMITS.MAX_TITLE_LENGTH,
|
|
`Judul maksimal ${LIMITS.MAX_TITLE_LENGTH} karakter`
|
|
),
|
|
description: z.preprocess(
|
|
(val) => {
|
|
if (val == null) return undefined;
|
|
const s = String(val).trim();
|
|
return s === "" ? undefined : s;
|
|
},
|
|
z
|
|
.string()
|
|
.max(
|
|
LIMITS.MAX_DESCRIPTION_LENGTH,
|
|
`Deskripsi maksimal ${LIMITS.MAX_DESCRIPTION_LENGTH} karakter`
|
|
)
|
|
.optional()
|
|
),
|
|
destination: z
|
|
.string()
|
|
.trim()
|
|
.min(2, "Destinasi harus diisi")
|
|
.max(
|
|
LIMITS.MAX_DESTINATION_LENGTH,
|
|
`Destinasi maksimal ${LIMITS.MAX_DESTINATION_LENGTH} karakter`
|
|
),
|
|
location: z
|
|
.string()
|
|
.trim()
|
|
.min(2, "Lokasi harus diisi")
|
|
.max(
|
|
LIMITS.MAX_LOCATION_LENGTH,
|
|
`Lokasi maksimal ${LIMITS.MAX_LOCATION_LENGTH} karakter`
|
|
),
|
|
date: z
|
|
.string()
|
|
.refine((val) => !Number.isNaN(Date.parse(val)), "Tanggal berangkat tidak valid"),
|
|
endDate: z
|
|
.string()
|
|
.optional()
|
|
.refine(
|
|
(val) => !val || !Number.isNaN(Date.parse(val)),
|
|
"Tanggal pulang tidak valid"
|
|
),
|
|
maxParticipants: z.coerce
|
|
.number()
|
|
.int("Jumlah peserta harus bilangan bulat")
|
|
.min(
|
|
LIMITS.MIN_PARTICIPANTS,
|
|
`Minimal ${LIMITS.MIN_PARTICIPANTS} peserta`
|
|
)
|
|
.max(
|
|
LIMITS.MAX_PARTICIPANTS,
|
|
`Maksimal ${LIMITS.MAX_PARTICIPANTS} peserta`
|
|
),
|
|
price: z.coerce
|
|
.number()
|
|
.int("Harga harus bilangan bulat (tanpa desimal)")
|
|
.min(0, "Harga tidak valid")
|
|
.max(
|
|
LIMITS.MAX_PRICE_IDR,
|
|
`Harga maksimal Rp ${LIMITS.MAX_PRICE_IDR.toLocaleString("id-ID")}`
|
|
),
|
|
meetingPoint: z.preprocess(
|
|
(val) => {
|
|
if (val == null) return undefined;
|
|
const s = String(val).trim();
|
|
return s === "" ? undefined : s;
|
|
},
|
|
z
|
|
.string()
|
|
.max(
|
|
LIMITS.MAX_MEETING_POINT_LENGTH,
|
|
`Meeting point maksimal ${LIMITS.MAX_MEETING_POINT_LENGTH} karakter`
|
|
)
|
|
.optional()
|
|
),
|
|
whatsIncluded: z.preprocess(
|
|
(val) => {
|
|
if (val == null) return undefined;
|
|
const s = String(val).trim();
|
|
return s === "" ? undefined : s;
|
|
},
|
|
z
|
|
.string()
|
|
.max(
|
|
LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH,
|
|
`Bagian 'Termasuk' maksimal ${LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH} karakter`
|
|
)
|
|
.optional()
|
|
),
|
|
whatsExcluded: z.preprocess(
|
|
(val) => {
|
|
if (val == null) return undefined;
|
|
const s = String(val).trim();
|
|
return s === "" ? undefined : s;
|
|
},
|
|
z
|
|
.string()
|
|
.max(
|
|
LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH,
|
|
`Bagian 'Tidak termasuk' maksimal ${LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH} karakter`
|
|
)
|
|
.optional()
|
|
),
|
|
vibe: z.preprocess(
|
|
(val) => {
|
|
if (val == null) return undefined;
|
|
const s = String(val).trim();
|
|
return s === "" ? undefined : s;
|
|
},
|
|
z.enum([...VIBES]).optional()
|
|
),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
const dep = tripStoredInstantFromYmd(data.date);
|
|
if (!Number.isNaN(dep.getTime()) && isTripDepartureDayPast(dep)) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "Tanggal berangkat tidak boleh di masa lalu",
|
|
path: ["date"],
|
|
});
|
|
}
|
|
if (data.endDate) {
|
|
const startY = data.date.slice(0, 10);
|
|
const endY = data.endDate.slice(0, 10);
|
|
if (endY < startY) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "Tanggal pulang tidak boleh sebelum tanggal berangkat",
|
|
path: ["endDate"],
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
export type CreateTripInput = z.infer<typeof createTripSchema>;
|