CG-Feedback-Monitor/server/db/queries/assignmentTransfer.ts

528 lines
21 KiB
TypeScript
Raw Permalink Normal View History

/**
* Einzel-Assignment Export/Import für Lead-Übergabe (Urlaub/Krankheit).
* Enthält Runtime-Daten inkl. Checklisten + referenzierte Berater/Gruppen.
* Struktur wird per stabilem Schlüssel gemappt; Berater werden bei Namensgleichheit
* wiederverwendet, sonst neu angelegt.
*/
import { db } from '../../database'
import {
getAllRows, getRowById, getRowsWhereEquals, getRowsWhereIn, insertRow,
} from '../crud'
import type {
Assignment, AssignmentType, PhaseTemplate, ChecklistItemDef, ChecklistResponse,
Group, Consultant, MeetingInstance, ConversationEntry, ConversationSkillScore,
Assessment, FeedbackDraft, AssignmentFeedback,
FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText,
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, ConversationLineTag,
AssignmentTransferPackage, AssignmentImportResult,
} from '../../../src/db/types'
import { ASSIGNMENT_TRANSFER_KIND } from '../../../src/db/types'
import { listMeetingInstancesForAssignment } from './meetings'
import { listChecklistItemDefsForPhaseTemplates, listChecklistResponsesForMeetings } from './checklists'
import { listConversationEntriesForMeetings } from './conversationEntries'
import { listAssessmentsForMeetings } from './assessments'
import { listMeetingCategoryRatingsForMeetings } from './meetingCategoryRatings'
const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' ')
const personKey = (c: Pick<Consultant, 'firstName' | 'lastName'>) =>
`${norm(c.firstName)}|${norm(c.lastName)}`
const emailKey = (c: Pick<Consultant, 'email'>): string | null => {
const e = (c.email ?? '').trim().toLowerCase()
return e || null
}
const groupKey = (g: Pick<Group, 'name' | 'cohortYear'>) =>
`${norm(g.name)}|${g.cohortYear}`
function collectCriterionIds(pkgParts: {
assignmentType: AssignmentType
phaseTemplates: PhaseTemplate[]
checklistItemDefs: ChecklistItemDef[]
assessments: Assessment[]
checklistResponses: ChecklistResponse[]
conversationEntries: ConversationEntry[]
meetingInstances: MeetingInstance[]
}): Set<number> {
const ids = new Set<number>()
for (const id of pkgParts.assignmentType.criteriaIds ?? []) ids.add(id)
for (const p of pkgParts.phaseTemplates) for (const id of p.criteriaIds ?? []) ids.add(id)
for (const c of pkgParts.checklistItemDefs) {
if (c.linkedCriterionItemId != null) ids.add(c.linkedCriterionItemId)
}
for (const a of pkgParts.assessments) ids.add(a.criteriaId)
for (const r of pkgParts.checklistResponses) {
if (r.criterionItemId != null) ids.add(r.criterionItemId)
}
for (const e of pkgParts.conversationEntries) {
for (const t of e.lineTags ?? []) ids.add(t.criterionItemId)
}
for (const m of pkgParts.meetingInstances) {
for (const t of m.generalNoteTags ?? []) ids.add(t.criterionItemId)
}
return ids
}
function remapTags(
tags: ConversationLineTag[] | undefined,
criterionMap: Map<number, number>,
): ConversationLineTag[] | undefined {
if (!tags) return tags
return tags.map(t => ({
text: t.text,
criterionItemId: criterionMap.get(t.criterionItemId) ?? t.criterionItemId,
}))
}
function requireMap(map: Map<number, number>, oldId: number, label: string): number {
const next = map.get(oldId)
if (next == null) throw new Error(`${label}: Quell-ID ${oldId} konnte nicht gemappt werden`)
return next
}
/** Exportiert ein Assignment inkl. aller Meetings/Notizen/Checklisten/Feedbacks und referenzierter Berater. */
export function exportAssignmentTransfer(assignmentId: number): AssignmentTransferPackage {
const assignment = getRowById<Assignment>('assignments', assignmentId)
if (!assignment) throw new Error('Assignment nicht gefunden')
const assignmentType = getRowById<AssignmentType>('assignmentTypes', assignment.assignmentTypeId)
if (!assignmentType) throw new Error('Assignment-Typ nicht gefunden')
const phaseTemplates = getRowsWhereEquals<PhaseTemplate>('phaseTemplates', 'assignmentTypeId', assignment.assignmentTypeId)
.sort((a, b) => a.order - b.order)
const phaseIds = phaseTemplates.map(p => p.id!)
const checklistItemDefs = listChecklistItemDefsForPhaseTemplates(phaseIds)
const meetingInstances = listMeetingInstancesForAssignment(assignmentId)
const meetingIds = meetingInstances.map(m => m.id!)
const conversationEntries = listConversationEntriesForMeetings(meetingIds)
const entryIds = conversationEntries.map(e => e.id!)
const conversationSkillScores = getAllRows<ConversationSkillScore>('conversationSkillScores')
.filter(s => entryIds.includes(s.conversationEntryId))
const assessments = listAssessmentsForMeetings(meetingIds)
const meetingCategoryRatings = listMeetingCategoryRatingsForMeetings(meetingIds)
const checklistResponses = listChecklistResponsesForMeetings(meetingIds)
const feedbackDrafts = getRowsWhereEquals<FeedbackDraft>('feedbackDrafts', 'assignmentId', assignmentId)
const assignmentFeedbacks = getRowsWhereEquals<AssignmentFeedback>('assignmentFeedbacks', 'assignmentId', assignmentId)
const feedbackIds = assignmentFeedbacks.map(f => f.id!)
const feedbackCategoryRatings = getRowsWhereIn<FeedbackCategoryRating>('feedbackCategoryRatings', 'assignmentFeedbackId', feedbackIds)
const feedbackDimensionTexts = getRowsWhereIn<FeedbackDimensionText>('feedbackDimensionTexts', 'assignmentFeedbackId', feedbackIds)
const feedbackCategoryTexts = getRowsWhereIn<FeedbackCategoryText>('feedbackCategoryTexts', 'assignmentFeedbackId', feedbackIds)
const instituteeIds = new Set<number>(assignment.instituteeIds)
for (const e of assignment.instituteeEnrollments ?? []) instituteeIds.add(e.instituteeId)
for (const e of conversationEntries) instituteeIds.add(e.instituteeId)
for (const a of assessments) instituteeIds.add(a.instituteeId)
for (const r of meetingCategoryRatings) instituteeIds.add(r.instituteeId)
for (const r of checklistResponses) if (r.instituteeId != null) instituteeIds.add(r.instituteeId)
for (const f of feedbackDrafts) instituteeIds.add(f.instituteeId)
for (const f of assignmentFeedbacks) instituteeIds.add(f.instituteeId)
const consultants = getRowsWhereIn<Consultant>('consultants', 'id', [...instituteeIds])
const groupIds = [...new Set(consultants.map(c => c.groupId))]
const groups = getRowsWhereIn<Group>('groups', 'id', groupIds)
const criterionIds = collectCriterionIds({
assignmentType, phaseTemplates, checklistItemDefs, assessments,
checklistResponses, conversationEntries, meetingInstances,
})
for (const r of meetingCategoryRatings) {
for (const i of getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', r.categoryId)) {
criterionIds.add(i.id!)
}
}
for (const r of feedbackCategoryRatings) {
for (const i of getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', r.feedbackCategoryId)) {
criterionIds.add(i.id!)
}
}
for (const s of conversationSkillScores) {
for (const i of getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', s.criteriaId)) {
criterionIds.add(i.id!)
}
}
const feedbackCriterionItems = getRowsWhereIn<FeedbackCriterionItem>('feedbackCriterionItems', 'id', [...criterionIds])
.map(i => ({ ...i, learnedPatternHints: undefined }))
const categoryIds = new Set(feedbackCriterionItems.map(i => i.categoryId))
for (const r of meetingCategoryRatings) categoryIds.add(r.categoryId)
for (const r of feedbackCategoryRatings) categoryIds.add(r.feedbackCategoryId)
for (const t of feedbackCategoryTexts) categoryIds.add(t.feedbackCategoryId)
for (const s of conversationSkillScores) categoryIds.add(s.criteriaId)
const feedbackCategories = getRowsWhereIn<FeedbackCategory>('feedbackCategories', 'id', [...categoryIds])
const dimensionIds = new Set(feedbackCategories.map(c => c.dimensionId))
for (const t of feedbackDimensionTexts) dimensionIds.add(t.feedbackDimensionId)
const feedbackDimensions = getRowsWhereIn<FeedbackDimension>('feedbackDimensions', 'id', [...dimensionIds])
return {
kind: ASSIGNMENT_TRANSFER_KIND,
version: 1,
exportedAt: new Date().toISOString(),
source: {
assignmentId,
assignmentNumber: assignment.assignmentNumber,
title: assignment.title,
client: assignment.client,
},
groups,
consultants,
assignmentType,
phaseTemplates,
checklistItemDefs,
feedbackDimensions,
feedbackCategories,
feedbackCriterionItems,
assignment,
meetingInstances,
conversationEntries,
conversationSkillScores,
assessments,
meetingCategoryRatings,
checklistResponses,
feedbackDrafts,
assignmentFeedbacks,
feedbackCategoryRatings,
feedbackDimensionTexts,
feedbackCategoryTexts,
}
}
/** Importiert ein Transfer-Paket als neues Assignment (Merge, kein Wipe). */
export function importAssignmentTransfer(pkg: AssignmentTransferPackage): AssignmentImportResult {
if (!pkg || pkg.kind !== ASSIGNMENT_TRANSFER_KIND || pkg.version !== 1) {
throw new Error('Ungültiges Assignment-Übergabe-Format')
}
if (!pkg.assignment || !pkg.assignmentType) {
throw new Error('Paket unvollständig (Assignment/Typ fehlt)')
}
const groupMap = new Map<number, number>()
const consultantMap = new Map<number, number>()
const dimensionMap = new Map<number, number>()
const categoryMap = new Map<number, number>()
const criterionMap = new Map<number, number>()
const typeMap = new Map<number, number>()
const phaseMap = new Map<number, number>()
const checklistDefMap = new Map<number, number>()
const meetingMap = new Map<number, number>()
const entryMap = new Map<number, number>()
const feedbackMap = new Map<number, number>()
const consultantsMatched: string[] = []
const consultantsCreated: string[] = []
const localDims = getAllRows<FeedbackDimension>('feedbackDimensions')
for (const d of pkg.feedbackDimensions) {
const byId = d.id != null ? getRowById<FeedbackDimension>('feedbackDimensions', d.id) : undefined
if (byId && norm(byId.name) === norm(d.name)) {
dimensionMap.set(d.id!, d.id!)
continue
}
const hit = localDims.find(x => norm(x.name) === norm(d.name))
if (!hit?.id) {
throw new Error(`Feedback-Dimension „${d.name}" fehlt lokal — bitte zuerst npm run setup / Starter laden.`)
}
dimensionMap.set(d.id!, hit.id)
}
const localCats = getAllRows<FeedbackCategory>('feedbackCategories')
for (const c of pkg.feedbackCategories) {
const dimId = requireMap(dimensionMap, c.dimensionId, 'Dimension')
const byId = c.id != null ? getRowById<FeedbackCategory>('feedbackCategories', c.id) : undefined
if (byId && norm(byId.name) === norm(c.name) && byId.dimensionId === dimId) {
categoryMap.set(c.id!, c.id!)
continue
}
const hit = localCats.find(x => x.dimensionId === dimId && norm(x.name) === norm(c.name))
if (!hit?.id) {
throw new Error(`Feedback-Kategorie „${c.name}" fehlt lokal — bitte Starter-Struktur angleichen.`)
}
categoryMap.set(c.id!, hit.id)
}
const localItems = getAllRows<FeedbackCriterionItem>('feedbackCriterionItems')
for (const item of pkg.feedbackCriterionItems) {
const catId = requireMap(categoryMap, item.categoryId, 'Kategorie')
const byId = item.id != null ? getRowById<FeedbackCriterionItem>('feedbackCriterionItems', item.id) : undefined
if (byId && norm(byId.name) === norm(item.name) && byId.categoryId === catId) {
criterionMap.set(item.id!, item.id!)
continue
}
const hit = localItems.find(x => x.categoryId === catId && norm(x.name) === norm(item.name))
if (!hit?.id) {
throw new Error(`Kriterium „${item.name}" fehlt lokal — bitte Starter-Struktur angleichen.`)
}
criterionMap.set(item.id!, hit.id)
}
const localTypes = getAllRows<AssignmentType>('assignmentTypes')
{
const t = pkg.assignmentType
const byId = t.id != null ? getRowById<AssignmentType>('assignmentTypes', t.id) : undefined
let typeId: number
if (byId && norm(byId.name) === norm(t.name)) {
typeId = t.id!
} else {
const hit = localTypes.find(x => norm(x.name) === norm(t.name))
if (hit?.id) {
typeId = hit.id
} else {
typeId = insertRow('assignmentTypes', {
name: t.name,
defaultPhaseKeys: t.defaultPhaseKeys,
criteriaIds: (t.criteriaIds ?? [])
.map(id => criterionMap.get(id))
.filter((id): id is number => id != null),
})
}
}
typeMap.set(t.id!, typeId)
}
const targetTypeId = requireMap(typeMap, pkg.assignmentType.id!, 'Assignment-Typ')
const localPhases = getRowsWhereEquals<PhaseTemplate>('phaseTemplates', 'assignmentTypeId', targetTypeId)
for (const p of pkg.phaseTemplates) {
const byKey = localPhases.find(x => x.phaseKey === p.phaseKey && norm(x.label) === norm(p.label))
?? localPhases.find(x => x.phaseKey === p.phaseKey)
let phaseId: number
if (byKey?.id) {
phaseId = byKey.id
} else {
phaseId = insertRow('phaseTemplates', {
assignmentTypeId: targetTypeId,
phaseKey: p.phaseKey,
label: p.label,
withWhom: p.withWhom,
isRepeatable: p.isRepeatable,
hasConversation: p.hasConversation,
hasAssessment: p.hasAssessment,
hasChecklist: p.hasChecklist,
criteriaIds: (p.criteriaIds ?? [])
.map(id => criterionMap.get(id))
.filter((id): id is number => id != null),
order: p.order,
})
}
phaseMap.set(p.id!, phaseId)
}
for (const def of pkg.checklistItemDefs) {
const phaseId = requireMap(phaseMap, def.phaseTemplateId, 'Phase')
const localDefs = getRowsWhereEquals<ChecklistItemDef>('checklistItemDefs', 'phaseTemplateId', phaseId)
const hit = localDefs.find(x => norm(x.label) === norm(def.label) && x.order === def.order)
?? localDefs.find(x => norm(x.label) === norm(def.label))
let defId: number
if (hit?.id) {
defId = hit.id
} else {
defId = insertRow('checklistItemDefs', {
phaseTemplateId: phaseId,
label: def.label,
linkedCriterionItemId: def.linkedCriterionItemId != null
? (criterionMap.get(def.linkedCriterionItemId) ?? null)
: null,
scope: def.scope,
order: def.order,
scoreContribution: def.scoreContribution,
})
}
checklistDefMap.set(def.id!, defId)
}
const pkgGroupsById = new Map(pkg.groups.map(g => [g.id!, g]))
for (const g of pkg.groups) {
const locals = getAllRows<Group>('groups')
const hit = locals.find(x => groupKey(x) === groupKey(g))
groupMap.set(g.id!, hit?.id ?? insertRow('groups', { name: g.name, cohortYear: g.cohortYear }))
}
for (const c of pkg.consultants) {
const label = `${c.firstName} ${c.lastName}`.trim()
const locals = getAllRows<Consultant>('consultants')
const ek = emailKey(c)
const hit = ek
? locals.find(x => emailKey(x) === ek)
: locals.find(x => personKey(x) === personKey(c))
if (hit?.id) {
consultantMap.set(c.id!, hit.id)
consultantsMatched.push(label)
continue
}
const srcGroup = pkgGroupsById.get(c.groupId)
if (!srcGroup) throw new Error(`Gruppe für Berater „${label}" fehlt im Paket`)
let groupId = groupMap.get(srcGroup.id!)
if (groupId == null) {
const gLocals = getAllRows<Group>('groups')
const gHit = gLocals.find(x => groupKey(x) === groupKey(srcGroup))
groupId = gHit?.id ?? insertRow('groups', { name: srcGroup.name, cohortYear: srcGroup.cohortYear })
groupMap.set(srcGroup.id!, groupId)
}
const newId = insertRow('consultants', {
groupId,
firstName: c.firstName,
lastName: c.lastName,
email: ek ?? '',
})
consultantMap.set(c.id!, newId)
consultantsCreated.push(label)
}
let newAssignmentId = 0
let checklistResponseCount = 0
db.exec('BEGIN')
try {
const a = pkg.assignment
newAssignmentId = insertRow('assignments', {
title: a.title,
client: a.client,
assignmentTypeId: targetTypeId,
instituteeIds: a.instituteeIds.map(id => requireMap(consultantMap, id, 'Berater')),
leadName: a.leadName,
status: a.status,
createdAt: a.createdAt || new Date().toISOString(),
assignmentNumber: a.assignmentNumber,
description: a.description,
instituteeEnrollments: (a.instituteeEnrollments ?? []).map(e => ({
...e,
instituteeId: requireMap(consultantMap, e.instituteeId, 'Berater'),
})),
deletedAt: null,
archivedAt: null,
})
for (const m of pkg.meetingInstances) {
const meetingId = insertRow('meetingInstances', {
assignmentId: newAssignmentId,
phaseTemplateId: requireMap(phaseMap, m.phaseTemplateId, 'Phase'),
phaseIndex: m.phaseIndex,
date: m.date,
status: m.status,
generalNotes: m.generalNotes ?? '',
deletedAt: m.deletedAt ?? null,
generalNoteTags: remapTags(m.generalNoteTags, criterionMap),
})
meetingMap.set(m.id!, meetingId)
}
for (const e of pkg.conversationEntries) {
const entryId = insertRow('conversationEntries', {
meetingInstanceId: requireMap(meetingMap, e.meetingInstanceId, 'Meeting'),
instituteeId: requireMap(consultantMap, e.instituteeId, 'Berater'),
sequenceIndex: e.sequenceIndex,
note: e.note,
fillerCount: e.fillerCount ?? 0,
updatedAt: e.updatedAt || new Date().toISOString(),
lineTags: remapTags(e.lineTags, criterionMap),
})
entryMap.set(e.id!, entryId)
}
for (const s of pkg.conversationSkillScores) {
insertRow('conversationSkillScores', {
conversationEntryId: requireMap(entryMap, s.conversationEntryId, 'Protokoll-Eintrag'),
criteriaId: requireMap(categoryMap, s.criteriaId, 'Kategorie (Skill-Score)'),
score: s.score,
})
}
for (const asmt of pkg.assessments) {
insertRow('assessments', {
meetingInstanceId: requireMap(meetingMap, asmt.meetingInstanceId, 'Meeting'),
instituteeId: requireMap(consultantMap, asmt.instituteeId, 'Berater'),
criteriaId: requireMap(criterionMap, asmt.criteriaId, 'Kriterium'),
score: asmt.score,
note: asmt.note ?? '',
updatedAt: asmt.updatedAt || new Date().toISOString(),
scoreNeedsReview: asmt.scoreNeedsReview ?? false,
})
}
for (const r of pkg.meetingCategoryRatings) {
insertRow('meetingCategoryRatings', {
meetingInstanceId: requireMap(meetingMap, r.meetingInstanceId, 'Meeting'),
instituteeId: requireMap(consultantMap, r.instituteeId, 'Berater'),
categoryId: requireMap(categoryMap, r.categoryId, 'Kategorie'),
score: r.score,
note: r.note,
})
}
for (const r of pkg.checklistResponses) {
insertRow('checklistResponses', {
meetingInstanceId: requireMap(meetingMap, r.meetingInstanceId, 'Meeting'),
instituteeId: r.instituteeId != null ? requireMap(consultantMap, r.instituteeId, 'Berater') : null,
checklistItemId: requireMap(checklistDefMap, r.checklistItemId, 'Checklistenpunkt'),
rating: r.rating,
note: r.note ?? '',
criterionItemId: r.criterionItemId != null ? (criterionMap.get(r.criterionItemId) ?? null) : null,
criterionOverride: r.criterionOverride ?? false,
})
checklistResponseCount++
}
for (const d of pkg.feedbackDrafts) {
insertRow('feedbackDrafts', {
assignmentId: newAssignmentId,
instituteeId: requireMap(consultantMap, d.instituteeId, 'Berater'),
content: d.content,
updatedAt: d.updatedAt || new Date().toISOString(),
})
}
for (const f of pkg.assignmentFeedbacks) {
const fbId = insertRow('assignmentFeedbacks', {
assignmentId: newAssignmentId,
instituteeId: requireMap(consultantMap, f.instituteeId, 'Berater'),
status: f.status,
aiModel: f.aiModel ?? '',
updatedAt: f.updatedAt || new Date().toISOString(),
})
feedbackMap.set(f.id!, fbId)
}
for (const r of pkg.feedbackCategoryRatings) {
insertRow('feedbackCategoryRatings', {
assignmentFeedbackId: requireMap(feedbackMap, r.assignmentFeedbackId, 'Feedback'),
feedbackCategoryId: requireMap(categoryMap, r.feedbackCategoryId, 'Kategorie'),
rating: r.rating,
})
}
for (const t of pkg.feedbackDimensionTexts) {
insertRow('feedbackDimensionTexts', {
assignmentFeedbackId: requireMap(feedbackMap, t.assignmentFeedbackId, 'Feedback'),
feedbackDimensionId: requireMap(dimensionMap, t.feedbackDimensionId, 'Dimension'),
achievements: t.achievements ?? '',
developmentNeeds: t.developmentNeeds ?? '',
suggestedAchievements: t.suggestedAchievements,
suggestedDevelopmentNeeds: t.suggestedDevelopmentNeeds,
})
}
for (const t of pkg.feedbackCategoryTexts) {
insertRow('feedbackCategoryTexts', {
assignmentFeedbackId: requireMap(feedbackMap, t.assignmentFeedbackId, 'Feedback'),
feedbackCategoryId: requireMap(categoryMap, t.feedbackCategoryId, 'Kategorie'),
benefits: t.benefits ?? '',
concerns: t.concerns ?? '',
suggestedBenefits: t.suggestedBenefits,
suggestedConcerns: t.suggestedConcerns,
})
}
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
throw err
}
return {
assignmentId: newAssignmentId,
consultantsMatched,
consultantsCreated,
meetings: pkg.meetingInstances.length,
checklistResponses: checklistResponseCount,
}
}