"use server"; import { randomBytes } from "node:crypto"; import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { and, eq, sql } from "drizzle-orm"; import { db } from "./client"; import { requireUserId } from "./auth"; import { collectionMembers, collectionPlaces, collections, invitations, places, userPlaceData, users, } from "./schema"; import type { CategoryId, CollectionType, Place, Role } from "@/lib/types"; import { buildInviteEmail, sendEmail } from "@/lib/email"; function makeInviteToken(): string { return randomBytes(16).toString("base64url"); } // Privacy guard: user must own the place or be a member of a collection // containing it. Mirrors the spec in CLAUDE.md / RLS policy. async function assertCanAccessPlace(placeId: number, userId: number) { const [row] = await db.execute<{ ok: boolean }>(sql` SELECT EXISTS( SELECT 1 FROM ${places} p WHERE p.id = ${placeId} AND ( p.created_by = ${userId} OR p.id IN ( SELECT cp.place_id FROM ${collectionPlaces} cp JOIN ${collectionMembers} cm ON cm.collection_id = cp.collection_id AND cm.user_id = ${userId} ) ) ) AS ok `).then((r) => r.rows); if (!row?.ok) throw new Error("forbidden: cannot access place"); } export async function toggleVisited( placeId: number, ): Promise<{ visited: boolean; visited_at: string | null }> { const uid = await requireUserId(); await assertCanAccessPlace(placeId, uid); const [current] = await db .select({ visited: userPlaceData.visited }) .from(userPlaceData) .where( and(eq(userPlaceData.userId, uid), eq(userPlaceData.placeId, placeId)), ) .limit(1); const next = !current?.visited; const visitedAt = next ? new Date() : null; await db .insert(userPlaceData) .values({ userId: uid, placeId, visited: next, visitedAt }) .onConflictDoUpdate({ target: [userPlaceData.userId, userPlaceData.placeId], set: { visited: next, visitedAt }, }); revalidatePath("/"); return { visited: next, visited_at: visitedAt ? visitedAt.toISOString().slice(0, 10) : null, }; } export async function setRating(placeId: number, rating: number): Promise { if (rating < 0 || rating > 5) throw new Error("rating out of range"); const uid = await requireUserId(); await assertCanAccessPlace(placeId, uid); if (rating === 0) { await db .update(userPlaceData) .set({ rating: null }) .where( and(eq(userPlaceData.userId, uid), eq(userPlaceData.placeId, placeId)), ); } else { await db .insert(userPlaceData) .values({ userId: uid, placeId, rating }) .onConflictDoUpdate({ target: [userPlaceData.userId, userPlaceData.placeId], set: { rating }, }); } revalidatePath("/"); } export async function setNotes(placeId: number, notes: string): Promise { const uid = await requireUserId(); await assertCanAccessPlace(placeId, uid); const trimmed = notes.trim(); if (!trimmed) { await db .update(userPlaceData) .set({ notes: null }) .where( and(eq(userPlaceData.userId, uid), eq(userPlaceData.placeId, placeId)), ); } else { await db .insert(userPlaceData) .values({ userId: uid, placeId, notes: trimmed }) .onConflictDoUpdate({ target: [userPlaceData.userId, userPlaceData.placeId], set: { notes: trimmed }, }); } revalidatePath("/"); } export type NewPlaceInput = { name: string; address: string; short_address: string; city: string; category: CategoryId; tags: string[]; cover_url: string | null; rating?: number; notes?: string; }; export async function addPlace(input: NewPlaceInput): Promise { const uid = await requireUserId(); const [row] = await db .insert(places) .values({ createdBy: uid, name: input.name, address: input.address, shortAddress: input.short_address, city: input.city, category: input.category, tags: input.tags, coverUrl: input.cover_url, }) .returning(); if (input.rating || input.notes) { await db.insert(userPlaceData).values({ userId: uid, placeId: row.id, rating: input.rating ?? null, notes: input.notes ?? null, }); } revalidatePath("/"); return { id: row.id, name: row.name, address: row.address, short_address: row.shortAddress, city: row.city, category: row.category, tags: row.tags, cover_url: row.coverUrl, created_by: uid, created_at: row.createdAt.toISOString().slice(0, 10), my_rating: input.rating, my_notes: input.notes, visited: false, }; } export async function deletePlace(placeId: number): Promise { const uid = await requireUserId(); const [row] = await db .select({ createdBy: places.createdBy }) .from(places) .where(eq(places.id, placeId)) .limit(1); if (!row) return; if (row.createdBy !== uid) throw new Error("not authorized"); await db.delete(places).where(eq(places.id, placeId)); revalidatePath("/"); } export type EditPlaceInput = { name: string; address: string; short_address: string; city: string; category: CategoryId; tags: string[]; cover_url: string | null; }; export async function editPlace( placeId: number, input: EditPlaceInput, ): Promise { const uid = await requireUserId(); const [row] = await db .select({ createdBy: places.createdBy }) .from(places) .where(eq(places.id, placeId)) .limit(1); if (!row) throw new Error("not found"); if (row.createdBy !== uid) throw new Error("not authorized"); await db .update(places) .set({ name: input.name, address: input.address, shortAddress: input.short_address, city: input.city, category: input.category, tags: input.tags, coverUrl: input.cover_url, }) .where(eq(places.id, placeId)); revalidatePath("/"); } export type CollectionInput = { name: string; type: CollectionType; trip_start?: string; trip_end?: string; }; export async function createCollection( input: CollectionInput, ): Promise<{ id: number }> { const uid = await requireUserId(); const name = input.name.trim(); if (!name) throw new Error("name required"); const [row] = await db .insert(collections) .values({ ownerId: uid, name, type: input.type, tripStart: input.type === "trip" ? input.trip_start ?? null : null, tripEnd: input.type === "trip" ? input.trip_end ?? null : null, }) .returning({ id: collections.id }); await db .insert(collectionMembers) .values({ collectionId: row.id, userId: uid, role: "owner" }); revalidatePath("/"); return { id: row.id }; } export async function editCollection( collectionId: number, input: CollectionInput, ): Promise { const uid = await requireUserId(); const [row] = await db .select({ ownerId: collections.ownerId }) .from(collections) .where(eq(collections.id, collectionId)) .limit(1); if (!row) throw new Error("not found"); if (row.ownerId !== uid) throw new Error("not authorized"); await db .update(collections) .set({ name: input.name.trim(), type: input.type, tripStart: input.type === "trip" ? input.trip_start ?? null : null, tripEnd: input.type === "trip" ? input.trip_end ?? null : null, }) .where(eq(collections.id, collectionId)); revalidatePath("/"); } export async function deleteCollection(collectionId: number): Promise { const uid = await requireUserId(); const [row] = await db .select({ ownerId: collections.ownerId }) .from(collections) .where(eq(collections.id, collectionId)) .limit(1); if (!row) return; if (row.ownerId !== uid) throw new Error("not authorized"); await db.delete(collections).where(eq(collections.id, collectionId)); revalidatePath("/"); } async function assertCanWriteCollection( collectionId: number, userId: number, ) { const [row] = await db .select({ role: collectionMembers.role }) .from(collectionMembers) .where( and( eq(collectionMembers.collectionId, collectionId), eq(collectionMembers.userId, userId), ), ) .limit(1); if (!row) throw new Error("forbidden: not a member"); if (row.role === "viewer") throw new Error("forbidden: viewer cannot write"); } export async function addPlaceToCollection( collectionId: number, placeId: number, ): Promise { const uid = await requireUserId(); await assertCanWriteCollection(collectionId, uid); await assertCanAccessPlace(placeId, uid); const [next] = await db .select({ max: sql`max(${collectionPlaces.sortOrder})` }) .from(collectionPlaces) .where(eq(collectionPlaces.collectionId, collectionId)); await db .insert(collectionPlaces) .values({ collectionId, placeId, addedBy: uid, sortOrder: (next?.max ?? -1) + 1, }) .onConflictDoNothing({ target: [collectionPlaces.collectionId, collectionPlaces.placeId], }); revalidatePath("/"); } export async function removePlaceFromCollection( collectionId: number, placeId: number, ): Promise { const uid = await requireUserId(); await assertCanWriteCollection(collectionId, uid); await db .delete(collectionPlaces) .where( and( eq(collectionPlaces.collectionId, collectionId), eq(collectionPlaces.placeId, placeId), ), ); revalidatePath("/"); } export async function createInviteLink( collectionId: number, ): Promise<{ token: string; expires_at: string }> { const uid = await requireUserId(); const [row] = await db .select({ ownerId: collections.ownerId }) .from(collections) .where(eq(collections.id, collectionId)) .limit(1); if (!row) throw new Error("not found"); if (row.ownerId !== uid) throw new Error("not authorized"); const token = makeInviteToken(); const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); await db .update(collections) .set({ inviteToken: token, tokenExpiresAt: expires }) .where(eq(collections.id, collectionId)); revalidatePath("/"); return { token, expires_at: expires.toISOString() }; } export async function revokeInviteLink(collectionId: number): Promise { const uid = await requireUserId(); const [row] = await db .select({ ownerId: collections.ownerId }) .from(collections) .where(eq(collections.id, collectionId)) .limit(1); if (!row) throw new Error("not found"); if (row.ownerId !== uid) throw new Error("not authorized"); await db .update(collections) .set({ inviteToken: null, tokenExpiresAt: null }) .where(eq(collections.id, collectionId)); revalidatePath("/"); } export async function acceptInvite( token: string, ): Promise<{ collectionId: number }> { const uid = await requireUserId(); const { acceptInviteCore } = await import("./invites"); const result = await acceptInviteCore(token, uid); revalidatePath("/"); return result; } // ─── Email invitations ────────────────────────────────── async function resolveOrigin(): Promise { if (process.env.APP_URL) return process.env.APP_URL; const h = await headers(); const host = h.get("host") ?? "localhost:3000"; const proto = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https"); return `${proto}://${host}`; } export type PendingInvitation = { id: number; email: string; role: Role; createdAt: string; expiresAt: string; }; export async function fetchPendingInvitations( collectionId: number, ): Promise { const uid = await requireUserId(); const [col] = await db .select({ ownerId: collections.ownerId }) .from(collections) .where(eq(collections.id, collectionId)) .limit(1); if (!col) throw new Error("not found"); if (col.ownerId !== uid) throw new Error("not authorized"); const { listPendingInvitations } = await import("./invites"); const rows = await listPendingInvitations(collectionId); return rows.map((r) => ({ id: r.id, email: r.email, role: r.role, createdAt: r.createdAt.toISOString(), expiresAt: r.expiresAt.toISOString(), })); } export async function sendEmailInvite( collectionId: number, emailRaw: string, role: Role, ): Promise { const uid = await requireUserId(); const email = emailRaw.trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { throw new Error("Email không hợp lệ"); } if (role !== "editor" && role !== "viewer") { throw new Error("Vai trò không hợp lệ"); } const [col] = await db .select({ id: collections.id, name: collections.name, ownerId: collections.ownerId }) .from(collections) .where(eq(collections.id, collectionId)) .limit(1); if (!col) throw new Error("Bộ sưu tập không tồn tại"); if (col.ownerId !== uid) throw new Error("Chỉ chủ sở hữu mới được mời"); const [inviter] = await db .select({ name: users.name }) .from(users) .where(eq(users.id, uid)) .limit(1); const token = randomBytes(16).toString("base64url"); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // Upsert by (collection_id, email) — re-sending replaces the old token. const [row] = await db .insert(invitations) .values({ collectionId, email, role, token, expiresAt, invitedBy: uid, }) .onConflictDoUpdate({ target: [invitations.collectionId, invitations.email], set: { token, expiresAt, role, invitedBy: uid, acceptedAt: null }, }) .returning(); const origin = await resolveOrigin(); const inviteUrl = `${origin}/invite/${token}`; const msg = buildInviteEmail({ collectionName: col.name, inviterName: inviter?.name ?? "Thành viên Places", inviteUrl, role, }); // Don't fail the action if email delivery fails — the row is persisted and // the owner can copy the URL from the pending list if needed. try { await sendEmail({ ...msg, to: email }); } catch (e) { console.error("[email] send failed:", (e as Error).message); } revalidatePath("/"); return { id: row.id, email: row.email, role: row.role, createdAt: row.createdAt.toISOString(), expiresAt: row.expiresAt.toISOString(), }; } export async function revokeInvitation(invitationId: number): Promise { const uid = await requireUserId(); const [row] = await db .select({ id: invitations.id, collectionId: invitations.collectionId, ownerId: collections.ownerId, }) .from(invitations) .innerJoin(collections, eq(collections.id, invitations.collectionId)) .where(eq(invitations.id, invitationId)) .limit(1); if (!row) return; if (row.ownerId !== uid) throw new Error("not authorized"); await db.delete(invitations).where(eq(invitations.id, invitationId)); revalidatePath("/"); }