aaa
This commit is contained in:
270
src/lib/db/queries.ts
Normal file
270
src/lib/db/queries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user