import { and, asc, count, desc, eq, ilike, inArray, isNotNull, lte, or, sql, type SQL } from 'drizzle-orm' import { db } from '../db' import { blocks, postTypes, posts, projects, type Block, type Post, type Project } from '../schema' import type { BlockDraft } from '~/domain/blocks' import type { Audience, PostStatus, PostTypeRef } from '~/domain/types' export type EntryValues = { title: string teaser: string | null slug: string typeId: string audience: Audience coverMediaId: string | null } export type EntryCreateValues = EntryValues & { projectId: string authorName: string | null } export type EntryFilter = { projectIds?: string[] projectId?: string status?: PostStatus type?: string search?: string page: number perPage: number } export type EntryListItem = { id: string number: number | null slug: string title: string teaser: string | null type: PostTypeRef audience: Audience status: PostStatus publishAt: Date | null updatedAt: Date authorName: string | null projectId: string projectSlug: string projectName: string projectCode: string | null projectColor: string } export type EntryListResult = { items: EntryListItem[] total: number } export type EntryRecord = Post & { project: Project type: PostTypeRef } export type PublishedEntry = { id: string projectId: string number: number slug: string title: string } const typeJoin = eq(postTypes.id, posts.typeId) const typeSelection = { id: postTypes.id, key: postTypes.key, labelDe: postTypes.labelDe, labelEn: postTypes.labelEn, color: postTypes.color, } const listSelection = { id: posts.id, number: posts.number, slug: posts.slug, title: posts.title, teaser: posts.teaser, type: typeSelection, audience: posts.audience, status: posts.status, publishAt: posts.publishAt, updatedAt: posts.updatedAt, authorName: posts.authorName, projectId: posts.projectId, projectSlug: projects.slug, projectName: projects.name, projectCode: projects.code, projectColor: projects.color, } function filters(filter: EntryFilter): SQL[] { const parts: SQL[] = [] if (filter.projectIds) { parts.push(filter.projectIds.length === 0 ? sql`false` : inArray(posts.projectId, filter.projectIds)) } if (filter.projectId) { parts.push(eq(posts.projectId, filter.projectId)) } if (filter.status) { parts.push(eq(posts.status, filter.status)) } if (filter.type) { parts.push(eq(postTypes.key, filter.type)) } const search = filter.search?.trim() if (search) { const pattern = `%${search}%` const match = or(ilike(posts.title, pattern), ilike(posts.teaser, pattern)) if (match) { parts.push(match) } } return parts } export async function listEntries(filter: EntryFilter): Promise { const where = and(...filters(filter)) const items = await db .select(listSelection) .from(posts) .innerJoin(projects, eq(projects.id, posts.projectId)) .innerJoin(postTypes, typeJoin) .where(where) .orderBy(desc(posts.updatedAt), desc(posts.id)) .limit(filter.perPage) .offset((filter.page - 1) * filter.perPage) const [totals] = await db .select({ value: count() }) .from(posts) .innerJoin(projects, eq(projects.id, posts.projectId)) .innerJoin(postTypes, typeJoin) .where(where) return { items, total: Number(totals?.value ?? 0) } } export async function findEntry(id: string): Promise { const rows = await db .select({ post: posts, project: projects, type: typeSelection }) .from(posts) .innerJoin(projects, eq(projects.id, posts.projectId)) .innerJoin(postTypes, typeJoin) .where(eq(posts.id, id)) .limit(1) const row = rows[0] return row ? { ...row.post, project: row.project, type: row.type } : undefined } export async function findEntryBySlug(projectId: string, slug: string): Promise { const rows = await db .select() .from(posts) .where(and(eq(posts.projectId, projectId), eq(posts.slug, slug))) .limit(1) return rows[0] } export async function listEntryBlocks(postId: string): Promise { return db.select().from(blocks).where(eq(blocks.postId, postId)).orderBy(asc(blocks.sort), asc(blocks.id)) } export async function createEntry(values: EntryCreateValues): Promise { const rows = await db.execute<{ id: string }>(sql` with used as ( select count(*) + 1 as value from ${posts} where ${posts.projectId} = ${values.projectId}::uuid and (${posts.slug} = ${values.slug} or ${posts.slug} like ${values.slug} || '-%') ) insert into post (project_id, slug, title, teaser, type_id, audience, author_name, cover_media_id) select ${values.projectId}::uuid, case when exists ( select 1 from post taken_slug where taken_slug.project_id = ${values.projectId}::uuid and taken_slug.slug = ${values.slug} ) then ${values.slug} || '-' || used.value else ${values.slug} end, ${values.title}, ${values.teaser}, ${values.typeId}::uuid, ${values.audience}::post_audience, ${values.authorName}, ${values.coverMediaId}::uuid from used returning id `) const id = rows.rows[0]?.id if (!id) { throw new Error('post insert returned no row') } const created = await db.select().from(posts).where(eq(posts.id, id)).limit(1) return created[0]! } export async function moveEntryToProject(id: string, projectId: string): Promise { const current = await db.select().from(posts).where(eq(posts.id, id)).limit(1) const post = current[0] if (!post || post.projectId === projectId) { return post } await db.execute(sql` with taken as ( select coalesce(max(${posts.number}), 0) + 1 as value from ${posts} where ${posts.projectId} = ${projectId}::uuid ), counter as ( insert into post_counter (project_id, value) select ${projectId}::uuid, taken.value from taken on conflict (project_id) do update set value = greatest(post_counter.value + 1, excluded.value) returning value ) update post set project_id = ${projectId}::uuid, number = case when post.number is null then null else counter.value end, slug = case when exists ( select 1 from post taken_slug where taken_slug.project_id = ${projectId}::uuid and taken_slug.slug = ${post.slug} and taken_slug.id <> ${id}::uuid ) then ${post.slug} || '-' || counter.value else ${post.slug} end, updated_at = now() from counter where post.id = ${id}::uuid `) const moved = await db.select().from(posts).where(eq(posts.id, id)).limit(1) return moved[0] } export async function updateEntry(id: string, values: EntryValues): Promise { const rows = await db .update(posts) .set({ ...values, updatedAt: new Date() }) .where(eq(posts.id, id)) .returning() return rows[0] } export async function setEntryStatus( id: string, status: PostStatus, publishAt: Date | null, ): Promise { const rows = await db .update(posts) .set({ status, publishAt, updatedAt: new Date() }) .where(eq(posts.id, id)) .returning() const post = rows[0] if (!post || status !== 'published' || post.number !== null) { return post } return assignEntryNumber(id) } export async function assignEntryNumber(id: string): Promise { await db.execute(sql` with target as ( select project_id from post where id = ${id}::uuid and number is null ), taken as ( select coalesce(max(${posts.number}), 0) + 1 as value from ${posts}, target where ${posts.projectId} = target.project_id ), counter as ( insert into post_counter (project_id, value) select target.project_id, taken.value from target, taken on conflict (project_id) do update set value = greatest(post_counter.value + 1, excluded.value) returning value ) update post set number = counter.value from counter where post.id = ${id}::uuid and post.number is null `) const rows = await db.select().from(posts).where(eq(posts.id, id)).limit(1) return rows[0] } export async function deleteEntry(id: string): Promise { const rows = await db.delete(posts).where(eq(posts.id, id)).returning({ id: posts.id }) return rows.length > 0 } export async function replaceEntryBlocks(postId: string, drafts: BlockDraft[]): Promise { await db.transaction(async trx => { await trx.delete(blocks).where(eq(blocks.postId, postId)) if (drafts.length === 0) { return } await trx.insert(blocks).values(drafts.map((draft, index) => ({ postId, sort: index, type: draft.type, data: draft.data, }))) }) } export async function publishDue(now: Date): Promise { const rows = await db .update(posts) .set({ status: 'published', updatedAt: new Date() }) .where(and(eq(posts.status, 'scheduled'), isNotNull(posts.publishAt), lte(posts.publishAt, now))) .returning({ id: posts.id, projectId: posts.projectId, number: posts.number, slug: posts.slug, title: posts.title, }) const done: PublishedEntry[] = [] for (const row of rows) { const numbered = row.number === null ? await assignEntryNumber(row.id) : undefined done.push({ ...row, number: numbered?.number ?? row.number ?? 0 }) } return done }