feat(schema): Phase 1 Symbol Architecture — SymbolTemplate, TenantCategory, TenantSymbol refactor + seed + migration scripts
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 14m40s

This commit is contained in:
Pepe Ziberi
2026-05-20 11:11:32 +02:00
parent 9b96de0a21
commit 4b92df8fea
5 changed files with 918 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
/**
* Post-Migration Script: Migrate existing TenantSymbols to new architecture.
*
* Run AFTER applying the schema migration (20260520_symbol_architecture).
*
* Steps:
* 1. Create a default "Meine Symbole" TenantCategory for each tenant that has TenantSymbols
* 2. Migrate existing TenantSymbols: set name, svgPath, categoryId, migratedFromIconId
* 3. Verify consistency
*
* Usage:
* npx ts-node prisma/migrate-tenant-symbols.ts
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
console.log('🔧 Migrating existing TenantSymbols...')
// Step 1: Get all tenants that have existing tenantSymbols
const tenantsWithSymbols = await (prisma as any).tenantSymbol.groupBy({
by: ['tenantId'],
_count: { id: true },
})
console.log(`Found ${tenantsWithSymbols.length} tenants with existing symbols`)
let categoriesCreated = 0
let symbolsMigrated = 0
for (const group of tenantsWithSymbols) {
const tenantId = group.tenantId
// Step 2: Create default category for this tenant
let defaultCategory = await (prisma as any).tenantCategory.findFirst({
where: { tenantId, name: 'Meine Symbole' },
})
if (!defaultCategory) {
defaultCategory = await (prisma as any).tenantCategory.create({
data: {
tenantId,
name: 'Meine Symbole',
sortOrder: 0,
},
})
categoriesCreated++
}
// Step 3: Migrate all tenantSymbols for this tenant
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
where: { tenantId },
include: { icon: true },
})
for (const ts of tenantSymbols) {
const updates: any = {}
// name = customName || icon.name
if (!ts.name) {
updates.name = ts.customName || ts.icon?.name || 'Unbenannt'
}
// svgPath = icon.fileKey
if (!ts.svgPath && ts.icon?.fileKey) {
updates.svgPath = ts.icon.fileKey
}
// categoryId = default category
if (!ts.categoryId) {
updates.categoryId = defaultCategory.id
}
// migratedFromIconId = current iconId
if (!ts.migratedFromIconId) {
updates.migratedFromIconId = ts.iconId
}
if (Object.keys(updates).length > 0) {
await (prisma as any).tenantSymbol.update({
where: { id: ts.id },
data: updates,
})
symbolsMigrated++
}
}
}
console.log(`✅ Done: ${categoriesCreated} categories created, ${symbolsMigrated} symbols migrated`)
// Verification
const unmappedCount = await (prisma as any).tenantSymbol.count({
where: { categoryId: null },
})
if (unmappedCount > 0) {
console.warn(`⚠️ ${unmappedCount} tenantSymbols still have no categoryId!`)
} else {
console.log('✅ All tenantSymbols have a category assigned')
}
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -90,6 +90,7 @@ model Tenant {
iconCategories IconCategory[]
iconAssets IconAsset[]
tenantSymbols TenantSymbol[]
tenantCategories TenantCategory[]
upgradeRequests UpgradeRequest[]
dictionaryEntries DictionaryEntry[]
rapports Rapport[]
@@ -392,10 +393,53 @@ model TenantSymbol {
iconId String
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
// New fields for Phase 1 Symbol Architecture
categoryId String?
category TenantCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
name String? // Display name (migrated from customName || icon.name)
svgPath String? // e.g. "signaturen/TLF.svg" or tenant-specific MinIO key
isUploaded Boolean @default(false)
migratedFromIconId String?
@@index([tenantId])
@@index([categoryId])
@@map("tenant_symbols")
}
// ─── Symbol Templates (global read-only packages) ─────────
model SymbolTemplate {
id String @id @default(uuid())
packageId String // e.g. "feuerwehr-ch"
packageName String // e.g. "Feuerwehr Schweiz"
categoryName String // e.g. "Fahrzeuge"
name String
svgPath String // relative path in public/ or MinIO key
tags String[] @default([])
sortOrder Int @default(0)
@@index([packageId])
@@map("symbol_templates")
}
// ─── Tenant Categories (per-tenant, user-managed) ─────────
model TenantCategory {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
sortOrder Int @default(0)
icon String? // Optional emoji or Lucide icon name for UI
symbols TenantSymbol[]
@@unique([tenantId, name])
@@index([tenantId])
@@map("tenant_categories")
}
// ─── Dictionary (Global + Tenant word library) ────────────
model DictionaryEntry {

View File

@@ -0,0 +1,123 @@
/**
* Seed-Skript: Erstellt SymbolTemplate-Einträge aus bestehenden IconAsset (isSystem=true)
* oder aus dem Dateisystem (public/signaturen/*.svg).
*
* Ausführen:
* npx ts-node prisma/seed-symbol-templates.ts
* oder als Teil des Deployments via npx prisma db seed
*
* Idempotent: bereits existierende Templates werden übersprungen.
*/
import { PrismaClient } from '@prisma/client'
import { readdirSync, statSync } from 'fs'
import { join } from 'path'
const prisma = new PrismaClient()
const SIGNATUREN_DIR = join(process.cwd(), 'public', 'signaturen')
const PACKAGE_ID = 'feuerwehr-ch'
const PACKAGE_NAME = 'Feuerwehr Schweiz'
/**
* Versucht Kategorie-Namen aus bestehenden IconAsset / IconCategory abzuleiten.
* Fallback: "Sonstiges"
*/
async function getCategoryMapping(): Promise<Map<string, string>> {
const mapping = new Map<string, string>()
const iconAssets = await (prisma as any).iconAsset.findMany({
where: { isSystem: true },
include: { category: { select: { name: true } } },
})
for (const asset of iconAssets) {
const fileName = asset.fileKey.replace(/^signaturen\//, '').replace(/\.svg$/i, '')
const categoryName = asset.category?.name || 'Sonstiges'
mapping.set(fileName.toLowerCase(), categoryName)
}
return mapping
}
/**
* Liest alle .svg Dateien aus public/signaturen/
*/
function getSvgFiles(dir: string): string[] {
const files: string[] = []
try {
const entries = readdirSync(dir)
for (const entry of entries) {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isFile() && entry.endsWith('.svg')) {
files.push(entry)
}
}
} catch (err) {
console.warn('Could not read signaturen directory:', (err as Error).message)
}
return files.sort()
}
async function main() {
console.log('🌱 Seeding SymbolTemplate...')
const categoryMapping = await getCategoryMapping()
const svgFiles = getSvgFiles(SIGNATUREN_DIR)
console.log(`Found ${svgFiles.length} SVG files in public/signaturen/`)
console.log(`IconAsset mapping covers ${categoryMapping.size} entries`)
let created = 0
let skipped = 0
for (let i = 0; i < svgFiles.length; i++) {
const fileName = svgFiles[i]
const nameWithoutExt = fileName.replace(/\.svg$/i, '')
const displayName = nameWithoutExt
.replace(/_/g, ' ')
.replace(/-/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
const fileKey = `signaturen/${fileName}`
// Check if already exists
const existing = await (prisma as any).symbolTemplate.findFirst({
where: { svgPath: fileKey },
})
if (existing) {
skipped++
continue
}
const categoryName =
categoryMapping.get(nameWithoutExt.toLowerCase()) || 'Sonstiges'
await (prisma as any).symbolTemplate.create({
data: {
packageId: PACKAGE_ID,
packageName: PACKAGE_NAME,
categoryName,
name: displayName,
svgPath: fileKey,
tags: [nameWithoutExt.toLowerCase(), categoryName.toLowerCase()],
sortOrder: i,
},
})
created++
}
console.log(`✅ Done: ${created} created, ${skipped} skipped`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})