import { dbGlobal } from "drizzle-pkg/lib/db"; import { cards, cardImages, cardTags, tags, categories, favorites, articleCards, articles, type CardType, CardTypes, } from "drizzle-pkg/lib/schema/content"; import { eq, desc, asc, and, or, like, sql, inArray } from "drizzle-orm"; export { type CardType, CardTypes }; // ============ Types ============ export interface CardImageData { id: number; url: string; sortOrder: number; } export interface TagData { id: number; name: string; slug: string; } export interface ArticleRef { id: number; title: string; } export interface CardWithRelations { id: number; type: CardType; title: string; description: string | null; content: string | null; aspectRatio: number | null; categoryId: string | null; createdAt: Date; updatedAt: Date; images: CardImageData[]; tags: TagData[]; articles: ArticleRef[]; isFavorited?: boolean; } export interface CreateCardInput { type: CardType; title: string; description?: string | null; content?: string | null; aspectRatio?: number | null; categoryId?: string | null; images?: { url: string; sortOrder?: number }[] | null; tagIds?: number[] | null; articleIds?: number[] | null; } export interface UpdateCardInput { type?: CardType; title?: string; description?: string | null; content?: string | null; aspectRatio?: number | null; categoryId?: string | null; images?: { url: string; sortOrder?: number }[] | null; tagIds?: number[] | null; articleIds?: number[] | null; } export interface ListCardsOptions { page?: number; pageSize?: number; categoryId?: string; tagId?: number; type?: CardType; q?: string; favorited?: boolean; userId?: number; } // ============ Helpers ============ function now() { return new Date(); } async function incrCategoryCount(categoryId: string | null, delta: number) { if (!categoryId) return const [row] = await dbGlobal .select({ count: categories.count }) .from(categories) .where(eq(categories.id, categoryId)) .limit(1) if (!row) return await dbGlobal .update(categories) .set({ count: Math.max(0, row.count + delta) }) .where(eq(categories.id, categoryId)) } // ============ CRUD ============ export async function listCards( opts: ListCardsOptions, ): Promise<{ items: CardWithRelations[]; total: number; page: number; pageSize: number; hasMore: boolean }> { const page = opts.page ?? 1; const pageSize = opts.pageSize ?? 12; const conditions = []; if (opts.categoryId === '__uncategorized__') { conditions.push(sql`${cards.categoryId} IS NULL`); } else if (opts.categoryId) { conditions.push(eq(cards.categoryId, opts.categoryId)); } if (opts.type) conditions.push(eq(cards.type, opts.type)); // search if (opts.q) { const term = `%${opts.q}%` conditions.push( or( like(cards.title, term), like(cards.description, term), like(cards.content, term), )! ) } // tag filter: find card IDs that have this tag, then filter let tagCardIds: number[] | null = null; if (opts.tagId) { const tagRows = await dbGlobal .select({ cardId: cardTags.cardId }) .from(cardTags) .where(eq(cardTags.tagId, opts.tagId)); tagCardIds = tagRows.map((r) => r.cardId); if (tagCardIds.length === 0) { return { items: [], total: 0, page, pageSize, hasMore: false }; } conditions.push(inArray(cards.id, tagCardIds)); } // favorites filter (requires authenticated user) if (opts.favorited && opts.userId) { const favCardIds = await dbGlobal .select({ cardId: favorites.cardId }) .from(favorites) .where(eq(favorites.userId, opts.userId)); const favIds = favCardIds.map((r) => r.cardId); if (favIds.length === 0) { return { items: [], total: 0, page, pageSize, hasMore: false }; } conditions.push(inArray(cards.id, favIds)); } const where = conditions.length > 0 ? and(...conditions) : undefined; const [rows, countResult] = await Promise.all([ dbGlobal .select() .from(cards) .where(where) .orderBy(desc(cards.createdAt)) .limit(pageSize) .offset((page - 1) * pageSize), dbGlobal .select({ count: sql`count(*)` }) .from(cards) .where(where), ]); const total = countResult[0]?.count ?? 0; // Load relations for this page const cardIds = rows.map((r) => r.id); const [allImages, allCardTags] = await Promise.all([ cardIds.length > 0 ? dbGlobal .select() .from(cardImages) .where(inArray(cardImages.cardId, cardIds)) .orderBy(asc(cardImages.sortOrder)) : Promise.resolve([]), cardIds.length > 0 ? dbGlobal .select() .from(cardTags) .where(inArray(cardTags.cardId, cardIds)) : Promise.resolve([]), ]); // Load favorite status for current user let favoritedSet = new Set(); if (opts.userId && cardIds.length > 0) { const favRows = await dbGlobal .select({ cardId: favorites.cardId }) .from(favorites) .where( and( eq(favorites.userId, opts.userId), inArray(favorites.cardId, cardIds), ), ); favoritedSet = new Set(favRows.map((r) => r.cardId)); } // Load referenced tags const tagIds = [...new Set(allCardTags.map((ct) => ct.tagId))]; const tagRows = tagIds.length > 0 ? await dbGlobal.select().from(tags).where(inArray(tags.id, tagIds)) : []; const tagMap = new Map(tagRows.map((t) => [t.id, t])); const imagesByCard = new Map(); for (const img of allImages) { if (!imagesByCard.has(img.cardId)) imagesByCard.set(img.cardId, []); imagesByCard.get(img.cardId)!.push({ id: img.id, url: img.url, sortOrder: img.sortOrder, }); } const tagsByCard = new Map(); for (const ct of allCardTags) { const tag = tagMap.get(ct.tagId); if (!tag) continue; if (!tagsByCard.has(ct.cardId)) tagsByCard.set(ct.cardId, []); tagsByCard.get(ct.cardId)!.push({ id: tag.id, name: tag.name, slug: tag.slug, }); } const items: CardWithRelations[] = rows.map((row) => ({ id: row.id, type: row.type as CardType, title: row.title, description: row.description, content: row.content, aspectRatio: row.aspectRatio, categoryId: row.categoryId, createdAt: row.createdAt, updatedAt: row.updatedAt, images: imagesByCard.get(row.id) ?? [], tags: tagsByCard.get(row.id) ?? [], articles: [], isFavorited: opts.userId ? favoritedSet.has(row.id) : undefined, })); return { items, total, page, pageSize, hasMore: page * pageSize < total, }; } export async function getCardById(id: number): Promise { const rows = await dbGlobal .select() .from(cards) .where(eq(cards.id, id)) .limit(1); const row = rows[0]; if (!row) return null; const [images, cardTagRows, articleCardRows] = await Promise.all([ dbGlobal .select() .from(cardImages) .where(eq(cardImages.cardId, id)) .orderBy(asc(cardImages.sortOrder)), dbGlobal.select().from(cardTags).where(eq(cardTags.cardId, id)), dbGlobal.select().from(articleCards).where(eq(articleCards.cardId, id)), ]); const tagIds = cardTagRows.map((ct) => ct.tagId); const tagRows = tagIds.length > 0 ? await dbGlobal.select().from(tags).where(inArray(tags.id, tagIds)) : []; const articleIds = articleCardRows.map((ac) => ac.articleId); const articleRows = articleIds.length > 0 ? await dbGlobal .select({ id: articles.id, title: articles.title }) .from(articles) .where(inArray(articles.id, articleIds)) : []; return { id: row.id, type: row.type as CardType, title: row.title, description: row.description, content: row.content, aspectRatio: row.aspectRatio, categoryId: row.categoryId, createdAt: row.createdAt, updatedAt: row.updatedAt, images: images.map((img) => ({ id: img.id, url: img.url, sortOrder: img.sortOrder, })), tags: tagRows.map((t) => ({ id: t.id, name: t.name, slug: t.slug, })), articles: articleRows.map((a) => ({ id: a.id, title: a.title, })), }; } export async function createCard(input: CreateCardInput): Promise { const [inserted] = await dbGlobal .insert(cards) .values({ type: input.type, title: input.title, description: input.description ?? null, content: input.content ?? null, aspectRatio: input.aspectRatio ?? null, categoryId: input.categoryId ?? null, }) .returning({ id: cards.id }); if (!inserted) throw new Error("Failed to create card"); const cardId = inserted.id; // Insert images if (input.images && input.images.length > 0) { await dbGlobal.insert(cardImages).values( input.images.map((img, i) => ({ cardId, url: img.url, sortOrder: img.sortOrder ?? i, })), ); } // Insert tag associations if (input.tagIds && input.tagIds.length > 0) { await dbGlobal.insert(cardTags).values( input.tagIds.map((tagId) => ({ cardId, tagId, })), ); } // Insert article associations if (input.articleIds && input.articleIds.length > 0) { await dbGlobal.insert(articleCards).values( input.articleIds.map((articleId, i) => ({ articleId, cardId, sortOrder: i, })), ); } // Update category count if (input.categoryId) { await incrCategoryCount(input.categoryId, 1) } return (await getCardById(cardId))!; } export async function updateCard( id: number, input: UpdateCardInput, ): Promise { const existing = await getCardById(id); if (!existing) return null; const oldCatId = existing.categoryId await dbGlobal .update(cards) .set({ ...(input.type !== undefined && { type: input.type }), ...(input.title !== undefined && { title: input.title }), ...(input.description !== undefined && { description: input.description }), ...(input.content !== undefined && { content: input.content }), ...(input.aspectRatio !== undefined && { aspectRatio: input.aspectRatio }), ...(input.categoryId !== undefined && { categoryId: input.categoryId }), updatedAt: now(), }) .where(eq(cards.id, id)); // Update category counts if category changed if (input.categoryId !== undefined && input.categoryId !== oldCatId) { if (oldCatId) await incrCategoryCount(oldCatId, -1) if (input.categoryId) await incrCategoryCount(input.categoryId, 1) } // Replace images if provided if (input.images !== undefined) { await dbGlobal.delete(cardImages).where(eq(cardImages.cardId, id)); if (input.images !== null && input.images.length > 0) { await dbGlobal.insert(cardImages).values( input.images.map((img, i) => ({ cardId: id, url: img.url, sortOrder: img.sortOrder ?? i, })), ); } } // Replace tags if provided if (input.tagIds !== undefined) { await dbGlobal.delete(cardTags).where(eq(cardTags.cardId, id)); if (input.tagIds !== null && input.tagIds.length > 0) { await dbGlobal.insert(cardTags).values( input.tagIds.map((tagId) => ({ cardId: id, tagId, })), ); } } // Replace article associations if provided if (input.articleIds !== undefined) { await dbGlobal.delete(articleCards).where(eq(articleCards.cardId, id)); if (input.articleIds !== null && input.articleIds.length > 0) { await dbGlobal.insert(articleCards).values( input.articleIds.map((articleId, i) => ({ articleId, cardId: id, sortOrder: i, })), ); } } return getCardById(id); } export async function deleteCard(id: number) { // Get category before deleting (for count update) const [row] = await dbGlobal .select({ categoryId: cards.categoryId }) .from(cards) .where(eq(cards.id, id)) .limit(1) // Clean up related records await dbGlobal.delete(cardImages).where(eq(cardImages.cardId, id)); await dbGlobal.delete(cardTags).where(eq(cardTags.cardId, id)); await dbGlobal.delete(articleCards).where(eq(articleCards.cardId, id)); await dbGlobal.delete(favorites).where(eq(favorites.cardId, id)); await dbGlobal.delete(cards).where(eq(cards.id, id)); // Decrement category count if (row?.categoryId) { await incrCategoryCount(row.categoryId, -1) } }