create review and profile
This commit is contained in:
@@ -25,4 +25,31 @@ export const participantRepo = {
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
},
|
||||
|
||||
async reactivate(tripId: string, userId: string) {
|
||||
return prisma.tripParticipant.update({
|
||||
where: { tripId_userId: { tripId, userId } },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
},
|
||||
|
||||
/** Partisipasi user beserta trip (untuk profil & riwayat). */
|
||||
async findWithTripForProfile(userId: string) {
|
||||
return prisma.tripParticipant.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
trip: {
|
||||
include: {
|
||||
organizer: { select: { id: true, name: true } },
|
||||
images: { orderBy: { order: "asc" }, take: 1 },
|
||||
reviews: {
|
||||
where: { userId },
|
||||
select: { id: true, rating: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const reviewRepo = {
|
||||
async upsert(data: {
|
||||
tripId: string;
|
||||
userId: string;
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
}) {
|
||||
return prisma.tripReview.upsert({
|
||||
where: {
|
||||
tripId_userId: { tripId: data.tripId, userId: data.userId },
|
||||
},
|
||||
create: {
|
||||
tripId: data.tripId,
|
||||
userId: data.userId,
|
||||
rating: data.rating,
|
||||
comment: data.comment,
|
||||
},
|
||||
update: {
|
||||
rating: data.rating,
|
||||
comment: data.comment,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,11 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
import {
|
||||
utcStartOfDay,
|
||||
utcDayStartFromYmd,
|
||||
utcDayEndFromYmd,
|
||||
maxUtcDate,
|
||||
} from "@/lib/trip-dates";
|
||||
|
||||
export const tripRepo = {
|
||||
async findAll() {
|
||||
@@ -7,44 +13,76 @@ export const tripRepo = {
|
||||
include: {
|
||||
organizer: { select: { id: true, name: true, image: true } },
|
||||
images: { orderBy: { order: "asc" }, take: 1 },
|
||||
_count: { select: { participants: true } },
|
||||
_count: {
|
||||
select: {
|
||||
participants: { where: { status: { not: "CANCELLED" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
},
|
||||
|
||||
async findOpen(filters?: { q?: string; from?: string; to?: string }) {
|
||||
const where: Prisma.TripWhereInput = {
|
||||
status: "OPEN",
|
||||
date: { gte: new Date() },
|
||||
};
|
||||
const todayStart = utcStartOfDay(new Date());
|
||||
|
||||
const andParts: Prisma.TripWhereInput[] = [{ status: "OPEN" }];
|
||||
|
||||
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) {
|
||||
where.OR = [
|
||||
{ title: { contains: filters.q, mode: "insensitive" } },
|
||||
{ mountain: { contains: filters.q, mode: "insensitive" } },
|
||||
{ location: { contains: filters.q, mode: "insensitive" } },
|
||||
];
|
||||
andParts.push({
|
||||
OR: [
|
||||
{ title: { contains: filters.q, mode: "insensitive" } },
|
||||
{ mountain: { contains: filters.q, mode: "insensitive" } },
|
||||
{ location: { contains: filters.q, mode: "insensitive" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (filters?.from || filters?.to) {
|
||||
const dateFilter: Prisma.DateTimeFilter = { gte: new Date() };
|
||||
if (filters.from) {
|
||||
const fromDate = new Date(filters.from);
|
||||
if (fromDate > new Date()) dateFilter.gte = fromDate;
|
||||
}
|
||||
if (filters.to) {
|
||||
dateFilter.lte = new Date(filters.to + "T23:59:59.999Z");
|
||||
}
|
||||
where.date = dateFilter;
|
||||
}
|
||||
const where: Prisma.TripWhereInput = { AND: andParts };
|
||||
|
||||
return prisma.trip.findMany({
|
||||
where,
|
||||
include: {
|
||||
organizer: { select: { id: true, name: true, image: true } },
|
||||
images: { orderBy: { order: "asc" }, take: 1 },
|
||||
_count: { select: { participants: true } },
|
||||
_count: {
|
||||
select: {
|
||||
participants: { where: { status: { not: "CANCELLED" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
@@ -59,10 +97,38 @@ export const tripRepo = {
|
||||
participants: {
|
||||
include: { user: { select: { id: true, name: true, image: 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 } },
|
||||
});
|
||||
},
|
||||
|
||||
/** 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 });
|
||||
},
|
||||
|
||||
@@ -10,6 +10,20 @@ export const userRepo = {
|
||||
return prisma.user.findUnique({ where: { id } });
|
||||
},
|
||||
|
||||
/** Profil publik (tanpa password) untuk halaman akun. */
|
||||
async findPublicProfileById(id: string) {
|
||||
return prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async create(data: Prisma.UserCreateInput) {
|
||||
return prisma.user.create({ data });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { userRepo } from "@/server/repositories/user.repo";
|
||||
import { tripRepo } from "@/server/repositories/trip.repo";
|
||||
import { participantRepo } from "@/server/repositories/participant.repo";
|
||||
import { isPastTripLastDayForReview } from "@/lib/trip-dates";
|
||||
|
||||
export const profileService = {
|
||||
async getProfileDashboard(userId: string) {
|
||||
const user = await userRepo.findPublicProfileById(userId);
|
||||
if (!user) {
|
||||
throw new Error("Pengguna tidak ditemukan");
|
||||
}
|
||||
|
||||
const [organizedTrips, participations] = await Promise.all([
|
||||
tripRepo.findByOrganizerId(userId),
|
||||
participantRepo.findWithTripForProfile(userId),
|
||||
]);
|
||||
|
||||
const activeJoined = participations
|
||||
.filter((p) => p.status !== "CANCELLED")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.trip.date).getTime() - new Date(a.trip.date).getTime()
|
||||
);
|
||||
const cancelledJoined = participations
|
||||
.filter((p) => p.status === "CANCELLED")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.trip.date).getTime() - new Date(a.trip.date).getTime()
|
||||
);
|
||||
|
||||
const reviewable = activeJoined
|
||||
.filter((p) => {
|
||||
if (p.status !== "CONFIRMED") return false;
|
||||
const t = p.trip;
|
||||
if (t.organizerId === userId) return false;
|
||||
return isPastTripLastDayForReview(t.date, t.endDate);
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.trip.date).getTime() - new Date(a.trip.date).getTime()
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
organizedTrips,
|
||||
activeJoined,
|
||||
cancelledJoined,
|
||||
reviewable,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { tripRepo } from "@/server/repositories/trip.repo";
|
||||
import { participantRepo } from "@/server/repositories/participant.repo";
|
||||
import { reviewRepo } from "@/server/repositories/review.repo";
|
||||
import { isPastTripLastDayForReview } from "@/lib/trip-dates";
|
||||
|
||||
export const reviewService = {
|
||||
async upsertReview(
|
||||
tripId: string,
|
||||
userId: string,
|
||||
input: { rating: number; comment?: string | null }
|
||||
) {
|
||||
const trip = await tripRepo.findById(tripId);
|
||||
if (!trip) {
|
||||
throw new Error("Trip tidak ditemukan");
|
||||
}
|
||||
|
||||
if (trip.organizerId === userId) {
|
||||
throw new Error("Organizer tidak bisa mengulas trip sendiri");
|
||||
}
|
||||
|
||||
const participation = await participantRepo.findByTripAndUser(
|
||||
tripId,
|
||||
userId
|
||||
);
|
||||
if (!participation || participation.status !== "CONFIRMED") {
|
||||
throw new Error(
|
||||
"Hanya peserta yang terdaftar (aktif) yang bisa memberi ulasan"
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPastTripLastDayForReview(trip.date, trip.endDate)) {
|
||||
throw new Error(
|
||||
"Ulasan bisa diberikan setelah tanggal selesai trip (hari terakhir pendakian)"
|
||||
);
|
||||
}
|
||||
|
||||
return reviewRepo.upsert({
|
||||
tripId,
|
||||
userId,
|
||||
rating: input.rating,
|
||||
comment: input.comment ?? null,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { tripRepo } from "@/server/repositories/trip.repo";
|
||||
import { participantRepo } from "@/server/repositories/participant.repo";
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||
|
||||
interface CreateTripInput {
|
||||
title: string;
|
||||
@@ -32,6 +34,25 @@ export const tripService = {
|
||||
},
|
||||
|
||||
async createTrip(input: CreateTripInput) {
|
||||
const since = utcStartOfDay(new Date());
|
||||
const todayCount = await tripRepo.countByOrganizerSince(
|
||||
input.organizerId,
|
||||
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 (UTC). Coba lagi besok.`
|
||||
);
|
||||
}
|
||||
|
||||
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 images = input.imageUrls?.length
|
||||
? {
|
||||
create: input.imageUrls.map((url, i) => ({ url, order: i })),
|
||||
@@ -62,6 +83,12 @@ export const tripService = {
|
||||
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");
|
||||
}
|
||||
@@ -77,7 +104,10 @@ export const tripService = {
|
||||
throw new Error("Trip sudah penuh");
|
||||
}
|
||||
|
||||
const participant = await participantRepo.create(tripId, userId);
|
||||
const participant =
|
||||
existing?.status === "CANCELLED"
|
||||
? await participantRepo.reactivate(tripId, userId)
|
||||
: await participantRepo.create(tripId, userId);
|
||||
|
||||
const newCount = await participantRepo.countByTrip(tripId);
|
||||
if (newCount >= trip.maxParticipants) {
|
||||
@@ -88,6 +118,17 @@ export const tripService = {
|
||||
},
|
||||
|
||||
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");
|
||||
@@ -95,8 +136,7 @@ export const tripService = {
|
||||
|
||||
const result = await participantRepo.cancel(tripId, userId);
|
||||
|
||||
const trip = await tripRepo.findById(tripId);
|
||||
if (trip && trip.status === "FULL") {
|
||||
if (trip.status === "FULL") {
|
||||
const count = await participantRepo.countByTrip(tripId);
|
||||
if (count < trip.maxParticipants) {
|
||||
await tripRepo.updateStatus(tripId, "OPEN");
|
||||
|
||||
Reference in New Issue
Block a user