import { dbGlobal } from "drizzle-pkg/lib/db"; import { articles, articleCards, cards, cardImages, type ArticleStatus, ArticleStatuses, } from "drizzle-pkg/lib/schema/content"; import { eq, desc, and, or, like, sql, inArray } from "drizzle-orm"; export { type ArticleStatus, ArticleStatuses }; // ============ Types ============ export interface CardRef { id: number; title: string; type: string; description: string | null; aspectRatio: number | null; coverUrl: string | null; sortOrder: number; } export interface ArticleWithCards { id: number; title: string; content: string; summary: string | null; cover: string | null; status: ArticleStatus; createdAt: Date; updatedAt: Date; cards: CardRef[]; } export interface CreateArticleInput { title: string; content: string; summary?: string | null; cover?: string | null; status?: ArticleStatus; cardIds?: { cardId: number; sortOrder?: number }[] | null; } export interface UpdateArticleInput { title?: string; content?: string; summary?: string | null; cover?: string | null; status?: ArticleStatus; cardIds?: { cardId: number; sortOrder?: number }[] | null; } export interface ListArticlesOptions { page?: number; pageSize?: number; status?: ArticleStatus; q?: string; } // ============ Helpers ============ function now() { return new Date(); } // ============ CRUD ============ export async function listArticles( opts: ListArticlesOptions, ): Promise<{ items: ArticleWithCards[]; total: number; page: number; pageSize: number; hasMore: boolean; }> { const page = opts.page ?? 1; const pageSize = opts.pageSize ?? 12; const conditions = []; if (opts.status) conditions.push(eq(articles.status, opts.status)); if (opts.q) { const term = `%${opts.q}%`; conditions.push(or(like(articles.title, term), like(articles.content, term))!); } const where = conditions.length > 0 ? and(...conditions) : undefined; const [rows, countResult] = await Promise.all([ dbGlobal .select() .from(articles) .where(where) .orderBy(desc(articles.createdAt)) .limit(pageSize) .offset((page - 1) * pageSize), dbGlobal .select({ count: sql`count(*)` }) .from(articles) .where(where), ]); const total = countResult[0]?.count ?? 0; // Load related cards for this page const articleIds = rows.map((r) => r.id); const cardMap = articleIds.length > 0 ? await loadCardMap(articleIds) : new Map(); const items: ArticleWithCards[] = rows.map((row) => ({ id: row.id, title: row.title, content: row.content, summary: row.summary, cover: row.cover, status: row.status as ArticleStatus, createdAt: row.createdAt, updatedAt: row.updatedAt, cards: cardMap.get(row.id) ?? [], })); return { items, total, page, pageSize, hasMore: page * pageSize < total }; } export async function getArticleById( id: number, ): Promise { const rows = await dbGlobal .select() .from(articles) .where(eq(articles.id, id)) .limit(1); const row = rows[0]; if (!row) return null; const cardMap = await loadCardMap([id]); return { id: row.id, title: row.title, content: row.content, summary: row.summary, cover: row.cover, status: row.status as ArticleStatus, createdAt: row.createdAt, updatedAt: row.updatedAt, cards: cardMap.get(id) ?? [], }; } export async function createArticle( input: CreateArticleInput, ): Promise { const [inserted] = await dbGlobal .insert(articles) .values({ title: input.title, content: input.content, summary: input.summary ?? null, cover: input.cover ?? null, status: input.status ?? "draft", }) .returning({ id: articles.id }); if (!inserted) throw new Error("Failed to create article"); const articleId = inserted.id; // Insert card associations if (input.cardIds && input.cardIds.length > 0) { await dbGlobal.insert(articleCards).values( input.cardIds.map((c, i) => ({ articleId, cardId: c.cardId, sortOrder: c.sortOrder ?? i, })), ); } return (await getArticleById(articleId))!; } export async function updateArticle( id: number, input: UpdateArticleInput, ): Promise { const existing = await getArticleById(id); if (!existing) return null; await dbGlobal .update(articles) .set({ ...(input.title !== undefined && { title: input.title }), ...(input.content !== undefined && { content: input.content }), ...(input.summary !== undefined && { summary: input.summary }), ...(input.cover !== undefined && { cover: input.cover }), ...(input.status !== undefined && { status: input.status }), updatedAt: now(), }) .where(eq(articles.id, id)); // Replace card associations if provided if (input.cardIds !== undefined) { await dbGlobal.delete(articleCards).where(eq(articleCards.articleId, id)); if (input.cardIds !== null && input.cardIds.length > 0) { await dbGlobal.insert(articleCards).values( input.cardIds.map((c, i) => ({ articleId: id, cardId: c.cardId, sortOrder: c.sortOrder ?? i, })), ); } } return getArticleById(id); } export async function deleteArticle(id: number) { // articleCards cascade-deletes via FK, but clean up explicitly too await dbGlobal.delete(articleCards).where(eq(articleCards.articleId, id)); await dbGlobal.delete(articles).where(eq(articles.id, id)); } // ============ Internal ============ async function loadCardMap( articleIds: number[], ): Promise> { const acRows = await dbGlobal .select() .from(articleCards) .where(inArray(articleCards.articleId, articleIds)) .orderBy(articleCards.sortOrder); if (acRows.length === 0) return new Map(); const cardIds = [...new Set(acRows.map((ac) => ac.cardId))]; // Load cards + their first image as cover const cardRows = await dbGlobal .select() .from(cards) .where(inArray(cards.id, cardIds)); // Load first image per card (as cover) const imgRows = cardIds.length > 0 ? await dbGlobal .select() .from(cardImages) .where(inArray(cardImages.cardId, cardIds)) .orderBy(cardImages.sortOrder) : []; // Build card lookup const cardLookup = new Map( cardRows.map((c) => [c.id, c]), ); // First image per card const coverMap = new Map(); for (const img of imgRows) { if (!coverMap.has(img.cardId)) { coverMap.set(img.cardId, img.url); } } const result = new Map(); for (const ac of acRows) { const card = cardLookup.get(ac.cardId); if (!card) continue; if (!result.has(ac.articleId)) result.set(ac.articleId, []); result.get(ac.articleId)!.push({ id: card.id, title: card.title, type: card.type, description: card.description, aspectRatio: card.aspectRatio, coverUrl: coverMap.get(card.id) ?? null, sortOrder: ac.sortOrder, }); } return result; }