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:
@@ -0,0 +1,76 @@
|
||||
import 'dotenv/config'
|
||||
import { pool } from '~/data/db'
|
||||
import { auth, minPasswordLength } from '~/lib/auth'
|
||||
|
||||
type Outcome = 'created' | 'updated'
|
||||
|
||||
function readEnv(name: string): string {
|
||||
const value = process.env[name]?.trim()
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`${name} fehlt. Aufruf: ${name}=... pnpm admin:create`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export async function createAdmin(email: string, password: string, name: string): Promise<Outcome> {
|
||||
if (password.length < minPasswordLength) {
|
||||
throw new Error(`ADMIN_PASSWORD ist zu kurz. Es braucht mindestens ${minPasswordLength} Zeichen.`)
|
||||
}
|
||||
|
||||
const context = await auth.$context
|
||||
const hashed = await context.password.hash(password)
|
||||
const existing = await context.internalAdapter.findUserByEmail(email, { includeAccounts: true })
|
||||
|
||||
if (!existing) {
|
||||
const user = await context.internalAdapter.createUser({ email, name, emailVerified: true, isAdmin: true })
|
||||
|
||||
await context.internalAdapter.createAccount({
|
||||
userId: user.id,
|
||||
providerId: 'credential',
|
||||
accountId: user.id,
|
||||
password: hashed,
|
||||
})
|
||||
|
||||
return 'created'
|
||||
}
|
||||
|
||||
const credential = existing.accounts.find(account => account.providerId === 'credential')
|
||||
|
||||
if (credential) {
|
||||
await context.internalAdapter.updatePassword(existing.user.id, hashed)
|
||||
} else {
|
||||
await context.internalAdapter.createAccount({
|
||||
userId: existing.user.id,
|
||||
providerId: 'credential',
|
||||
accountId: existing.user.id,
|
||||
password: hashed,
|
||||
})
|
||||
}
|
||||
|
||||
await context.internalAdapter.updateUser(existing.user.id, { isAdmin: true })
|
||||
|
||||
return 'updated'
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const email = readEnv('ADMIN_EMAIL')
|
||||
const password = readEnv('ADMIN_PASSWORD')
|
||||
const name = process.env.ADMIN_NAME?.trim() || email.split('@')[0] || email
|
||||
|
||||
const outcome = await createAdmin(email, password, name)
|
||||
|
||||
if (outcome === 'created') {
|
||||
console.log(`Admin angelegt: ${email}. Anmeldung ab sofort unter /login möglich.`)
|
||||
} else {
|
||||
console.log(`Admin aktualisiert: ${email}. Passwort neu gesetzt, Verwaltungsrecht bestätigt.`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => pool.end())
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'dotenv/config'
|
||||
import { pool } from '~/data/db'
|
||||
import { publishDueEntries } from '~/lib/publish-due'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { published } = await publishDueEntries()
|
||||
|
||||
if (published.length === 0) {
|
||||
console.log('Kein terminierter Beitrag ist fällig.')
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of published) {
|
||||
console.log(`Veröffentlicht: ${entry.number} ${entry.slug} (${entry.title})`)
|
||||
}
|
||||
|
||||
console.log(`${published.length} Beiträge veröffentlicht.`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => pool.end())
|
||||
@@ -0,0 +1,307 @@
|
||||
import { findMedia } from '~/data/repositories/media'
|
||||
import { renderPng, renderVariant } from '~/lib/media-images'
|
||||
import { uploadMedia } from '~/lib/media-upload'
|
||||
import { getStorage, objectPath } from '~/lib/storage'
|
||||
|
||||
export const sampleWidth = 2000
|
||||
|
||||
export const sampleHeight = 1125
|
||||
|
||||
export type SampleImage = {
|
||||
key: string
|
||||
id: string
|
||||
project: string
|
||||
filename: string
|
||||
alt: string
|
||||
caption: string
|
||||
svg: () => string
|
||||
}
|
||||
|
||||
function rng(seed: number): () => number {
|
||||
let state = seed % 4294967296
|
||||
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) % 4294967296
|
||||
|
||||
return state / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
function frame(body: string, tint: string): string {
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${sampleWidth}" height="${sampleHeight}" viewBox="0 0 ${sampleWidth} ${sampleHeight}">`,
|
||||
'<rect width="100%" height="100%" fill="#f2f1ec"/>',
|
||||
`<rect x="0" y="0" width="${sampleWidth}" height="14" fill="${tint}"/>`,
|
||||
'<rect x="60" y="74" width="1880" height="991" fill="#fbfaf6" stroke="#dedcd3" stroke-width="2"/>',
|
||||
body,
|
||||
'</svg>',
|
||||
].join('')
|
||||
}
|
||||
|
||||
function ruleGrid(): string {
|
||||
const lines: string[] = []
|
||||
|
||||
for (let y = 150; y < 1040; y += 74) {
|
||||
lines.push(`<line x1="120" y1="${y}" x2="1880" y2="${y}" stroke="#dedcd3" stroke-width="1"/>`)
|
||||
}
|
||||
|
||||
return lines.join('')
|
||||
}
|
||||
|
||||
function ruleEngine(): string {
|
||||
const tint = '#2e7d5b'
|
||||
const parts: string[] = [ruleGrid()]
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const y = 240 + index * 250
|
||||
|
||||
parts.push(`<rect x="180" y="${y}" width="520" height="150" rx="6" fill="#f2f1ec" stroke="#8a8f95" stroke-width="2"/>`)
|
||||
parts.push(`<rect x="220" y="${y + 42}" width="${360 - index * 60}" height="16" rx="8" fill="#8a8f95"/>`)
|
||||
parts.push(`<rect x="220" y="${y + 84}" width="${240 - index * 40}" height="12" rx="6" fill="#dedcd3"/>`)
|
||||
|
||||
parts.push(`<rect x="1300" y="${y}" width="520" height="150" rx="6" fill="${tint}" opacity="${round(0.9 - index * 0.2)}"/>`)
|
||||
parts.push(`<rect x="1340" y="${y + 42}" width="${340 - index * 50}" height="16" rx="8" fill="#fbfaf6" opacity="0.85"/>`)
|
||||
|
||||
parts.push(
|
||||
`<path d="M700 ${y + 75} C 900 ${y + 75}, 1100 ${y + 75}, 1300 ${y + 75}" fill="none" stroke="${tint}" stroke-width="4"/>`,
|
||||
)
|
||||
parts.push(`<circle cx="1300" cy="${y + 75}" r="10" fill="${tint}"/>`)
|
||||
}
|
||||
|
||||
return frame(parts.join(''), tint)
|
||||
}
|
||||
|
||||
function columnMenu(): string {
|
||||
const tint = '#2b4a9b'
|
||||
const parts: string[] = []
|
||||
const columns = [180, 560, 940, 1320, 1620]
|
||||
const widths = [340, 340, 340, 260, 220]
|
||||
|
||||
parts.push('<rect x="120" y="150" width="1760" height="70" fill="#f2f1ec"/>')
|
||||
|
||||
columns.forEach((x, column) => {
|
||||
const width = widths[column] ?? 300
|
||||
|
||||
parts.push(`<rect x="${x}" y="176" width="${Math.round(width * 0.55)}" height="18" rx="9" fill="#4c5157"/>`)
|
||||
|
||||
for (let row = 0; row < 9; row += 1) {
|
||||
const y = 260 + row * 84
|
||||
const bar = Math.round(width * (0.45 + ((row + column) % 4) * 0.13))
|
||||
const fill = column === 3 ? tint : '#dedcd3'
|
||||
const opacity = column === 3 ? round(0.85 - (row % 4) * 0.15) : 1
|
||||
|
||||
parts.push(`<rect x="${x}" y="${y}" width="${bar}" height="16" rx="8" fill="${fill}" opacity="${opacity}"/>`)
|
||||
}
|
||||
})
|
||||
|
||||
for (let row = 0; row < 9; row += 1) {
|
||||
const y = 244 + row * 84
|
||||
|
||||
parts.push(`<line x1="120" y1="${y}" x2="1880" y2="${y}" stroke="#dedcd3" stroke-width="1"/>`)
|
||||
}
|
||||
|
||||
parts.push(`<rect x="1300" y="228" width="360" height="300" rx="6" fill="#fbfaf6" stroke="${tint}" stroke-width="3"/>`)
|
||||
|
||||
for (let entry = 0; entry < 4; entry += 1) {
|
||||
parts.push(`<rect x="1340" y="${262 + entry * 66}" width="${280 - entry * 30}" height="14" rx="7" fill="#8a8f95"/>`)
|
||||
}
|
||||
|
||||
return frame(parts.join(''), tint)
|
||||
}
|
||||
|
||||
function trackerMap(): string {
|
||||
const tint = '#b4761a'
|
||||
const parts: string[] = []
|
||||
const random = rng(20260730)
|
||||
|
||||
for (let x = 180; x < 1880; x += 106) {
|
||||
parts.push(`<line x1="${x}" y1="120" x2="${x}" y2="1020" stroke="#f2f1ec" stroke-width="2"/>`)
|
||||
}
|
||||
|
||||
for (let y = 150; y < 1020; y += 96) {
|
||||
parts.push(`<line x1="120" y1="${y}" x2="1880" y2="${y}" stroke="#f2f1ec" stroke-width="2"/>`)
|
||||
}
|
||||
|
||||
for (let index = 0; index < 220; index += 1) {
|
||||
const x = round(160 + random() * 1700)
|
||||
const y = round(140 + random() * 860)
|
||||
const radius = round(3 + random() * 4)
|
||||
|
||||
parts.push(`<circle cx="${x}" cy="${y}" r="${radius}" fill="#8a8f95" opacity="0.55"/>`)
|
||||
}
|
||||
|
||||
const clusters = [
|
||||
{ x: 520, y: 400, size: 96 },
|
||||
{ x: 1180, y: 620, size: 132 },
|
||||
{ x: 1560, y: 320, size: 74 },
|
||||
]
|
||||
|
||||
for (const cluster of clusters) {
|
||||
parts.push(`<circle cx="${cluster.x}" cy="${cluster.y}" r="${cluster.size}" fill="${tint}" opacity="0.14"/>`)
|
||||
parts.push(`<circle cx="${cluster.x}" cy="${cluster.y}" r="${cluster.size}" fill="none" stroke="${tint}" stroke-width="3"/>`)
|
||||
parts.push(`<circle cx="${cluster.x}" cy="${cluster.y}" r="14" fill="${tint}"/>`)
|
||||
}
|
||||
|
||||
parts.push(`<path d="M520 400 L1180 620 L1560 320" fill="none" stroke="${tint}" stroke-width="3" stroke-dasharray="14 12"/>`)
|
||||
|
||||
return frame(parts.join(''), tint)
|
||||
}
|
||||
|
||||
function slaClock(): string {
|
||||
const tint = '#2e7d5b'
|
||||
const parts: string[] = []
|
||||
const pauses = [
|
||||
{ x: 700, width: 220 },
|
||||
{ x: 1420, width: 220 },
|
||||
]
|
||||
|
||||
for (const pause of pauses) {
|
||||
parts.push(`<rect x="${pause.x}" y="150" width="${pause.width}" height="890" fill="#f2f1ec"/>`)
|
||||
}
|
||||
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
const y = 250 + index * 110
|
||||
const start = 180 + index * 42
|
||||
const width = 420 + ((index * 7) % 5) * 190
|
||||
|
||||
parts.push(`<line x1="120" y1="${y + 44}" x2="1880" y2="${y + 44}" stroke="#dedcd3" stroke-width="1"/>`)
|
||||
parts.push(`<rect x="${start}" y="${y}" width="${width}" height="26" rx="13" fill="${tint}" opacity="${round(0.85 - index * 0.08)}"/>`)
|
||||
parts.push(`<circle cx="${start}" cy="${y + 13}" r="9" fill="#4c5157"/>`)
|
||||
}
|
||||
|
||||
parts.push('<rect x="120" y="150" width="1760" height="60" fill="#f2f1ec"/>')
|
||||
|
||||
for (let tick = 0; tick < 12; tick += 1) {
|
||||
parts.push(`<rect x="${180 + tick * 146}" y="168" width="42" height="12" rx="6" fill="#8a8f95"/>`)
|
||||
}
|
||||
|
||||
return frame(parts.join(''), tint)
|
||||
}
|
||||
|
||||
function inbox(): string {
|
||||
const tint = '#6b4ba8'
|
||||
const parts: string[] = []
|
||||
const lanes = [200, 700, 1200]
|
||||
|
||||
lanes.forEach((x, lane) => {
|
||||
for (let row = 0; row < 4; row += 1) {
|
||||
const y = 210 + row * 120
|
||||
|
||||
parts.push(`<rect x="${x}" y="${y}" width="420" height="88" rx="6" fill="#f2f1ec" stroke="#dedcd3" stroke-width="2"/>`)
|
||||
parts.push(`<circle cx="${x + 44}" cy="${y + 44}" r="18" fill="${tint}" opacity="${round(0.75 - lane * 0.18)}"/>`)
|
||||
parts.push(`<rect x="${x + 84}" y="${y + 28}" width="${280 - row * 40}" height="14" rx="7" fill="#8a8f95"/>`)
|
||||
parts.push(`<rect x="${x + 84}" y="${y + 54}" width="${200 - row * 30}" height="10" rx="5" fill="#dedcd3"/>`)
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`<path d="M${x + 210} 698 C ${x + 210} 800, 1000 800, 1000 880" fill="none" stroke="${tint}" stroke-width="3" opacity="0.6"/>`,
|
||||
)
|
||||
})
|
||||
|
||||
parts.push(`<rect x="640" y="880" width="720" height="120" rx="8" fill="${tint}"/>`)
|
||||
parts.push('<rect x="700" y="920" width="420" height="18" rx="9" fill="#fbfaf6" opacity="0.9"/>')
|
||||
parts.push('<rect x="700" y="954" width="300" height="14" rx="7" fill="#fbfaf6" opacity="0.6"/>')
|
||||
|
||||
return frame(parts.join(''), tint)
|
||||
}
|
||||
|
||||
export const sampleImages: SampleImage[] = [
|
||||
{
|
||||
key: 'regel-engine',
|
||||
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00001',
|
||||
project: 'trakk',
|
||||
filename: 'regel-engine-bedingung-aktion.png',
|
||||
alt: 'Schema der Regel-Engine: links drei Bedingungen, rechts drei Aktionen, jeweils mit einer Linie verbunden',
|
||||
caption: 'Jede Bedingung zeigt auf genau eine Aktion.',
|
||||
svg: ruleEngine,
|
||||
},
|
||||
{
|
||||
key: 'spaltenmenue',
|
||||
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00002',
|
||||
project: 'mta360',
|
||||
filename: 'spaltenmenue-rechtsklick.png',
|
||||
alt: 'Tabelle mit fünf Spalten, über der vierten Spalte ist ein Menü mit vier Einträgen geöffnet',
|
||||
caption: 'Das Spaltenmenü öffnet sich über der Spalte, die es betrifft.',
|
||||
svg: columnMenu,
|
||||
},
|
||||
{
|
||||
key: 'tracker-karte',
|
||||
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00003',
|
||||
project: 'mta-telematik',
|
||||
filename: 'tracker-karte-cluster.png',
|
||||
alt: 'Kartenausschnitt mit vielen kleinen Punkten und drei hervorgehobenen Ballungen, die mit einer gestrichelten Linie verbunden sind',
|
||||
caption: 'Dicht beieinander liegende Tracker werden zu Ballungen zusammengefasst.',
|
||||
svg: trackerMap,
|
||||
},
|
||||
{
|
||||
key: 'sla-uhr',
|
||||
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00004',
|
||||
project: 'trakk',
|
||||
filename: 'sla-uhr-pausiert.png',
|
||||
alt: 'Zeitleiste mit sieben Balken, zwei helle senkrechte Bänder markieren die Zeiten, in denen die Uhr steht',
|
||||
caption: 'Die hellen Bänder sind Wochenenden und Feiertage, dort läuft die Uhr nicht.',
|
||||
svg: slaClock,
|
||||
},
|
||||
{
|
||||
key: 'eingang',
|
||||
id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00005',
|
||||
project: 'orbit',
|
||||
filename: 'kommentare-ein-eingang.png',
|
||||
alt: 'Drei Spalten mit Kommentaren laufen über geschwungene Linien in einen gemeinsamen Eingang unten zusammen',
|
||||
caption: 'Drei Kanäle, ein Eingang.',
|
||||
svg: inbox,
|
||||
},
|
||||
]
|
||||
|
||||
export async function ensureSampleMedia(projectIds: Map<string, string>): Promise<Map<string, string>> {
|
||||
const storage = getStorage()
|
||||
const ids = new Map<string, string>()
|
||||
|
||||
for (const sample of sampleImages) {
|
||||
const projectId = projectIds.get(sample.project)
|
||||
|
||||
if (!projectId) {
|
||||
continue
|
||||
}
|
||||
|
||||
ids.set(sample.key, sample.id)
|
||||
|
||||
const existing = await findMedia(sample.id)
|
||||
|
||||
if (existing) {
|
||||
if (!(await storage.exists(existing.path))) {
|
||||
const body = await renderPng(sample.svg())
|
||||
|
||||
await storage.put(existing.path, body, 'image/png')
|
||||
|
||||
for (const variant of existing.variants) {
|
||||
const rendered = await renderVariant(body, variant.width)
|
||||
|
||||
await storage.put(variant.path, rendered.body, 'image/webp')
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const path = objectPath(projectId, sample.id, 'original.png')
|
||||
const stored = await storage.read(path)
|
||||
const body = stored ? stored.body : await renderPng(sample.svg())
|
||||
|
||||
await uploadMedia({
|
||||
id: sample.id,
|
||||
projectId,
|
||||
filename: sample.filename,
|
||||
body,
|
||||
alt: sample.alt,
|
||||
caption: sample.caption,
|
||||
reuse: true,
|
||||
})
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user