CG-Feedback-Monitor/server/db/starterPackage.ts

166 lines
5.9 KiB
TypeScript
Raw Permalink Normal View History

/**
* Starter-Paket: nur Konfigurations-/Strukturdaten für Team-Verteilung.
* Keine Personen-, Assignment- oder Meeting-Daten (GDPR).
* Produktiv (`AssigmentMonitor`) wird nie angefasst — Skripte nutzen dieselben Pfad-Guards.
*/
import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
import { dirname, join, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TABLE_DEFINITIONS, TABLE_NAMES } from './tableRegistry'
import { getAllRows, insertRowWithId } from './crud'
import { db } from '../database'
const __dirname = dirname(fileURLToPath(import.meta.url))
export const PROJECT_ROOT = resolve(join(__dirname, '../..'))
export const STARTER_DIR = join(PROJECT_ROOT, 'docs', 'starter')
export const STARTER_FILE = join(STARTER_DIR, 'starter-structure.json')
/** Tabellen mit Stammdaten / Config — IDs bleiben stabil (FK zwischen Struktur-Tabellen). */
export const STARTER_STRUCTURE_TABLES = [
'feedbackDimensions',
'feedbackCategories',
'feedbackCriterionItems',
'feedbackDimensionPrompts',
'assignmentTypes',
'phaseTemplates',
'checklistItemDefs',
'appSettings',
'criterionLevelDescriptions',
] as const
const STRUCTURE_SET = new Set<string>(STARTER_STRUCTURE_TABLES)
export interface StarterPackage {
kind: 'assignment-monitor-starter'
version: 1
exportedAt: string
note: string
tables: Record<string, Array<Record<string, unknown>>>
}
/** Verweigert jeden Pfad außerhalb V2 und jeden Produktiv-DB-Pfad. */
export function assertV2DevOnly(resolvedPath: string, label: string): void {
const pathNorm = resolvedPath.toLowerCase()
const rootNorm = PROJECT_ROOT.toLowerCase()
const rootPrefix = rootNorm.endsWith(sep) ? rootNorm : rootNorm + sep
if (!pathNorm.startsWith(rootPrefix) && pathNorm !== rootNorm) {
throw new Error(`[${label}] SAFETY: Pfad außerhalb AssignmentMonitorV2:\n ${resolvedPath}`)
}
if (
/[/\\]assigmentmonitor[/\\]/.test(pathNorm) &&
!/[/\\]assigmentmonitorv2[/\\]/.test(pathNorm)
) {
throw new Error(`[${label}] SAFETY: Produktiv-Pfad blockiert:\n ${resolvedPath}`)
}
}
export function assertStarterEnv(): void {
const apiPort = process.env.API_PORT?.trim() || '4001'
const vitePort = process.env.PORT?.trim() || '5174'
const sqliteFilename = process.env.SQLITE_FILENAME?.trim() || 'assignment-monitor-v2-dev.sqlite3'
if (apiPort === '4000') {
throw new Error('[starter] SAFETY: API_PORT=4000 ist der Produktiv-Port — abgebrochen.')
}
if (vitePort === '5173') {
throw new Error('[starter] SAFETY: PORT=5173 ist der Produktiv-Port — abgebrochen.')
}
if (sqliteFilename === 'assignment-monitor.sqlite3') {
throw new Error('[starter] SAFETY: Produktiv-DB-Dateiname blockiert.')
}
if (sqliteFilename.includes('/') || sqliteFilename.includes('\\') || sqliteFilename.includes('..')) {
throw new Error(`[starter] SAFETY: SQLITE_FILENAME ungültig: "${sqliteFilename}"`)
}
}
function sanitizeStructureRow(tableName: string, row: Record<string, unknown>): Record<string, unknown> {
const copy = { ...row }
// Destillierte Muster stammen aus echten Protokollen — nicht mitverteilen.
if (tableName === 'feedbackCriterionItems' && 'learnedPatternHints' in copy) {
copy.learnedPatternHints = null
}
return copy
}
/** Liest Struktur aus der aktuellen V2-Dev-DB (leere Laufzeit-Tabellen im Payload). */
export function buildStarterPackageFromDb(): StarterPackage {
assertStarterEnv()
assertV2DevOnly(PROJECT_ROOT, 'starter:export')
const tables: Record<string, Array<Record<string, unknown>>> = {}
for (const name of TABLE_NAMES) {
if (STRUCTURE_SET.has(name)) {
tables[name] = getAllRows<Record<string, unknown>>(name).map(r => sanitizeStructureRow(name, r))
} else {
tables[name] = []
}
}
return {
kind: 'assignment-monitor-starter',
version: 1,
exportedAt: new Date().toISOString(),
note: 'Nur Struktur/Config — keine Personen, Assignments oder Meetings. Nur für AssignmentMonitorV2.',
tables,
}
}
export function writeStarterPackage(pkg: StarterPackage, targetPath = STARTER_FILE): string {
assertV2DevOnly(targetPath, 'starter:export')
mkdirSync(dirname(targetPath), { recursive: true })
writeFileSync(targetPath, JSON.stringify(pkg, null, 2), 'utf8')
return targetPath
}
export function readStarterPackage(sourcePath = STARTER_FILE): StarterPackage {
assertV2DevOnly(sourcePath, 'starter:import')
if (!existsSync(sourcePath)) {
throw new Error(`[starter] Datei fehlt: ${sourcePath}`)
}
const raw = JSON.parse(readFileSync(sourcePath, 'utf8')) as StarterPackage
if (raw.kind !== 'assignment-monitor-starter' || raw.version !== 1 || !raw.tables) {
throw new Error('[starter] Ungültiges Starter-Paket (kind/version/tables).')
}
return raw
}
/**
* Ersetzt den kompletten DB-Inhalt durch das Starter-Paket (Struktur befüllt, Rest leer).
* Absichtlich voller Wipe — für frische Team-Installationen.
*/
export function importStarterPackage(pkg: StarterPackage): { structureRows: number } {
assertStarterEnv()
assertV2DevOnly(PROJECT_ROOT, 'starter:import')
let structureRows = 0
db.exec('BEGIN')
try {
for (const table of TABLE_DEFINITIONS) {
db.prepare(`DELETE FROM "${table.name}"`).run()
}
for (const table of TABLE_DEFINITIONS) {
const rows = pkg.tables[table.name] ?? []
for (const row of rows) {
const { id, ...rest } = row
if (typeof id !== 'number') continue
insertRowWithId(table.name, id, sanitizeStructureRow(table.name, rest))
if (STRUCTURE_SET.has(table.name)) structureRows++
}
}
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
throw err
}
return { structureRows }
}
export function summarizeStarter(pkg: StarterPackage): Record<string, number> {
const counts: Record<string, number> = {}
for (const name of STARTER_STRUCTURE_TABLES) {
counts[name] = pkg.tables[name]?.length ?? 0
}
return counts
}