533 lines
15 KiB
TypeScript
533 lines
15 KiB
TypeScript
"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("/");
|
|
}
|
|
|