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:
+13
-18
@@ -1,6 +1,6 @@
|
|||||||
# Logbuch API
|
# 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
|
## Wofür Logbuch da ist
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ curl -s -H "Authorization: Bearer $TOKEN" \
|
|||||||
|
|
||||||
Antwort: `{ "items": [...], "meta": { "total", "page", "per_page" } }`
|
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
|
### GET /api/v1/posts/:slug
|
||||||
|
|
||||||
@@ -123,6 +123,15 @@ curl -s -X POST -H "Authorization: Bearer $TOKEN" \
|
|||||||
|
|
||||||
Grenzen: nur Bildformate, höchstens 15 MB.
|
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/...
|
### GET /api/v1/media/file/...
|
||||||
|
|
||||||
Liefert eine Variante aus. Die Pfade stehen am Medium unter `variants`, je Eintrag mit `width`, `format` und `path`.
|
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.
|
**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:
|
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.
|
||||||
|
|
||||||
| 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.
|
|
||||||
|
|
||||||
## Blocktypen
|
## Blocktypen
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -71,6 +71,13 @@
|
|||||||
"when": 1785502444366,
|
"when": 1785502444366,
|
||||||
"tag": "0009_clean_mac_gargan",
|
"tag": "0009_clean_mac_gargan",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785579010161,
|
||||||
|
"tag": "0010_steady_wither",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+7
-2
@@ -703,7 +703,10 @@
|
|||||||
"card": {
|
"card": {
|
||||||
"meta": "{width} × {height} · {size} KB · {variants, plural, =0 {keine Fassung} one {# Fassung} other {# Fassungen}}",
|
"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.",
|
"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": {
|
"messages": {
|
||||||
"uploaded": "Das Bild ist hochgeladen und die Fassungen sind erzeugt.",
|
"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.",
|
"unreadableImage": "Die Datei lässt sich nicht als Bild lesen. Prüfe, ob sie vollständig ist.",
|
||||||
"projectUnknown": "Dieses Projekt gibt es nicht.",
|
"projectUnknown": "Dieses Projekt gibt es nicht.",
|
||||||
"mediaUnknown": "Dieses Bild gibt es nicht mehr.",
|
"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": {
|
"mail": {
|
||||||
|
|||||||
+7
-2
@@ -703,7 +703,10 @@
|
|||||||
"card": {
|
"card": {
|
||||||
"meta": "{width} × {height} · {size} KB · {variants, plural, =0 {no rendition} one {# rendition} other {# renditions}}",
|
"meta": "{width} × {height} · {size} KB · {variants, plural, =0 {no rendition} one {# rendition} other {# renditions}}",
|
||||||
"altMissing": "No alt text. It is mandatory for public entries.",
|
"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": {
|
"messages": {
|
||||||
"uploaded": "The image is uploaded and the renditions are created.",
|
"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.",
|
"unreadableImage": "The file cannot be read as an image. Check whether it is complete.",
|
||||||
"projectUnknown": "This project does not exist.",
|
"projectUnknown": "This project does not exist.",
|
||||||
"mediaUnknown": "This image no longer exists.",
|
"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": {
|
"mail": {
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { findPost, findPostIdBySlug } from '~/data/repositories/posts'
|
import { findPost, findPostIdBySlug } from '~/data/repositories/posts'
|
||||||
import { resolveClient } from '~/lib/api-auth'
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { withCover } from '~/lib/api-covers'
|
||||||
import { apiDeleteEntry, apiReadEntry, apiUpdateEntry } from '~/lib/api-entries'
|
import { apiDeleteEntry, apiReadEntry, apiUpdateEntry } from '~/lib/api-entries'
|
||||||
import { entryProblem } from '~/lib/api-problem'
|
import { entryProblem } from '~/lib/api-problem'
|
||||||
import { problem } from '~/lib/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)
|
const post = await findPost(slug, client.scope, client.projectId ?? undefined)
|
||||||
|
|
||||||
if (post) {
|
if (post) {
|
||||||
return Response.json(post)
|
return Response.json(await withCover(post))
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = await findPostIdBySlug(slug)
|
const id = await findPostIdBySlug(slug)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { isPostTypeKey } from '~/domain/post-type'
|
|||||||
import { apiCreateEntry } from '~/lib/api-entries'
|
import { apiCreateEntry } from '~/lib/api-entries'
|
||||||
import { entryProblem } from '~/lib/api-problem'
|
import { entryProblem } from '~/lib/api-problem'
|
||||||
import { resolveClient } from '~/lib/api-auth'
|
import { resolveClient } from '~/lib/api-auth'
|
||||||
|
import { withCovers } from '~/lib/api-covers'
|
||||||
import { problem } from '~/lib/problem'
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
@@ -42,7 +43,7 @@ export async function GET(request: Request) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
items: result.items,
|
items: await withCovers(result.items),
|
||||||
meta: { total: result.total, page: query.page, per_page: query.per_page },
|
meta: { total: result.total, page: query.page, per_page: query.per_page },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
textareaClass,
|
textareaClass,
|
||||||
} from './styles'
|
} from './styles'
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
|
import { entryMark } from '~/components/ui/Plate'
|
||||||
import { blockTypes, emptyBlockData, isBlockEmpty, type BlockType, type BlockValues } from '~/domain/blocks'
|
import { blockTypes, emptyBlockData, isBlockEmpty, type BlockType, type BlockValues } from '~/domain/blocks'
|
||||||
import { checkPublish } from '~/domain/publish-checks'
|
import { checkPublish } from '~/domain/publish-checks'
|
||||||
import { defaultPostTypeColor } from '~/domain/post-type'
|
import { defaultPostTypeColor } from '~/domain/post-type'
|
||||||
@@ -64,7 +65,7 @@ export type EntryEditorProject = {
|
|||||||
|
|
||||||
export type EntryEditorEntry = {
|
export type EntryEditorEntry = {
|
||||||
id: string
|
id: string
|
||||||
number: number
|
number: number | null
|
||||||
slug: string
|
slug: string
|
||||||
status: PostStatus
|
status: PostStatus
|
||||||
publishAt: string | null
|
publishAt: string | null
|
||||||
@@ -498,7 +499,7 @@ export function EntryEditor({ projects, types, media: initialMedia, accept, defa
|
|||||||
|
|
||||||
{status ? (
|
{status ? (
|
||||||
<span className={metaClass}>
|
<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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from 'next-intl/server'
|
||||||
import { TbAlertTriangle } from 'react-icons/tb'
|
import { TbAlertTriangle } from 'react-icons/tb'
|
||||||
|
import { MediaDeleteButton } from './MediaDeleteButton'
|
||||||
import { MediaMetaForm } from './MediaMetaForm'
|
import { MediaMetaForm } from './MediaMetaForm'
|
||||||
import { metaClass } from './styles'
|
import { metaClass } from './styles'
|
||||||
import { hasAlt, mediaAlt, mediaThumbnail } from '~/lib/media-url'
|
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 ?? ''} />
|
<MediaMetaForm mediaId={item.id} project={project} alt={item.alt ?? ''} caption={item.caption ?? ''} />
|
||||||
|
|
||||||
|
<MediaDeleteButton mediaId={item.id} project={project} />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -132,7 +132,9 @@ export function PostDetail({ detail }: PostDetailProps) {
|
|||||||
{media.length > 0 ? (
|
{media.length > 0 ? (
|
||||||
<FieldRow label={t('fields.images')}>{entry('images', { count: media.length })}</FieldRow>
|
<FieldRow label={t('fields.images')}>{entry('images', { count: media.length })}</FieldRow>
|
||||||
) : null}
|
) : 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>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type PlateSize = 'lead' | 'card' | 'row'
|
|||||||
export type PlateProps = {
|
export type PlateProps = {
|
||||||
project: ProjectCodeSource
|
project: ProjectCodeSource
|
||||||
color: string
|
color: string
|
||||||
number: number
|
number: number | null
|
||||||
size?: PlateSize
|
size?: PlateSize
|
||||||
showNumber?: boolean
|
showNumber?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
@@ -22,8 +22,8 @@ export function entryNumber(number: number): string {
|
|||||||
return String(number).padStart(4, '0')
|
return String(number).padStart(4, '0')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function entryMark(project: ProjectCodeSource, number: number): string {
|
export function entryMark(project: ProjectCodeSource, number: number | null): string {
|
||||||
return `${projectCode(project)}-${entryNumber(number)}`
|
return number === null ? projectCode(project) : `${projectCode(project)}-${entryNumber(number)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const shells: Record<PlateSize, string> = {
|
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 ?? ''}`}
|
className={`${projectSurface} ${shells[size]} font-mono font-semibold text-paper ${className ?? ''}`}
|
||||||
>
|
>
|
||||||
<span className={`block uppercase ${codeSizes[size]}`}>{projectCode(project)}</span>
|
<span className={`block uppercase ${codeSizes[size]}`}>{projectCode(project)}</span>
|
||||||
{showNumber ? (
|
{showNumber && number !== null ? (
|
||||||
<span className={`block tabular-nums ${numberSizes[size]}`}>{entryNumber(number)}</span>
|
<span className={`block tabular-nums ${numberSizes[size]}`}>{entryNumber(number)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { entryNumber } from './Plate'
|
import { entryNumber } from './Plate'
|
||||||
|
|
||||||
export type WatermarkProps = {
|
export type WatermarkProps = {
|
||||||
number: number
|
number: number | null
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Watermark({ number, className }: WatermarkProps) {
|
export function Watermark({ number, className }: WatermarkProps) {
|
||||||
|
if (number === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ const selection = {
|
|||||||
audience: posts.audience,
|
audience: posts.audience,
|
||||||
publishAt: posts.publishAt,
|
publishAt: posts.publishAt,
|
||||||
authorName: posts.authorName,
|
authorName: posts.authorName,
|
||||||
number: posts.number,
|
number: sql<number>`coalesce(${posts.number}, 0)`.as('number'),
|
||||||
projectSlug: projects.slug,
|
projectSlug: projects.slug,
|
||||||
projectName: projects.name,
|
projectName: projects.name,
|
||||||
projectCode: projects.code,
|
projectCode: projects.code,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export type EntryFilter = {
|
|||||||
|
|
||||||
export type EntryListItem = {
|
export type EntryListItem = {
|
||||||
id: string
|
id: string
|
||||||
number: number
|
number: number | null
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
teaser: string | null
|
teaser: string | null
|
||||||
@@ -180,26 +180,21 @@ export async function listEntryBlocks(postId: string): Promise<Block[]> {
|
|||||||
|
|
||||||
export async function createEntry(values: EntryCreateValues): Promise<Post> {
|
export async function createEntry(values: EntryCreateValues): Promise<Post> {
|
||||||
const rows = await db.execute<{ id: string }>(sql`
|
const rows = await db.execute<{ id: string }>(sql`
|
||||||
with taken as (
|
with used as (
|
||||||
select coalesce(max(${posts.number}), 0) + 1 as value
|
select count(*) + 1 as value
|
||||||
from ${posts}
|
from ${posts}
|
||||||
where ${posts.projectId} = ${values.projectId}::uuid
|
where ${posts.projectId} = ${values.projectId}::uuid
|
||||||
), counter as (
|
and (${posts.slug} = ${values.slug} or ${posts.slug} like ${values.slug} || '-%')
|
||||||
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
|
|
||||||
)
|
)
|
||||||
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
|
select
|
||||||
${values.projectId}::uuid,
|
${values.projectId}::uuid,
|
||||||
counter.value,
|
|
||||||
case
|
case
|
||||||
when exists (
|
when exists (
|
||||||
select 1 from post taken_slug
|
select 1 from post taken_slug
|
||||||
where taken_slug.project_id = ${values.projectId}::uuid and taken_slug.slug = ${values.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}
|
else ${values.slug}
|
||||||
end,
|
end,
|
||||||
${values.title},
|
${values.title},
|
||||||
@@ -208,7 +203,7 @@ export async function createEntry(values: EntryCreateValues): Promise<Post> {
|
|||||||
${values.audience}::post_audience,
|
${values.audience}::post_audience,
|
||||||
${values.authorName},
|
${values.authorName},
|
||||||
${values.coverMediaId}::uuid
|
${values.coverMediaId}::uuid
|
||||||
from counter
|
from used
|
||||||
returning id
|
returning id
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -244,7 +239,7 @@ export async function moveEntryToProject(id: string, projectId: string): Promise
|
|||||||
)
|
)
|
||||||
update post set
|
update post set
|
||||||
project_id = ${projectId}::uuid,
|
project_id = ${projectId}::uuid,
|
||||||
number = counter.value,
|
number = case when post.number is null then null else counter.value end,
|
||||||
slug = case
|
slug = case
|
||||||
when exists (
|
when exists (
|
||||||
select 1 from post taken_slug
|
select 1 from post taken_slug
|
||||||
@@ -286,6 +281,36 @@ export async function setEntryStatus(
|
|||||||
.where(eq(posts.id, id))
|
.where(eq(posts.id, id))
|
||||||
.returning()
|
.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]
|
return rows[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,5 +350,13 @@ export async function publishDue(now: Date): Promise<PublishedEntry[]> {
|
|||||||
title: posts.title,
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { db } from '../db'
|
||||||
import { media, type Media, type NewMedia } from '../schema'
|
import { media, type Media, type NewMedia } from '../schema'
|
||||||
|
|
||||||
@@ -51,3 +51,19 @@ export async function deleteMedia(id: string, projectId: string): Promise<Media
|
|||||||
|
|
||||||
return row
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type PostListItem = {
|
|||||||
audience: string
|
audience: string
|
||||||
publishAt: Date | null
|
publishAt: Date | null
|
||||||
authorName: string | null
|
authorName: string | null
|
||||||
|
coverMediaId: string | null
|
||||||
number: number
|
number: number
|
||||||
projectSlug: string
|
projectSlug: string
|
||||||
projectName: string
|
projectName: string
|
||||||
@@ -55,7 +56,8 @@ const selection = {
|
|||||||
audience: posts.audience,
|
audience: posts.audience,
|
||||||
publishAt: posts.publishAt,
|
publishAt: posts.publishAt,
|
||||||
authorName: posts.authorName,
|
authorName: posts.authorName,
|
||||||
number: posts.number,
|
coverMediaId: posts.coverMediaId,
|
||||||
|
number: sql<number>`coalesce(${posts.number}, 0)`.as('number'),
|
||||||
projectSlug: projects.slug,
|
projectSlug: projects.slug,
|
||||||
projectName: projects.name,
|
projectName: projects.name,
|
||||||
projectCode: projects.code,
|
projectCode: projects.code,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function listUnread(args: UnreadArgs): Promise<PostListItem[]> {
|
|||||||
audience: posts.audience,
|
audience: posts.audience,
|
||||||
publishAt: posts.publishAt,
|
publishAt: posts.publishAt,
|
||||||
authorName: posts.authorName,
|
authorName: posts.authorName,
|
||||||
number: posts.number,
|
number: sql<number>`coalesce(${posts.number}, 0)`.as('number'),
|
||||||
projectSlug: projects.slug,
|
projectSlug: projects.slug,
|
||||||
projectName: projects.name,
|
projectName: projects.name,
|
||||||
projectCode: projects.code,
|
projectCode: projects.code,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const posts = pgTable('post', {
|
|||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
|
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
issueId: uuid('issue_id').references(() => issues.id, { onDelete: 'set null' }),
|
issueId: uuid('issue_id').references(() => issues.id, { onDelete: 'set null' }),
|
||||||
number: integer('number').notNull(),
|
number: integer('number'),
|
||||||
slug: text('slug').notNull(),
|
slug: text('slug').notNull(),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
teaser: text('teaser'),
|
teaser: text('teaser'),
|
||||||
|
|||||||
@@ -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!
|
||||||
|
}
|
||||||
@@ -41,7 +41,8 @@ export type ApiEntryResult<T> =
|
|||||||
|
|
||||||
export type ApiEntryView = {
|
export type ApiEntryView = {
|
||||||
id: string
|
id: string
|
||||||
number: number
|
number: number | null
|
||||||
|
cover_media_id: string | null
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
teaser: string | null
|
teaser: string | null
|
||||||
@@ -89,6 +90,7 @@ function view(entry: EntryRecord, blocks: Block[]): ApiEntryView {
|
|||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
number: entry.number,
|
number: entry.number,
|
||||||
|
cover_media_id: entry.coverMediaId,
|
||||||
slug: entry.slug,
|
slug: entry.slug,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
teaser: entry.teaser,
|
teaser: entry.teaser,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export type EntryErrorKey =
|
|||||||
|
|
||||||
export type EntryState = {
|
export type EntryState = {
|
||||||
id: string
|
id: string
|
||||||
number: number
|
number: number | null
|
||||||
slug: string
|
slug: string
|
||||||
status: PostStatus
|
status: PostStatus
|
||||||
publishAt: string | null
|
publishAt: string | null
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { findProjectBySlug } from '~/data/repositories/projects'
|
|||||||
import { invalid, readField, type AdminFormState } from './admin-forms'
|
import { invalid, readField, type AdminFormState } from './admin-forms'
|
||||||
import { adminMediaProjectPath } from './auth-routes'
|
import { adminMediaProjectPath } from './auth-routes'
|
||||||
import { requireProjectAccess } from './auth-guards'
|
import { requireProjectAccess } from './auth-guards'
|
||||||
|
import { removeMedia } from './media-delete'
|
||||||
import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload'
|
import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload'
|
||||||
|
|
||||||
const failureKeys: Record<UploadFailure, string> = {
|
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 }
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -33,6 +33,8 @@ const postListItem = {
|
|||||||
audience: { type: 'string', enum: audiences },
|
audience: { type: 'string', enum: audiences },
|
||||||
publishAt: { type: 'string', format: 'date-time', nullable: true },
|
publishAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
number: { type: 'integer' },
|
number: { type: 'integer' },
|
||||||
|
coverMediaId: { type: 'string', format: 'uuid', nullable: true },
|
||||||
|
coverUrl: { type: 'string', nullable: true },
|
||||||
projectSlug: { type: 'string' },
|
projectSlug: { type: 'string' },
|
||||||
projectName: { type: 'string' },
|
projectName: { type: 'string' },
|
||||||
projectCode: { type: 'string', nullable: true },
|
projectCode: { type: 'string', nullable: true },
|
||||||
@@ -44,7 +46,8 @@ const entryView = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', format: 'uuid' },
|
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' },
|
slug: { type: 'string' },
|
||||||
title: { type: 'string' },
|
title: { type: 'string' },
|
||||||
teaser: { type: 'string', nullable: true },
|
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]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe('POST /api/v1/posts', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(201)
|
expect(response.status).toBe(201)
|
||||||
expect(body.status).toBe('draft')
|
expect(body.status).toBe('draft')
|
||||||
expect(body.number).toBe(1)
|
expect(body.number).toBeNull()
|
||||||
expect(body.slug).toBe('regel-engine')
|
expect(body.slug).toBe('regel-engine')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+63
-14
@@ -3,10 +3,12 @@ import { asc, eq } from 'drizzle-orm'
|
|||||||
import { db } from '~/data/db'
|
import { db } from '~/data/db'
|
||||||
import { posts } from '~/data/schema'
|
import { posts } from '~/data/schema'
|
||||||
import {
|
import {
|
||||||
|
assignEntryNumber,
|
||||||
createEntry,
|
createEntry,
|
||||||
listEntries,
|
listEntries,
|
||||||
listEntryBlocks,
|
listEntryBlocks,
|
||||||
replaceEntryBlocks,
|
replaceEntryBlocks,
|
||||||
|
setEntryStatus,
|
||||||
type EntryCreateValues,
|
type EntryCreateValues,
|
||||||
} from '~/data/repositories/entries'
|
} from '~/data/repositories/entries'
|
||||||
import { makeBrand, makeProject } from '../support/admin'
|
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
|
const rows = await db
|
||||||
.select({ number: posts.number })
|
.select({ number: posts.number })
|
||||||
.from(posts)
|
.from(posts)
|
||||||
@@ -36,28 +38,62 @@ async function numbersOf(projectId: string): Promise<number[]> {
|
|||||||
return rows.map(row => row.number)
|
return rows.map(row => row.number)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('createEntry vergibt Beitragsnummern', () => {
|
async function publish(id: string): Promise<number | null> {
|
||||||
it('zählt je Projekt getrennt hoch', async () => {
|
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 brand = await makeBrand()
|
||||||
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
|
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
|
||||||
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
|
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
|
||||||
|
|
||||||
await createEntry(values(trakk.id, 'eins'))
|
const first = await createEntry(values(trakk.id, 'eins'))
|
||||||
await createEntry(values(trakk.id, 'zwei'))
|
const second = await createEntry(values(trakk.id, 'zwei'))
|
||||||
await createEntry(values(orbit.id, 'eins'))
|
const third = await createEntry(values(orbit.id, 'eins'))
|
||||||
|
|
||||||
expect(await numbersOf(trakk.id)).toEqual([1, 2])
|
expect(await publish(first.id)).toBe(1)
|
||||||
expect(await numbersOf(orbit.id)).toEqual([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 brand = await makeBrand()
|
||||||
const project = await makeProject(brand.id)
|
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}`))),
|
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))
|
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'))
|
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 brand = await makeBrand()
|
||||||
const project = await makeProject(brand.id)
|
const project = await makeProject(brand.id)
|
||||||
|
|
||||||
@@ -80,7 +129,7 @@ describe('createEntry vergibt Beitragsnummern', () => {
|
|||||||
const second = await createEntry(values(project.id, 'regel-engine'))
|
const second = await createEntry(values(project.id, 'regel-engine'))
|
||||||
|
|
||||||
expect(first.slug).toBe('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 () => {
|
it('übernimmt Titel, Autor und Zielgruppe', async () => {
|
||||||
@@ -94,7 +143,7 @@ describe('createEntry vergibt Beitragsnummern', () => {
|
|||||||
authorName: 'Paul',
|
authorName: 'Paul',
|
||||||
audience: 'customer',
|
audience: 'customer',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
number: 1,
|
number: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe('performSaveEntry legt an', () => {
|
|||||||
it('speichert Beitrag, Blöcke und Autor', async () => {
|
it('speichert Beitrag, Blöcke und Autor', async () => {
|
||||||
const { entry } = await ready()
|
const { entry } = await ready()
|
||||||
|
|
||||||
expect(entry.number).toBe(1)
|
expect(entry.number).toBeNull()
|
||||||
expect(entry.slug).toBe('regel-engine')
|
expect(entry.slug).toBe('regel-engine')
|
||||||
expect(entry.status).toBe('draft')
|
expect(entry.status).toBe('draft')
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ describe('performSaveEntry ändert', () => {
|
|||||||
|
|
||||||
expect(state).toMatchObject({ ok: true })
|
expect(state).toMatchObject({ ok: true })
|
||||||
expect(state.ok && state.entry.slug).toBe('regel-engine')
|
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 () => {
|
it('schreibt Blöcke in neuer Reihenfolge', async () => {
|
||||||
@@ -120,7 +120,7 @@ describe('performSaveEntry ändert', () => {
|
|||||||
expect(state).toMatchObject({ ok: false, error: 'slugTaken' })
|
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 brand = await makeBrand()
|
||||||
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
|
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
|
||||||
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
|
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
|
||||||
@@ -136,7 +136,7 @@ describe('performSaveEntry ändert', () => {
|
|||||||
const saved = await findEntry(created.ok ? created.entry.id : '')
|
const saved = await findEntry(created.ok ? created.entry.id : '')
|
||||||
|
|
||||||
expect(saved?.projectId).toBe(orbit.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', () => {
|
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 brand = await makeBrand()
|
||||||
const from = await makeProject(brand.id, 'quelle', 'Quelle')
|
const from = await makeProject(brand.id, 'quelle', 'Quelle')
|
||||||
const to = await makeProject(brand.id, 'ziel', 'Ziel')
|
const to = await makeProject(brand.id, 'ziel', 'Ziel')
|
||||||
@@ -303,7 +303,7 @@ describe('performSaveEntry wechselt das Projekt', () => {
|
|||||||
const saved = await findEntry(created.entry.id)
|
const saved = await findEntry(created.entry.id)
|
||||||
|
|
||||||
expect(saved?.projectId).toBe(to.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 () => {
|
it('macht den Slug im Zielprojekt eindeutig', async () => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('seed', () => {
|
|||||||
expect.arrayContaining(['trakk', 'mta360', 'mta-telematik', 'orbit', 'pulsar']),
|
expect.arrayContaining(['trakk', 'mta360', 'mta-telematik', 'orbit', 'pulsar']),
|
||||||
)
|
)
|
||||||
expect(postRows.length).toBeGreaterThanOrEqual(6)
|
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 () => {
|
it('ist zweimal hintereinander ausführbar', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user