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
This commit is contained in:
Matthias G
2026-08-01 12:25:01 +02:00
parent afaa323848
commit 2fd71fd406
31 changed files with 2286 additions and 76 deletions
+13 -18
View File
@@ -1,6 +1,6 @@
# Logbuch API
Stand: 31.07.2026. Diese Datei beschreibt den tatsächlichen Stand. Was noch nicht gebaut ist, steht am Ende unter "Noch nicht vorhanden" und ist dort auch so gekennzeichnet.
Stand: 01.08.2026. Diese Datei beschreibt den tatsächlichen Stand der Schnittstelle.
## Wofür Logbuch da ist
@@ -73,7 +73,7 @@ curl -s -H "Authorization: Bearer $TOKEN" \
Antwort: `{ "items": [...], "meta": { "total", "page", "per_page" } }`
Ein Eintrag in der Liste trägt: `id`, `slug`, `title`, `teaser`, `type`, `audience`, `publishAt`, `number`, `projectSlug`, `projectName`, `projectCode`, `projectColor`.
Ein Eintrag in der Liste trägt: `id`, `slug`, `title`, `teaser`, `type`, `audience`, `publishAt`, `number`, `coverMediaId`, `coverUrl`, `projectSlug`, `projectName`, `projectCode`, `projectColor`.
### GET /api/v1/posts/:slug
@@ -123,6 +123,15 @@ curl -s -X POST -H "Authorization: Bearer $TOKEN" \
Grenzen: nur Bildformate, höchstens 15 MB.
### DELETE /api/v1/media/:id
Löscht ein Bild samt seiner Fassungen. Hängt es noch an einem Beitrag, als Aufmacher oder in einem Block, kommt 409 zurück.
```bash
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:4700/api/v1/media/<id>
```
### GET /api/v1/media/file/...
Liefert eine Variante aus. Die Pfade stehen am Medium unter `variants`, je Eintrag mit `width`, `format` und `path`.
@@ -153,23 +162,9 @@ Wer über die API schreibt, schreibt für Menschen. Diese Regeln gelten für jed
**Wer veröffentlicht.** Nicht der Zugang. Ein Mensch schaut drauf und gibt frei. Ein über die API verfasster Beitrag ist immer ein Vorschlag.
## Noch nicht vorhanden
## Nummern
Ehrlich benannt, damit niemand daran vorbei entwickelt. Diese Endpunkte werden gerade gebaut:
| Geplant | Zweck |
|---|---|
| `POST /api/v1/posts` | Beitrag als Entwurf anlegen |
| `PATCH /api/v1/posts/:id` | Entwurf ändern |
| `PUT /api/v1/posts/:id/blocks` | Inhaltsblöcke setzen |
| `GET /api/v1/posts/:id/preview` | Entwurf zurücklesen |
| `DELETE /api/v1/posts/:id` | Entwurf löschen |
| `GET /api/v1/post-types` | verwaltbare Beitragsarten |
| `GET /api/v1/style-guide` | dieser Leitfaden, maschinenlesbar |
| `GET /api/v1/openapi.json` | maschinenlesbare Beschreibung |
| `/admin/api` | diese Dokumentation im Adminbereich |
Solange sie fehlen, kann ein Zugang mit Schreibrecht ausschließlich Bilder hochladen.
Die Nummer eines Eintrags wird beim Veröffentlichen vergeben, nicht beim Anlegen. Ein Entwurf hat `number: null`. Damit bleiben die Nummern im Archiv lückenlos, auch wenn Entwürfe wieder verworfen werden.
## Blocktypen
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "post" ALTER COLUMN "number" DROP NOT NULL;--> statement-breakpoint
UPDATE "post" SET "number" = NULL WHERE "status" = 'draft';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -71,6 +71,13 @@
"when": 1785502444366,
"tag": "0009_clean_mac_gargan",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1785579010161,
"tag": "0010_steady_wither",
"breakpoints": true
}
]
}
+7 -2
View File
@@ -703,7 +703,10 @@
"card": {
"meta": "{width} × {height} · {size} KB · {variants, plural, =0 {keine Fassung} one {# Fassung} other {# Fassungen}}",
"altMissing": "Ohne Alt-Text. Für öffentliche Beiträge ist er Pflicht.",
"variantError": "Die Fassungen konnten nicht erzeugt werden. Das Original ist gespeichert und nutzbar."
"variantError": "Die Fassungen konnten nicht erzeugt werden. Das Original ist gespeichert und nutzbar.",
"delete": "Bild löschen",
"deleteConfirm": "Wirklich löschen?",
"keep": "Behalten"
},
"messages": {
"uploaded": "Das Bild ist hochgeladen und die Fassungen sind erzeugt.",
@@ -714,7 +717,9 @@
"unreadableImage": "Die Datei lässt sich nicht als Bild lesen. Prüfe, ob sie vollständig ist.",
"projectUnknown": "Dieses Projekt gibt es nicht.",
"mediaUnknown": "Dieses Bild gibt es nicht mehr.",
"uploadFailed": "Das Hochladen hat gerade nicht geklappt. Versuch es in einem Moment noch einmal."
"uploadFailed": "Das Hochladen hat gerade nicht geklappt. Versuch es in einem Moment noch einmal.",
"deleted": "Das Bild ist gelöscht.",
"mediaInUse": "Das Bild hängt noch an einem Beitrag. Nimm es dort zuerst heraus."
}
},
"mail": {
+7 -2
View File
@@ -703,7 +703,10 @@
"card": {
"meta": "{width} × {height} · {size} KB · {variants, plural, =0 {no rendition} one {# rendition} other {# renditions}}",
"altMissing": "No alt text. It is mandatory for public entries.",
"variantError": "The renditions could not be created. The original is stored and usable."
"variantError": "The renditions could not be created. The original is stored and usable.",
"delete": "Delete image",
"deleteConfirm": "Really delete?",
"keep": "Keep"
},
"messages": {
"uploaded": "The image is uploaded and the renditions are created.",
@@ -714,7 +717,9 @@
"unreadableImage": "The file cannot be read as an image. Check whether it is complete.",
"projectUnknown": "This project does not exist.",
"mediaUnknown": "This image no longer exists.",
"uploadFailed": "Uploading did not work just now. Try again in a moment."
"uploadFailed": "Uploading did not work just now. Try again in a moment.",
"deleted": "The image is deleted.",
"mediaInUse": "The image is still used in an entry. Remove it there first."
}
},
"mail": {
+33
View File
@@ -0,0 +1,33 @@
import { findMedia } from '~/data/repositories/media'
import { resolveClient } from '~/lib/api-auth'
import { removeMedia } from '~/lib/media-delete'
import { problem } from '~/lib/problem'
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
if (client.mode !== 'write') {
return problem(403, 'forbidden', 'Dieses Token darf nur lesen.')
}
const { id } = await context.params
const item = await findMedia(id)
if (!item || (client.projectId !== null && item.projectId !== client.projectId)) {
return problem(404, 'media_not_found')
}
const result = await removeMedia(id, item.projectId)
if (!result.ok) {
return result.reason === 'in_use'
? problem(409, 'media_in_use', 'Das Bild hängt noch an einem Beitrag.')
: problem(404, 'media_not_found')
}
return Response.json({ deleted: id })
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { findPost, findPostIdBySlug } from '~/data/repositories/posts'
import { resolveClient } from '~/lib/api-auth'
import { withCover } from '~/lib/api-covers'
import { apiDeleteEntry, apiReadEntry, apiUpdateEntry } from '~/lib/api-entries'
import { entryProblem } from '~/lib/api-problem'
import { problem } from '~/lib/problem'
@@ -34,7 +35,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
const post = await findPost(slug, client.scope, client.projectId ?? undefined)
if (post) {
return Response.json(post)
return Response.json(await withCover(post))
}
const id = await findPostIdBySlug(slug)
+2 -1
View File
@@ -4,6 +4,7 @@ import { isPostTypeKey } from '~/domain/post-type'
import { apiCreateEntry } from '~/lib/api-entries'
import { entryProblem } from '~/lib/api-problem'
import { resolveClient } from '~/lib/api-auth'
import { withCovers } from '~/lib/api-covers'
import { problem } from '~/lib/problem'
const querySchema = z.object({
@@ -42,7 +43,7 @@ export async function GET(request: Request) {
})
return Response.json({
items: result.items,
items: await withCovers(result.items),
meta: { total: result.total, page: query.page, per_page: query.per_page },
})
}
+3 -2
View File
@@ -35,6 +35,7 @@ import {
textareaClass,
} from './styles'
import { Select } from './Select'
import { entryMark } from '~/components/ui/Plate'
import { blockTypes, emptyBlockData, isBlockEmpty, type BlockType, type BlockValues } from '~/domain/blocks'
import { checkPublish } from '~/domain/publish-checks'
import { defaultPostTypeColor } from '~/domain/post-type'
@@ -64,7 +65,7 @@ export type EntryEditorProject = {
export type EntryEditorEntry = {
id: string
number: number
number: number | null
slug: string
status: PostStatus
publishAt: string | null
@@ -498,7 +499,7 @@ export function EntryEditor({ projects, types, media: initialMedia, accept, defa
{status ? (
<span className={metaClass}>
{project ? `${project.code}-${String(status.number).padStart(4, '0')}` : status.number}
{entryMark({ code: project?.code ?? null, slug: project?.slug ?? '' }, status.number)}
</span>
) : null}
+3
View File
@@ -1,6 +1,7 @@
import Image from 'next/image'
import { getTranslations } from 'next-intl/server'
import { TbAlertTriangle } from 'react-icons/tb'
import { MediaDeleteButton } from './MediaDeleteButton'
import { MediaMetaForm } from './MediaMetaForm'
import { metaClass } from './styles'
import { hasAlt, mediaAlt, mediaThumbnail } from '~/lib/media-url'
@@ -58,6 +59,8 @@ export async function MediaCard({ item, project }: MediaCardProps) {
)}
<MediaMetaForm mediaId={item.id} project={project} alt={item.alt ?? ''} caption={item.caption ?? ''} />
<MediaDeleteButton mediaId={item.id} project={project} />
</div>
</li>
)
@@ -0,0 +1,53 @@
'use client'
import { useActionState, useState } from 'react'
import { useTranslations } from 'next-intl'
import { TbTrash } from 'react-icons/tb'
import { AdminSubmit } from './AdminSubmit'
import { MediaMessage } from './MediaMessage'
import { deleteMediaAction } from '~/lib/media-actions'
import { emptyAdminFormState, type AdminFormState } from '~/lib/admin-forms'
export type MediaDeleteButtonProps = {
mediaId: string
project: string
}
export function MediaDeleteButton({ mediaId, project }: MediaDeleteButtonProps) {
const t = useTranslations('media')
const [asking, setAsking] = useState(false)
const [state, formAction] = useActionState<AdminFormState, FormData>(deleteMediaAction, emptyAdminFormState)
return (
<form action={formAction} className="flex flex-col gap-2">
<input type="hidden" name="mediaId" value={mediaId} />
<input type="hidden" name="project" value={project} />
<MediaMessage state={state} />
{asking ? (
<span className="flex flex-wrap items-center gap-3">
<AdminSubmit quiet pendingLabel={t('card.delete')} icon={<TbTrash className="size-4" />}>
{t('card.deleteConfirm')}
</AdminSubmit>
<button
type="button"
onClick={() => setAsking(false)}
className="cursor-pointer bg-transparent p-1 font-mono text-micro font-semibold uppercase tracking-label text-ink-3 hover:text-ink"
>
{t('card.keep')}
</button>
</span>
) : (
<button
type="button"
onClick={() => setAsking(true)}
className="inline-flex cursor-pointer items-center gap-1.5 self-start bg-transparent p-1 font-mono text-micro font-semibold uppercase tracking-label text-ink-3 hover:text-signal"
>
<TbTrash aria-hidden="true" className="size-4 shrink-0" />
{t('card.delete')}
</button>
)}
</form>
)
}
+3 -1
View File
@@ -132,7 +132,9 @@ export function PostDetail({ detail }: PostDetailProps) {
{media.length > 0 ? (
<FieldRow label={t('fields.images')}>{entry('images', { count: media.length })}</FieldRow>
) : null}
<FieldRow label={t('fields.number')}>{entryNumber(post.number)}</FieldRow>
{post.number === null ? null : (
<FieldRow label={t('fields.number')}>{entryNumber(post.number)}</FieldRow>
)}
</div>
<div className="mt-8">
<Link
+4 -4
View File
@@ -6,7 +6,7 @@ export type PlateSize = 'lead' | 'card' | 'row'
export type PlateProps = {
project: ProjectCodeSource
color: string
number: number
number: number | null
size?: PlateSize
showNumber?: boolean
className?: string
@@ -22,8 +22,8 @@ export function entryNumber(number: number): string {
return String(number).padStart(4, '0')
}
export function entryMark(project: ProjectCodeSource, number: number): string {
return `${projectCode(project)}-${entryNumber(number)}`
export function entryMark(project: ProjectCodeSource, number: number | null): string {
return number === null ? projectCode(project) : `${projectCode(project)}-${entryNumber(number)}`
}
const shells: Record<PlateSize, string> = {
@@ -51,7 +51,7 @@ export function Plate({ project, color, number, size = 'row', showNumber = true,
className={`${projectSurface} ${shells[size]} font-mono font-semibold text-paper ${className ?? ''}`}
>
<span className={`block uppercase ${codeSizes[size]}`}>{projectCode(project)}</span>
{showNumber ? (
{showNumber && number !== null ? (
<span className={`block tabular-nums ${numberSizes[size]}`}>{entryNumber(number)}</span>
) : null}
</span>
+5 -1
View File
@@ -1,11 +1,15 @@
import { entryNumber } from './Plate'
export type WatermarkProps = {
number: number
number: number | null
className?: string
}
export function Watermark({ number, className }: WatermarkProps) {
if (number === null) {
return null
}
return (
<span
aria-hidden="true"
+1 -1
View File
@@ -106,7 +106,7 @@ const selection = {
audience: posts.audience,
publishAt: posts.publishAt,
authorName: posts.authorName,
number: posts.number,
number: sql<number>`coalesce(${posts.number}, 0)`.as('number'),
projectSlug: projects.slug,
projectName: projects.name,
projectCode: projects.code,
+47 -14
View File
@@ -30,7 +30,7 @@ export type EntryFilter = {
export type EntryListItem = {
id: string
number: number
number: number | null
slug: string
title: string
teaser: string | null
@@ -180,26 +180,21 @@ export async function listEntryBlocks(postId: string): Promise<Block[]> {
export async function createEntry(values: EntryCreateValues): Promise<Post> {
const rows = await db.execute<{ id: string }>(sql`
with taken as (
select coalesce(max(${posts.number}), 0) + 1 as value
with used as (
select count(*) + 1 as value
from ${posts}
where ${posts.projectId} = ${values.projectId}::uuid
), counter as (
insert into post_counter (project_id, value)
select ${values.projectId}::uuid, taken.value from taken
on conflict (project_id) do update set value = greatest(post_counter.value + 1, excluded.value)
returning value
and (${posts.slug} = ${values.slug} or ${posts.slug} like ${values.slug} || '-%')
)
insert into post (project_id, number, slug, title, teaser, type_id, audience, author_name, cover_media_id)
insert into post (project_id, slug, title, teaser, type_id, audience, author_name, cover_media_id)
select
${values.projectId}::uuid,
counter.value,
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} || '-' || counter.value
then ${values.slug} || '-' || used.value
else ${values.slug}
end,
${values.title},
@@ -208,7 +203,7 @@ export async function createEntry(values: EntryCreateValues): Promise<Post> {
${values.audience}::post_audience,
${values.authorName},
${values.coverMediaId}::uuid
from counter
from used
returning id
`)
@@ -244,7 +239,7 @@ export async function moveEntryToProject(id: string, projectId: string): Promise
)
update post set
project_id = ${projectId}::uuid,
number = counter.value,
number = case when post.number is null then null else counter.value end,
slug = case
when exists (
select 1 from post taken_slug
@@ -286,6 +281,36 @@ export async function setEntryStatus(
.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]
}
@@ -325,5 +350,13 @@ export async function publishDue(now: Date): Promise<PublishedEntry[]> {
title: posts.title,
})
return rows
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
}
+17 -1
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'
import { db } from '../db'
import { media, type Media, type NewMedia } from '../schema'
@@ -51,3 +51,19 @@ export async function deleteMedia(id: string, projectId: string): Promise<Media
return row
}
export async function countMediaReferences(id: string): Promise<number> {
const rows = await db.execute<{ value: number }>(sql`
select
(select count(*) from post where cover_media_id = ${id}::uuid)
+ (
select count(*) from block
where data->>'mediaId' = ${id}
or data->>'beforeMediaId' = ${id}
or data->>'afterMediaId' = ${id}
or (jsonb_typeof(data->'mediaIds') = 'array' and data->'mediaIds' @> to_jsonb(${id}::text))
) as value
`)
return Number(rows.rows[0]?.value ?? 0)
}
+3 -1
View File
@@ -24,6 +24,7 @@ export type PostListItem = {
audience: string
publishAt: Date | null
authorName: string | null
coverMediaId: string | null
number: number
projectSlug: string
projectName: string
@@ -55,7 +56,8 @@ const selection = {
audience: posts.audience,
publishAt: posts.publishAt,
authorName: posts.authorName,
number: posts.number,
coverMediaId: posts.coverMediaId,
number: sql<number>`coalesce(${posts.number}, 0)`.as('number'),
projectSlug: projects.slug,
projectName: projects.name,
projectCode: projects.code,
+1 -1
View File
@@ -32,7 +32,7 @@ export async function listUnread(args: UnreadArgs): Promise<PostListItem[]> {
audience: posts.audience,
publishAt: posts.publishAt,
authorName: posts.authorName,
number: posts.number,
number: sql<number>`coalesce(${posts.number}, 0)`.as('number'),
projectSlug: projects.slug,
projectName: projects.name,
projectCode: projects.code,
+1 -1
View File
@@ -24,7 +24,7 @@ export const posts = pgTable('post', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
issueId: uuid('issue_id').references(() => issues.id, { onDelete: 'set null' }),
number: integer('number').notNull(),
number: integer('number'),
slug: text('slug').notNull(),
title: text('title').notNull(),
teaser: text('teaser'),
+22
View File
@@ -0,0 +1,22 @@
import { listMediaByIds } from '~/data/repositories/media'
import type { PostListItem } from '~/data/repositories/posts'
import { mediaSrc } from './media-url'
export type PostListItemWithCover = PostListItem & { coverUrl: string | null }
export async function withCovers(items: PostListItem[]): Promise<PostListItemWithCover[]> {
const ids = [...new Set(items.map(item => item.coverMediaId).filter((id): id is string => id !== null))]
const found = ids.length === 0 ? [] : await listMediaByIds(ids)
const urls = new Map(found.map(item => [item.id, mediaSrc(item)]))
return items.map(item => ({
...item,
coverUrl: item.coverMediaId === null ? null : urls.get(item.coverMediaId) ?? null,
}))
}
export async function withCover(item: PostListItem): Promise<PostListItemWithCover> {
const [done] = await withCovers([item])
return done!
}
+3 -1
View File
@@ -41,7 +41,8 @@ export type ApiEntryResult<T> =
export type ApiEntryView = {
id: string
number: number
number: number | null
cover_media_id: string | null
slug: string
title: string
teaser: string | null
@@ -89,6 +90,7 @@ function view(entry: EntryRecord, blocks: Block[]): ApiEntryView {
return {
id: entry.id,
number: entry.number,
cover_media_id: entry.coverMediaId,
slug: entry.slug,
title: entry.title,
teaser: entry.teaser,
+1 -1
View File
@@ -64,7 +64,7 @@ export type EntryErrorKey =
export type EntryState = {
id: string
number: number
number: number | null
slug: string
status: PostStatus
publishAt: string | null
+25
View File
@@ -6,6 +6,7 @@ import { findProjectBySlug } from '~/data/repositories/projects'
import { invalid, readField, type AdminFormState } from './admin-forms'
import { adminMediaProjectPath } from './auth-routes'
import { requireProjectAccess } from './auth-guards'
import { removeMedia } from './media-delete'
import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload'
const failureKeys: Record<UploadFailure, string> = {
@@ -80,3 +81,27 @@ export async function saveMediaMetaAction(_state: AdminFormState, formData: Form
return { status: 'ok', message: 'saved', id: saved.id }
}
export async function deleteMediaAction(_state: AdminFormState, formData: FormData): Promise<AdminFormState> {
const item = await findMedia(readField(formData, 'mediaId'))
if (!item) {
return invalid('mediaUnknown')
}
await requireProjectAccess(item.projectId)
const result = await removeMedia(item.id, item.projectId)
if (!result.ok) {
return invalid(result.reason === 'in_use' ? 'mediaInUse' : 'mediaUnknown')
}
const slug = readField(formData, 'project')
if (slug !== '') {
revalidatePath(adminMediaProjectPath(slug))
}
return { status: 'ok', message: 'deleted', id: item.id }
}
+36
View File
@@ -0,0 +1,36 @@
import { countMediaReferences, deleteMedia, findMedia } from '~/data/repositories/media'
import { getStorage, type Storage } from './storage'
export type MediaDeleteFailure = 'not_found' | 'in_use'
export type MediaDeleteResult =
| { ok: true }
| { ok: false, reason: MediaDeleteFailure }
export async function removeMedia(id: string, projectId: string, storage?: Storage): Promise<MediaDeleteResult> {
const item = await findMedia(id)
if (!item || item.projectId !== projectId) {
return { ok: false, reason: 'not_found' }
}
if (await countMediaReferences(id) > 0) {
return { ok: false, reason: 'in_use' }
}
const removed = await deleteMedia(id, projectId)
if (!removed) {
return { ok: false, reason: 'not_found' }
}
const files = storage ?? getStorage()
await files.remove(removed.path)
for (const variant of removed.variants) {
await files.remove(variant.path)
}
return { ok: true }
}
+15 -1
View File
@@ -33,6 +33,8 @@ const postListItem = {
audience: { type: 'string', enum: audiences },
publishAt: { type: 'string', format: 'date-time', nullable: true },
number: { type: 'integer' },
coverMediaId: { type: 'string', format: 'uuid', nullable: true },
coverUrl: { type: 'string', nullable: true },
projectSlug: { type: 'string' },
projectName: { type: 'string' },
projectCode: { type: 'string', nullable: true },
@@ -44,7 +46,8 @@ const entryView = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
number: { type: 'integer' },
number: { type: 'integer', nullable: true, description: 'Wird beim Veröffentlichen vergeben, Entwürfe haben keine.' },
cover_media_id: { type: 'string', format: 'uuid', nullable: true },
slug: { type: 'string' },
title: { type: 'string' },
teaser: { type: 'string', nullable: true },
@@ -335,6 +338,17 @@ export function openApiDocument(baseUrl: string) {
},
},
},
'/api/v1/media/{id}': {
delete: {
summary: 'Bild löschen',
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
'200': { description: 'Gelöscht' },
'409': { description: 'Das Bild hängt noch an einem Beitrag' },
...errors([401, 403, 404]),
},
},
},
},
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ describe('POST /api/v1/posts', () => {
expect(response.status).toBe(201)
expect(body.status).toBe('draft')
expect(body.number).toBe(1)
expect(body.number).toBeNull()
expect(body.slug).toBe('regel-engine')
})
+63 -14
View File
@@ -3,10 +3,12 @@ import { asc, eq } from 'drizzle-orm'
import { db } from '~/data/db'
import { posts } from '~/data/schema'
import {
assignEntryNumber,
createEntry,
listEntries,
listEntryBlocks,
replaceEntryBlocks,
setEntryStatus,
type EntryCreateValues,
} from '~/data/repositories/entries'
import { makeBrand, makeProject } from '../support/admin'
@@ -26,7 +28,7 @@ function values(projectId: string, slug: string, title = 'Beitrag'): EntryCreate
}
}
async function numbersOf(projectId: string): Promise<number[]> {
async function numbersOf(projectId: string): Promise<(number | null)[]> {
const rows = await db
.select({ number: posts.number })
.from(posts)
@@ -36,28 +38,62 @@ async function numbersOf(projectId: string): Promise<number[]> {
return rows.map(row => row.number)
}
describe('createEntry vergibt Beitragsnummern', () => {
it('zählt je Projekt getrennt hoch', async () => {
async function publish(id: string): Promise<number | null> {
const post = await setEntryStatus(id, 'published', new Date())
return post?.number ?? null
}
describe('Beitragsnummern', () => {
it('lässt Entwürfe ohne Nummer', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const created = await createEntry(values(project.id, 'entwurf'))
expect(created.number).toBeNull()
expect(await numbersOf(project.id)).toEqual([null])
})
it('zählt beim Veröffentlichen je Projekt getrennt hoch', async () => {
const brand = await makeBrand()
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
await createEntry(values(trakk.id, 'eins'))
await createEntry(values(trakk.id, 'zwei'))
await createEntry(values(orbit.id, 'eins'))
const first = await createEntry(values(trakk.id, 'eins'))
const second = await createEntry(values(trakk.id, 'zwei'))
const third = await createEntry(values(orbit.id, 'eins'))
expect(await numbersOf(trakk.id)).toEqual([1, 2])
expect(await numbersOf(orbit.id)).toEqual([1])
expect(await publish(first.id)).toBe(1)
expect(await publish(second.id)).toBe(2)
expect(await publish(third.id)).toBe(1)
})
it('vergibt bei gleichzeitigem Anlegen lückenlose Nummern ohne Doppelung', async () => {
it('lässt keine Lücke, wenn ein Entwurf dazwischen gelöscht wird', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
await Promise.all(
const first = await createEntry(values(project.id, 'eins'))
const scrapped = await createEntry(values(project.id, 'verworfen'))
const second = await createEntry(values(project.id, 'zwei'))
await publish(first.id)
await db.delete(posts).where(eq(posts.id, scrapped.id))
await publish(second.id)
expect(await numbersOf(project.id)).toEqual([1, 2])
})
it('vergibt bei gleichzeitigem Veröffentlichen lückenlose Nummern ohne Doppelung', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const created = await Promise.all(
Array.from({ length: 20 }, (_, index) => createEntry(values(project.id, `beitrag-${index}`))),
)
await Promise.all(created.map(entry => publish(entry.id)))
expect(await numbersOf(project.id)).toEqual(Array.from({ length: 20 }, (_, index) => index + 1))
})
@@ -69,10 +105,23 @@ describe('createEntry vergibt Beitragsnummern', () => {
const created = await createEntry(values(project.id, 'neuer-beitrag'))
expect(created.number).toBe(143)
expect(await publish(created.id)).toBe(143)
})
it('hängt die Nummer an, wenn der Slug im Projekt schon vergeben ist', async () => {
it('vergibt eine bereits gesetzte Nummer nicht neu', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const created = await createEntry(values(project.id, 'eins'))
await publish(created.id)
const again = await assignEntryNumber(created.id)
expect(again?.number).toBe(1)
})
it('hängt eine Zahl an, wenn der Slug im Projekt schon vergeben ist', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
@@ -80,7 +129,7 @@ describe('createEntry vergibt Beitragsnummern', () => {
const second = await createEntry(values(project.id, 'regel-engine'))
expect(first.slug).toBe('regel-engine')
expect(second.slug).toBe(`regel-engine-${second.number}`)
expect(second.slug).toBe('regel-engine-2')
})
it('übernimmt Titel, Autor und Zielgruppe', async () => {
@@ -94,7 +143,7 @@ describe('createEntry vergibt Beitragsnummern', () => {
authorName: 'Paul',
audience: 'customer',
status: 'draft',
number: 1,
number: null,
})
})
})
+6 -6
View File
@@ -29,7 +29,7 @@ describe('performSaveEntry legt an', () => {
it('speichert Beitrag, Blöcke und Autor', async () => {
const { entry } = await ready()
expect(entry.number).toBe(1)
expect(entry.number).toBeNull()
expect(entry.slug).toBe('regel-engine')
expect(entry.status).toBe('draft')
@@ -91,7 +91,7 @@ describe('performSaveEntry ändert', () => {
expect(state).toMatchObject({ ok: true })
expect(state.ok && state.entry.slug).toBe('regel-engine')
expect(state.ok && state.entry.number).toBe(1)
expect(state.ok && state.entry.number).toBeNull()
})
it('schreibt Blöcke in neuer Reihenfolge', async () => {
@@ -120,7 +120,7 @@ describe('performSaveEntry ändert', () => {
expect(state).toMatchObject({ ok: false, error: 'slugTaken' })
})
it('lässt das Projekt wechseln und nummeriert im Zielprojekt neu', async () => {
it('lässt das Projekt wechseln, ohne dem Entwurf eine Nummer zu geben', async () => {
const brand = await makeBrand()
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
@@ -136,7 +136,7 @@ describe('performSaveEntry ändert', () => {
const saved = await findEntry(created.ok ? created.entry.id : '')
expect(saved?.projectId).toBe(orbit.id)
expect(saved?.number).toBe(1)
expect(saved?.number).toBeNull()
})
})
@@ -279,7 +279,7 @@ describe('Veröffentlichungsprüfungen', () => {
})
describe('performSaveEntry wechselt das Projekt', () => {
it('vergibt im Zielprojekt die nächste freie Nummer', async () => {
it('lässt die Nummer des Entwurfs im Zielprojekt offen', async () => {
const brand = await makeBrand()
const from = await makeProject(brand.id, 'quelle', 'Quelle')
const to = await makeProject(brand.id, 'ziel', 'Ziel')
@@ -303,7 +303,7 @@ describe('performSaveEntry wechselt das Projekt', () => {
const saved = await findEntry(created.entry.id)
expect(saved?.projectId).toBe(to.id)
expect(moved.entry.number).toBe(2)
expect(moved.entry.number).toBeNull()
})
it('macht den Slug im Zielprojekt eindeutig', async () => {
+1 -1
View File
@@ -25,7 +25,7 @@ describe('seed', () => {
expect.arrayContaining(['trakk', 'mta360', 'mta-telematik', 'orbit', 'pulsar']),
)
expect(postRows.length).toBeGreaterThanOrEqual(6)
expect(postRows.every(post => post.number > 0)).toBe(true)
expect(postRows.every(post => post.status !== 'published' || (post.number ?? 0) > 0)).toBe(true)
})
it('ist zweimal hintereinander ausführbar', async () => {