import { getAllRows, getRowsWhereIn, insertRow, updateRow, getRowById, insertRowWithId, getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById, } from '../crud' import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, FeedbackDimensionPrompt, Assessment, ConversationEntry, MeetingInstance, Assignment, AssignmentType, PhaseTemplate, Consultant, AppSettings, } from '../../../src/db/types' import { AI_CONFIG } from '../../../src/config/constants' import { getOrSeedDefaultDimensionPromptTemplate } from './appSettings' import { parseFeedbackStructureMarkdown, type FeedbackStructureSeed, } from '../../../src/utils/feedbackStructureImport' import { existsSync, readFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) export interface FeedbackStructure { dimensions: FeedbackDimension[] categories: FeedbackCategory[] items: FeedbackCriterionItem[] } const byOrder = (rows: T[]) => [...rows].sort((a, b) => a.order - b.order) /** Vollständige, nach `order` sortierte Feedback-Taxonomie. */ export function loadFeedbackStructure(): FeedbackStructure { return { dimensions: byOrder(getAllRows('feedbackDimensions')), categories: byOrder(getAllRows('feedbackCategories')), items: byOrder(getAllRows('feedbackCriterionItems')), } } export const listFeedbackCategoriesOrdered = (): FeedbackCategory[] => byOrder(getAllRows('feedbackCategories')) export const listFeedbackCategoriesAll = (): FeedbackCategory[] => getAllRows('feedbackCategories') export const listFeedbackCriterionItemsAll = (): FeedbackCriterionItem[] => getAllRows('feedbackCriterionItems') export const getFeedbackCriterionItemsByIds = (ids: number[]): FeedbackCriterionItem[] => getRowsWhereIn('feedbackCriterionItems', 'id', ids) export interface CriterionUsage { assessmentCount: number lineTagCount: number generalNoteTagCount: number assignmentTypeNames: string[] phaseTemplateLabels: string[] meetings: { meetingId: number; assignmentId: number; assignmentTitle: string; date: string }[] } /** * Verwendungs-Übersicht für ALLE Kriterien in einem Durchlauf (nicht pro Kriterium einzeln) — * macht sichtbar, wo ein Kriterium schon referenziert ist, bevor man es beim Umbau der * Feedback-Struktur umbenennt/verschiebt/löscht. Siehe CLAUDE.md. */ export function getCriterionUsageMap(): Record { const usage = new Map() const ensure = (id: number): CriterionUsage => { let u = usage.get(id) if (!u) { u = { assessmentCount: 0, lineTagCount: 0, generalNoteTagCount: 0, assignmentTypeNames: [], phaseTemplateLabels: [], meetings: [] } usage.set(id, u) } return u } const meetings = getAllRows('meetingInstances') const meetingMap = new Map(meetings.map(m => [m.id!, m])) const assignments = getAllRows('assignments') const assignmentMap = new Map(assignments.map(a => [a.id!, a])) const addMeetingRef = (u: CriterionUsage, meetingId: number) => { if (u.meetings.some(x => x.meetingId === meetingId)) return const m = meetingMap.get(meetingId) if (!m) return const a = assignmentMap.get(m.assignmentId) u.meetings.push({ meetingId, assignmentId: m.assignmentId, assignmentTitle: a?.title ?? '—', date: m.date }) } for (const a of getAllRows('assessments')) { const u = ensure(a.criteriaId) u.assessmentCount++ addMeetingRef(u, a.meetingInstanceId) } for (const e of getAllRows('conversationEntries')) { for (const tag of e.lineTags ?? []) { const u = ensure(tag.criterionItemId) u.lineTagCount++ addMeetingRef(u, e.meetingInstanceId) } } for (const m of meetings) { for (const tag of m.generalNoteTags ?? []) { const u = ensure(tag.criterionItemId) u.generalNoteTagCount++ addMeetingRef(u, m.id!) } } for (const at of getAllRows('assignmentTypes')) { for (const cid of at.criteriaIds ?? []) ensure(cid).assignmentTypeNames.push(at.name) } for (const pt of getAllRows('phaseTemplates')) { for (const cid of pt.criteriaIds ?? []) ensure(cid).phaseTemplateLabels.push(pt.label) } return Object.fromEntries(usage) } export type CriterionUsageInstance = | { kind: 'assessment'; assessmentId: number; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string } | { kind: 'lineTag'; conversationEntryId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string } | { kind: 'generalNoteTag'; meetingInstanceId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string } | { kind: 'assignmentTypeVisibility'; assignmentTypeId: number; criterionId: number; text: string } | { kind: 'phaseTemplateVisibility'; phaseTemplateId: number; criterionId: number; text: string } /** * Einzelinstanzen-Liste für EIN Kriterium (im Unterschied zu `getCriterionUsageMap`, das nur * Zählungen für ALLE Kriterien liefert) — Grundlage dafür, eine einzelne Zuordnung direkt * umzuhängen oder zu entfernen, ohne dafür ins jeweilige Meeting navigieren zu müssen. * Bewusst lazy/pro-Kriterium (nicht eager für alle), da hier echte Texte/Namen aufgelöst werden. */ export function getCriterionUsageDetails(criterionId: number): CriterionUsageInstance[] { const instances: CriterionUsageInstance[] = [] const meetings = getAllRows('meetingInstances') const meetingMap = new Map(meetings.map(m => [m.id!, m])) const assignments = getAllRows('assignments') const assignmentMap = new Map(assignments.map(a => [a.id!, a])) const consultants = getAllRows('consultants') const consultantMap = new Map(consultants.map(c => [c.id!, c])) const meetingInfo = (meetingId: number) => { const m = meetingMap.get(meetingId) const a = m ? assignmentMap.get(m.assignmentId) : undefined return { meetingId, assignmentTitle: a?.title ?? '—', date: m?.date ?? '' } } const personLabel = (instituteeId: number | undefined) => { if (instituteeId === undefined) return 'Allgemein' const c = consultantMap.get(instituteeId) return c ? `${c.firstName} ${c.lastName}` : '—' } for (const a of getAllRows('assessments')) { if (a.criteriaId !== criterionId) continue instances.push({ kind: 'assessment', assessmentId: a.id!, personLabel: personLabel(a.instituteeId), text: `Score: ${a.score ?? '—'}${a.note ? ` · Notiz: ${a.note}` : ''}`, ...meetingInfo(a.meetingInstanceId), }) } for (const e of getAllRows('conversationEntries')) { for (const tag of e.lineTags ?? []) { if (tag.criterionItemId !== criterionId) continue instances.push({ kind: 'lineTag', conversationEntryId: e.id!, tagText: tag.text, personLabel: personLabel(e.instituteeId), text: tag.text, ...meetingInfo(e.meetingInstanceId), }) } } for (const m of meetings) { for (const tag of m.generalNoteTags ?? []) { if (tag.criterionItemId !== criterionId) continue instances.push({ kind: 'generalNoteTag', meetingInstanceId: m.id!, tagText: tag.text, personLabel: personLabel(undefined), text: tag.text, ...meetingInfo(m.id!), }) } } for (const at of getAllRows('assignmentTypes')) { if (!at.criteriaIds?.includes(criterionId)) continue instances.push({ kind: 'assignmentTypeVisibility', assignmentTypeId: at.id!, criterionId, text: at.name }) } for (const pt of getAllRows('phaseTemplates')) { if (!pt.criteriaIds?.includes(criterionId)) continue instances.push({ kind: 'phaseTemplateVisibility', phaseTemplateId: pt.id!, criterionId, text: pt.label }) } return instances } /** Hängt EINE einzelne Verwendungsinstanz auf ein anderes Kriterium um (Konsolidieren ohne den ganzen Rest mitzuziehen). */ export function reassignUsageInstance(instance: CriterionUsageInstance, toId: number): { merged: boolean } { if (instance.kind === 'assessment') { const allAssessments = getAllRows('assessments') const row = allAssessments.find(a => a.id === instance.assessmentId) if (!row) return { merged: false } return { merged: mergeOrReassignAssessment(row, toId, allAssessments) } } if (instance.kind === 'lineTag') { const e = getAllRows('conversationEntries').find(x => x.id === instance.conversationEntryId) if (!e?.lineTags) return { merged: false } const lineTags = e.lineTags.map(t => t.text === instance.tagText ? { ...t, criterionItemId: toId } : t) updateRow('conversationEntries', e.id!, { lineTags }) return { merged: false } } if (instance.kind === 'generalNoteTag') { const m = getAllRows('meetingInstances').find(x => x.id === instance.meetingInstanceId) if (!m?.generalNoteTags) return { merged: false } const generalNoteTags = m.generalNoteTags.map(t => t.text === instance.tagText ? { ...t, criterionItemId: toId } : t) updateRow('meetingInstances', m.id!, { generalNoteTags }) return { merged: false } } if (instance.kind === 'assignmentTypeVisibility') { const at = getAllRows('assignmentTypes').find(x => x.id === instance.assignmentTypeId) if (!at?.criteriaIds) return { merged: false } const criteriaIds = [...new Set(at.criteriaIds.map(id => id === instance.criterionId ? toId : id))] updateRow('assignmentTypes', at.id!, { criteriaIds }) return { merged: false } } // phaseTemplateVisibility const pt = getAllRows('phaseTemplates').find(x => x.id === instance.phaseTemplateId) if (!pt?.criteriaIds) return { merged: false } const criteriaIds = [...new Set(pt.criteriaIds.map(id => id === instance.criterionId ? toId : id))] updateRow('phaseTemplates', pt.id!, { criteriaIds }) return { merged: false } } /** Entfernt EINE einzelne Verwendungsinstanz (Assessment wird gelöscht, Tags nur von der Kriterium-Zuordnung befreit, Text bleibt). */ export function removeUsageInstance(instance: CriterionUsageInstance): void { if (instance.kind === 'assessment') { deleteRowById('assessments', instance.assessmentId) return } if (instance.kind === 'lineTag') { const e = getAllRows('conversationEntries').find(x => x.id === instance.conversationEntryId) if (!e?.lineTags) return updateRow('conversationEntries', e.id!, { lineTags: e.lineTags.filter(t => t.text !== instance.tagText) }) return } if (instance.kind === 'generalNoteTag') { const m = getAllRows('meetingInstances').find(x => x.id === instance.meetingInstanceId) if (!m?.generalNoteTags) return updateRow('meetingInstances', m.id!, { generalNoteTags: m.generalNoteTags.filter(t => t.text !== instance.tagText) }) return } if (instance.kind === 'assignmentTypeVisibility') { const at = getAllRows('assignmentTypes').find(x => x.id === instance.assignmentTypeId) if (!at?.criteriaIds) return updateRow('assignmentTypes', at.id!, { criteriaIds: at.criteriaIds.filter(id => id !== instance.criterionId) }) return } // phaseTemplateVisibility const pt = getAllRows('phaseTemplates').find(x => x.id === instance.phaseTemplateId) if (!pt?.criteriaIds) return updateRow('phaseTemplates', pt.id!, { criteriaIds: pt.criteriaIds.filter(id => id !== instance.criterionId) }) } /** * Schreibt EINE Assessment-Zeile auf `toId` um. Konfliktfall: existiert für dieselbe * (meetingInstanceId, instituteeId) bereits eine Zeile mit criteriaId=toId (keine DB-Unique- * Constraint verhindert das), wird `row` stattdessen in die bestehende Zeile gemergt (Notizen * zusammengeführt, vorhandener Score der Zielzeile hat Vorrang) statt eine Dubletten-Zeile zu * erzeugen. Von `reassignCriterionReferences` (alle Zeilen) UND `reassignUsageInstance` (eine * einzelne Zeile) genutzt, damit die Konflikt-Logik nicht doppelt gepflegt werden muss. */ function mergeOrReassignAssessment(row: Assessment, toId: number, allAssessments: Assessment[]): boolean { const conflict = allAssessments.find(a => a.id !== row.id && a.criteriaId === toId && a.meetingInstanceId === row.meetingInstanceId && a.instituteeId === row.instituteeId, ) if (conflict) { const mergedNote = [conflict.note, row.note].filter(n => n?.trim()).join('\n') updateRow('assessments', conflict.id!, { note: mergedNote, score: conflict.score ?? row.score }) deleteRowById('assessments', row.id!) return true } updateRow('assessments', row.id!, { criteriaId: toId }) return false } /** * Überträgt alle Referenzen von `fromId` auf `toId` (Assessments, Protokoll-/Notiz-Tags, * Sichtbarkeits-Listen) — für sicheres Löschen mit Zusammenführung statt Datenverlust. */ export function reassignCriterionReferences(fromId: number, toId: number): { mergedConflicts: number } { let mergedConflicts = 0 const allAssessments = getAllRows('assessments') for (const row of allAssessments.filter(a => a.criteriaId === fromId)) { if (mergeOrReassignAssessment(row, toId, allAssessments)) mergedConflicts++ } for (const e of getAllRows('conversationEntries')) { if (!e.lineTags?.some(t => t.criterionItemId === fromId)) continue const lineTags = e.lineTags.map(t => t.criterionItemId === fromId ? { ...t, criterionItemId: toId } : t) updateRow('conversationEntries', e.id!, { lineTags }) } for (const m of getAllRows('meetingInstances')) { if (!m.generalNoteTags?.some(t => t.criterionItemId === fromId)) continue const generalNoteTags = m.generalNoteTags.map(t => t.criterionItemId === fromId ? { ...t, criterionItemId: toId } : t) updateRow('meetingInstances', m.id!, { generalNoteTags }) } for (const at of getAllRows('assignmentTypes')) { if (!at.criteriaIds?.includes(fromId)) continue const criteriaIds = [...new Set(at.criteriaIds.map(id => id === fromId ? toId : id))] updateRow('assignmentTypes', at.id!, { criteriaIds }) } for (const pt of getAllRows('phaseTemplates')) { if (!pt.criteriaIds?.includes(fromId)) continue const criteriaIds = [...new Set(pt.criteriaIds.map(id => id === fromId ? toId : id))] updateRow('phaseTemplates', pt.id!, { criteriaIds }) } return { mergedConflicts } } /** Entfernt alle Referenzen auf `id` (Assessments werden gelöscht, Tags/Sichtbarkeitslisten nur von der ID befreit). */ function clearCriterionReferences(id: number): void { deleteRowsWhereEquals('assessments', 'criteriaId', id) for (const e of getAllRows('conversationEntries')) { if (!e.lineTags?.some(t => t.criterionItemId === id)) continue updateRow('conversationEntries', e.id!, { lineTags: e.lineTags.filter(t => t.criterionItemId !== id) }) } for (const m of getAllRows('meetingInstances')) { if (!m.generalNoteTags?.some(t => t.criterionItemId === id)) continue updateRow('meetingInstances', m.id!, { generalNoteTags: m.generalNoteTags.filter(t => t.criterionItemId !== id) }) } for (const at of getAllRows('assignmentTypes')) { if (!at.criteriaIds?.includes(id)) continue updateRow('assignmentTypes', at.id!, { criteriaIds: at.criteriaIds.filter(cid => cid !== id) }) } for (const pt of getAllRows('phaseTemplates')) { if (!pt.criteriaIds?.includes(id)) continue updateRow('phaseTemplates', pt.id!, { criteriaIds: pt.criteriaIds.filter(cid => cid !== id) }) } } /** Legt zusätzlich einen Default-KI-Prompt für die neue Dimension an, siehe CLAUDE.md „Mehrstufiges Prompt-System". */ export function addFeedbackDimension(d: Omit): number { const id = insertRow('feedbackDimensions', d) insertRow('feedbackDimensionPrompts', { dimensionId: id, promptTemplate: getOrSeedDefaultDimensionPromptTemplate() }) return id } export const updateFeedbackDimension = (id: number, patch: Partial): number => updateRow('feedbackDimensions', id, patch) /** Cascade: Items der Kategorien (über deleteFeedbackCriterionItem, räumt Verwendungen mit auf) → Kategorien → Prompt-Konfiguration → Dimension. */ export function deleteFeedbackDimension(id: number): void { const cats = getRowsWhereEquals('feedbackCategories', 'dimensionId', id) for (const cat of cats) { const catItems = getRowsWhereEquals('feedbackCriterionItems', 'categoryId', cat.id!) for (const item of catItems) deleteFeedbackCriterionItem(item.id!) } deleteRowsWhereEquals('feedbackCategories', 'dimensionId', id) deleteRowsWhereEquals('feedbackDimensionPrompts', 'dimensionId', id) deleteRowById('feedbackDimensions', id) } /** * Selbstheilend: Dimensionen, die vor Einführung des Prompt-Systems (2026-07-07) angelegt wurden, * haben noch keine `feedbackDimensionPrompts`-Zeile — wird hier per Backfill nachgezogen, statt eine * separate Migration zu brauchen. */ export function listFeedbackDimensionPrompts(): FeedbackDimensionPrompt[] { const dims = getAllRows('feedbackDimensions') const existingDimIds = new Set(getAllRows('feedbackDimensionPrompts').map(p => p.dimensionId)) const missing = dims.filter(dim => !existingDimIds.has(dim.id!)) if (missing.length > 0) { const defaultTemplate = getOrSeedDefaultDimensionPromptTemplate() for (const dim of missing) { insertRow('feedbackDimensionPrompts', { dimensionId: dim.id!, promptTemplate: defaultTemplate }) } } return getAllRows('feedbackDimensionPrompts') } /** Selbstheilend wie `listFeedbackDimensionPrompts` — legt die Zeile bei Bedarf lazy an. */ export function getFeedbackDimensionPrompt(dimensionId: number): FeedbackDimensionPrompt { const existing = getRowsWhereEquals('feedbackDimensionPrompts', 'dimensionId', dimensionId)[0] if (existing) return existing const promptTemplate = getOrSeedDefaultDimensionPromptTemplate() const id = insertRow('feedbackDimensionPrompts', { dimensionId, promptTemplate }) return { id, dimensionId, promptTemplate } } export const updateFeedbackDimensionPrompt = (id: number, patch: Partial): number => updateRow('feedbackDimensionPrompts', id, patch) /** Setzt alle Dimension-Prompts auf den aktuellen Code-Standard (AI_CONFIG) und synchronisiert appSettings. */ export function resetAllDimensionPromptsToDefault(): { updated: number } { const template = AI_CONFIG.defaultDimensionPromptTemplate const settings = getRowById('appSettings', 1) if (settings) updateRow('appSettings', 1, { defaultDimensionPromptTemplate: template }) else insertRowWithId('appSettings', 1, { ratingTrendWeightStep: 0.5, defaultDimensionPromptTemplate: template, }) const dims = getAllRows('feedbackDimensions') let updated = 0 for (const dim of dims) { const existing = getRowsWhereEquals('feedbackDimensionPrompts', 'dimensionId', dim.id!)[0] if (existing?.id) { updateRow('feedbackDimensionPrompts', existing.id, { promptTemplate: template }) } else { insertRow('feedbackDimensionPrompts', { dimensionId: dim.id!, promptTemplate: template }) } updated++ } return { updated } } export const addFeedbackCategory = (c: Omit): number => insertRow('feedbackCategories', c) export const updateFeedbackCategory = (id: number, patch: Partial): number => updateRow('feedbackCategories', id, patch) /** Cascade: Items der Kategorie → Kategorie. */ export function deleteFeedbackCategory(id: number): void { const catItems = getRowsWhereEquals('feedbackCriterionItems', 'categoryId', id) for (const item of catItems) deleteFeedbackCriterionItem(item.id!) deleteRowById('feedbackCategories', id) } export const addFeedbackCriterionItem = (i: Omit): number => insertRow('feedbackCriterionItems', i) export const updateFeedbackCriterionItem = (id: number, patch: Partial): number => updateRow('feedbackCriterionItems', id, patch) /** * Räumt vor dem eigentlichen Löschen selbst alle Verwendungen auf (Verteidigung in der Tiefe, * unabhängig davon ob die UI vorher per reassignCriterionReferences umgehängt hat) — siehe * clearCriterionReferences() oben. Dadurch kann nach dem Löschen nie eine verwaiste Referenz * übrig bleiben, egal über welchen Aufrufpfad (auch Kategorie-/Dimension-Cascade). */ export function deleteFeedbackCriterionItem(id: number): void { clearCriterionReferences(id) deleteRowById('feedbackCriterionItems', id) } export interface ReplaceFeedbackStructureResult { dimensions: number categories: number items: number clearedDimensions: number } /** * Ersetzt die gesamte Feedback-Struktur durch ein Seed-Objekt (MD-Import / Preload). * Löscht bestehende Dimensionen inkl. Cascade (Kategorien, Kriterien, Verwendungen, Prompts). */ export function replaceFeedbackStructure(seed: FeedbackStructureSeed): ReplaceFeedbackStructureResult { if (!seed?.dimensions?.length) throw new Error('Seed enthält keine Dimensionen.') const existing = getAllRows('feedbackDimensions') for (const dim of existing) deleteFeedbackDimension(dim.id!) let categoryCount = 0 let itemCount = 0 for (let di = 0; di < seed.dimensions.length; di++) { const dimSeed = seed.dimensions[di] const dimId = addFeedbackDimension({ name: dimSeed.name, order: di + 1 }) for (let ci = 0; ci < dimSeed.categories.length; ci++) { const catSeed = dimSeed.categories[ci] const catId = addFeedbackCategory({ dimensionId: dimId, name: catSeed.name, order: ci + 1, description: catSeed.description, }) categoryCount++ for (let ii = 0; ii < catSeed.items.length; ii++) { const itemSeed = catSeed.items[ii] addFeedbackCriterionItem({ categoryId: catId, name: itemSeed.name, order: ii + 1, weight: itemSeed.weight, description: itemSeed.description, }) itemCount++ } } } return { dimensions: seed.dimensions.length, categories: categoryCount, items: itemCount, clearedDimensions: existing.length, } } /** Parst Markdown (Export-Format) und ersetzt die Struktur. */ export function importFeedbackStructureFromMarkdown(md: string): ReplaceFeedbackStructureResult { return replaceFeedbackStructure(parseFeedbackStructureMarkdown(md)) } /** * Lädt die finale Struktur aus docs/preload/Feedback-Struktur-final.md (DEV-Preload). * Pfad relativ zum Projektroot — nur innerhalb AssigmentMonitorV2. */ export function preloadFinalFeedbackStructure(): ReplaceFeedbackStructureResult { const projectRoot = resolve(join(__dirname, '../../..')) const mdPath = resolve(join(projectRoot, 'docs', 'preload', 'Feedback-Struktur-final.md')) const rootNorm = projectRoot.toLowerCase() const pathNorm = mdPath.toLowerCase() if (!pathNorm.startsWith(rootNorm)) { throw new Error(`[preload] SAFETY: Preload-Datei außerhalb des V2-Projekts: ${mdPath}`) } if (!existsSync(mdPath)) { throw new Error(`Preload-Datei fehlt: ${mdPath}`) } const md = readFileSync(mdPath, 'utf8') return importFeedbackStructureFromMarkdown(md) }