From 3606c9a2a4ffa307a576a3e4029363c62e3da320 Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Thu, 21 May 2026 13:40:34 +0200 Subject: [PATCH] fix(auto-migrate): tenant_categories on-the-fly erstellung + migrate.js fail-fast --- prisma/migrate.js | 22 ++++- src/app/api/tenant/categories/route.ts | 111 ++++++++++++++++++------- src/lib/auto-migrate.ts | 104 +++++++++++++++++++++++ 3 files changed, 202 insertions(+), 35 deletions(-) create mode 100644 src/lib/auto-migrate.ts diff --git a/prisma/migrate.js b/prisma/migrate.js index 832d626..d54698c 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -212,7 +212,7 @@ async function migrate() { console.log(' Privacy consent columns skipped:', e.message) } - // ─── Step 12: Create tenant_symbols table ─── + // ─── Step 12: Create tenant_symbols table (CRITICAL — fail-fast) ─── console.log(' [12] Creating tenant_symbols table...') try { await prisma.$executeRawUnsafe(` @@ -226,7 +226,8 @@ async function migrate() { `) console.log(' tenant_symbols table created (or already exists)') } catch (e) { - console.log(' tenant_symbols table skipped:', e.message) + console.error(' ❌ CRITICAL: tenant_symbols table creation failed:', e.message) + throw e } // ─── Step 13: Create symbol_templates table ─── @@ -251,7 +252,7 @@ async function migrate() { console.log(' symbol_templates table skipped:', e.message) } - // ─── Step 14: Create tenant_categories table ─── + // ─── Step 14: Create tenant_categories table (CRITICAL — fail-fast) ─── console.log(' [14] Creating tenant_categories table...') try { await prisma.$executeRawUnsafe(` @@ -268,7 +269,20 @@ async function migrate() { `) console.log(' tenant_categories table created (or already exists)') } catch (e) { - console.log(' tenant_categories table skipped:', e.message) + console.error(' ❌ CRITICAL: tenant_categories table creation failed:', e.message) + throw e + } + + // ─── Step 14b: Ensure tenant_categories has NOT NULL defaults ─── + console.log(' [14b] Ensuring tenant_categories columns...') + const tcColumns = [ + `ALTER TABLE tenant_categories ALTER COLUMN "isActive" SET DEFAULT true`, + `ALTER TABLE tenant_categories ALTER COLUMN "sortOrder" SET DEFAULT 0`, + `ALTER TABLE tenant_categories ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP`, + `ALTER TABLE tenant_categories ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`, + ] + for (const sql of tcColumns) { + try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* ignore */ } } // ─── Step 15: Extend tenant_symbols with Phase 1 columns ─── diff --git a/src/app/api/tenant/categories/route.ts b/src/app/api/tenant/categories/route.ts index 0e7b06b..c28bc8e 100644 --- a/src/app/api/tenant/categories/route.ts +++ b/src/app/api/tenant/categories/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { getSession } from '@/lib/auth' +import { ensureTenantCategoriesTable } from '@/lib/auto-migrate' + +function isMissingTable(err: any) { + return err?.message?.includes('tenant_categories') || err?.code === 'P2021' +} async function getTenantId() { const user = await getSession() @@ -37,10 +42,25 @@ export async function GET() { }) } catch (dbErr: any) { console.error('tenantCategory.findMany failed:', dbErr) - if (dbErr?.message?.includes('tenant_categories') || dbErr?.code === 'P2021') { - return NextResponse.json({ categories: [] }) + if (isMissingTable(dbErr)) { + try { + await ensureTenantCategoriesTable() + categories = await (prisma as any).tenantCategory.findMany({ + where: { tenantId }, + include: { + _count: { + select: { symbols: true }, + }, + }, + orderBy: { sortOrder: 'asc' }, + }) + } catch (retryErr) { + console.error('Retry after auto-migrate failed:', retryErr) + return NextResponse.json({ categories: [] }) + } + } else { + throw dbErr } - throw dbErr } const result = categories.map((cat: any) => ({ @@ -79,19 +99,23 @@ export async function POST(req: NextRequest) { } // Check for duplicate name within tenant + let existing: any = null try { - const existing = await (prisma as any).tenantCategory.findFirst({ + existing = await (prisma as any).tenantCategory.findFirst({ where: { tenantId, name: { equals: name, mode: 'insensitive' } }, }) - if (existing) { - return NextResponse.json({ error: 'Kategorie existiert bereits' }, { status: 409 }) - } } catch (dbErr: any) { - console.error('tenantCategory.findFirst failed:', dbErr) - if (dbErr?.message?.includes('tenant_categories') || dbErr?.code === 'P2021') { - return NextResponse.json({ error: 'Datenbank nicht bereit – Migration läuft möglicherweise noch' }, { status: 503 }) + if (isMissingTable(dbErr)) { + await ensureTenantCategoriesTable() + existing = await (prisma as any).tenantCategory.findFirst({ + where: { tenantId, name: { equals: name, mode: 'insensitive' } }, + }) + } else { + throw dbErr } - throw dbErr + } + if (existing) { + return NextResponse.json({ error: 'Kategorie existiert bereits' }, { status: 409 }) } try { @@ -105,9 +129,17 @@ export async function POST(req: NextRequest) { }) return NextResponse.json({ category }, { status: 201 }) } catch (dbErr: any) { - console.error('tenantCategory.create failed:', dbErr) - if (dbErr?.message?.includes('tenant_categories') || dbErr?.code === 'P2021') { - return NextResponse.json({ error: 'Datenbank nicht bereit – Migration läuft möglicherweise noch' }, { status: 503 }) + if (isMissingTable(dbErr)) { + await ensureTenantCategoriesTable() + const category = await (prisma as any).tenantCategory.create({ + data: { + tenantId, + name: name.trim(), + sortOrder: sortOrder ?? 0, + icon: icon || null, + }, + }) + return NextResponse.json({ category }, { status: 201 }) } throw dbErr } @@ -141,23 +173,31 @@ export async function PATCH(req: NextRequest) { // If renaming, check for duplicates if (name) { + let existing: any = null try { - const existing = await (prisma as any).tenantCategory.findFirst({ + existing = await (prisma as any).tenantCategory.findFirst({ where: { tenantId, name: { equals: name.trim(), mode: 'insensitive' }, id: { not: id }, }, }) - if (existing) { - return NextResponse.json({ error: 'Kategorie existiert bereits' }, { status: 409 }) - } } catch (dbErr: any) { - console.error('tenantCategory.findFirst failed:', dbErr) - if (dbErr?.message?.includes('tenant_categories') || dbErr?.code === 'P2021') { - return NextResponse.json({ error: 'Datenbank nicht bereit – Migration läuft möglicherweise noch' }, { status: 503 }) + if (isMissingTable(dbErr)) { + await ensureTenantCategoriesTable() + existing = await (prisma as any).tenantCategory.findFirst({ + where: { + tenantId, + name: { equals: name.trim(), mode: 'insensitive' }, + id: { not: id }, + }, + }) + } else { + throw dbErr } - throw dbErr + } + if (existing) { + return NextResponse.json({ error: 'Kategorie existiert bereits' }, { status: 409 }) } } @@ -171,11 +211,18 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ error: 'Kategorie nicht gefunden' }, { status: 404 }) } } catch (dbErr: any) { - console.error('tenantCategory.updateMany failed:', dbErr) - if (dbErr?.message?.includes('tenant_categories') || dbErr?.code === 'P2021') { - return NextResponse.json({ error: 'Datenbank nicht bereit – Migration läuft möglicherweise noch' }, { status: 503 }) + if (isMissingTable(dbErr)) { + await ensureTenantCategoriesTable() + const category = await (prisma as any).tenantCategory.updateMany({ + where: { id, tenantId }, + data, + }) + if (category.count === 0) { + return NextResponse.json({ error: 'Kategorie nicht gefunden' }, { status: 404 }) + } + } else { + throw dbErr } - throw dbErr } return NextResponse.json({ success: true }) @@ -214,7 +261,6 @@ export async function DELETE(req: NextRequest) { ) } } catch (dbErr: any) { - console.error('tenantSymbol.count failed:', dbErr) if (dbErr?.message?.includes('tenant_symbols') || dbErr?.code === 'P2021') { // Skip check if table doesn't exist } else { @@ -227,11 +273,14 @@ export async function DELETE(req: NextRequest) { where: { id, tenantId }, }) } catch (dbErr: any) { - console.error('tenantCategory.deleteMany failed:', dbErr) - if (dbErr?.message?.includes('tenant_categories') || dbErr?.code === 'P2021') { - return NextResponse.json({ error: 'Datenbank nicht bereit – Migration läuft möglicherweise noch' }, { status: 503 }) + if (isMissingTable(dbErr)) { + await ensureTenantCategoriesTable() + await (prisma as any).tenantCategory.deleteMany({ + where: { id, tenantId }, + }) + } else { + throw dbErr } - throw dbErr } return NextResponse.json({ success: true }) diff --git a/src/lib/auto-migrate.ts b/src/lib/auto-migrate.ts new file mode 100644 index 0000000..fcc5352 --- /dev/null +++ b/src/lib/auto-migrate.ts @@ -0,0 +1,104 @@ +/** + * Auto-migrate helper: ensures critical tables exist on-the-fly. + * Called from API endpoints when a table-missing error is detected. + */ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export async function ensureTenantCategoriesTable() { + try { + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS tenant_categories ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "description" TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE + ) + `) + console.log('[auto-migrate] tenant_categories ensured') + } catch (e: any) { + if (e.message?.includes('already exists')) return + throw e + } +} + +export async function ensureTenantSymbolsTable() { + try { + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS tenant_symbols ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + "iconId" TEXT REFERENCES icon_assets(id) ON DELETE SET NULL, + UNIQUE("tenantId", "iconId") + ) + `) + console.log('[auto-migrate] tenant_symbols ensured') + } catch (e: any) { + if (e.message?.includes('already exists')) return + throw e + } +} + +export async function ensureTenantSymbolsColumns() { + const columns = [ + `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "name" TEXT`, + `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "svgPath" TEXT`, + `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "isUploaded" BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "categoryId" TEXT`, + `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "migratedFromIconId" TEXT`, + `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`, + `ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`, + ] + for (const sql of columns) { + try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* ignore */ } + } +} + +export async function ensureTenantSymbolsCategoryFk() { + try { + await prisma.$executeRawUnsafe(` + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'tenant_symbols_categoryId_fkey' + AND table_name = 'tenant_symbols' + ) THEN + ALTER TABLE tenant_symbols + ADD CONSTRAINT "tenant_symbols_categoryId_fkey" + FOREIGN KEY ("categoryId") REFERENCES tenant_categories(id) ON DELETE SET NULL; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'categoryId FK skipped: %', SQLERRM; + END $$; + `) + } catch (e) { /* ignore */ } +} + +export async function ensureSymbolTemplatesTable() { + try { + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS symbol_templates ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + "fileKey" TEXT NOT NULL, + "originalFilename" TEXT NOT NULL, + "displayName" TEXT, + "categoryName" TEXT, + "svgPath" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE("fileKey") + ) + `) + console.log('[auto-migrate] symbol_templates ensured') + } catch (e: any) { + if (e.message?.includes('already exists')) return + throw e + } +}