import { dbGlobal } from "drizzle-pkg/lib/db"; import { posts } from "drizzle-pkg/lib/schema/content"; import { users } from "drizzle-pkg/lib/schema/auth"; import { and, desc, eq } from "drizzle-orm"; import { visibilitySchema, type Visibility } from "#server/constants/visibility"; import { visibilityShareToken } from "#server/utils/share-token"; import { nextIntegerId } from "#server/utils/sqlite-id"; const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,98}[a-z0-9]$|^[a-z0-9]$/; function assertSlug(slug: string) { if (!SLUG_RE.test(slug)) { throw createError({ statusCode: 400, statusMessage: "slug 格式无效(小写字母、数字、短横线,1–100 字符)" }); } } export async function listPostsForUser(userId: number) { return dbGlobal .select() .from(posts) .where(eq(posts.userId, userId)) .orderBy(desc(posts.publishedAt), desc(posts.id)); } export async function getPostForUser(userId: number, id: number) { const [row] = await dbGlobal .select() .from(posts) .where(and(eq(posts.id, id), eq(posts.userId, userId))) .limit(1); return row ?? null; } export async function createPost( userId: number, input: { title: string; slug: string; bodyMarkdown: string; excerpt: string; coverUrl?: string | null; tagsJson?: string; publishedAt?: Date | null; visibility: Visibility; }, ) { assertSlug(input.slug); const vis = visibilitySchema.parse(input.visibility); const shareToken = visibilityShareToken(vis, null); const id = await nextIntegerId(posts, posts.id); await dbGlobal.insert(posts).values({ id, userId, title: input.title.trim(), slug: input.slug.trim(), bodyMarkdown: input.bodyMarkdown, excerpt: input.excerpt, coverUrl: input.coverUrl?.trim() || undefined, tagsJson: input.tagsJson ?? "[]", publishedAt: input.publishedAt ?? new Date(), visibility: vis, shareToken, }); return getPostForUser(userId, id); } export async function updatePost( userId: number, id: number, patch: Partial<{ title: string; slug: string; bodyMarkdown: string; excerpt: string; coverUrl: string | null; tagsJson: string; publishedAt: Date | null; visibility: Visibility; }>, ) { const existing = await getPostForUser(userId, id); if (!existing) { return null; } if (patch.slug !== undefined) { assertSlug(patch.slug); } const nextVis = patch.visibility !== undefined ? visibilitySchema.parse(patch.visibility) : (existing.visibility as Visibility); const shareToken = patch.visibility !== undefined ? visibilityShareToken(nextVis, existing.shareToken) : existing.shareToken; await dbGlobal .update(posts) .set({ ...(patch.title !== undefined ? { title: patch.title.trim() } : {}), ...(patch.slug !== undefined ? { slug: patch.slug.trim() } : {}), ...(patch.bodyMarkdown !== undefined ? { bodyMarkdown: patch.bodyMarkdown } : {}), ...(patch.excerpt !== undefined ? { excerpt: patch.excerpt } : {}), ...(patch.coverUrl !== undefined ? { coverUrl: patch.coverUrl?.trim() || null } : {}), ...(patch.tagsJson !== undefined ? { tagsJson: patch.tagsJson } : {}), ...(patch.publishedAt !== undefined ? { publishedAt: patch.publishedAt } : {}), ...(patch.visibility !== undefined ? { visibility: nextVis, shareToken } : {}), }) .where(and(eq(posts.id, id), eq(posts.userId, userId))); return getPostForUser(userId, id); } export async function deletePost(userId: number, id: number) { const existing = await getPostForUser(userId, id); if (!existing) { return false; } await dbGlobal.delete(posts).where(and(eq(posts.id, id), eq(posts.userId, userId))); return true; } export async function listPublicPostsBySlug(publicSlug: string) { return dbGlobal .select({ title: posts.title, excerpt: posts.excerpt, slug: posts.slug, coverUrl: posts.coverUrl, publishedAt: posts.publishedAt, }) .from(posts) .innerJoin(users, eq(posts.userId, users.id)) .where( and( eq(users.publicSlug, publicSlug), eq(users.status, "active"), eq(posts.visibility, "public"), ), ) .orderBy(desc(posts.publishedAt), desc(posts.id)); } export async function getPublicPostByPublicSlugAndSlug(publicSlug: string, postSlug: string) { const slug = postSlug.trim(); if (!slug) { return null; } const [row] = await dbGlobal .select({ title: posts.title, slug: posts.slug, excerpt: posts.excerpt, bodyMarkdown: posts.bodyMarkdown, coverUrl: posts.coverUrl, publishedAt: posts.publishedAt, }) .from(posts) .innerJoin(users, eq(posts.userId, users.id)) .where( and( eq(users.publicSlug, publicSlug), eq(users.status, "active"), eq(posts.visibility, "public"), eq(posts.slug, slug), ), ) .limit(1); return row ?? null; } export async function getPublicPostCommentContext( publicSlug: string, postSlug: string, ): Promise<{ id: number; userId: number } | null> { const slug = postSlug.trim(); if (!slug) { return null; } const [row] = await dbGlobal .select({ id: posts.id, userId: posts.userId, }) .from(posts) .innerJoin(users, eq(posts.userId, users.id)) .where( and( eq(users.publicSlug, publicSlug), eq(users.status, "active"), eq(posts.visibility, "public"), eq(posts.slug, slug), ), ) .limit(1); return row ?? null; } export async function getUnlistedPostCommentContext( publicSlug: string, shareToken: string, ): Promise<{ id: number; userId: number } | null> { const [row] = await dbGlobal .select({ id: posts.id, userId: posts.userId, }) .from(posts) .innerJoin(users, eq(posts.userId, users.id)) .where( and( eq(users.publicSlug, publicSlug), eq(users.status, "active"), eq(posts.visibility, "unlisted"), eq(posts.shareToken, shareToken), ), ) .limit(1); return row ?? null; } export async function getUnlistedPost(publicSlug: string, shareToken: string) { const [row] = await dbGlobal .select({ post: posts }) .from(posts) .innerJoin(users, eq(posts.userId, users.id)) .where( and( eq(users.publicSlug, publicSlug), eq(users.status, "active"), eq(posts.visibility, "unlisted"), eq(posts.shareToken, shareToken), ), ) .limit(1); return row?.post ?? null; }