import { db } from "./db"; import { decodeCursor, encodeCursor, type FeedData, type Post, type PostKind, type Tag } from "./model"; import { works } from "../content/registry"; const tagTree = `WITH RECURSIVE tree AS (SELECT *,slug::text AS path FROM tags WHERE parent_id IS NULL UNION ALL SELECT t.*,tree.path||'/'||t.slug FROM tags t JOIN tree ON t.parent_id=tree.id) SELECT * FROM tree`; export async function tags(): Promise { return (await db().query(`${tagTree} ORDER BY path`)).rows; } const expand = `SELECT p.*, COALESCE((SELECT jsonb_agg(ae ORDER BY ae.position) FROM (SELECT a.*,pa.position,pa.role,COALESCE((SELECT jsonb_agg(v.role) FROM asset_variants v WHERE v.asset_id=a.id),'[]') AS variants FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id) ae),'[]') AS assets, COALESCE((SELECT jsonb_agg(t) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id WHERE pt.post_id=p.id),'[]') AS tags FROM posts p`; function serialize(posts: Post[], allTags: Tag[]): Post[] { return posts.map(p => ({...p, created_at: new Date(p.created_at).toISOString(), updated_at:new Date(p.updated_at).toISOString(), tags:p.tags.map(t=>allTags.find(a=>a.id===t.id)!).filter(Boolean)})); } export async function getPost(slug: string): Promise { const result = await db().query(`${expand} WHERE p.slug=$1`,[slug]); return serialize(result.rows,await tags())[0] || null; } export async function getPostById(id:string):Promise{ const result=await db().query(`${expand} WHERE p.id=$1`,[id]); return serialize(result.rows,await tags())[0]||null; } export async function feed(options: {kind?: PostKind; path?: string; cursor?: string; q?: string} = {}): Promise { const allTags=await tags(); const target=options.path ? allTags.find(t=>t.path===options.path) : undefined; if(options.path&&!target) throw new Error("Tag not found"); const cursor=decodeCursor(options.cursor); const values: unknown[]=[]; const where=[`((p.kind IN ('article','motion') AND p.page_key=ANY($1::text[])) OR EXISTS(SELECT 1 FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id AND pa.role='content' AND a.kind=p.kind AND a.storage_key IS NOT NULL AND a.byte_size>0))`]; values.push(Object.keys(works)); if(options.kind){values.push(options.kind);where.push(`p.kind=$${values.length}`);} if(target){values.push(allTags.filter(t=>t.path===target.path||t.path.startsWith(target.path+"/")).map(t=>t.id));where.push(`EXISTS(SELECT 1 FROM post_tags WHERE post_id=p.id AND tag_id=ANY($${values.length}::uuid[]))`);} if(options.q){values.push(`%${options.q.replace(/[\\%_]/g,"\\$&")}%`);where.push(`p.title ILIKE $${values.length}`);} if(cursor){values.push(...cursor);where.push(`(p.created_at,p.id)<($${values.length-1}::timestamptz,$${values.length}::uuid)`);} const rows=(await db().query(`${expand} WHERE ${where.join(' AND ')} ORDER BY p.created_at DESC,p.id DESC LIMIT 25`,values)).rows as Post[]; const hasMore=rows.length>24; const posts=serialize(rows.slice(0,24),allTags); const last=posts.at(-1); const contributions=(await db().query(`SELECT to_char(occurred_at AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day,sum(amount)::integer AS total FROM contribution_events WHERE occurred_at>=now()-interval '366 days' GROUP BY day ORDER BY day`)).rows; return {posts,tags:allTags,contributions,nextCursor:hasMore&&last?encodeCursor(last.created_at,last.id):null}; }