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.
 
 
 
 

475 lines
12 KiB

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<number>`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<number>();
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<number, CardImageData[]>();
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<number, TagData[]>();
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<CardWithRelations | null> {
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<CardWithRelations> {
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<CardWithRelations | null> {
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)
}
}