CG-Feedback-Monitor/server/db/queries/consultants.ts
Lars d894c3c8e4 V2-Stand: UX-Dashboard, Personen v2, Starter-Paket und Dexie-Legacy entfernt.
Sichert den lokalen Entwicklungsstand nach Gitea: Nav/Dashboard, Personen-Excel/Archiv, Team-Setup, Assignment-Übergabe und C6/C7/T2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 14:17:25 +02:00

267 lines
9.6 KiB
TypeScript

import {
getAllRows, getRowById, getRowsWhereIn,
insertRow, updateRow, deleteRowById, deleteRowsWhereEquals, deleteRowsWhereIn,
} from '../crud'
import type {
Group, Consultant, Assignment, AssignmentFeedback, FeedbackCategoryRating,
FeedbackCategory, ConversationEntry, RatingScore,
ConsultantImportRow, ConsultantImportResult, HardDeleteConsultantResult,
ConsultantAssignmentSummary, ConsultantDetail,
} from '../../../src/db/types'
export type {
ConsultantImportRow, ConsultantImportResult, HardDeleteConsultantResult,
ConsultantAssignmentSummary, ConsultantDetail,
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase()
}
export function listGroups(): Group[] {
return getAllRows<Group>('groups').sort((a, b) =>
a.cohortYear !== b.cohortYear ? b.cohortYear - a.cohortYear : a.name.localeCompare(b.name),
)
}
export const getGroup = (id: number): Group | undefined => getRowById<Group>('groups', id)
export const addGroup = (group: Omit<Group, 'id'>): number => insertRow('groups', group)
export function updateGroup(id: number, patch: Partial<Group>): number {
return updateRow('groups', id, patch)
}
/** Nur leere Gruppen — sonst Fehlertext. */
export function deleteGroup(id: number): { ok: true } | { ok: false; reason: string; memberCount: number } {
const members = getAllRows<Consultant>('consultants').filter(c => c.groupId === id)
if (members.length > 0) {
return {
ok: false,
reason: 'Gruppe hat noch Personen — zuerst umziehen oder archivieren/löschen.',
memberCount: members.length,
}
}
deleteRowById('groups', id)
return { ok: true }
}
export const listConsultants = (): Consultant[] => getAllRows<Consultant>('consultants')
export function listActiveConsultants(): Consultant[] {
return listConsultants().filter(c => !c.archivedAt)
}
export function listArchivedConsultants(): Consultant[] {
return listConsultants().filter(c => !!c.archivedAt)
}
export const getConsultant = (id: number): Consultant | undefined => getRowById<Consultant>('consultants', id)
export const getConsultantsByIds = (ids: number[]): Consultant[] =>
getRowsWhereIn<Consultant>('consultants', 'id', ids)
export function findConsultantByEmail(email: string): Consultant | undefined {
const key = normalizeEmail(email)
if (!key) return undefined
return listConsultants().find(c => normalizeEmail(c.email ?? '') === key)
}
function assertUniqueEmail(email: string, exceptId?: number): void {
const key = normalizeEmail(email)
if (!key) throw new Error('E-Mail ist Pflicht.')
const hit = listConsultants().find(c => normalizeEmail(c.email ?? '') === key && c.id !== exceptId)
if (hit) throw new Error(`E-Mail „${key}" ist bereits vergeben (${hit.firstName} ${hit.lastName}).`)
}
export function addConsultant(consultant: Omit<Consultant, 'id'>): number {
const email = normalizeEmail(consultant.email ?? '')
assertUniqueEmail(email)
return insertRow('consultants', {
...consultant,
email,
firstName: consultant.firstName.trim(),
lastName: consultant.lastName.trim(),
})
}
export function updateConsultant(id: number, patch: Partial<Consultant>): number {
const next: Partial<Consultant> = { ...patch }
if (patch.email !== undefined) {
const email = normalizeEmail(patch.email)
assertUniqueEmail(email, id)
next.email = email
}
if (patch.firstName !== undefined) next.firstName = patch.firstName.trim()
if (patch.lastName !== undefined) next.lastName = patch.lastName.trim()
return updateRow('consultants', id, next)
}
export function archiveConsultant(id: number): number {
return updateRow('consultants', id, { archivedAt: new Date().toISOString() })
}
export function unarchiveConsultant(id: number): number {
return updateRow('consultants', id, { archivedAt: null as unknown as undefined })
}
/**
* Import in eine gewählte Zielgruppe. Abgleich nur über E-Mail.
* moveIfDifferentGroup: bekannte Personen mit anderer Gruppe in die Zielgruppe verschieben.
*/
export function importConsultantsToGroup(
groupId: number,
rows: ConsultantImportRow[],
moveIfDifferentGroup = true,
): ConsultantImportResult {
const group = getGroup(groupId)
if (!group) throw new Error('Zielgruppe nicht gefunden.')
const result: ConsultantImportResult = { created: 0, updated: 0, moved: 0, skipped: 0, errors: [] }
const seenInFile = new Set<string>()
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!
const line = i + 1
const email = normalizeEmail(row.email ?? '')
const firstName = (row.firstName ?? '').trim()
const lastName = (row.lastName ?? '').trim()
if (!email) {
result.errors.push(`Zeile ${line}: E-Mail fehlt`)
result.skipped++
continue
}
if (!firstName || !lastName) {
result.errors.push(`Zeile ${line}: Name unvollständig (${email})`)
result.skipped++
continue
}
if (seenInFile.has(email)) {
result.errors.push(`Zeile ${line}: doppelte E-Mail in Datei (${email})`)
result.skipped++
continue
}
seenInFile.add(email)
const existing = findConsultantByEmail(email)
if (!existing?.id) {
insertRow('consultants', { groupId, firstName, lastName, email })
result.created++
continue
}
const patch: Partial<Consultant> = { firstName, lastName }
if (existing.groupId !== groupId) {
if (moveIfDifferentGroup) {
patch.groupId = groupId
result.moved++
}
}
updateRow('consultants', existing.id, patch)
result.updated++
}
return result
}
/** Feedback + personenbezogene Meeting-Daten weg; Assignment bleibt, Person wird entkoppelt. */
export function hardDeleteConsultant(id: number): HardDeleteConsultantResult {
const person = getConsultant(id)
if (!person) throw new Error('Person nicht gefunden.')
const feedbacks = getAllRows<AssignmentFeedback>('assignmentFeedbacks').filter(f => f.instituteeId === id)
const feedbackIds = feedbacks.map(f => f.id!).filter(Boolean)
if (feedbackIds.length) {
deleteRowsWhereIn('feedbackCategoryRatings', 'assignmentFeedbackId', feedbackIds)
deleteRowsWhereIn('feedbackDimensionTexts', 'assignmentFeedbackId', feedbackIds)
deleteRowsWhereIn('feedbackCategoryTexts', 'assignmentFeedbackId', feedbackIds)
}
deleteRowsWhereEquals('assignmentFeedbacks', 'instituteeId', id)
deleteRowsWhereEquals('feedbackDrafts', 'instituteeId', id)
const entries = getAllRows<ConversationEntry>('conversationEntries').filter(e => e.instituteeId === id)
const entryIds = entries.map(e => e.id!).filter(Boolean)
if (entryIds.length) {
deleteRowsWhereIn('conversationSkillScores', 'conversationEntryId', entryIds)
}
deleteRowsWhereEquals('conversationEntries', 'instituteeId', id)
deleteRowsWhereEquals('assessments', 'instituteeId', id)
deleteRowsWhereEquals('meetingCategoryRatings', 'instituteeId', id)
deleteRowsWhereEquals('checklistResponses', 'instituteeId', id)
const emptiedAssignmentIds: number[] = []
let assignmentsTouched = 0
const assignments = getAllRows<Assignment>('assignments')
for (const a of assignments) {
if (!a.id || !a.instituteeIds.includes(id)) continue
assignmentsTouched++
const instituteeIds = a.instituteeIds.filter(x => x !== id)
const instituteeEnrollments = (a.instituteeEnrollments ?? []).filter(e => e.instituteeId !== id)
updateRow('assignments', a.id, { instituteeIds, instituteeEnrollments })
if (instituteeIds.length === 0) emptiedAssignmentIds.push(a.id)
}
deleteRowById('consultants', id)
return {
emptiedAssignmentIds,
feedbacksDeleted: feedbacks.length,
assignmentsTouched,
}
}
/** @deprecated — nutze hardDeleteConsultant; bleibt als Alias für alte Client-Aufrufe. */
export function deleteConsultant(id: number): void {
hardDeleteConsultant(id)
}
export function getConsultantDetail(id: number): ConsultantDetail | undefined {
const consultant = getConsultant(id)
if (!consultant) return undefined
const group = getGroup(consultant.groupId)
const types = getAllRows<{ id?: number; name: string }>('assignmentTypes')
const typeMap = new Map(types.map(t => [t.id!, t.name]))
const categories = getAllRows<FeedbackCategory>('feedbackCategories')
const catMap = new Map(categories.map(c => [c.id!, c.name]))
const allFeedbacks = getAllRows<AssignmentFeedback>('assignmentFeedbacks')
const allRatings = getAllRows<FeedbackCategoryRating>('feedbackCategoryRatings')
const assignments = getAllRows<Assignment>('assignments')
.filter(a => a.instituteeIds.includes(id))
.map(a => {
const fb = allFeedbacks.find(f => f.assignmentId === a.id && f.instituteeId === id)
const ratings = fb?.id
? allRatings
.filter(r => r.assignmentFeedbackId === fb.id)
.map(r => ({
categoryId: r.feedbackCategoryId,
categoryName: catMap.get(r.feedbackCategoryId) ?? `Kategorie ${r.feedbackCategoryId}`,
rating: (r.rating ?? null) as RatingScore | null,
}))
: []
return {
assignmentId: a.id!,
title: a.title,
assignmentNumber: a.assignmentNumber,
client: a.client,
status: a.status,
deletedAt: a.deletedAt,
archivedAt: a.archivedAt,
plannedStart: a.plannedStart,
plannedEnd: a.plannedEnd,
createdAt: a.createdAt,
typeName: typeMap.get(a.assignmentTypeId) ?? '—',
feedbackStatus: (!fb ? 'none' : fb.status === 'final' ? 'final' : 'draft') as 'none' | 'draft' | 'final',
categoryRatings: ratings,
}
})
.sort((a, b) => {
const ae = a.plannedEnd ?? a.createdAt
const be = b.plannedEnd ?? b.createdAt
return be.localeCompare(ae)
})
return { consultant, group, assignments }
}