import type {PoolClient} from 'pg'; import { db } from "./db"; import { decodeCursor, encodeCursor, type FeedData, type Post, type PostKind, type Tag } from "./model"; export async function tags(client?:PoolClient): Promise { return (await (client||db()).query('SELECT *,slug AS path FROM tags ORDER BY name,id')).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.derivation) FROM assets v WHERE v.source_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,client?:PoolClient):Promise{ const result=await (client||db()).query(`${expand} WHERE ${/^[0-9a-f]{24}$/.test(id)?'p.public_id':'p.id'}=$1`,[id]); return serialize(result.rows,await tags(client))[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=[`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 IN ('content','caption') AND ((a.storage_key IS NOT NULL AND a.byte_size>0) OR (a.kind='text' AND a.body IS NOT NULL)))`]; if(options.kind){values.push(options.kind);where.push(`p.kind=$${values.length}`);} if(target){values.push([target.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}; } export async function publishedPublicIds():Promise{return (await db().query("SELECT public_id FROM posts WHERE public_id IS NOT NULL")).rows.map(p=>p.public_id);}