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

270
src/lib/db/queries.ts Normal file
View File

@@ -0,0 +1,270 @@
import "server-only";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import { db } from "./client";
import {
collectionMembers,
collectionPlaces,
collections,
places,
userPlaceData,
users,
} from "./schema";
import { getCurrentUserId, requireUserId } from "./auth";
import type { Collection, Place, User } from "@/lib/types";
function dateToStr(d: Date | string | null | undefined): string | undefined {
if (!d) return undefined;
return (typeof d === "string" ? new Date(d) : d).toISOString().slice(0, 10);
}
function toPlace(r: {
id: number;
name: string;
address: string;
shortAddress: string;
category: Place["category"];
tags: string[];
coverUrl: string | null;
createdBy: number;
createdAt: Date | string;
avgRating: string | null;
city: string;
myRating: number | null;
myNotes: string | null;
visited: boolean | null;
visitedAt: Date | string | null;
}): Place {
return {
id: r.id,
name: r.name,
address: r.address,
short_address: r.shortAddress,
category: r.category,
tags: r.tags ?? [],
cover_url: r.coverUrl,
created_by: r.createdBy,
created_at: dateToStr(r.createdAt) ?? "",
avg_rating: r.avgRating != null ? Number(r.avgRating) : undefined,
city: r.city,
my_rating: r.myRating ?? undefined,
my_notes: r.myNotes ?? undefined,
visited: !!r.visited,
visited_at: dateToStr(r.visitedAt),
};
}
// Privacy filter: user sees place if (a) they created it, or
// (b) it lives in a collection where they're a member. CLAUDE.md spec.
function placeVisibilityFilter(userId: number) {
return or(
eq(places.createdBy, userId),
inArray(
places.id,
db
.select({ id: collectionPlaces.placeId })
.from(collectionPlaces)
.innerJoin(
collectionMembers,
and(
eq(collectionMembers.collectionId, collectionPlaces.collectionId),
eq(collectionMembers.userId, userId),
),
),
),
);
}
export async function getPlacesForUser(userId?: number): Promise<Place[]> {
const uid = userId ?? (await requireUserId());
const rows = await db
.select({
id: places.id,
name: places.name,
address: places.address,
shortAddress: places.shortAddress,
category: places.category,
tags: places.tags,
coverUrl: places.coverUrl,
createdBy: places.createdBy,
createdAt: places.createdAt,
avgRating: places.avgRating,
city: places.city,
myRating: userPlaceData.rating,
myNotes: userPlaceData.notes,
visited: userPlaceData.visited,
visitedAt: userPlaceData.visitedAt,
})
.from(places)
.leftJoin(
userPlaceData,
and(
eq(userPlaceData.placeId, places.id),
eq(userPlaceData.userId, uid),
),
)
.where(placeVisibilityFilter(uid))
.orderBy(desc(places.createdAt));
return rows.map(toPlace);
}
export async function getPlaceById(
placeId: number,
userId?: number,
): Promise<Place | null> {
const uid = userId ?? (await requireUserId());
const [row] = await db
.select({
id: places.id,
name: places.name,
address: places.address,
shortAddress: places.shortAddress,
category: places.category,
tags: places.tags,
coverUrl: places.coverUrl,
createdBy: places.createdBy,
createdAt: places.createdAt,
avgRating: places.avgRating,
city: places.city,
myRating: userPlaceData.rating,
myNotes: userPlaceData.notes,
visited: userPlaceData.visited,
visitedAt: userPlaceData.visitedAt,
})
.from(places)
.leftJoin(
userPlaceData,
and(
eq(userPlaceData.placeId, places.id),
eq(userPlaceData.userId, uid),
),
)
.where(and(eq(places.id, placeId), placeVisibilityFilter(uid)))
.limit(1);
return row ? toPlace(row) : null;
}
type CollectionRow = {
id: number;
owner_id: number;
name: string;
type: Collection["type"];
trip_start: Date | string | null;
trip_end: Date | string | null;
member_count: number;
place_count: number;
my_role: Collection["my_role"] | null;
cover_place_ids: number[];
place_ids: number[];
members: number[];
};
export async function getCollectionsForUser(
userId?: number,
): Promise<Collection[]> {
const uid = userId ?? (await requireUserId());
// Aggregates (member_count, place_ids, members…) are cheaper as correlated
// subqueries than as N+1 round-trips. Drizzle's sql template keeps it readable.
const result = await db.execute<CollectionRow>(sql`
SELECT
c.id,
c.owner_id,
c.name,
c.type,
c.trip_start,
c.trip_end,
(SELECT count(*)::int FROM ${collectionMembers} WHERE collection_id = c.id) AS member_count,
(SELECT count(*)::int FROM ${collectionPlaces} WHERE collection_id = c.id) AS place_count,
(SELECT role FROM ${collectionMembers} WHERE collection_id = c.id AND user_id = ${uid}) AS my_role,
COALESCE((
SELECT array_agg(place_id ORDER BY sort_order)
FROM (
SELECT place_id, sort_order
FROM ${collectionPlaces}
WHERE collection_id = c.id
ORDER BY sort_order
LIMIT 3
) cover
), ARRAY[]::int[]) AS cover_place_ids,
COALESCE((
SELECT array_agg(place_id ORDER BY sort_order)
FROM ${collectionPlaces}
WHERE collection_id = c.id
), ARRAY[]::int[]) AS place_ids,
COALESCE((
SELECT array_agg(user_id ORDER BY (role = 'owner') DESC, joined_at)
FROM ${collectionMembers}
WHERE collection_id = c.id
), ARRAY[]::int[]) AS members
FROM ${collections} c
WHERE c.id IN (
SELECT collection_id FROM ${collectionMembers} WHERE user_id = ${uid}
) OR c.owner_id = ${uid}
ORDER BY c.created_at DESC
`);
return result.rows.map((r) => ({
id: r.id,
name: r.name,
type: r.type,
owner_id: r.owner_id,
trip_start: dateToStr(r.trip_start),
trip_end: dateToStr(r.trip_end),
member_count: Number(r.member_count),
place_count: Number(r.place_count),
my_role: r.my_role ?? "viewer",
cover_place_ids: r.cover_place_ids ?? [],
place_ids: r.place_ids ?? [],
members: r.members ?? [],
}));
}
export async function getAllUsers(): Promise<Record<number, User>> {
const rows = await db
.select({
id: users.id,
name: users.name,
email: users.email,
initials: users.initials,
avatarUrl: users.avatarUrl,
color: users.color,
})
.from(users);
return Object.fromEntries(
rows.map((u) => [
u.id,
{
id: u.id,
name: u.name,
email: u.email,
initials: u.initials,
avatar_url: u.avatarUrl,
color: u.color ?? undefined,
} as User,
]),
);
}
export async function getCurrentUser(): Promise<User | null> {
const uid = await getCurrentUserId();
if (!uid) return null;
const [u] = await db
.select({
id: users.id,
name: users.name,
email: users.email,
initials: users.initials,
avatarUrl: users.avatarUrl,
color: users.color,
})
.from(users)
.where(eq(users.id, uid))
.limit(1);
if (!u) return null;
return {
id: u.id,
name: u.name,
email: u.email,
initials: u.initials,
avatar_url: u.avatarUrl,
color: u.color ?? undefined,
};
}