Add Logbuch: project update blog with admin, media and API

Public archive with sidebar navigation, project pages, month archive,
search and entry pages with image blocks. Admin area for brands,
projects, post types, users, api clients, media and the entry editor
with drag and drop images, preview per audience, scheduling and
publish checks. Read and write API with bearer tokens, audience
scoping, idempotent creation, OpenAPI document and editorial guide.
Magic link login with configurable allowed domains, whole app behind
the session gate. 456 tests including design rule checks.
This commit is contained in:
Matthias Giesselmann
2026-07-31 21:33:42 +02:00
commit b90ff252d1
291 changed files with 43671 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { db } from '~/data/db'
import { apiClients, brands, postReads, posts, projects } from '~/data/schema'
import { hashToken } from '~/lib/api-auth'
import { GET as getUnread } from '~/app/api/v1/unread/route'
import { POST as postRead } from '~/app/api/v1/read/route'
async function seed() {
const [brand] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket' }).returning()
const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' }).returning()
const [mta] = await db.insert(projects).values({ brandId: brand!.id, slug: 'mta360', name: 'MTA360' }).returning()
const inserted = await db.insert(posts).values([
{
projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine',
audience: 'customer', status: 'published', publishAt: new Date('2026-07-30T08:00:00Z'),
},
{
projectId: trakk!.id, number: 2, slug: 'sla-uhr', title: 'SLA-Uhr',
audience: 'customer', status: 'published', publishAt: new Date('2026-07-25T08:00:00Z'),
},
]).returning()
const [internal] = await db.insert(posts).values({
projectId: trakk!.id, number: 3, slug: 'interner-umbau', title: 'Interner Umbau',
audience: 'internal', status: 'published', publishAt: new Date('2026-07-24T08:00:00Z'),
}).returning()
const [foreign] = await db.insert(posts).values({
projectId: mta!.id, number: 1, slug: 'spaltenmenue', title: 'Spaltenmenü',
audience: 'customer', status: 'published', publishAt: new Date('2026-07-28T08:00:00Z'),
}).returning()
await db.insert(apiClients).values([
{ name: 'Trakk', tokenHash: hashToken('lb_trakk'), mode: 'read', scope: 'customer', projectId: trakk!.id },
{ name: 'MTA360', tokenHash: hashToken('lb_mta'), mode: 'read', scope: 'customer', projectId: mta!.id },
])
return {
postIds: inserted.map(post => post.id),
internalId: internal!.id,
foreignId: foreign!.id,
}
}
function get(url: string, token = 'lb_trakk') {
return new Request(url, { headers: { authorization: `Bearer ${token}` } })
}
function post(body: unknown, token = 'lb_trakk') {
return new Request('http://localhost/api/v1/read', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('gelesen-Status', () => {
it('zählt zuerst alle Beiträge als ungelesen', async () => {
await seed()
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42'))
const body = await response.json()
expect(body.count).toBe(2)
expect(body.items).toHaveLength(2)
})
it('meldet Beiträge als gelesen und zählt danach weniger', async () => {
const { postIds } = await seed()
const marked = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] }))
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42'))
const body = await response.json()
expect(marked.status).toBe(200)
expect(body.count).toBe(1)
expect(body.items[0].slug).toBe('sla-uhr')
})
it('bleibt bei doppelter Meldung stabil', async () => {
const { postIds } = await seed()
await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] }))
const second = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] }))
expect(second.status).toBe(200)
})
it('trennt Nutzer voneinander', async () => {
const { postIds } = await seed()
await postRead(post({ external_user_id: 'u-42', post_ids: postIds }))
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-99'))
const body = await response.json()
expect(body.count).toBe(2)
})
it('verlangt eine Nutzerkennung', async () => {
await seed()
const response = await getUnread(get('http://localhost/api/v1/unread'))
expect(response.status).toBe(400)
})
it('zählt interne Beiträge für einen Kundenclient nicht als ungelesen', async () => {
await seed()
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42'))
const body = await response.json()
expect(body.items.map((item: { slug: string }) => item.slug)).not.toContain('interner-umbau')
})
})
describe('POST /api/v1/read weist ab, was dem Client nicht gehört', () => {
it('antwortet auf eine unbekannte Beitrags-Kennung mit 200 und markiert nichts', async () => {
await seed()
const response = await postRead(post({ external_user_id: 'u-42', post_ids: ['00000000-0000-4000-8000-000000000000'] }))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.marked).toBe(0)
})
it('markiert keinen Beitrag eines fremden Projekts und speichert dazu keine Zeile', async () => {
const { foreignId } = await seed()
const response = await postRead(post({ external_user_id: 'u-42', post_ids: [foreignId] }))
const body = await response.json()
const rows = await db.select().from(postReads)
expect(response.status).toBe(200)
expect(body.marked).toBe(0)
expect(rows).toHaveLength(0)
})
it('lässt den gelesen-Status des fremden Projekts unberührt', async () => {
const { foreignId } = await seed()
await postRead(post({ external_user_id: 'u-42', post_ids: [foreignId] }))
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42', 'lb_mta'))
const body = await response.json()
expect(body.count).toBe(1)
expect(body.items[0].slug).toBe('spaltenmenue')
})
it('markiert für einen Kundenclient keinen internen Beitrag', async () => {
const { internalId } = await seed()
const response = await postRead(post({ external_user_id: 'u-42', post_ids: [internalId] }))
const body = await response.json()
expect(body.marked).toBe(0)
})
it('markiert erlaubte Beiträge und meldet bei Wiederholung null neue', async () => {
const { postIds } = await seed()
const first = await postRead(post({ external_user_id: 'u-42', post_ids: postIds }))
const second = await postRead(post({ external_user_id: 'u-42', post_ids: postIds }))
expect((await first.json()).marked).toBe(2)
expect((await second.json()).marked).toBe(0)
})
it('weist einen Client ohne Projektbindung mit 422 ab', async () => {
await seed()
await db.insert(apiClients).values({
name: 'Ohne Projekt', tokenHash: hashToken('lb_ohne'), mode: 'read', scope: 'internal',
})
const readResponse = await postRead(post({ external_user_id: 'u-42', post_ids: ['00000000-0000-4000-8000-000000000000'] }, 'lb_ohne'))
const unreadResponse = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42', 'lb_ohne'))
expect(readResponse.status).toBe(422)
expect(unreadResponse.status).toBe(422)
})
it('ignoriert eine gelesen-Zeile, die einem anderen Projekt zugeordnet ist', async () => {
const { postIds } = await seed()
const [mta] = await db.select().from(projects).where(eq(projects.slug, 'mta360'))
await db.insert(postReads).values({
postId: postIds[0]!,
projectId: mta!.id,
externalUserId: 'u-42',
})
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42'))
const body = await response.json()
expect(body.count).toBe(2)
})
it('weist einen ungültigen Rumpf mit 400 und Problem-JSON ab', async () => {
await seed()
const response = await postRead(post({ external_user_id: 'u-42', post_ids: ['keine-uuid'] }))
const body = await response.json()
expect(response.status).toBe(400)
expect(response.headers.get('content-type')).toContain('application/problem+json')
expect(body.type).toBe('invalid_body')
})
it('verwirft unbekannte Kennungen im Stapel und markiert den Rest', async () => {
const { postIds } = await seed()
const response = await postRead(post({
external_user_id: 'u-42',
post_ids: [postIds[0], '00000000-0000-4000-8000-000000000000'],
}))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.marked).toBe(1)
})
})