2fd71fd406
- 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
363 lines
9.0 KiB
TypeScript
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
|
|
}
|