Initial commit: Lageplan v1.0 - Next.js 15.5, React 19

This commit is contained in:
Pepe Ziberi
2026-02-21 11:57:44 +01:00
commit adf3dc8c1d
167 changed files with 34265 additions and 0 deletions

226
prisma/migrate.js Normal file
View File

@@ -0,0 +1,226 @@
/**
* Database migration script using PrismaClient raw SQL.
* Does NOT require the Prisma CLI (npx prisma) — only the runtime client.
* Safe to run multiple times (all statements are idempotent).
*/
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
async function migrate() {
console.log('🔧 Running database migrations...')
// ─── Step 1: Ensure enum values exist ───
console.log(' [1/7] Ensuring enum values...')
const enumMigrations = [
// Role enum
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'SERVER_ADMIN' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'SERVER_ADMIN'; END IF; END$$;`,
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'TENANT_ADMIN' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'TENANT_ADMIN'; END IF; END$$;`,
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'OPERATOR' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'OPERATOR'; END IF; END$$;`,
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'VIEWER' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Role')) THEN ALTER TYPE "Role" ADD VALUE 'VIEWER'; END IF; END$$;`,
// DictionaryScope enum
`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'DictionaryScope') THEN CREATE TYPE "DictionaryScope" AS ENUM ('GLOBAL', 'TENANT'); END IF; END$$;`,
]
for (const sql of enumMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* enum might already exist */ }
}
// ─── Step 2: Migrate old enum data ───
console.log(' [2/7] Migrating old data...')
const dataMigrations = [
`UPDATE users SET role = 'SERVER_ADMIN' WHERE role = 'ADMIN'`,
`UPDATE users SET role = 'OPERATOR' WHERE role = 'EDITOR'`,
`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`,
]
for (const sql of dataMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might not exist yet */ }
}
// ─── Step 3: Add missing columns (idempotent) ───
console.log(' [3/7] Adding missing columns...')
const columnMigrations = [
// Tenants
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "logoUrl" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "logoFileKey" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "hiddenIconIds" TEXT[] DEFAULT '{}'`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "journalSuggestions" TEXT[] DEFAULT '{}'`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "contactEmail" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "contactPhone" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "address" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "notes" TEXT`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "maxUsers" INTEGER DEFAULT 5`,
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "maxProjects" INTEGER DEFAULT 10`,
// Users
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "lastLoginAt" TIMESTAMP`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "emailVerified" BOOLEAN DEFAULT true`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "emailVerificationToken" TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetToken" TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetTokenExpiry" TIMESTAMP`,
// Icon categories
`ALTER TABLE icon_categories ADD COLUMN IF NOT EXISTS "isGlobal" BOOLEAN DEFAULT false`,
`ALTER TABLE icon_categories ADD COLUMN IF NOT EXISTS "tenantId" TEXT`,
// Projects
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "planImageKey" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "planBounds" JSONB`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "einsatzleiter" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingStartedAt" TIMESTAMP`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingHeartbeat" TIMESTAMP`,
// Journal
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "isCorrected" BOOLEAN DEFAULT false`,
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "correctionOfId" TEXT`,
]
let added = 0
for (const sql of columnMigrations) {
try {
await prisma.$executeRawUnsafe(sql)
added++
} catch (e) {
// Table might not exist yet — that's OK, prisma db push or first seed will create it
}
}
console.log(` ${added}/${columnMigrations.length} column migrations executed`)
// ─── Step 4: Create new tables ───
console.log(' [4/7] Creating new tables...')
const tableMigrations = [
// Dictionary entries table
`CREATE TABLE IF NOT EXISTS dictionary_entries (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
word TEXT NOT NULL,
scope "DictionaryScope" NOT NULL DEFAULT 'GLOBAL',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tenantId" TEXT REFERENCES tenants(id) ON DELETE CASCADE,
UNIQUE(word, "tenantId")
)`,
// Rapports table
`CREATE TABLE IF NOT EXISTS rapports (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"reportNumber" TEXT NOT NULL,
token TEXT NOT NULL UNIQUE DEFAULT gen_random_uuid(),
data JSONB NOT NULL DEFAULT '{}',
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
"createdById" TEXT REFERENCES users(id) ON DELETE SET NULL,
UNIQUE("tenantId", "reportNumber")
)`,
]
for (const sql of tableMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
}
// ─── Step 5: Set safe defaults ───
console.log(' [5/7] Setting defaults...')
try {
await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`)
} catch (e) { /* ignore */ }
// ─── Step 6: Clean up orphan users ───
console.log(' [6/7] Cleaning up orphan users...')
try {
// Find users who are NOT SERVER_ADMIN and have NO tenant membership
const orphans = await prisma.user.findMany({
where: {
role: { not: 'SERVER_ADMIN' },
memberships: { none: {} },
},
select: { id: true, email: true, name: true },
})
if (orphans.length > 0) {
console.log(` Found ${orphans.length} orphan user(s):`)
for (const o of orphans) {
console.log(` - ${o.email} (${o.name})`)
}
// Delete orphan users and their related data
await prisma.user.deleteMany({
where: {
id: { in: orphans.map(o => o.id) },
},
})
console.log(` 🗑️ ${orphans.length} orphan user(s) removed`)
} else {
console.log(' No orphan users found')
}
} catch (e) {
console.log(' Orphan cleanup skipped:', e.message)
}
// ─── Step 7: Backfill logoFileKey from logoUrl ───
console.log(' [7/7] Backfilling logoFileKey...')
try {
// Extract fileKey from MinIO URLs: the path after the bucket name
// e.g. http://localhost:9002/lageplan-icons/logos/tenant-xxx.png → logos/tenant-xxx.png
const tenants = await prisma.tenant.findMany({
where: { logoUrl: { not: null }, logoFileKey: null },
select: { id: true, logoUrl: true },
})
for (const t of tenants) {
if (!t.logoUrl) continue
// Try to extract the fileKey from the URL
const match = t.logoUrl.match(/logos\/[^?]+/)
if (match) {
await prisma.tenant.update({
where: { id: t.id },
data: { logoFileKey: match[0] },
})
}
}
if (tenants.length > 0) console.log(` Backfilled ${tenants.length} logo fileKey(s)`)
} catch (e) {
console.log(' Logo backfill skipped:', e.message)
}
// ─── Step 8: Drop unique constraint on rapports(tenantId, reportNumber) ───
console.log(' [8] Dropping rapports unique constraint on (tenantId, reportNumber)...')
try {
await prisma.$executeRawUnsafe(`ALTER TABLE "rapports" DROP CONSTRAINT IF EXISTS "rapports_tenantId_reportNumber_key"`)
console.log(' Constraint dropped (or did not exist)')
} catch (e) {
console.log(' Constraint drop skipped:', e.message)
}
// ─── Step 9: Add einsatzNr column to projects ───
console.log(' [9] Adding einsatzNr column to projects...')
try {
await prisma.$executeRawUnsafe(`ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "einsatzNr" TEXT`)
console.log(' einsatzNr column added (or already exists)')
} catch (e) {
console.log(' einsatzNr column skipped:', e.message)
}
// ─── Step 10: Make rapports.tenantId nullable ───
console.log(' [10] Making rapports.tenantId nullable...')
try {
await prisma.$executeRawUnsafe(`ALTER TABLE "rapports" ALTER COLUMN "tenantId" DROP NOT NULL`)
console.log(' rapports.tenantId is now nullable')
} catch (e) {
console.log(' rapports.tenantId nullable skipped:', e.message)
}
// ─── Step 11: Add privacy consent columns to tenants ───
console.log(' [11] Adding privacy consent columns to tenants...')
try {
await prisma.$executeRawUnsafe(`ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "privacyAccepted" BOOLEAN DEFAULT false`)
await prisma.$executeRawUnsafe(`ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "privacyAcceptedAt" TIMESTAMP`)
await prisma.$executeRawUnsafe(`ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "adminAccessAccepted" BOOLEAN DEFAULT false`)
console.log(' Privacy consent columns added')
} catch (e) {
console.log(' Privacy consent columns skipped:', e.message)
}
console.log('✅ Database migrations complete')
}
migrate()
.then(async () => { await prisma.$disconnect() })
.catch(async (e) => {
console.error('Migration error:', e.message)
await prisma.$disconnect()
process.exit(1)
})

414
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,414 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
SERVER_ADMIN
TENANT_ADMIN
OPERATOR
VIEWER
}
enum IconType {
STANDARD
RETTUNG
GEFAHRSTOFF
FEUER
WASSER
FAHRZEUG
}
enum ItemKind {
SYMBOL
LINE
POLYGON
RECTANGLE
CIRCLE
ARROW
TEXT
}
enum SubscriptionPlan {
FREE
PRO
}
enum DictionaryScope {
GLOBAL
TENANT
}
enum SubscriptionStatus {
ACTIVE
TRIAL
SUSPENDED
EXPIRED
CANCELLED
}
model Tenant {
id String @id @default(uuid())
name String
slug String @unique
description String?
isActive Boolean @default(true)
contactEmail String?
contactPhone String?
address String?
logoUrl String?
logoFileKey String?
// Subscription
plan SubscriptionPlan @default(FREE)
subscriptionStatus SubscriptionStatus @default(ACTIVE)
trialEndsAt DateTime?
subscriptionEndsAt DateTime?
maxUsers Int @default(5)
maxProjects Int @default(10)
notes String?
hiddenIconIds String[] @default([])
journalSuggestions String[] @default([])
// Privacy consent
privacyAccepted Boolean @default(false)
privacyAcceptedAt DateTime?
adminAccessAccepted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships TenantMembership[]
projects Project[]
hoseTypes HoseType[]
checkTemplates JournalCheckTemplate[]
iconCategories IconCategory[]
iconAssets IconAsset[]
upgradeRequests UpgradeRequest[]
dictionaryEntries DictionaryEntry[]
rapports Rapport[]
@@map("tenants")
}
// ─── System Settings (Key-Value, encrypted for secrets) ──────
model SystemSetting {
id String @id @default(uuid())
key String @unique
value String
isSecret Boolean @default(false)
category String @default("general")
updatedAt DateTime @updatedAt
@@map("system_settings")
}
model User {
id String @id @default(uuid())
email String @unique
password String
name String
role Role @default(OPERATOR)
emailVerified Boolean @default(true)
emailVerificationToken String? @unique
resetToken String? @unique
resetTokenExpiry DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships TenantMembership[]
projects Project[]
iconAssets IconAsset[]
upgradeRequests UpgradeRequest[]
rapports Rapport[]
@@map("users")
}
model TenantMembership {
id String @id @default(uuid())
role Role @default(OPERATOR)
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([userId, tenantId])
@@map("tenant_memberships")
}
model Project {
id String @id @default(uuid())
einsatzNr String?
title String
location String?
description String?
einsatzleiter String?
journalfuehrer String?
mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}")
mapZoom Float @default(15)
isLocked Boolean @default(false)
// Live editing lock (session-based for same-account multi-device)
editingById String?
editingUserName String?
editingSessionId String?
editingStartedAt DateTime?
editingHeartbeat DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull)
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
features Feature[]
items Item[]
journalEntries JournalEntry[]
journalCheckItems JournalCheckItem[]
journalPendenzen JournalPendenz[]
rapports Rapport[]
@@map("projects")
}
model Feature {
id String @id @default(uuid())
type String
geometry Json
properties Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@map("features")
}
model IconCategory {
id String @id @default(uuid())
name String
description String?
sortOrder Int @default(0)
isGlobal Boolean @default(false)
createdAt DateTime @default(now())
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
icons IconAsset[]
@@map("icon_categories")
}
model IconAsset {
id String @id @default(uuid())
name String
fileKey String
mimeType String
width Int?
height Int?
isSystem Boolean @default(false)
isActive Boolean @default(true)
iconType IconType @default(STANDARD)
tags String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categoryId String?
category IconCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull)
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
@@map("icon_assets")
}
model HoseType {
id String @id @default(uuid())
name String @unique
diameterMm Int
lengthPerPieceM Int @default(10)
flowRateLpm Float
frictionCoeff Float
description String?
isDefault Boolean @default(false)
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
@@map("hose_types")
}
// ─── Journal ───────────────────────────────────────────────
model JournalEntry {
id String @id @default(uuid())
time DateTime @default(now())
what String
who String?
done Boolean @default(false)
doneAt DateTime?
sortOrder Int @default(0)
isCorrected Boolean @default(false)
correctionOfId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@map("journal_entries")
}
model JournalCheckItem {
id String @id @default(uuid())
label String
confirmed Boolean @default(false)
confirmedAt DateTime?
ok Boolean @default(false)
okAt DateTime?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@map("journal_check_items")
}
model JournalPendenz {
id String @id @default(uuid())
what String
who String?
whenHow String?
done Boolean @default(false)
doneAt DateTime?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@map("journal_pendenzen")
}
model JournalCheckTemplate {
id String @id @default(uuid())
label String
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
@@map("journal_check_templates")
}
model Item {
id String @id @default(uuid())
kind ItemKind
geometry Json
style Json @default("{}")
properties Json @default("{}")
isVisible Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
iconId String?
@@map("items")
}
// ─── Upgrade Requests ─────────────────────────────────────
enum UpgradeRequestStatus {
PENDING
APPROVED
REJECTED
}
model UpgradeRequest {
id String @id @default(uuid())
requestedPlan SubscriptionPlan
currentPlan SubscriptionPlan
message String?
status UpgradeRequestStatus @default(PENDING)
adminNote String?
processedAt DateTime?
processedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
requestedById String
requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade)
@@map("upgrade_requests")
}
// ─── Dictionary (Global + Tenant word library) ────────────
model DictionaryEntry {
id String @id @default(uuid())
word String
scope DictionaryScope @default(GLOBAL)
createdAt DateTime @default(now())
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([word, tenantId])
@@map("dictionary_entries")
}
// ─── Rapport (PDF reports with public token access) ───────
model Rapport {
id String @id @default(uuid())
reportNumber String
token String @unique @default(uuid())
data Json
generatedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
createdById String?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@map("rapports")
}

127
prisma/seed-icons-only.js Normal file
View File

@@ -0,0 +1,127 @@
/**
* Seed SVG icons and categories ONLY — does NOT touch tenants, users, or projects.
* Used when upgrading an existing database to new SVG icons.
*/
const { PrismaClient } = require('@prisma/client')
const fs = require('fs')
const path = require('path')
const prisma = new PrismaClient()
async function main() {
console.log('🎨 Seeding SVG icons only...')
const catDefs = [
{ name: 'Feuer / Brand', description: 'Feuer, Brand, Brandschutz', sortOrder: 1,
patterns: ['Feuer', 'Brand', 'Explosion', 'Entrauchung', 'Rauch', 'Kamin', 'Luefter', 'Sprinkler'] },
{ name: 'Wasser', description: 'Wasser, Hydranten, Leitungen', sortOrder: 2,
patterns: ['Wasser', 'Hydrant', 'Reservoir', 'Druckleitung', 'Druckleistung', 'Transportleitung',
'Schieber', 'Wasserloeschposten', 'Wasserversorgung', 'Wasserleitung', 'Ueberschwemmung',
'Offener_Wasserverlauf', 'Moeglicher_Wasserbezugsort', 'Stehendes_Gewaesser',
'Kleinloeschgeraet', 'Gefahr_durch_Loeschen_mit_Wasser'] },
{ name: 'Gefahren / Stoffe', description: 'Gefahrstoffe, Chemie, ABC', sortOrder: 3,
patterns: ['Gefaehrlich', 'Chemie', 'Chemikalien', 'Biologisch', 'Radioaktiv', 'Gas', 'Elektri',
'Elektrotableau', 'Achtung', 'Leitungsdaehte'] },
{ name: 'Rettung / Personen', description: 'Rettung, Sanität, Personen', sortOrder: 4,
patterns: ['Rettung', 'Retten', 'Sanitaet', 'Patienten', 'Sammelplatz', 'Sammelstelle',
'Totensammelstelle', 'Sprungretter', 'Helikopterlandeplatz'] },
{ name: 'Leitern / Geräte', description: 'Leitern, Fahrzeuge, Geräte', sortOrder: 5,
patterns: ['Leiter', 'Anhaengeleiter', 'Strebenleiter', 'ADL', 'TLF', 'HRF', 'SWHP'] },
{ name: 'Gebäude / Schäden', description: 'Gebäude, Zerstörung, Schäden', sortOrder: 6,
patterns: ['Beschaedigung', 'Teilzerstoerung', 'Totalzerstoerung', 'Decke_eingestuerzt',
'Umfassungswaende', 'AnzahlGeschosse', 'Eingang', 'Treppen', 'Lift', 'Bruecke',
'Rutschgebiet', 'Strasse', 'Bahnlin'] },
{ name: 'Einsatzführung', description: 'Führung, Organisation, Kommunikation', sortOrder: 7,
patterns: ['Einsatzleiter', 'KP_Font', 'Offizier', 'Beobachtungsposten', 'Kontrollstelle',
'Funk', 'Informationszentrum', 'Medienkontaktstelle', 'Fernsignaltableau',
'Materialdepot', 'Schluesseldepot', 'Abschnitt', 'Absperrung'] },
{ name: 'Organisationen', description: 'Feuerwehr, Polizei, Armee, Zivilschutz', sortOrder: 8,
patterns: ['Feuerwehr', 'Polizei', 'Armee', 'Zivilschutz', 'Chemiewehr', 'MS_de', 'Anmarsch'] },
{ name: 'Entwicklung / Taktik', description: 'Entwicklungsgrenzen, Ausbreitung', sortOrder: 9,
patterns: ['Entwicklungsgrenze', 'Horizontale_Entwicklung', 'Vertikale_Entwicklung',
'VertikaleEntwicklung', 'Unfall'] },
{ name: 'Verschiedenes', description: 'Weitere Signaturen', sortOrder: 10,
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
]
// Delete ALL old system icons (regardless of fileKey pattern)
const deleted = await prisma.iconAsset.deleteMany({
where: { isSystem: true },
})
console.log(`🗑️ ${deleted.count} old system icons removed`)
// Upsert global categories (preserves tenant categories)
const catMap = {}
for (const def of catDefs) {
const catId = def.name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
const cat = await prisma.iconCategory.upsert({
where: { id: catId },
update: { name: def.name, description: def.description, sortOrder: def.sortOrder, isGlobal: true },
create: {
id: catId,
name: def.name,
description: def.description,
sortOrder: def.sortOrder,
isGlobal: true,
tenantId: null,
},
})
catMap[def.name] = cat
}
console.log(`${Object.keys(catMap).length} icon categories upserted`)
function findCategory(filename) {
for (const def of catDefs) {
for (const pattern of def.patterns) {
if (filename.includes(pattern)) return catMap[def.name]
}
}
return catMap['Verschiedenes']
}
// Load SVG icons from public/signaturen/
const signaturenDir = path.join(process.cwd(), 'public', 'signaturen')
let svgFiles = []
try {
svgFiles = fs.readdirSync(signaturenDir).filter(f => f.endsWith('.svg')).sort()
} catch (e) {
console.warn('⚠️ public/signaturen/ not found')
return
}
let created = 0
for (const file of svgFiles) {
let name = file.replace('.svg', '')
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
name = name.replace(/[_-]/g, ' ').replace(/\s+/g, ' ').trim()
const fileKey = `signaturen/${file}`
const category = findCategory(file)
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
if (!existing) {
await prisma.iconAsset.create({
data: {
name,
categoryId: category.id,
fileKey,
mimeType: 'image/svg+xml',
isSystem: true,
width: 48,
height: 48,
},
})
created++
}
}
console.log(`✅ FKS Signaturen: ${created} new SVG icons created (${svgFiles.length} total)`)
}
main()
.then(async () => { await prisma.$disconnect() })
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

291
prisma/seed.js Normal file
View File

@@ -0,0 +1,291 @@
const { PrismaClient } = require('@prisma/client')
const bcrypt = require('bcryptjs')
const prisma = new PrismaClient()
async function main() {
console.log('🌱 Starting seed...')
// Create default tenant
const defaultTenant = await prisma.tenant.upsert({
where: { slug: 'demo-feuerwehr' },
update: {},
create: {
name: 'Demo Feuerwehr',
slug: 'demo-feuerwehr',
description: 'Standard-Mandant für Demonstrationszwecke',
},
})
console.log('✅ Default tenant created:', defaultTenant.name)
// Create default users
const adminPassword = await bcrypt.hash('admin123', 12)
const editorPassword = await bcrypt.hash('editor123', 12)
const viewerPassword = await bcrypt.hash('viewer123', 12)
const admin = await prisma.user.upsert({
where: { email: 'admin@lageplan.local' },
update: { role: 'SERVER_ADMIN' },
create: {
email: 'admin@lageplan.local',
name: 'Server Admin',
password: adminPassword,
role: 'SERVER_ADMIN',
},
})
const editor = await prisma.user.upsert({
where: { email: 'editor@lageplan.local' },
update: { role: 'TENANT_ADMIN' },
create: {
email: 'editor@lageplan.local',
name: 'Einsatzleiter',
password: editorPassword,
role: 'TENANT_ADMIN',
},
})
const viewer = await prisma.user.upsert({
where: { email: 'viewer@lageplan.local' },
update: { role: 'OPERATOR' },
create: {
email: 'viewer@lageplan.local',
name: 'Bediener',
password: viewerPassword,
role: 'OPERATOR',
},
})
console.log('✅ Users created:', { admin: admin.email, editor: editor.email, viewer: viewer.email })
// Create tenant memberships
for (const u of [editor, viewer]) {
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: u.id, tenantId: defaultTenant.id } },
update: {},
create: {
userId: u.id,
tenantId: defaultTenant.id,
role: u.id === editor.id ? 'TENANT_ADMIN' : 'OPERATOR',
},
})
}
console.log('✅ Tenant memberships created')
// ─── FKS Icon Categories & Symbols (Bildquelle: Feuerwehr Koordination Schweiz FKS) ───
const fs = require('fs')
const path = require('path')
// Category definitions with file-name patterns for auto-assignment
const catDefs = [
{ name: 'Feuer / Brand', description: 'Feuer, Brand, Brandschutz', sortOrder: 1,
patterns: ['Feuer', 'Brand', 'Explosion', 'Entrauchung', 'Rauch', 'Kamin', 'Luefter', 'Sprinkler'] },
{ name: 'Wasser', description: 'Wasser, Hydranten, Leitungen', sortOrder: 2,
patterns: ['Wasser', 'Hydrant', 'Reservoir', 'Druckleitung', 'Druckleistung', 'Transportleitung',
'Schieber', 'Wasserloeschposten', 'Wasserversorgung', 'Wasserleitung', 'Ueberschwemmung',
'Offener_Wasserverlauf', 'Moeglicher_Wasserbezugsort', 'Stehendes_Gewaesser',
'Kleinloeschgeraet', 'Gefahr_durch_Loeschen_mit_Wasser'] },
{ name: 'Gefahren / Stoffe', description: 'Gefahrstoffe, Chemie, ABC', sortOrder: 3,
patterns: ['Gefaehrlich', 'Chemie', 'Chemikalien', 'Biologisch', 'Radioaktiv', 'Gas', 'Elektri',
'Elektrotableau', 'Achtung', 'Leitungsdaehte'] },
{ name: 'Rettung / Personen', description: 'Rettung, Sanität, Personen', sortOrder: 4,
patterns: ['Rettung', 'Retten', 'Sanitaet', 'Patienten', 'Sammelplatz', 'Sammelstelle',
'Totensammelstelle', 'Sprungretter', 'Helikopterlandeplatz'] },
{ name: 'Leitern / Geräte', description: 'Leitern, Fahrzeuge, Geräte', sortOrder: 5,
patterns: ['Leiter', 'Anhaengeleiter', 'Strebenleiter', 'ADL', 'TLF', 'HRF', 'SWHP'] },
{ name: 'Gebäude / Schäden', description: 'Gebäude, Zerstörung, Schäden', sortOrder: 6,
patterns: ['Beschaedigung', 'Teilzerstoerung', 'Totalzerstoerung', 'Decke_eingestuerzt',
'Umfassungswaende', 'AnzahlGeschosse', 'Eingang', 'Treppen', 'Lift', 'Bruecke',
'Rutschgebiet', 'Strasse', 'Bahnlin'] },
{ name: 'Einsatzführung', description: 'Führung, Organisation, Kommunikation', sortOrder: 7,
patterns: ['Einsatzleiter', 'KP_Font', 'Offizier', 'Beobachtungsposten', 'Kontrollstelle',
'Funk', 'Informationszentrum', 'Medienkontaktstelle', 'Fernsignaltableau',
'Materialdepot', 'Schluesseldepot', 'Abschnitt', 'Absperrung'] },
{ name: 'Organisationen', description: 'Feuerwehr, Polizei, Armee, Zivilschutz', sortOrder: 8,
patterns: ['Feuerwehr', 'Polizei', 'Armee', 'Zivilschutz', 'Chemiewehr', 'MS_de', 'Anmarsch'] },
{ name: 'Entwicklung / Taktik', description: 'Entwicklungsgrenzen, Ausbreitung', sortOrder: 9,
patterns: ['Entwicklungsgrenze', 'Horizontale_Entwicklung', 'Vertikale_Entwicklung',
'VertikaleEntwicklung', 'Unfall'] },
{ name: 'Verschiedenes', description: 'Weitere Signaturen', sortOrder: 10,
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
]
// Delete ALL old system icons (regardless of fileKey pattern)
const deletedIcons = await prisma.iconAsset.deleteMany({
where: { isSystem: true },
})
console.log(`🗑️ ${deletedIcons.count} old system icons removed`)
// Clean up empty global categories
const oldGlobalCats = await prisma.iconCategory.findMany({ where: { tenantId: null } })
for (const oldCat of oldGlobalCats) {
const remaining = await prisma.iconAsset.count({ where: { categoryId: oldCat.id } })
if (remaining === 0) {
await prisma.iconCategory.delete({ where: { id: oldCat.id } }).catch(() => {})
}
}
// Create new global categories
const catMap = {}
for (const def of catDefs) {
const cat = await prisma.iconCategory.upsert({
where: { id: def.name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() },
update: { name: def.name, description: def.description, sortOrder: def.sortOrder, isGlobal: true },
create: {
id: def.name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(),
name: def.name,
description: def.description,
sortOrder: def.sortOrder,
isGlobal: true,
tenantId: null,
},
})
catMap[def.name] = cat
}
console.log(`${Object.keys(catMap).length} icon categories created`)
// Assign icon to category based on filename pattern matching
function findCategory(filename) {
for (const def of catDefs) {
for (const pattern of def.patterns) {
if (filename.includes(pattern)) return catMap[def.name]
}
}
return catMap['Verschiedenes']
}
// Load SVG icons from public/signaturen/
const signaturenDir = path.join(process.cwd(), 'public', 'signaturen')
let svgFiles = []
try {
svgFiles = fs.readdirSync(signaturenDir).filter(f => f.endsWith('.svg')).sort()
} catch (e) {
console.warn('⚠️ public/signaturen/ not found, skipping icon seed')
}
let created = 0
for (const file of svgFiles) {
// Clean name: remove .svg, remove _de/_DE suffix
let name = file.replace('.svg', '')
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
name = name.replace(/[_-]/g, ' ').replace(/\s+/g, ' ').trim()
const fileKey = `signaturen/${file}`
const category = findCategory(file)
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
if (!existing) {
await prisma.iconAsset.create({
data: {
name,
categoryId: category.id,
fileKey,
mimeType: 'image/svg+xml',
isSystem: true,
width: 48,
height: 48,
},
})
created++
}
}
console.log(`✅ FKS Signaturen: ${created} new icons created (${svgFiles.length} total SVGs)`)
// Create a demo project
const demoProject = await prisma.project.upsert({
where: { id: 'demo-project-001' },
update: {},
create: {
id: 'demo-project-001',
title: 'Demo Einsatz - Wohnungsbrand',
location: 'Musterstrasse 42, 8000 Zürich',
description: 'Beispiel-Einsatz zur Demonstration der Funktionen',
ownerId: editor.id,
tenantId: defaultTenant.id,
mapCenter: { lng: 8.5417, lat: 47.3769 },
mapZoom: 17,
},
})
console.log('✅ Demo project created:', demoProject.title)
// Create default hose types (Swiss fire department standards)
const hoseTypes = [
{
name: '55mm Transportleitung',
diameterMm: 55,
lengthPerPieceM: 10,
flowRateLpm: 500,
frictionCoeff: 0.034,
description: 'Standard Transportleitung, 3er Verteiler (1500/3 = 500 l/min)',
isDefault: true,
sortOrder: 1,
},
{
name: '75mm Zubringerleitung',
diameterMm: 75,
lengthPerPieceM: 20,
flowRateLpm: 1500,
frictionCoeff: 0.012,
description: 'Zubringerleitung vom Hydranten zum Verteiler',
isDefault: false,
sortOrder: 2,
},
]
for (const ht of hoseTypes) {
await prisma.hoseType.upsert({
where: { name: ht.name },
update: {
diameterMm: ht.diameterMm,
lengthPerPieceM: ht.lengthPerPieceM,
flowRateLpm: ht.flowRateLpm,
frictionCoeff: ht.frictionCoeff,
description: ht.description,
isDefault: ht.isDefault,
sortOrder: ht.sortOrder,
},
create: ht,
})
}
console.log('✅ Hose types created:', hoseTypes.length)
// ─── Journal Check Templates (SOMA) ─────────────────────
const somaTemplates = [
{ label: 'Stopp Zutritt / Ex-Gefahr', sortOrder: 1 },
{ label: 'Alarmierung Gesamt-Fw', sortOrder: 2 },
{ label: 'Alarmierung Ofw Wohlen', sortOrder: 3 },
{ label: 'Alarmierung Stp Baden', sortOrder: 4 },
{ label: 'Alarmierung Ambulanz', sortOrder: 5 },
{ label: 'Alarmierung BWL', sortOrder: 6 },
{ label: 'Alarmierung BL/Meister', sortOrder: 7 },
{ label: 'Brunnenchef Villmergen', sortOrder: 8 },
{ label: 'Berieselung Tank / Anl', sortOrder: 9 },
{ label: 'Druckerhöhung', sortOrder: 10 },
]
for (const tpl of somaTemplates) {
await prisma.journalCheckTemplate.upsert({
where: { id: tpl.label.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() },
update: { label: tpl.label, sortOrder: tpl.sortOrder },
create: {
id: tpl.label.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(),
label: tpl.label,
sortOrder: tpl.sortOrder,
},
})
}
console.log('✅ SOMA templates created:', somaTemplates.length)
console.log('🎉 Seed completed successfully!')
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

156
prisma/seed.ts Normal file
View File

@@ -0,0 +1,156 @@
import { PrismaClient, Role } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
console.log('🌱 Starting seed...')
// Create default users
const adminPassword = await bcrypt.hash('admin123', 12)
const editorPassword = await bcrypt.hash('editor123', 12)
const viewerPassword = await bcrypt.hash('viewer123', 12)
const admin = await prisma.user.upsert({
where: { email: 'admin@lageplan.local' },
update: {},
create: {
email: 'admin@lageplan.local',
name: 'Administrator',
password: adminPassword,
role: Role.ADMIN,
},
})
const editor = await prisma.user.upsert({
where: { email: 'editor@lageplan.local' },
update: {},
create: {
email: 'editor@lageplan.local',
name: 'Einsatzleiter',
password: editorPassword,
role: Role.EDITOR,
},
})
const viewer = await prisma.user.upsert({
where: { email: 'viewer@lageplan.local' },
update: {},
create: {
email: 'viewer@lageplan.local',
name: 'Beobachter',
password: viewerPassword,
role: Role.VIEWER,
},
})
console.log('✅ Users created:', { admin: admin.email, editor: editor.email, viewer: viewer.email })
// Create icon categories
const categories = [
{ name: 'Feuer', description: 'Brand- und Feuersymbole', sortOrder: 1 },
{ name: 'Wasser', description: 'Wasser- und Überflutungssymbole', sortOrder: 2 },
{ name: 'Gefahrstoffe', description: 'Chemie- und Gefahrstoffsymbole', sortOrder: 3 },
{ name: 'Verkehr', description: 'Verkehrs- und Unfallsymbole', sortOrder: 4 },
{ name: 'Personen', description: 'Personen- und Rettungssymbole', sortOrder: 5 },
{ name: 'Fahrzeuge', description: 'Einsatzfahrzeuge und Geräte', sortOrder: 6 },
{ name: 'Infrastruktur', description: 'Gebäude und Infrastruktursymbole', sortOrder: 7 },
{ name: 'Taktik', description: 'Taktische Zeichen und Symbole', sortOrder: 8 },
{ name: 'Eigene', description: 'Benutzerdefinierte Symbole', sortOrder: 99 },
]
for (const cat of categories) {
await prisma.iconCategory.upsert({
where: { name: cat.name },
update: { description: cat.description, sortOrder: cat.sortOrder },
create: cat,
})
}
console.log('✅ Icon categories created:', categories.length)
// Get category IDs for system icons
const feuerCat = await prisma.iconCategory.findUnique({ where: { name: 'Feuer' } })
const wasserCat = await prisma.iconCategory.findUnique({ where: { name: 'Wasser' } })
const gefahrstoffeCat = await prisma.iconCategory.findUnique({ where: { name: 'Gefahrstoffe' } })
const verkehrCat = await prisma.iconCategory.findUnique({ where: { name: 'Verkehr' } })
const personenCat = await prisma.iconCategory.findUnique({ where: { name: 'Personen' } })
const fahrzeugeCat = await prisma.iconCategory.findUnique({ where: { name: 'Fahrzeuge' } })
const infrastrukturCat = await prisma.iconCategory.findUnique({ where: { name: 'Infrastruktur' } })
const taktikCat = await prisma.iconCategory.findUnique({ where: { name: 'Taktik' } })
// Create system icons (these use inline SVG data URIs - in production, upload to MinIO)
const systemIcons = [
{ name: 'Brand', categoryId: feuerCat!.id, fileKey: 'system/fire.svg' },
{ name: 'Rauch', categoryId: feuerCat!.id, fileKey: 'system/smoke.svg' },
{ name: 'Explosion', categoryId: feuerCat!.id, fileKey: 'system/explosion.svg' },
{ name: 'Brandstelle', categoryId: feuerCat!.id, fileKey: 'system/fire-location.svg' },
{ name: 'Überflutung', categoryId: wasserCat!.id, fileKey: 'system/flood.svg' },
{ name: 'Wasserschaden', categoryId: wasserCat!.id, fileKey: 'system/water-damage.svg' },
{ name: 'Hydrant', categoryId: wasserCat!.id, fileKey: 'system/hydrant.svg' },
{ name: 'Gefahrstoff', categoryId: gefahrstoffeCat!.id, fileKey: 'system/hazmat.svg' },
{ name: 'Radioaktiv', categoryId: gefahrstoffeCat!.id, fileKey: 'system/radioactive.svg' },
{ name: 'Giftig', categoryId: gefahrstoffeCat!.id, fileKey: 'system/toxic.svg' },
{ name: 'Unfall', categoryId: verkehrCat!.id, fileKey: 'system/accident.svg' },
{ name: 'Strassensperre', categoryId: verkehrCat!.id, fileKey: 'system/road-block.svg' },
{ name: 'Verletzte Person', categoryId: personenCat!.id, fileKey: 'system/injured.svg' },
{ name: 'Vermisste Person', categoryId: personenCat!.id, fileKey: 'system/missing.svg' },
{ name: 'Sammelplatz', categoryId: personenCat!.id, fileKey: 'system/assembly-point.svg' },
{ name: 'Löschfahrzeug', categoryId: fahrzeugeCat!.id, fileKey: 'system/fire-truck.svg' },
{ name: 'Rettungswagen', categoryId: fahrzeugeCat!.id, fileKey: 'system/ambulance.svg' },
{ name: 'Kommandoposten', categoryId: taktikCat!.id, fileKey: 'system/command-post.svg' },
{ name: 'Einsatzleitung', categoryId: taktikCat!.id, fileKey: 'system/incident-command.svg' },
{ name: 'Absperrung', categoryId: taktikCat!.id, fileKey: 'system/barrier.svg' },
{ name: 'Gebäude', categoryId: infrastrukturCat!.id, fileKey: 'system/building.svg' },
{ name: 'Eingang', categoryId: infrastrukturCat!.id, fileKey: 'system/entrance.svg' },
]
for (const icon of systemIcons) {
const existing = await prisma.iconAsset.findFirst({
where: { fileKey: icon.fileKey },
})
if (!existing) {
await prisma.iconAsset.create({
data: {
name: icon.name,
categoryId: icon.categoryId,
fileKey: icon.fileKey,
mimeType: 'image/svg+xml',
isSystem: true,
width: 48,
height: 48,
},
})
}
}
console.log('✅ System icons created:', systemIcons.length)
// Create a demo project
const demoProject = await prisma.project.upsert({
where: { id: 'demo-project-001' },
update: {},
create: {
id: 'demo-project-001',
title: 'Demo Einsatz - Wohnungsbrand',
location: 'Musterstrasse 42, 8000 Zürich',
description: 'Beispiel-Einsatz zur Demonstration der Funktionen',
ownerId: editor.id,
mapCenter: { lng: 8.5417, lat: 47.3769 },
mapZoom: 17,
},
})
console.log('✅ Demo project created:', demoProject.title)
console.log('🎉 Seed completed successfully!')
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})