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 { 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, // Computed from per-user ratings — replaces the (unused) manual // places.avg_rating column for display. Returns null when no one rated. avgRating: sql`( SELECT to_char(avg(rating), 'FM999990.00') FROM ${userPlaceData} upd2 WHERE upd2.place_id = ${places.id} AND upd2.rating IS NOT NULL )`, 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 { 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, // Computed from per-user ratings — replaces the (unused) manual // places.avg_rating column for display. Returns null when no one rated. avgRating: sql`( SELECT to_char(avg(rating), 'FM999990.00') FROM ${userPlaceData} upd2 WHERE upd2.place_id = ${places.id} AND upd2.rating IS NOT NULL )`, 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[]; member_roles: Record | null; }; export async function getCollectionsForUser( userId?: number, ): Promise { 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(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, COALESCE(( SELECT jsonb_object_agg(user_id::text, role) FROM ${collectionMembers} WHERE collection_id = c.id ), '{}'::jsonb) AS member_roles 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 ?? [], member_roles: r.member_roles ?? {}, })); } export async function getAllUsers(): Promise> { 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 { 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, }; }