290 lines
8.2 KiB
TypeScript
290 lines
8.2 KiB
TypeScript
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,
|
|
// Computed from per-user ratings — replaces the (unused) manual
|
|
// places.avg_rating column for display. Returns null when no one rated.
|
|
avgRating: sql<string | null>`(
|
|
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<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,
|
|
// Computed from per-user ratings — replaces the (unused) manual
|
|
// places.avg_rating column for display. Returns null when no one rated.
|
|
avgRating: sql<string | null>`(
|
|
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<string, Collection["my_role"]> | null;
|
|
};
|
|
|
|
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,
|
|
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<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,
|
|
};
|
|
}
|