This commit is contained in:
2026-05-20 15:40:17 +07:00
parent 230eb9010c
commit dd3fd889a3
48 changed files with 3374 additions and 737 deletions

532
src/lib/db/actions.ts Normal file
View File

@@ -0,0 +1,532 @@
"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<void> {
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<void> {
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<Place> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const uid = await requireUserId();
await assertCanWriteCollection(collectionId, uid);
await assertCanAccessPlace(placeId, uid);
const [next] = await db
.select({ max: sql<number | null>`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<void> {
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<void> {
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<string> {
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<PendingInvitation[]> {
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<PendingInvitation> {
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<void> {
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("/");
}