b90ff252d1
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.
164 lines
10 KiB
TypeScript
164 lines
10 KiB
TypeScript
import 'dotenv/config'
|
|
import { eq } from 'drizzle-orm'
|
|
import { db, pool } from '~/data/db'
|
|
import { apiClients, blocks, brands, postTypes, posts, projects } from '~/data/schema'
|
|
import { defaultPostTypes } from '~/domain/post-type'
|
|
import { hashToken } from '~/lib/api-auth'
|
|
import { ensureSampleMedia } from './sample-images'
|
|
|
|
const brandRows = [
|
|
{ slug: 'nyo', name: 'NYO', color: '#2b4a9b', sort: 1 },
|
|
{ slug: 'pocket-rocket', name: 'Pocket Rocket', color: '#c43a22', sort: 2 },
|
|
]
|
|
|
|
const projectRows = [
|
|
{ brand: 'pocket-rocket', slug: 'trakk', name: 'Trakk', code: 'TRK', color: '#2e7d5b', sort: 1 },
|
|
{ brand: 'nyo', slug: 'mta360', name: 'MTA360', code: 'MTA', color: '#2b4a9b', sort: 2 },
|
|
{ brand: 'nyo', slug: 'mta-telematik', name: 'MTA Telematik', code: 'TEL', color: '#b4761a', sort: 3 },
|
|
{ brand: 'pocket-rocket', slug: 'orbit', name: 'Orbit', code: 'ORB', color: '#6b4ba8', sort: 4 },
|
|
{ brand: 'nyo', slug: 'pulsar', name: 'Pulsar', code: 'PLS', color: '#a33b6a', sort: 5 },
|
|
]
|
|
|
|
const postRows = [
|
|
{
|
|
project: 'trakk', number: 142, slug: 'regel-engine-bedingung-trifft-aktion',
|
|
title: 'Regel-Engine: Bedingung trifft Aktion', type: 'feature' as const, audience: 'customer' as const,
|
|
teaser: 'Tickets reagieren jetzt selbst. Du setzt eine Bedingung und hängst eine Aktion daran: Status ändern, zuweisen, Nachricht schicken. Läuft über den Event-Bus im Worker, ohne Cronjobs.',
|
|
publishAt: new Date('2026-07-30T07:14:00Z'), author: 'Paul', media: 'regel-engine',
|
|
body: [
|
|
{ type: 'text', data: { text: 'Bisher war eine Regel im Grunde ein Cronjob mit einer Datenbankabfrage darin. Wer sie ändern wollte, brauchte einen Entwickler und ein Deployment. Das ist vorbei: eine Regel besteht jetzt aus einer Bedingung und einer Aktion, beides im Ticketsystem selbst zusammengeklickt.' } },
|
|
{ type: 'text', data: { text: 'Bedingungen greifen auf alles zu, was ein Ticket ausmacht: Status, Priorität, Kunde, Kategorie, Zeit seit der letzten Antwort. Aktionen setzen den Status, weisen zu, hängen ein Schlagwort an oder schicken eine Nachricht. Mehrere Aktionen an einer Bedingung sind erlaubt und laufen in der Reihenfolge, in der sie stehen.' } },
|
|
{ type: 'callout', data: { text: 'Regeln greifen ab sofort auch rückwirkend auf offene Tickets. Wer das nicht will, setzt beim Anlegen den Haken für neue Tickets.', tone: 'info' } },
|
|
{ type: 'text', data: { text: 'Technisch hängt das Ganze am Event-Bus im Worker. Jede Zustandsänderung an einem Ticket erzeugt ein Ereignis, die Regel-Engine hört mit und prüft nur die Regeln, die für dieses Ereignis überhaupt in Frage kommen. Damit bleibt die Last berechenbar, auch wenn ein Kunde hundert Regeln anlegt.' } },
|
|
],
|
|
},
|
|
{
|
|
project: 'mta360', number: 141, slug: 'spalten-per-rechtsklick-verwalten',
|
|
title: 'Spalten per Rechtsklick verwalten', type: 'improvement' as const, audience: 'customer' as const,
|
|
teaser: 'Rechtsklick auf eine Kopfzeile öffnet das Spaltenmenü: sortieren, anpinnen, ausblenden, Breite zurücksetzen. Wer das nicht mag, schaltet es in den Einstellungen ab.',
|
|
publishAt: new Date('2026-07-29T09:00:00Z'), author: 'Matthias', media: 'spaltenmenue',
|
|
body: [
|
|
{ type: 'text', data: { text: 'Das Spaltenmenü saß bisher hinter einem Zahnrad über der Tabelle, drei Klicks von der Spalte entfernt, die man eigentlich meinte. Jetzt sitzt es dort, wo man ohnehin hinschaut: auf der Kopfzeile selbst.' } },
|
|
{ type: 'text', data: { text: 'Im Menü liegen Sortierung, Anpinnen nach links oder rechts, Ausblenden und Breite zurücksetzen. Die Auswahl gilt sofort und wird in der Ansicht gespeichert, nicht im Browser. Wer dieselbe Ansicht auf einem anderen Rechner öffnet, findet seine Spalten wieder.' } },
|
|
{ type: 'callout', data: { text: 'Wer lieber ohne Rechtsklick arbeitet, schaltet das Menü in den Einstellungen unter Datentabelle ab. Der Weg über die Kopfzeilen-Schaltfläche bleibt bestehen.', tone: 'info' } },
|
|
],
|
|
},
|
|
{
|
|
project: 'mta-telematik', number: 140, slug: 'zehntausend-tracker-auf-einer-karte',
|
|
title: '10.000 Tracker auf einer Karte', type: 'feature' as const, audience: 'customer' as const,
|
|
teaser: 'Positionen kommen gebündelt aus TimescaleDB, Geofences werden serverseitig geprüft. Die Karte bleibt bei voller Flotte flüssig.',
|
|
publishAt: new Date('2026-07-28T10:30:00Z'), author: 'Paul', media: 'tracker-karte',
|
|
body: [
|
|
{ type: 'text', data: { text: 'Zehntausend Fahrzeuge gleichzeitig auf eine Karte zu legen scheiterte bisher an zwei Stellen: die Abfrage holte jede Position einzeln, und der Browser versuchte, zehntausend Marker zu zeichnen. Beides ist gelöst.' } },
|
|
{ type: 'text', data: { text: 'Die Positionen kommen jetzt gebündelt aus TimescaleDB, vorverdichtet auf die Zoomstufe, die tatsächlich sichtbar ist. Was nah beieinander liegt, wird serverseitig zu einer Gruppe zusammengefasst und erst beim Hineinzoomen aufgelöst.' } },
|
|
{ type: 'text', data: { text: 'Geofences werden ebenfalls serverseitig geprüft, über PostGIS. Ein Fahrzeug, das eine Zone verlässt, erzeugt ein Ereignis, ohne dass ein Browser offen sein muss. Das war vorher Aufgabe des Frontends und funktionierte nur, solange jemand hinschaute.' } },
|
|
],
|
|
},
|
|
{
|
|
project: 'trakk', number: 139, slug: 'sla-uhr-zaehlt-feiertage-nicht-mehr-mit',
|
|
title: 'SLA-Uhr zählt Feiertage nicht mehr mit', type: 'fix' as const, audience: 'customer' as const,
|
|
teaser: 'Reaktionszeiten liefen über Wochenenden und Feiertage weiter und lösten falsche Eskalationen aus. Der Kalender pro Kunde gilt jetzt auch für die Uhr.',
|
|
publishAt: new Date('2026-07-25T08:00:00Z'), author: 'Matthias', media: 'sla-uhr',
|
|
body: [
|
|
{ type: 'text', data: { text: 'Ein Ticket, das freitags um 17 Uhr aufschlug, war montags früh eskaliert, obwohl niemand hätte antworten können. Grund war eine SLA-Uhr, die schlicht durchlief.' } },
|
|
{ type: 'text', data: { text: 'Die Uhr pausiert jetzt außerhalb der Geschäftszeiten und an den Feiertagen, die am Kunden hinterlegt sind. Läuft ein Ticket über ein Wochenende, steht die Uhr still und läuft am Montag zur ersten Geschäftsstunde weiter.' } },
|
|
{ type: 'callout', data: { text: 'Bereits laufende Fristen wurden einmalig neu berechnet. Es kann sein, dass ein Ticket dadurch aus der Eskalation gefallen ist.', tone: 'caution' } },
|
|
],
|
|
},
|
|
{
|
|
project: 'mta360', number: 138, slug: 'lokale-tabellen-konfigurationen-entfallen',
|
|
title: 'Lokale Tabellen-Konfigurationen werden nicht mehr gelesen', type: 'breaking' as const, audience: 'customer' as const,
|
|
teaser: 'Ansichten liegen jetzt vollständig in der Datenbank. Wer noch eine Konfiguration im Browser hatte, sieht die Standardansicht und legt seine Ansicht einmal neu an.',
|
|
publishAt: new Date('2026-07-23T12:00:00Z'), author: 'Matthias', media: undefined,
|
|
body: [
|
|
{ type: 'text', data: { text: 'Tabellenansichten lagen historisch an zwei Orten: in der Datenbank und zusätzlich im Browser. Wer an zwei Rechnern arbeitete, hatte zwei Wahrheiten, und welche gewann, war schwer vorherzusagen.' } },
|
|
{ type: 'text', data: { text: 'Ab dieser Version wird ausschließlich gelesen, was in der Datenbank steht. Die lokale Kopie wird beim ersten Start verworfen.' } },
|
|
{ type: 'callout', data: { text: 'Wer eine Ansicht nur lokal gepflegt hatte, sieht einmalig die Standardansicht und legt seine Ansicht neu an. Danach gilt sie auf allen Geräten.', tone: 'caution' } },
|
|
],
|
|
},
|
|
{
|
|
project: 'orbit', number: 137, slug: 'ein-eingang-fuer-alle-kommentare',
|
|
title: 'Ein Eingang für alle Kommentare', type: 'info' as const, audience: 'internal' as const,
|
|
teaser: 'Kommentare aus Instagram, LinkedIn und Facebook landen in einem Eingang, Antworten gehen von dort zurück.',
|
|
publishAt: new Date('2026-07-21T15:45:00Z'), author: 'Paul', media: 'eingang',
|
|
body: [
|
|
{ type: 'text', data: { text: 'Kommentare kamen bisher dort an, wo sie geschrieben wurden: bei Instagram, bei LinkedIn, bei Facebook. Wer sie beantworten wollte, brauchte drei offene Fenster und ein gutes Gedächtnis.' } },
|
|
{ type: 'text', data: { text: 'Jetzt laufen alle Kommentare in einen Eingang. Antworten gehen von dort auf demselben Weg zurück, auf dem sie gekommen sind. Wer antwortet und wann, steht am Kommentar.' } },
|
|
{ type: 'text', data: { text: 'Der Eingang ist bewusst schlicht gehalten: ungelesen zuerst, danach nach Alter. Zuweisen und Erledigen gibt es, mehr nicht. Alles Weitere kommt erst, wenn wir wissen, ob es fehlt.' } },
|
|
],
|
|
},
|
|
]
|
|
|
|
export async function seed(): Promise<void> {
|
|
const brandIds = new Map<string, string>()
|
|
const projectIds = new Map<string, string>()
|
|
for (const row of defaultPostTypes) {
|
|
await db.insert(postTypes).values(row).onConflictDoNothing({ target: postTypes.key })
|
|
}
|
|
|
|
const typeRows = await db.select({ id: postTypes.id, key: postTypes.key }).from(postTypes)
|
|
const typeIds = new Map(typeRows.map(row => [row.key, row.id]))
|
|
|
|
for (const row of brandRows) {
|
|
const [brand] = await db.insert(brands).values(row)
|
|
.onConflictDoUpdate({ target: brands.slug, set: { name: row.name, color: row.color, sort: row.sort } })
|
|
.returning()
|
|
brandIds.set(row.slug, brand!.id)
|
|
}
|
|
|
|
for (const row of projectRows) {
|
|
const [project] = await db.insert(projects)
|
|
.values({ brandId: brandIds.get(row.brand)!, slug: row.slug, name: row.name, code: row.code, color: row.color, sort: row.sort })
|
|
.onConflictDoUpdate({ target: projects.slug, set: { name: row.name, code: row.code, color: row.color, sort: row.sort } })
|
|
.returning()
|
|
projectIds.set(row.slug, project!.id)
|
|
}
|
|
|
|
const mediaIds = await ensureSampleMedia(projectIds)
|
|
|
|
for (const row of postRows) {
|
|
const coverMediaId = row.media === undefined ? null : mediaIds.get(row.media) ?? null
|
|
const [post] = await db.insert(posts)
|
|
.values({
|
|
projectId: projectIds.get(row.project)!,
|
|
number: row.number,
|
|
slug: row.slug,
|
|
title: row.title,
|
|
teaser: row.teaser,
|
|
typeId: typeIds.get(row.type)!,
|
|
audience: row.audience,
|
|
status: 'published',
|
|
publishAt: row.publishAt,
|
|
authorName: row.author,
|
|
coverMediaId,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [posts.projectId, posts.slug],
|
|
set: { title: row.title, teaser: row.teaser, typeId: typeIds.get(row.type)!, coverMediaId },
|
|
})
|
|
.returning()
|
|
|
|
await db.delete(blocks).where(eq(blocks.postId, post!.id))
|
|
await db.insert(blocks).values(row.body.map((entry, index) => ({
|
|
postId: post!.id,
|
|
sort: index,
|
|
type: entry.type,
|
|
data: entry.data,
|
|
})))
|
|
|
|
}
|
|
|
|
await db.insert(apiClients).values({
|
|
name: 'Trakk Leseclient',
|
|
tokenHash: hashToken('lb_seed_trakk_read'),
|
|
mode: 'read',
|
|
scope: 'customer',
|
|
projectId: projectIds.get('trakk')!,
|
|
}).onConflictDoNothing()
|
|
}
|
|
|
|
if (process.argv[1]?.endsWith('seed.ts')) {
|
|
await seed()
|
|
await pool.end()
|
|
}
|