aaa
This commit is contained in:
532
src/lib/db/actions.ts
Normal file
532
src/lib/db/actions.ts
Normal 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("/");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user