Files
logbuch/src/data/repositories/entries.ts
T
Matthias G 2fd71fd406 Number entries on publish, delete media, read the cover
- drafts stay unnumbered, the number is handed out when an entry goes live
- DELETE /api/v1/media/:id and a delete control in the media library
- cover_media_id in the draft read, coverMediaId and coverUrl in the archive read
2026-08-01 12:25:01 +02:00

363 lines
9.0 KiB
TypeScript

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<EntryListResult> {
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<EntryRecord | undefined> {
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<Post | undefined> {
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<Block[]> {
return db.select().from(blocks).where(eq(blocks.postId, postId)).orderBy(asc(blocks.sort), asc(blocks.id))
}
export async function createEntry(values: EntryCreateValues): Promise<Post> {
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<Post | undefined> {
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<Post | undefined> {
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<Post | undefined> {
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<Post | undefined> {
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<boolean> {
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<void> {
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<PublishedEntry[]> {
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
}