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
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 14m40s
This commit is contained in:
112
prisma/migrate-tenant-symbols.ts
Normal file
112
prisma/migrate-tenant-symbols.ts
Normal 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()
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
123
prisma/seed-symbol-templates.ts
Normal file
123
prisma/seed-symbol-templates.ts
Normal 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()
|
||||
})
|
||||
Reference in New Issue
Block a user