35 lines
3.3 KiB
TypeScript
35 lines
3.3 KiB
TypeScript
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<Tag[]> { 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<Post | null> {
|
|
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<Post|null>{
|
|
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<FeedData> {
|
|
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};
|
|
}
|