You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

287 lines
7.1 KiB

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<number>`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<number, CardRef[]>();
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<ArticleWithCards | null> {
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<ArticleWithCards> {
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<ArticleWithCards | null> {
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<Map<number, CardRef[]>> {
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<number, string>();
for (const img of imgRows) {
if (!coverMap.has(img.cardId)) {
coverMap.set(img.cardId, img.url);
}
}
const result = new Map<number, CardRef[]>();
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;
}