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