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
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;
|
|
}
|
|
|