diff --git a/.env.coding-box.template b/.env.coding-box.template index bc620df20..0867a282a 100644 --- a/.env.coding-box.template +++ b/.env.coding-box.template @@ -15,6 +15,11 @@ POSTGRES_DB=coding-box ## Backend JWT_SECRET=random_string +## Redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PREFIX=coding-box + ## Infrastructure SERVER_NAME=hostname.de TRAEFIK_DIR= diff --git a/Makefile b/Makefile index 32fd0ef76..96e6a1741 100644 --- a/Makefile +++ b/Makefile @@ -70,6 +70,35 @@ dev-db-rollback-lastchangeset: dev-db-generate-docs: $(MAKE) -f $(MK_FILE_DIR)/dev-db.mk -C $(MK_FILE_DIR) $@ +dev-redis-registry-login: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-registry-logout: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-build: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-up: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-down: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-volumes-clean: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-images-clean: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-monitor: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-info: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-stats: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-ping: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-flush-all: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-flush-db: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ +dev-redis-cli: + $(MAKE) -f $(MK_FILE_DIR)/dev-redis.mk -C $(MK_FILE_DIR) $@ + dev-test-app: $(MAKE) -f $(MK_FILE_DIR)/dev-test.mk -C $(MK_FILE_DIR) $@ dev-test-backend: @@ -125,6 +154,21 @@ coding-box-restore-db-data-only: coding-box-update: $(MAKE) -f $(MK_FILE_DIR)/prod.mk -C $(MK_FILE_DIR) $@ +coding-box-redis-monitor: + $(MAKE) -f $(MK_FILE_DIR)/prod.mk -C $(MK_FILE_DIR) $@ +coding-box-redis-info: + $(MAKE) -f $(MK_FILE_DIR)/prod.mk -C $(MK_FILE_DIR) $@ +coding-box-redis-stats: + $(MAKE) -f $(MK_FILE_DIR)/prod.mk -C $(MK_FILE_DIR) $@ +coding-box-redis-ping: + $(MAKE) -f $(MK_FILE_DIR)/prod.mk -C $(MK_FILE_DIR) $@ +coding-box-redis-flush-all: + $(MAKE) -f $(MK_FILE_DIR)/prod.mk -C $(MK_FILE_DIR) $@ +coding-box-redis-flush-db: + $(MAKE) -f $(MK_FILE_DIR)/prod.mk -C $(MK_FILE_DIR) $@ +coding-box-redis-cli: + $(MAKE) -f $(MK_FILE_DIR)/prod.mk -C $(MK_FILE_DIR) $@ + push-dockerhub: $(MAKE) -f $(MK_FILE_DIR)/push.mk -C $(MK_FILE_DIR) $@ push-iqb-registry: diff --git a/api-dto/coding/codebook-content-setting.ts b/api-dto/coding/codebook-content-setting.ts new file mode 100644 index 000000000..6060d3df6 --- /dev/null +++ b/api-dto/coding/codebook-content-setting.ts @@ -0,0 +1,25 @@ +/** + * Settings for codebook content generation + */ +export interface CodeBookContentSetting { + /** Export format (docx or json) */ + exportFormat: string; + /** Missings profile name */ + missingsProfile: string; + /** Include only manual coding */ + hasOnlyManualCoding: boolean; + /** Include general instructions */ + hasGeneralInstructions: boolean; + /** Include derived variables */ + hasDerivedVars: boolean; + /** Include only variables with codes */ + hasOnlyVarsWithCodes: boolean; + /** Include closed variables */ + hasClosedVars: boolean; + /** Convert code labels to uppercase */ + codeLabelToUpper: boolean; + /** Show score */ + showScore: boolean; + /** Hide item-variable relation */ + hideItemVarRelation: boolean; +} diff --git a/api-dto/coding/missings-profiles.dto.ts b/api-dto/coding/missings-profiles.dto.ts new file mode 100644 index 000000000..f6f376070 --- /dev/null +++ b/api-dto/coding/missings-profiles.dto.ts @@ -0,0 +1,41 @@ +export interface MissingDto { + id: string; + label: string; + description: string; + code: number; +} + +export class MissingsProfilesDto { + id?: number; + label!: string; + missings!: string; + + parseMissings(): MissingDto[] { + try { + if (!this.missings) { + return []; + } + + if (Array.isArray(this.missings)) { + return this.missings as unknown as MissingDto[]; + } + + if (typeof this.missings === 'string') { + const parsed = JSON.parse(this.missings); + return Array.isArray(parsed) ? parsed : []; + } + + return []; + } catch (error) { + return []; + } + } + + setMissings(missings: MissingDto[]): void { + if (typeof missings === 'string') { + this.missings = missings; + } else { + this.missings = JSON.stringify(missings); + } + } +} diff --git a/api-dto/coding/variable-analysis-item.dto.ts b/api-dto/coding/variable-analysis-item.dto.ts new file mode 100644 index 000000000..dae4e3c2b --- /dev/null +++ b/api-dto/coding/variable-analysis-item.dto.ts @@ -0,0 +1,31 @@ +export interface VariableAnalysisItemDto { + // Link to the replay of unit with its responses + replayUrl: string; + + // Unit ID + unitId: string; + + // Variable ID + variableId: string; + + // Derivation + derivation: string; + + // Code + code: string; + + // Description + description: string; + + // Score + score: number; + + // How often this unitId in combination with variableId with that code is in responses + occurrenceCount: number; + + // Total amount of that combination variableId and unit Id + totalCount: number; + + // Relative occurrence (for bar chart) + relativeOccurrence: number; +} diff --git a/api-dto/unit-info/unit-coding-scheme-ref.dto.ts b/api-dto/unit-info/unit-coding-scheme-ref.dto.ts new file mode 100644 index 000000000..e7bb9d12d --- /dev/null +++ b/api-dto/unit-info/unit-coding-scheme-ref.dto.ts @@ -0,0 +1,10 @@ +/** + * Data transfer object for a coding scheme reference + * Based on the CodingSchemeRef element in unit.xsd schema + */ +export class UnitCodingSchemeRefDto { + content!: string; + schemer!: string; + schemeType?: string; + lastChange?: Date; +} diff --git a/api-dto/unit-info/unit-definition.dto.ts b/api-dto/unit-info/unit-definition.dto.ts new file mode 100644 index 000000000..0eb7f1f75 --- /dev/null +++ b/api-dto/unit-info/unit-definition.dto.ts @@ -0,0 +1,11 @@ +/** + * Data transfer object for unit definition + * Based on the Definition/DefinitionRef element in unit.xsd schema + */ +export class UnitDefinitionDto { + type!: 'Definition' | 'DefinitionRef'; + player!: string; + editor?: string; + content!: string; + lastChange?: Date; +} diff --git a/api-dto/unit-info/unit-dependency.dto.ts b/api-dto/unit-info/unit-dependency.dto.ts new file mode 100644 index 000000000..1774a3737 --- /dev/null +++ b/api-dto/unit-info/unit-dependency.dto.ts @@ -0,0 +1,9 @@ +/** + * Data transfer object for a unit dependency + * Based on the Dependency element in unit.xsd schema + */ +export class UnitDependencyDto { + type!: 'File' | 'Service'; + content!: string; + for!: 'player' | 'editor' | 'schemer' | 'coder'; +} diff --git a/api-dto/unit-info/unit-info.dto.ts b/api-dto/unit-info/unit-info.dto.ts new file mode 100644 index 000000000..e2468d1e4 --- /dev/null +++ b/api-dto/unit-info/unit-info.dto.ts @@ -0,0 +1,19 @@ +import { UnitMetadataDto } from './unit-metadata.dto'; +import { UnitDefinitionDto } from './unit-definition.dto'; +import { UnitVariableDto } from './unit-variable.dto'; +import { UnitCodingSchemeRefDto } from './unit-coding-scheme-ref.dto'; +import { UnitDependencyDto } from './unit-dependency.dto'; + +/** + * Data transfer object for unit information + * Based on the unit.xsd schema + */ +export class UnitInfoDto { + metadata!: UnitMetadataDto; + definition!: UnitDefinitionDto; + codingSchemeRef?: UnitCodingSchemeRefDto; + dependencies?: UnitDependencyDto[]; + baseVariables?: UnitVariableDto[]; + derivedVariables?: UnitVariableDto[]; + rawXml!: string; +} diff --git a/api-dto/unit-info/unit-metadata.dto.ts b/api-dto/unit-info/unit-metadata.dto.ts new file mode 100644 index 000000000..cb3d89bf3 --- /dev/null +++ b/api-dto/unit-info/unit-metadata.dto.ts @@ -0,0 +1,12 @@ +/** + * Data transfer object for unit metadata + * Based on the Metadata element in unit.xsd schema + */ +export class UnitMetadataDto { + id!: string; + label!: string; + description?: string; + transcript?: string; + reference?: string; + lastChange?: Date; +} diff --git a/api-dto/unit-info/unit-variable-value.dto.ts b/api-dto/unit-info/unit-variable-value.dto.ts new file mode 100644 index 000000000..5e4a6e83d --- /dev/null +++ b/api-dto/unit-info/unit-variable-value.dto.ts @@ -0,0 +1,8 @@ +/** + * Data transfer object for a variable value + * Based on the VariableValue element in unit.xsd schema + */ +export class UnitVariableValueDto { + label!: string; + value!: string; +} diff --git a/api-dto/unit-info/unit-variable.dto.ts b/api-dto/unit-info/unit-variable.dto.ts new file mode 100644 index 000000000..0b5b2d3b0 --- /dev/null +++ b/api-dto/unit-info/unit-variable.dto.ts @@ -0,0 +1,18 @@ +import { UnitVariableValueDto } from './unit-variable-value.dto'; + +/** + * Data transfer object for a unit variable + * Based on the Variable element in unit.xsd schema + */ +export class UnitVariableDto { + id!: string; + alias?: string; + type!: 'string' | 'integer' | 'number' | 'boolean' | 'attachment' | 'json' | 'no-value'; + format?: string; + multiple?: boolean; + nullable?: boolean; + page?: string; + values?: UnitVariableValueDto[]; + valuesComplete?: boolean; + valuePositionLabels?: string[]; +} diff --git a/apps/backend/src/app/admin/admin.module.ts b/apps/backend/src/app/admin/admin.module.ts index b0d781330..e8e27a367 100755 --- a/apps/backend/src/app/admin/admin.module.ts +++ b/apps/backend/src/app/admin/admin.module.ts @@ -20,8 +20,12 @@ import { VariableAnalysisController } from './variable-analysis/variable-analysi import { JobsController } from './jobs/jobs.controller'; import { ValidationTaskController } from './workspace/validation-task.controller'; import { BookletInfoController } from './workspace/booklet-info.controller'; +import { UnitInfoController } from './workspace/unit-info.controller'; +import { MissingsProfilesController } from './workspace/missings-profiles.controller'; import { BookletInfoService } from '../database/services/booklet-info.service'; +import { UnitInfoService } from '../database/services/unit-info.service'; import FileUpload from '../database/entities/file_upload.entity'; +import { ReplayStatisticsController } from './replay-statistics/replay-statistics.controller'; @Module({ imports: [ @@ -47,10 +51,14 @@ import FileUpload from '../database/entities/file_upload.entity'; VariableAnalysisController, JobsController, ValidationTaskController, - BookletInfoController + BookletInfoController, + UnitInfoController, + MissingsProfilesController, + ReplayStatisticsController ], providers: [ - BookletInfoService + BookletInfoService, + UnitInfoService ] }) export class AdminModule {} diff --git a/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.ts b/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.ts new file mode 100644 index 000000000..19a0f74ea --- /dev/null +++ b/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.ts @@ -0,0 +1,643 @@ +import { + AlignmentType, + Document, + HeadingLevel, + Packer, + Paragraph, + Table, + TableCell, + TableRow, + TextRun, + Footer, + WidthType, + PageNumber, + ITableCellBorders, + Header, FileChild +} from 'docx'; +import * as cheerio from 'cheerio'; +// Using type-only import to avoid dependency warning +import type { AnyNode, Element } from 'domhandler'; +import { + BookVariable, CodeBookContentSetting, CodebookUnitDto, ItemMetadata +} from './codebook.interfaces'; + +/** + * Class for generating DOCX files for codebooks + */ +export class CodebookDocxGenerator { + /** + * Generate a DOCX file for a codebook + * @param codingBookUnits List of codebook units + * @param contentSetting Codebook content settings + * @returns Buffer with DOCX file + */ + static async generateDocx( + codingBookUnits: CodebookUnitDto[], + contentSetting: CodeBookContentSetting + ): Promise { + if (codingBookUnits.length) { + const units: FileChild[] = []; + let missings: Paragraph[] = []; + codingBookUnits.forEach(variableCoding => { + missings = this.getMissings(variableCoding); + if (variableCoding.variables.length || !contentSetting.hasOnlyVarsWithCodes) { + units.push(...(this.createDocXForUnit( + variableCoding.items || [], + variableCoding.variables, + contentSetting, + this.getUnitHeader(variableCoding) + ) as FileChild[])); + } + }); + const b64string = await Packer.toBase64String( + this.setDocXDocument( + units, + missings) + ); + return Buffer.from(b64string, 'base64'); + } + return Buffer.from('', 'utf-8'); + } + + /** + * Get unit header + * @param variableCoding Codebook unit + * @returns Paragraph with unit header + */ + private static getUnitHeader(variableCoding: CodebookUnitDto): Paragraph { + return new Paragraph({ + border: { + bottom: { + color: '#000000', + style: 'single', + size: 10 + }, + top: { + color: '#000000', + style: 'single', + size: 10 + } + }, + spacing: { + after: 200 + }, + text: `${variableCoding.key} ${variableCoding.name}`, + heading: HeadingLevel.HEADING_1, + alignment: AlignmentType.CENTER + }); + } + + /** + * Get missings paragraphs + * @param variableCoding Codebook unit + * @returns List of paragraphs with missings + */ + private static getMissings(variableCoding: CodebookUnitDto): Paragraph[] { + const missings: Paragraph[] = []; + try { + variableCoding.missings.forEach(missing => { + if (missing.code && missing.label && missing.description) { + missings.push(new Paragraph({ + children: [new TextRun({ text: `${missing.code} ${missing.label}`, bold: true })], + spacing: { + after: 20 + } + })); + missings.push(new Paragraph({ + text: `${missing.description}`, + spacing: { + after: 100 + } + })); + } else { + missings.push(new Paragraph({ + text: 'kein valides Missing ', + spacing: { + after: 200 + } + })); + } + }); + } catch { + missings.push(new Paragraph({ + text: 'kein validen Missings gefunden', + spacing: { + after: 200 + } + })); + } + return missings; + } + + /** + * Get table borders + * @returns Table cell borders + */ + private static get TableBoarders(): ITableCellBorders { + return { + top: { + size: 1, + color: '#000000', + style: 'single' + }, + bottom: { + size: 1, + color: '#000000', + style: 'single' + }, + left: { + size: 1, + color: '#000000', + style: 'single' + }, + right: { + size: 1, + color: '#000000', + style: 'single' + } + }; + } + + /** + * Get code rows for a table + * @param variable Book variable + * @param contentSetting Codebook content settings + * @returns List of table rows + */ + private static getCodeRows(variable: BookVariable, contentSetting: CodeBookContentSetting): TableRow[] { + const rows: TableRow[] = []; + const headerRow = new TableRow({ + tableHeader: true, + children: [ + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[0], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Code', + bold: true + }) + ] + })] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[1], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Label', + bold: true + }) + ] + })] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[2], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Beschreibung', + bold: true + }) + ] + })] + }) + ] + }); + rows.push(headerRow); + if (contentSetting.showScore) { + headerRow.addChildElement( + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[3], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Score', + bold: true + }) + ] + })] + }) + ); + } + variable.codes.forEach(code => { + const row = new TableRow({ + children: [ + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[0], + type: WidthType.DXA + }, + children: [new Paragraph(code.id)] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[1], + type: WidthType.DXA + }, + children: [new Paragraph(code.label)] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[2], + type: WidthType.DXA + }, + children: this.htmlToDocx(code.description, contentSetting) + }) + ] + }); + if (contentSetting.showScore) { + row.addChildElement( + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[3], + type: WidthType.DXA + }, + children: [new Paragraph(code.score || '')] + }) + ); + } + rows.push(row); + }); + return rows; + } + + /** + * Get column widths for a table + * @param contentSetting Codebook content settings + * @returns List of column widths + */ + private static getColumnWidths(contentSetting: CodeBookContentSetting): number[] { + return contentSetting.showScore ? [1000, 2000, 5000, 1000] : [1000, 2000, 6000]; + } + + /** + * Get variables for a unit + * @param codeBookVariable List of book variables + * @param contentSetting Codebook content settings + * @param varItems List of item metadata + * @returns List of file children + */ + private static getVariables( + codeBookVariable: BookVariable[], + contentSetting: CodeBookContentSetting, + varItems: ItemMetadata[] + ): FileChild[] { + const children: FileChild[] = []; + codeBookVariable.forEach(variable => { + children.push(this.getVariableHeader(variable)); + if (!contentSetting.hideItemVarRelation) { + children.push(...this.getVariableItems(variable, varItems)); + } + if (variable.generalInstruction) { + children.push(...this.getGeneralInstruction(contentSetting, variable)); + } + if (variable.codes.length) { + children.push(this.getCodeTable(variable, contentSetting)); + } + }); + return children; + } + + /** + * Get variable header + * @param variable Book variable + * @returns Paragraph with variable header + */ + private static getVariableHeader(variable: BookVariable): Paragraph { + return new Paragraph({ + text: `${variable.id} ${variable.label}`, + heading: HeadingLevel.HEADING_2, + spacing: { + before: 400, + after: 200 + } + }); + } + + /** + * Get variable items + * @param variable Book variable + * @param varItems List of item metadata + * @returns List of paragraphs with variable items + */ + private static getVariableItems(variable: BookVariable, varItems: ItemMetadata[]): Paragraph[] { + const paragraphs: Paragraph[] = []; + const items = varItems.filter(item => { + const variableId = variable.id.replace(/\./g, '_'); + return item[variableId] !== undefined; + }); + if (items.length) { + paragraphs.push(new Paragraph({ + text: 'Items:', + spacing: { + after: 100 + } + })); + items.forEach(item => { + paragraphs.push(new Paragraph({ + text: `${item.key} ${item.label}`, + bullet: { + level: 0 + } + })); + }); + } + return paragraphs; + } + + /** + * Get general instruction + * @param contentSetting Codebook content settings + * @param codeBookVariable Book variable + * @returns List of paragraphs with general instruction + */ + private static getGeneralInstruction( + contentSetting: CodeBookContentSetting, + codeBookVariable: BookVariable + ): Paragraph[] { + return codeBookVariable.generalInstruction ? + this.htmlToDocx(codeBookVariable.generalInstruction, contentSetting) : []; + } + + /** + * Get code table + * @param codeBookVariable Book variable + * @param contentSetting Codebook content settings + * @returns Table with codes + */ + private static getCodeTable(codeBookVariable: BookVariable, contentSetting: CodeBookContentSetting): Table { + return new Table({ + rows: this.getCodeRows(codeBookVariable, contentSetting), + width: { + size: 9000, + type: WidthType.DXA + } + }); + } + + /** + * Create DOCX for a unit + * @param items List of item metadata + * @param codeBookVariable List of book variables + * @param contentSetting Codebook content settings + * @param unitHeader Paragraph with unit header + * @returns List of file children + */ + private static createDocXForUnit( + items: ItemMetadata[], + codeBookVariable: BookVariable[], + contentSetting: CodeBookContentSetting, + unitHeader: Paragraph + ): FileChild[] { + return [ + unitHeader, + ...this.getVariables(codeBookVariable, contentSetting, items) + ]; + } + + /** + * Set DOCX document + * @param children List of file children + * @param missings List of paragraphs with missings + * @returns Document + */ + private static setDocXDocument(children: FileChild[], missings: Paragraph[]): Document { + const doc = new Document({ + creator: 'IQB-Kodierbox', + title: 'Codebook', + description: 'Codebook', + styles: { + paragraphStyles: [ + { + id: 'Normal', + name: 'Normal', + basedOn: 'Normal', + next: 'Normal', + quickFormat: true, + run: { + size: 24, + font: 'Calibri' + }, + paragraph: { + spacing: { + after: 120 + } + } + } + ] + }, + sections: [ + { + properties: { + page: { + margin: { + top: 1000, + right: 1000, + bottom: 1000, + left: 1000 + } + } + }, + headers: { + default: new Header({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun('IQB-Kodierbox Codebook '), + new TextRun({ + children: [PageNumber.CURRENT], + font: 'Calibri' + }), + new TextRun({ + children: [' / '], + font: 'Calibri' + }), + new TextRun({ + children: [PageNumber.TOTAL_PAGES], + font: 'Calibri' + }) + ] + }) + ] + }) + }, + + footers: { + default: new Footer({ + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: new Date().toLocaleDateString(), + font: 'Calibri' + }) + ] + }) + ] + }) + }, + children: [ + new Paragraph({ + text: 'Missings', + heading: HeadingLevel.HEADING_1, + spacing: { + after: 200 + } + }), + ...missings, + ...children + ] + } + ] + }); + return doc; + } + + /** + * Convert HTML to DOCX + * @param html HTML string + * @param contentSetting Codebook content settings + * @returns List of paragraphs + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private static htmlToDocx(html: string, contentSetting: CodeBookContentSetting): Paragraph[] { + const paragraphs: Paragraph[] = []; + if (!html) return paragraphs; + + try { + const $ = cheerio.load(`
${html}
`); + const rootElement = $('div')[0]; + + if (rootElement && rootElement.children) { + this.processChildNodes(rootElement.children, paragraphs); + } + } catch (error) { + paragraphs.push(new Paragraph({ text: html })); + } + + return paragraphs; + } + + /** + * Process child nodes + * @param nodes List of nodes + * @param paragraphs List of paragraphs + */ + private static processChildNodes(nodes: AnyNode[], paragraphs: Paragraph[]): void { + for (const node of nodes) { + if (node.type === 'text') { + if ('data' in node && node.data && node.data.trim()) { + paragraphs.push(new Paragraph({ text: node.data.trim() })); + } + } else if (node.type === 'tag') { + const element = node as Element; + const tagName = element.name.toLowerCase(); + + if (tagName === 'p') { + const textRuns: TextRun[] = []; + this.processInlineElements(element.children, textRuns); + if (textRuns.length > 0) { + paragraphs.push(new Paragraph({ children: textRuns })); + } + } else if (tagName === 'ul' || tagName === 'ol') { + this.processListElements(element.children, paragraphs, tagName === 'ol'); + } else if (element.children && element.children.length > 0) { + this.processChildNodes(element.children, paragraphs); + } + } + } + } + + /** + * Process inline elements + * @param nodes List of nodes + * @param textRuns List of text runs + */ + private static processInlineElements(nodes: AnyNode[], textRuns: TextRun[]): void { + for (const node of nodes) { + if (node.type === 'text') { + if ('data' in node && node.data && node.data.trim()) { + textRuns.push(new TextRun({ text: node.data.trim() })); + } + } else if (node.type === 'tag') { + const element = node as Element; + const tagName = element.name.toLowerCase(); + + if (tagName === 'strong' || tagName === 'b') { + if (element.children) { + for (const child of element.children) { + if (child.type === 'text' && child.data) { + textRuns.push(new TextRun({ text: child.data.trim(), bold: true })); + } + } + } + } else if (tagName === 'em' || tagName === 'i') { + if (element.children) { + for (const child of element.children) { + if (child.type === 'text' && child.data) { + textRuns.push(new TextRun({ text: child.data.trim(), italics: true })); + } + } + } + } else if (element.children && element.children.length > 0) { + this.processInlineElements(element.children, textRuns); + } + } + } + } + + /** + * Process list elements + * @param nodes List of nodes + * @param paragraphs List of paragraphs + * @param isOrdered Whether the list is ordered + */ + private static processListElements(nodes: AnyNode[], paragraphs: Paragraph[], isOrdered: boolean): void { + let index = 1; + for (const node of nodes) { + if (node.type === 'tag') { + const element = node as Element; + if (element.name.toLowerCase() === 'li') { + const textRuns: TextRun[] = []; + this.processInlineElements(element.children, textRuns); + if (textRuns.length > 0) { + paragraphs.push(new Paragraph({ + children: textRuns, + bullet: { + level: 0 + }, + numbering: isOrdered ? { + reference: 'default-numbering', + level: 0, + instance: index += 1 + } : undefined + })); + } + } + } + } + } +} diff --git a/apps/backend/src/app/admin/code-book/codebook-generator.class.ts b/apps/backend/src/app/admin/code-book/codebook-generator.class.ts new file mode 100644 index 000000000..707c0d35b --- /dev/null +++ b/apps/backend/src/app/admin/code-book/codebook-generator.class.ts @@ -0,0 +1,192 @@ +import { + ToTextFactory, CodeAsText, CodingScheme, VariableCodingData, CodeData +} from '@iqb/responses'; +import { + BookVariable, + CodeBookContentSetting, + CodebookUnitDto, + CodeInfo, + Missing, + UnitPropertiesForCodebook +} from './codebook.interfaces'; +import { CodebookDocxGenerator } from './codebook-docx-generator.class'; + +/** + * Class for generating codebooks + */ +export class CodebookGenerator { + static generateCodebook( + units: UnitPropertiesForCodebook[], + contentSetting: CodeBookContentSetting, + missings: Missing[] + ): Promise { + if (units.length === 0) { + return Promise.resolve(Buffer.from('[]', 'utf-8')); + } + const codebook: CodebookUnitDto[] = units.map((unit: UnitPropertiesForCodebook) => this.getCodeBookDataForUnit(unit, contentSetting, missings)); + + if (contentSetting.exportFormat === 'docx') { + return CodebookDocxGenerator.generateDocx(codebook, contentSetting); + } + + return new Promise(resolve => { + const noItemsCodebook = codebook.map((unit: CodebookUnitDto) => ({ + key: unit.key, + name: unit.name, + variables: unit.variables, + missings: unit.missings + })); + const data = JSON.stringify(noItemsCodebook); + resolve(Buffer.from(data, 'utf-8')); + }); + } + + private static getCodeBookDataForUnit( + unit: UnitPropertiesForCodebook, + contentSetting: CodeBookContentSetting, + missings: Missing[] + ): CodebookUnitDto { + const parsedScheme = unit.scheme ? new CodingScheme(unit.scheme) : null; + const variableCodings = parsedScheme?.variableCodings || []; + const bookVariables = this.getBookVariables(variableCodings, contentSetting); + return { + key: unit.key, + name: unit.name, + variables: this.getSortedBookVariables(bookVariables.filter(v => v.sourceType !== 'BASE_NO_VALUE')), + missings: missings, + items: unit.metadata?.items + }; + } + + private static getBookVariables( + variableCodings: VariableCodingData[], + contentSetting: CodeBookContentSetting + ): BookVariable[] { + return variableCodings.reduce((bookVariables: BookVariable[], variableCoding) => { + const bookVariable = this.getBaseOrDerivedBookVariable(variableCoding, contentSetting); + if (bookVariable) bookVariables.push(bookVariable); + return bookVariables; + }, []); + } + + private static getSortedBookVariables(bookVariables: BookVariable[]): BookVariable[] { + return bookVariables.sort((a, b) => { + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; + }); + } + + private static getBaseOrDerivedBookVariable( + variableCoding: VariableCodingData, + contentSetting: CodeBookContentSetting + ): BookVariable | null { + const codes: CodeInfo[] = this.getCodes(variableCoding.codes, contentSetting); + const isDerived: boolean = (variableCoding.sourceType !== 'BASE' && variableCoding.sourceType !== 'BASE_NO_VALUE'); + if (!isDerived || contentSetting.hasDerivedVars) { + return this.getManualOrClosedCodedBookVariable(contentSetting, codes, variableCoding); + } + return null; + } + + private static getManualOrClosedCodedBookVariable( + contentSetting: CodeBookContentSetting, + codes: CodeInfo[], + variableCoding: VariableCodingData + ): BookVariable | null { + if (contentSetting.hasOnlyVarsWithCodes && codes.length === 0) { + return null; + } + if (contentSetting.hasOnlyManualCoding && !contentSetting.hasClosedVars) { + if (!this.isManualWithoutClosed(variableCoding)) { + return null; + } + } else if (contentSetting.hasOnlyManualCoding) { + if (!this.isManual(variableCoding)) { + return null; + } + } else if (!contentSetting.hasClosedVars) { + if (this.isClosedWithoutManual(variableCoding)) { + return null; + } + } + return { + id: variableCoding.alias || variableCoding.id, + label: variableCoding.label, + sourceType: variableCoding.sourceType, + generalInstruction: contentSetting.hasGeneralInstructions ? + variableCoding.manualInstruction : + '', + codes: codes + }; + } + + private static isClosed(variableCoding: VariableCodingData): boolean { + return variableCoding.codes.some(codeData => codeData.type === 'RESIDUAL_AUTO' || codeData.type === 'INTENDED_INCOMPLETE'); + } + + private static isManual(variableCoding: VariableCodingData): boolean { + return variableCoding.codes.some(codeData => codeData.manualInstruction); + } + + private static isManualWithoutClosed(variableCoding: VariableCodingData): boolean { + return variableCoding.codes.some(codeData => codeData.manualInstruction && + (codeData.type !== 'RESIDUAL_AUTO' && codeData.type !== 'INTENDED_INCOMPLETE')); + } + + private static isClosedWithoutManual(variableCoding: VariableCodingData): boolean { + return variableCoding.codes + .some(codeData => (codeData.type === 'RESIDUAL_AUTO' || codeData.type === 'INTENDED_INCOMPLETE') && !codeData.manualInstruction); + } + + private static getCodes(codes: CodeData[], contentSetting: CodeBookContentSetting): CodeInfo[] { + return codes.reduce((codeInfos: CodeInfo[], code) => { + if (code.id) { + try { + const codeInfo = this.getCodeInfoFromCodeAsText(code, contentSetting); + codeInfos.push(codeInfo); + } catch (error) { + const codeInfo = this.getCodeInfo(code, contentSetting); + codeInfos.push(codeInfo); + } + } + return codeInfos; + }, []); + } + + private static getCodeInfo(code: CodeData, contentSetting: CodeBookContentSetting): CodeInfo { + const codeInfo: CodeInfo = { + id: `${code.id}`, + label: '', + description: + '

Kodierschema mit Schemer Version ab 1.5 erzeugen!

' + }; + if (contentSetting.showScore) codeInfo.score = ''; + return codeInfo; + } + + private static getCodeInfoFromCodeAsText(code: CodeData, contentSetting: CodeBookContentSetting): CodeInfo { + const codeAsText = ToTextFactory.codeAsText(code, 'SIMPLE'); + const rulesDescription = contentSetting.hasOnlyManualCoding && !contentSetting.hasClosedVars ? '' : + this.getRulesDescription(codeAsText, code); + const codeInfo: CodeInfo = { + id: `${code.id}`, + label: contentSetting.codeLabelToUpper ? codeAsText.label.toUpperCase() : codeAsText.label, + description: `${rulesDescription}${code.manualInstruction}` + }; + if (contentSetting.showScore) codeInfo.score = codeAsText.score.toString(); + return codeInfo; + } + + private static getRulesDescription(codeAsText: CodeAsText, code: CodeData): string { + let rulesDescription = ''; + codeAsText.ruleSetDescriptions.forEach( + (ruleSetDescription: string) => { + if (ruleSetDescription !== 'Keine Regeln definiert.') { + rulesDescription += `

${ruleSetDescription}

`; + } else if (code.manualInstruction === '') rulesDescription += `

${ruleSetDescription}

`; + } + ); + return rulesDescription; + } +} diff --git a/apps/backend/src/app/admin/code-book/codebook-services.interfaces.ts b/apps/backend/src/app/admin/code-book/codebook-services.interfaces.ts new file mode 100644 index 000000000..eb5c8f105 --- /dev/null +++ b/apps/backend/src/app/admin/code-book/codebook-services.interfaces.ts @@ -0,0 +1,71 @@ +import { Observable } from 'rxjs'; +import { CodeBookContentSetting } from './codebook.interfaces'; + +/** + * Interface for workspace service abstraction + */ +export interface IWorkspaceService { + /** + * Get the selected workspace ID + */ + readonly selectedWorkspaceId: number; + + /** + * Get the selected workspace name + */ + readonly selectedWorkspaceName: string; + + /** + * Check if there are unsaved changes in the workspace + */ + isChanged(): boolean; +} + +/** + * Interface for workspace backend service abstraction + */ +export interface IWorkspaceBackendService { + /** + * Get missings profiles + */ + getMissingsProfiles(): Observable<{ label: string }[]>; + + /** + * Get coding book + * @param workspaceId Workspace ID + * @param missingsProfile Missings profile + * @param contentOptions Content options + * @param unitList Unit list + */ + getCodingBook( + workspaceId: number, + missingsProfile: string, + contentOptions: CodeBookContentSetting, + unitList: number[] + ): Observable; +} + +/** + * Interface for app service abstraction + */ +export interface IAppService { + /** + * Set data loading state + */ + dataLoading: boolean; +} + +/** + * Interface for unit selection component abstraction + */ +export interface IUnitSelectionComponent { + /** + * Workspace ID + */ + workspace: number; + + /** + * Event emitted when selection changes + */ + selectionChanged: Observable; +} diff --git a/apps/backend/src/app/admin/code-book/codebook.interfaces.ts b/apps/backend/src/app/admin/code-book/codebook.interfaces.ts new file mode 100644 index 000000000..db21c931d --- /dev/null +++ b/apps/backend/src/app/admin/code-book/codebook.interfaces.ts @@ -0,0 +1,115 @@ +import { VariableInfo } from '@iqb/responses'; + +/** + * Item metadata for codebook + */ +export interface ItemMetadata { + [key: string]: unknown; +} + +/** + * Settings for codebook content generation + */ +export interface CodeBookContentSetting { + /** Export format (docx or json) */ + exportFormat: string; + /** Missings profile name */ + missingsProfile: string; + /** Include only manual coding */ + hasOnlyManualCoding: boolean; + /** Include general instructions */ + hasGeneralInstructions: boolean; + /** Include derived variables */ + hasDerivedVars: boolean; + /** Include only variables with codes */ + hasOnlyVarsWithCodes: boolean; + /** Include closed variables */ + hasClosedVars: boolean; + /** Convert code labels to uppercase */ + codeLabelToUpper: boolean; + /** Show score */ + showScore: boolean; + /** Hide item-variable relation */ + hideItemVarRelation: boolean; +} + +/** + * Missing code definition + */ +export interface Missing { + /** Missing code */ + code: string; + /** Missing label */ + label: string; + /** Missing description */ + description: string; +} + +/** + * Code information for codebook + */ +export interface CodeInfo { + /** Code ID */ + id: string; + /** Code label */ + label: string; + /** Code description */ + description: string; + /** Code score (optional) */ + score?: string; +} + +/** + * Variable information for codebook + */ +export interface BookVariable { + /** Variable ID */ + id: string; + /** Variable label */ + label: string; + /** Variable source type */ + sourceType: string; + /** General instruction */ + generalInstruction: string; + /** Codes */ + codes: CodeInfo[]; +} + +/** + * Unit data for codebook + */ +export interface CodebookUnitDto { + /** Unit key */ + key: string; + /** Unit name */ + name: string; + /** Variables */ + variables: BookVariable[]; + /** Missings */ + missings: Missing[]; + /** Items (optional) */ + items?: ItemMetadata[]; +} + +/** + * Unit properties for codebook generation + */ +export interface UnitPropertiesForCodebook { + /** Unit ID */ + id: number; + /** Unit key */ + key: string; + /** Unit name */ + name: string; + /** Coding scheme */ + scheme?: string; + /** Scheme type */ + schemeType?: string; + /** Metadata */ + metadata?: { + /** Items */ + items?: ItemMetadata[]; + }; + /** Variables */ + variables?: VariableInfo[]; +} diff --git a/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts b/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts new file mode 100644 index 000000000..91c3244c7 --- /dev/null +++ b/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts @@ -0,0 +1,196 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import { + ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { ReplayStatisticsService } from '../../database/services/replay-statistics.service'; +import { ReplayStatistics } from '../../database/entities/replay-statistics.entity'; + +/** + * Controller for managing replay statistics + */ +@ApiTags('replay-statistics') +@Controller('admin/workspace/:workspace_id/replay-statistics') +export class ReplayStatisticsController { + constructor( + private readonly replayStatisticsService: ReplayStatisticsService + ) {} + + /** + * Store replay statistics + */ + @ApiOperation({ summary: 'Store replay statistics' }) + @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) + @ApiResponse({ status: 201, description: 'Replay statistics stored successfully' }) + @Post() + @UseGuards(JwtAuthGuard) + async storeReplayStatistics( + @Param('workspace_id') workspaceId: string, + @Body() data: { + unitId: string; + bookletId?: string; + testPersonLogin?: string; + testPersonCode?: string; + durationMilliseconds: number; + replayUrl?: string; + success?: boolean; + errorMessage?: string; + } + ): Promise { + return this.replayStatisticsService.storeReplayStatistics({ + workspaceId: Number(workspaceId), + ...data + }); + } + + /** + * Get all replay statistics for a workspace + */ + @ApiOperation({ summary: 'Get all replay statistics for a workspace' }) + @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) + @ApiResponse({ status: 200, description: 'Replay statistics retrieved successfully' }) + @Get() + @UseGuards(JwtAuthGuard) + async getReplayStatistics( + @Param('workspace_id') workspaceId: string + ): Promise { + return this.replayStatisticsService.getReplayStatistics(Number(workspaceId)); + } + + /** + * Get replay frequency by unit + */ + @ApiOperation({ summary: 'Get replay frequency by unit' }) + @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) + @ApiResponse({ status: 200, description: 'Replay frequency retrieved successfully' }) + @Get('frequency') + @UseGuards(JwtAuthGuard) + async getReplayFrequencyByUnit( + @Param('workspace_id') workspaceId: string + ): Promise> { + return this.replayStatisticsService.getReplayFrequencyByUnit(Number(workspaceId)); + } + + /** + * Get replay duration statistics + */ + @ApiOperation({ summary: 'Get replay duration statistics' }) + @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) + @ApiQuery({ name: 'unitId', required: false, description: 'Filter by unit ID' }) + @ApiResponse({ status: 200, description: 'Replay duration statistics retrieved successfully' }) + @Get('duration') + @UseGuards(JwtAuthGuard) + async getReplayDurationStatistics( + @Param('workspace_id') workspaceId: string, + @Query('unitId') unitId?: string + ): Promise<{ + min: number; + max: number; + average: number; + distribution: Record; + unitAverages?: Record; + }> { + return this.replayStatisticsService.getReplayDurationStatistics( + Number(workspaceId), + unitId + ); + } + + /** + * Get replay distribution by day + */ + @ApiOperation({ summary: 'Get replay distribution by day' }) + @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) + @ApiResponse({ status: 200, description: 'Replay distribution by day retrieved successfully' }) + @Get('distribution/day') + @UseGuards(JwtAuthGuard) + async getReplayDistributionByDay( + @Param('workspace_id') workspaceId: string + ): Promise> { + return this.replayStatisticsService.getReplayDistributionByDay(Number(workspaceId)); + } + + /** + * Get replay distribution by hour + */ + @ApiOperation({ summary: 'Get replay distribution by hour' }) + @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) + @ApiResponse({ status: 200, description: 'Replay distribution by hour retrieved successfully' }) + @Get('distribution/hour') + @UseGuards(JwtAuthGuard) + async getReplayDistributionByHour( + @Param('workspace_id') workspaceId: string + ): Promise> { + return this.replayStatisticsService.getReplayDistributionByHour(Number(workspaceId)); + } + + /** + * Get replay error statistics + */ + @ApiOperation({ summary: 'Get replay error statistics' }) + @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) + @ApiResponse({ status: 200, description: 'Replay error statistics retrieved successfully' }) + @Get('errors') + @UseGuards(JwtAuthGuard) + async getReplayErrorStatistics( + @Param('workspace_id') workspaceId: string + ): Promise<{ + successRate: number; + totalReplays: number; + successfulReplays: number; + failedReplays: number; + commonErrors: Array<{ message: string; count: number }>; + }> { + return this.replayStatisticsService.getReplayErrorStatistics(Number(workspaceId)); + } + + /** + * Get failure distribution by unit + */ + @ApiOperation({ summary: 'Get failure distribution by unit' }) + @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) + @ApiResponse({ status: 200, description: 'Failure distribution by unit retrieved successfully' }) + @Get('failures/unit') + @UseGuards(JwtAuthGuard) + async getFailureDistributionByUnit( + @Param('workspace_id') workspaceId: string + ): Promise> { + return this.replayStatisticsService.getFailureDistributionByUnit(Number(workspaceId)); + } + + /** + * Get failure distribution by day + */ + @ApiOperation({ summary: 'Get failure distribution by day' }) + @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) + @ApiResponse({ status: 200, description: 'Failure distribution by day retrieved successfully' }) + @Get('failures/day') + @UseGuards(JwtAuthGuard) + async getFailureDistributionByDay( + @Param('workspace_id') workspaceId: string + ): Promise> { + return this.replayStatisticsService.getFailureDistributionByDay(Number(workspaceId)); + } + + /** + * Get failure distribution by hour + */ + @ApiOperation({ summary: 'Get failure distribution by hour' }) + @ApiParam({ name: 'workspace_id', description: 'ID of the workspace' }) + @ApiResponse({ status: 200, description: 'Failure distribution by hour retrieved successfully' }) + @Get('failures/hour') + @UseGuards(JwtAuthGuard) + async getFailureDistributionByHour( + @Param('workspace_id') workspaceId: string + ): Promise> { + return this.replayStatisticsService.getFailureDistributionByHour(Number(workspaceId)); + } +} diff --git a/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis.dto.ts b/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis.dto.ts index ee3d83412..5aba9d74f 100644 --- a/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis.dto.ts +++ b/apps/backend/src/app/admin/variable-analysis/dto/variable-analysis.dto.ts @@ -1,27 +1,4 @@ -/** - * DTO for variable frequency data - */ -export class VariableFrequencyDto { - /** - * The ID of the variable - */ - variableId: string; - - /** - * The value of the variable - */ - value: string; - - /** - * The count of occurrences of this value - */ - count: number; - - /** - * The percentage of occurrences of this value - */ - percentage: number; -} +import { VariableFrequencyDto } from './variable-frequency.dto'; /** * DTO for variable analysis result diff --git a/apps/backend/src/app/admin/workspace/missings-profiles.controller.ts b/apps/backend/src/app/admin/workspace/missings-profiles.controller.ts new file mode 100644 index 000000000..5049abec6 --- /dev/null +++ b/apps/backend/src/app/admin/workspace/missings-profiles.controller.ts @@ -0,0 +1,48 @@ +import { + Controller, Get, Post, Body, Param, Delete, Put +} from '@nestjs/common'; +import { WorkspaceCodingService } from '../../database/services/workspace-coding.service'; +import { MissingsProfilesDto } from '../../../../../../api-dto/coding/missings-profiles.dto'; + +@Controller('admin/workspace/:workspaceId/missings-profiles') +export class MissingsProfilesController { + constructor(private readonly workspaceCodingService: WorkspaceCodingService) {} + + @Get() + async getMissingsProfiles(@Param('workspaceId') workspaceId: number) { + return this.workspaceCodingService.getMissingsProfiles(workspaceId); + } + + @Get(':label') + async getMissingsProfileDetails( + @Param('workspaceId') workspaceId: number, + @Param('label') label: string + ) { + return this.workspaceCodingService.getMissingsProfileDetails(workspaceId, label); + } + + @Post() + async createMissingsProfile( + @Param('workspaceId') workspaceId: number, + @Body() profile: MissingsProfilesDto + ) { + return this.workspaceCodingService.createMissingsProfile(workspaceId, profile); + } + + @Put(':label') + async updateMissingsProfile( + @Param('workspaceId') workspaceId: number, + @Param('label') label: string, + @Body() profile: MissingsProfilesDto + ) { + return this.workspaceCodingService.updateMissingsProfile(workspaceId, label, profile); + } + + @Delete(':label') + async deleteMissingsProfile( + @Param('workspaceId') workspaceId: number, + @Param('label') label: string + ) { + return this.workspaceCodingService.deleteMissingsProfile(workspaceId, label); + } +} diff --git a/apps/backend/src/app/admin/workspace/unit-info.controller.ts b/apps/backend/src/app/admin/workspace/unit-info.controller.ts new file mode 100644 index 000000000..33fe18e15 --- /dev/null +++ b/apps/backend/src/app/admin/workspace/unit-info.controller.ts @@ -0,0 +1,38 @@ +import { + Controller, + Get, + Param, + UseGuards +} from '@nestjs/common'; +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiTags +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { UnitInfoService } from '../../database/services/unit-info.service'; +import { UnitInfoDto } from '../../../../../../api-dto/unit-info/unit-info.dto'; + +@ApiTags('Unit Info') +@Controller('admin/workspace/:workspaceId/unit') +@UseGuards(JwtAuthGuard) +export class UnitInfoController { + constructor(private readonly unitInfoService: UnitInfoService) {} + + @Get(':unitId/info') + @ApiOperation({ summary: 'Get unit info from XML' }) + @ApiParam({ name: 'workspaceId', type: Number }) + @ApiParam({ name: 'unitId', type: String }) + @ApiResponse({ + status: 200, + description: 'Unit info retrieved successfully', + type: UnitInfoDto + }) + async getUnitInfo( + @Param('workspaceId') workspaceId: number, + @Param('unitId') unitId: string + ): Promise { + return this.unitInfoService.getUnitInfo(workspaceId, unitId); + } +} diff --git a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts index b9216ec46..a24802a7e 100644 --- a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts @@ -1,10 +1,10 @@ import { Controller, - Get, Param, Query, Res, UseGuards + Get, Param, Post, Query, Res, UseGuards, Body } from '@nestjs/common'; import { ApiOkResponse, - ApiParam, ApiQuery, ApiTags + ApiParam, ApiQuery, ApiTags, ApiBody } from '@nestjs/swagger'; import { Response } from 'express'; import { CodingStatistics } from '../../database/services/shared-types'; @@ -13,6 +13,7 @@ import { WorkspaceGuard } from './workspace.guard'; import { WorkspaceId } from './workspace.decorator'; import { WorkspaceCodingService } from '../../database/services/workspace-coding.service'; import { PersonService } from '../../database/services/person.service'; +import { VariableAnalysisItemDto } from '../../../../../../api-dto/coding/variable-analysis-item.dto'; @ApiTags('Admin Workspace Coding') @Controller('admin/workspace') @@ -246,6 +247,31 @@ export class WorkspaceCodingController { return this.workspaceCodingService.cancelJob(jobId); } + @Get(':workspace_id/coding/job/:jobId/delete') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiParam({ name: 'jobId', type: String, description: 'ID of the background job to delete' }) + @ApiOkResponse({ + description: 'Job deletion request processed.', + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Whether the deletion request was successful' + }, + message: { + type: 'string', + description: 'Message describing the result of the deletion request' + } + } + } + }) + async deleteJob(@Param('jobId') jobId: string): Promise<{ success: boolean; message: string }> { + return this.workspaceCodingService.deleteJob(jobId); + } + @Get(':workspace_id/coding/jobs') @UseGuards(JwtAuthGuard, WorkspaceGuard) @ApiTags('coding') @@ -306,8 +332,91 @@ export class WorkspaceCodingController { error?: string; workspaceId?: number; createdAt?: Date; + groupNames?: string; + durationMs?: number; + completedAt?: Date; + }[]> { + return this.workspaceCodingService.getBullJobs(workspace_id); + } + + @Get(':workspace_id/coding/bull-jobs') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiOkResponse({ + description: 'List of jobs from Redis Bull retrieved successfully.', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + jobId: { + type: 'string', + description: 'Unique identifier for the job' + }, + status: { + type: 'string', + enum: ['pending', 'processing', 'completed', 'failed', 'cancelled', 'paused'], + description: 'Current status of the job' + }, + progress: { + type: 'number', + description: 'Progress percentage (0-100)' + }, + result: { + type: 'object', + description: 'Result of the job (only available when status is completed)', + properties: { + totalResponses: { type: 'number' }, + statusCounts: { + type: 'object', + additionalProperties: { type: 'number' } + } + } + }, + error: { + type: 'string', + description: 'Error message (only available when status is failed)' + }, + workspaceId: { + type: 'number', + description: 'ID of the workspace the job belongs to' + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'Date and time when the job was created' + }, + groupNames: { + type: 'string', + description: 'Group names for the job' + }, + durationMs: { + type: 'number', + description: 'Duration of the job in milliseconds' + }, + completedAt: { + type: 'string', + format: 'date-time', + description: 'Date and time when the job was completed' + } + } + } + } + }) + async getBullJobs(@WorkspaceId() workspace_id: number): Promise<{ + jobId: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; + progress: number; + result?: CodingStatistics; + error?: string; + workspaceId?: number; + createdAt?: Date; + groupNames?: string; + durationMs?: number; + completedAt?: Date; }[]> { - return this.workspaceCodingService.getAllJobs(workspace_id); + return this.workspaceCodingService.getBullJobs(workspace_id); } @Get(':workspace_id/coding/groups') @@ -377,4 +486,226 @@ export class WorkspaceCodingController { async resumeJob(@Param('jobId') jobId: string): Promise<{ success: boolean; message: string }> { return this.workspaceCodingService.resumeJob(jobId); } + + @Get(':workspace_id/coding/missings-profiles') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiOkResponse({ + description: 'List of missings profiles retrieved successfully.', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + label: { + type: 'string', + description: 'Label of the missings profile' + } + } + } + } + }) + async getMissingsProfiles(@WorkspaceId() workspace_id: number): Promise<{ label: string }[]> { + return this.workspaceCodingService.getMissingsProfiles(workspace_id); + } + + @Post(':workspace_id/coding/codebook') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiBody({ + description: 'Codebook generation parameters', + schema: { + type: 'object', + properties: { + missingsProfile: { + type: 'string', + description: 'Name of the missings profile to use' + }, + contentOptions: { + type: 'object', + description: 'Options for codebook content generation', + properties: { + exportFormat: { type: 'string' }, + missingsProfile: { type: 'string' }, + hasOnlyManualCoding: { type: 'boolean' }, + hasGeneralInstructions: { type: 'boolean' }, + hasDerivedVars: { type: 'boolean' }, + hasOnlyVarsWithCodes: { type: 'boolean' }, + hasClosedVars: { type: 'boolean' }, + codeLabelToUpper: { type: 'boolean' }, + showScore: { type: 'boolean' }, + hideItemVarRelation: { type: 'boolean' } + } + }, + unitList: { + type: 'array', + items: { type: 'number' }, + description: 'List of unit IDs to include in the codebook' + } + }, + required: ['missingsProfile', 'contentOptions', 'unitList'] + } + }) + @ApiOkResponse({ + description: 'Codebook generated successfully.', + schema: { + type: 'string', + format: 'binary', + description: 'Generated codebook file' + } + }) + async generateCodebook( + @WorkspaceId() workspace_id: number, + @Body() body: { + missingsProfile: string; + contentOptions: { + exportFormat: string; + missingsProfile: string; + hasOnlyManualCoding: boolean; + hasGeneralInstructions: boolean; + hasDerivedVars: boolean; + hasOnlyVarsWithCodes: boolean; + hasClosedVars: boolean; + codeLabelToUpper: boolean; + showScore: boolean; + hideItemVarRelation: boolean; + }; + unitList: number[]; + }, + @Res() res: Response + ): Promise { + const { missingsProfile, contentOptions, unitList } = body; + + const codebook = await this.workspaceCodingService.generateCodebook( + workspace_id, + missingsProfile, + contentOptions, + unitList + ); + + if (!codebook) { + res.status(404).send('Failed to generate codebook'); + return; + } + + const contentType = contentOptions.exportFormat === 'docx' ? + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' : + 'application/json'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Disposition', `attachment; filename=codebook.${contentOptions.exportFormat.toLowerCase()}`); + res.send(codebook); + } + + @Get(':workspace_id/coding/variable-analysis') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiQuery({ + name: 'authToken', + required: true, + description: 'Authentication token for generating replay URLs', + type: String + }) + @ApiQuery({ + name: 'serverUrl', + required: false, + description: 'Server URL to use for generating links', + type: String + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number for pagination (default: 1)', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page (default: 100, max: 500)', + type: Number + }) + @ApiQuery({ + name: 'unitId', + required: false, + description: 'Filter by unit ID', + type: String + }) + @ApiQuery({ + name: 'variableId', + required: false, + description: 'Filter by variable ID', + type: String + }) + @ApiQuery({ + name: 'derivation', + required: false, + description: 'Filter by derivation type', + type: String + }) + @ApiOkResponse({ + description: 'Variable analysis data retrieved successfully.', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + replayUrl: { type: 'string', description: 'Link to the replay of unit with its responses' }, + unitId: { type: 'string', description: 'Unit ID' }, + variableId: { type: 'string', description: 'Variable ID' }, + derivation: { type: 'string', description: 'Derivation' }, + code: { type: 'string', description: 'Code' }, + description: { type: 'string', description: 'Description' }, + score: { type: 'number', description: 'Score' }, + occurrenceCount: { type: 'number', description: 'How often this unitId in combination with variableId with that code is in responses' }, + totalCount: { type: 'number', description: 'Total amount of that combination variableId and unit Id' }, + relativeOccurrence: { type: 'number', description: 'Relative occurrence (for bar chart)' } + } + } + }, + total: { type: 'number', description: 'Total number of items' }, + page: { type: 'number', description: 'Current page number' }, + limit: { type: 'number', description: 'Number of items per page' } + } + } + }) + async getVariableAnalysis( + @WorkspaceId() workspace_id: number, + @Query('authToken') authToken: string, + @Query('serverUrl') serverUrl?: string, + @Query('page') page: number = 1, + @Query('limit') limit: number = 100, + @Query('unitId') unitId?: string, + @Query('variableId') variableId?: string, + @Query('derivation') derivation?: string + ): Promise<{ + data: VariableAnalysisItemDto[]; + total: number; + page: number; + limit: number; + }> { + // Validate and sanitize pagination parameters + const validPage = Math.max(1, page); + const validLimit = Math.min(Math.max(1, limit), 500); // Set maximum limit to 500 + + if (unitId || variableId || derivation) { + console.log(`Applying filters - unitId: ${unitId || 'none'}, variableId: ${variableId || 'none'}, derivation: ${derivation || 'none'}`); + } + + return this.workspaceCodingService.getVariableAnalysis( + workspace_id, + authToken, + serverUrl, + validPage, + validLimit, + unitId, + variableId, + derivation + ); + } } diff --git a/apps/backend/src/app/admin/workspace/workspace-files.controller.ts b/apps/backend/src/app/admin/workspace/workspace-files.controller.ts index 566b65c63..c1b709afe 100644 --- a/apps/backend/src/app/admin/workspace/workspace-files.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-files.controller.ts @@ -12,6 +12,7 @@ import { } from '@nestjs/swagger'; import { FilesInterceptor } from '@nestjs/platform-express'; import { logger } from 'nx/src/utils/logger'; +import { VariableInfo } from '@iqb/responses'; import { FilesDto } from '../../../../../../api-dto/files/files.dto'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { WorkspaceGuard } from './workspace.guard'; @@ -652,4 +653,64 @@ export class WorkspaceFilesController { @Param('workspace_id') workspace_id: number): Promise { return this.workspaceFilesService.createDummyTestTakerFile(workspace_id); } + + @Get(':workspace_id/files/units-with-file-ids') + @ApiTags('admin workspace') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Get units with file IDs', description: 'Retrieves a list of units with file_type "Unit" and their file IDs' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiOkResponse({ + description: 'Units with file IDs retrieved successfully', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + unitId: { type: 'string' }, + fileName: { type: 'string' } + } + } + } + }) + @ApiBadRequestResponse({ + description: 'Failed to retrieve units with file IDs' + }) + async getUnitsWithFileIds( + @Param('workspace_id') workspace_id: number): Promise<{ unitId: string; fileName: string }[]> { + return this.workspaceFilesService.getUnitsWithFileIds(workspace_id); + } + + @Get(':workspace_id/files/variable-info/:scheme_file_id') + @ApiTags('admin workspace') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiOperation({ summary: 'Get variable info for scheme', description: 'Retrieves variable information from Unit files for a specific scheme file ID' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiParam({ name: 'scheme_file_id', type: String, description: 'ID of the scheme file' }) + @ApiOkResponse({ + description: 'Variable information retrieved successfully', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + alias: { type: 'string' }, + type: { type: 'string' }, + multiple: { type: 'boolean' }, + nullable: { type: 'boolean' }, + values: { type: 'array', items: { type: 'string' } }, + valuesComplete: { type: 'boolean' }, + page: { type: 'string' } + } + } + } + }) + @ApiBadRequestResponse({ + description: 'Failed to retrieve variable information' + }) + async getVariableInfoForScheme( + @Param('workspace_id') workspace_id: number, @Param('scheme_file_id') scheme_file_id: string + ): Promise { + return this.workspaceFilesService.getVariableInfoForScheme(workspace_id, scheme_file_id); + } } diff --git a/apps/backend/src/app/admin/workspace/workspace-player.controller.ts b/apps/backend/src/app/admin/workspace/workspace-player.controller.ts index 07340da5f..fc48fcf76 100644 --- a/apps/backend/src/app/admin/workspace/workspace-player.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-player.controller.ts @@ -67,7 +67,7 @@ export class WorkspacePlayerController { @ApiOperation({ summary: 'Get units from a booklet in order' }) @ApiResponse({ status: 200, - description: 'Returns an array of units from the booklet in the correct order', + description: 'Returns an array of units from the booklet in the correct order' }) async getBookletUnits( diff --git a/apps/backend/src/app/admin/workspace/workspace-users.controller.ts b/apps/backend/src/app/admin/workspace/workspace-users.controller.ts index 9089a5d24..5f11a34bb 100644 --- a/apps/backend/src/app/admin/workspace/workspace-users.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-users.controller.ts @@ -132,4 +132,45 @@ export class WorkspaceUsersController { @Param('workspaceId') workspaceId: number) { return this.workspaceUsersService.setWorkspaceUsers(workspaceId, userIds); } + + @Get(':workspace_id/coders') + @ApiTags('admin workspace users') + @ApiBearerAuth() + @ApiParam({ + name: 'workspace_id', + type: Number, + required: true, + description: 'Unique identifier for the workspace' + }) + @ApiOkResponse({ + description: 'List of coders (users with accessLevel 1) retrieved successfully', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/WorkspaceUser' } }, + total: { type: 'number' } + } + } + }) + @ApiNotFoundResponse({ + description: 'Workspace not found or no coders available' + }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async findCoders( + @Param('workspace_id') workspaceId: number + ): Promise<{ data: WorkspaceUser[]; total: number }> { + try { + const [coders, total] = await this.workspaceUsersService.findCoders(workspaceId); + return { + data: coders, + total + }; + } catch (error) { + logger.error(`Error retrieving coders for workspace ${workspaceId}`); + return { + data: [], + total: 0 + }; + } + } } diff --git a/apps/backend/src/app/app.module.ts b/apps/backend/src/app/app.module.ts index 0f0fea417..29446b601 100755 --- a/apps/backend/src/app/app.module.ts +++ b/apps/backend/src/app/app.module.ts @@ -5,13 +5,15 @@ import { AppController } from './app.controller'; import { AuthModule } from './auth/auth.module'; import { DatabaseModule } from './database/database.module'; import { AdminModule } from './admin/admin.module'; +import { JobQueueModule } from './job-queue/job-queue.module'; +import { HealthModule } from './health/health.module'; @Module({ imports: [ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env.dev', cache: true - }), AuthModule, DatabaseModule, AdminModule, HttpModule], + }), AuthModule, DatabaseModule, AdminModule, HttpModule, JobQueueModule, HealthModule], controllers: [AppController] }) export class AppModule {} diff --git a/apps/backend/src/app/database/database.module.ts b/apps/backend/src/app/database/database.module.ts index 62c5a3af2..73347c992 100755 --- a/apps/backend/src/app/database/database.module.ts +++ b/apps/backend/src/app/database/database.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { HttpModule } from '@nestjs/axios'; @@ -43,9 +43,23 @@ import { JobService } from './services/job.service'; import { ValidationTaskService } from './services/validation-task.service'; import { Job } from './entities/job.entity'; import { VariableAnalysisJob } from './entities/variable-analysis-job.entity'; -import { TestPersonCodingJob } from './entities/test-person-coding-job.entity'; import { ValidationTask } from './entities/validation-task.entity'; +import { Setting } from './entities/setting.entity'; +import { ReplayStatistics } from './entities/replay-statistics.entity'; +import { ReplayStatisticsService } from './services/replay-statistics.service'; +// eslint-disable-next-line import/no-cycle +import { JobQueueModule } from '../job-queue/job-queue.module'; +/** + * DatabaseModule provides database access and services for the application. + * + * Note: This module has a circular dependency with JobQueueModule because: + * - DatabaseModule exports WorkspaceCodingService which is used by JobQueueModule + * - DatabaseModule imports JobQueueModule for job queue functionality + * + * This circular dependency is resolved using forwardRef() both at the module level + * and at the injection point in the TestPersonCodingProcessor. + */ @Module({ imports: [ User, @@ -64,6 +78,7 @@ import { ValidationTask } from './entities/validation-task.entity'; ResourcePackage, WorkspaceUser, HttpModule, + forwardRef(() => JobQueueModule), TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ @@ -74,7 +89,7 @@ import { ValidationTask } from './entities/validation-task.entity'; password: configService.get('POSTGRES_PASSWORD'), database: configService.get('POSTGRES_DB'), entities: [BookletInfo, Booklet, Session, BookletLog, Unit, UnitLog, UnitLastState, ResponseEntity, - User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote, JournalEntry, Job, VariableAnalysisJob, TestPersonCodingJob, ValidationTask + User, Workspace, WorkspaceAdmin, FileUpload, WorkspaceUser, ResourcePackage, Logs, Persons, ChunkEntity, BookletLog, Session, UnitLog, UnitTag, UnitNote, JournalEntry, Job, VariableAnalysisJob, ValidationTask, Setting, ReplayStatistics ], synchronize: false }), @@ -103,8 +118,9 @@ import { ValidationTask } from './entities/validation-task.entity'; JournalEntry, Job, VariableAnalysisJob, - TestPersonCodingJob, - ValidationTask + ValidationTask, + Setting, + ReplayStatistics ]) ], providers: [ @@ -126,7 +142,8 @@ import { ValidationTask } from './entities/validation-task.entity'; JournalService, VariableAnalysisService, JobService, - ValidationTaskService + ValidationTaskService, + ReplayStatisticsService ], exports: [ User, @@ -154,7 +171,8 @@ import { ValidationTask } from './entities/validation-task.entity'; JournalService, VariableAnalysisService, JobService, - ValidationTaskService + ValidationTaskService, + ReplayStatisticsService ] }) export class DatabaseModule {} diff --git a/apps/backend/src/app/database/entities/file_upload.entity.ts b/apps/backend/src/app/database/entities/file_upload.entity.ts index bf29f305b..07b06b4cd 100755 --- a/apps/backend/src/app/database/entities/file_upload.entity.ts +++ b/apps/backend/src/app/database/entities/file_upload.entity.ts @@ -2,6 +2,15 @@ import { Column, Entity, Index, PrimaryColumn, Unique } from 'typeorm'; +export interface StructuredFileData { + extractedInfo?: { + [key: string]: unknown; + }; + metadata?: { + [key: string]: unknown; + }; +} + @Entity() @Unique('file_upload_id', ['file_id', 'workspace_id']) class FileUpload { @@ -30,6 +39,9 @@ class FileUpload { @Column({ type: 'varchar' }) data: string; + + @Column({ type: 'jsonb', nullable: true }) + structured_data: StructuredFileData; } export default FileUpload; diff --git a/apps/backend/src/app/database/entities/replay-statistics.entity.ts b/apps/backend/src/app/database/entities/replay-statistics.entity.ts new file mode 100644 index 000000000..1b16cf42d --- /dev/null +++ b/apps/backend/src/app/database/entities/replay-statistics.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn +} from 'typeorm'; + +/** + * Entity for storing replay statistics + * Records information about each replay session including timestamp, duration, and unit information + */ +@Entity() +export class ReplayStatistics { + @PrimaryGeneratedColumn() + id: number; + + @CreateDateColumn() + timestamp: Date; + + @Column({ type: 'int', nullable: false }) + workspace_id: number; + + @Column({ type: 'varchar', length: 255, nullable: false }) + unit_id: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + booklet_id: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + test_person_login: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + test_person_code: string; + + @Column({ type: 'int', nullable: false }) + duration_milliseconds: number; + + @Column({ type: 'varchar', length: 2000, nullable: true }) + replay_url: string; + + @Column({ type: 'boolean', default: true }) + success: boolean; + + @Column({ type: 'varchar', length: 2000, nullable: true }) + error_message: string; +} diff --git a/apps/backend/src/app/database/entities/setting.entity.ts b/apps/backend/src/app/database/entities/setting.entity.ts new file mode 100644 index 000000000..e7312e6dc --- /dev/null +++ b/apps/backend/src/app/database/entities/setting.entity.ts @@ -0,0 +1,17 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +/** + * Entity for storing application settings + * + * This entity uses a key-value pattern where: + * - key: A string that serves as the primary key (e.g., 'missings-profile-iqb-standard') + * - content: A string field that stores JSON data + */ +@Entity() +export class Setting { + @PrimaryColumn() + key: string; + + @Column('text') + content: string; +} diff --git a/apps/backend/src/app/database/entities/test-person-coding-job.entity.ts b/apps/backend/src/app/database/entities/test-person-coding-job.entity.ts index 095c0722e..b40089b6e 100644 --- a/apps/backend/src/app/database/entities/test-person-coding-job.entity.ts +++ b/apps/backend/src/app/database/entities/test-person-coding-job.entity.ts @@ -6,6 +6,10 @@ import { Job } from './job.entity'; /** * Entity for test person coding jobs + * + * NOTE: This entity is no longer actively used in the codebase as of 2025-07-28. + * Job management has been transitioned to use Bull queue directly. + * This entity is kept for database compatibility and historical data. */ @ChildEntity('test-person-coding') export class TestPersonCodingJob extends Job { diff --git a/apps/backend/src/app/database/services/booklet-info.service.ts b/apps/backend/src/app/database/services/booklet-info.service.ts index 31542409b..0b0f717a4 100644 --- a/apps/backend/src/app/database/services/booklet-info.service.ts +++ b/apps/backend/src/app/database/services/booklet-info.service.ts @@ -143,7 +143,7 @@ export class BookletInfoService { // If no units were found, the XML might be invalid if (units.length === 0) { - console.warn('Warning: No units found in booklet XML'); + // No units found in booklet XML } // Extract restrictions from Units.Restrictions @@ -169,7 +169,7 @@ export class BookletInfoService { return response; } catch (error) { - console.error('Error parsing booklet XML:', error); + // Error occurred while parsing booklet XML if (error instanceof Error) { throw new Error(`Failed to parse booklet XML: ${error.message}`); } else { diff --git a/apps/backend/src/app/database/services/replay-statistics.service.ts b/apps/backend/src/app/database/services/replay-statistics.service.ts new file mode 100644 index 000000000..1c307bc3f --- /dev/null +++ b/apps/backend/src/app/database/services/replay-statistics.service.ts @@ -0,0 +1,359 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReplayStatistics } from '../entities/replay-statistics.entity'; + +/** + * Service for managing replay statistics + * Provides methods for storing and retrieving replay statistics data + */ +@Injectable() +export class ReplayStatisticsService { + private readonly logger = new Logger(ReplayStatisticsService.name); + + constructor( + @InjectRepository(ReplayStatistics) + private replayStatisticsRepository: Repository + ) {} + + /** + * Store replay statistics data + * @param data Replay statistics data to store + * @returns The stored replay statistics entity + */ + async storeReplayStatistics(data: { + workspaceId: number; + unitId: string; + bookletId?: string; + testPersonLogin?: string; + testPersonCode?: string; + durationMilliseconds: number; + replayUrl?: string; + success?: boolean; + errorMessage?: string; + }): Promise { + try { + // Map camelCase properties to snake_case properties to match the entity + const mappedData = { + workspace_id: data.workspaceId, + unit_id: data.unitId, + booklet_id: data.bookletId, + test_person_login: data.testPersonLogin, + test_person_code: data.testPersonCode, + duration_milliseconds: data.durationMilliseconds, + replay_url: data.replayUrl, + success: data.success !== undefined ? data.success : true, + error_message: data.errorMessage + }; + + const replayStatistics = this.replayStatisticsRepository.create(mappedData); + return await this.replayStatisticsRepository.save(replayStatistics); + } catch (error) { + this.logger.error(`Error storing replay statistics: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Get replay statistics for a workspace + * @param workspaceId The ID of the workspace + * @returns Array of replay statistics for the workspace + */ + async getReplayStatistics(workspaceId: number): Promise { + try { + return await this.replayStatisticsRepository.find({ + where: { workspace_id: workspaceId }, + order: { timestamp: 'DESC' } + }); + } catch (error) { + this.logger.error(`Error retrieving replay statistics: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Get replay frequency by unit + * @param workspaceId The ID of the workspace + * @returns Object with unit IDs as keys and replay counts as values + */ + async getReplayFrequencyByUnit(workspaceId: number): Promise> { + try { + const statistics = await this.getReplayStatistics(workspaceId); + return statistics.reduce((acc, stat) => { + acc[stat.unit_id] = (acc[stat.unit_id] || 0) + 1; + return acc; + }, {} as Record); + } catch (error) { + this.logger.error(`Error calculating replay frequency: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Get replay duration statistics + * @param workspaceId The ID of the workspace + * @param unitId Optional unit ID to filter by + * @returns Object with min, max, average duration and distribution data + */ + async getReplayDurationStatistics( + workspaceId: number, + unitId?: string + ): Promise<{ + min: number; + max: number; + average: number; + distribution: Record; + unitAverages?: Record; + }> { + try { + const query = this.replayStatisticsRepository.createQueryBuilder('stats') + .where('stats.workspace_id = :workspaceId', { workspaceId }); + + if (unitId) { + query.andWhere('stats.unit_id = :unitId', { unitId }); + } + + const statistics = await query.getMany(); + + if (statistics.length === 0) { + return { + min: 0, + max: 0, + average: 0, + distribution: {}, + unitAverages: {} + }; + } + + // Calculate min, max, and average + const durations = statistics.map(stat => stat.duration_milliseconds); + const min = Math.min(...durations); + const max = Math.max(...durations); + const average = durations.reduce((sum, duration) => sum + duration, 0) / durations.length; + + // Create duration distribution (in 10-second buckets) + const distribution: Record = {}; + durations.forEach(duration => { + const bucket = Math.floor(duration / 10) * 10; + const bucketKey = `${bucket}-${bucket + 10}`; + distribution[bucketKey] = (distribution[bucketKey] || 0) + 1; + }); + + // Calculate average duration per unit if not filtering by unit + let unitAverages: Record | undefined; + if (!unitId) { + unitAverages = {}; + const unitDurations: Record = {}; + + statistics.forEach(stat => { + if (!unitDurations[stat.unit_id]) { + unitDurations[stat.unit_id] = []; + } + unitDurations[stat.unit_id].push(stat.duration_milliseconds); + }); + + Object.entries(unitDurations).forEach(([unitKey, durationArray]) => { + unitAverages![unitKey] = durationArray.reduce((sum, duration) => sum + duration, 0) / durationArray.length; + }); + } + + return { + min, + max, + average, + distribution, + unitAverages + }; + } catch (error) { + this.logger.error(`Error calculating duration statistics: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Get replay distribution by day + * @param workspaceId The ID of the workspace + * @returns Object with days as keys and replay counts as values + */ + async getReplayDistributionByDay(workspaceId: number): Promise> { + try { + const statistics = await this.getReplayStatistics(workspaceId); + + // Group replays by day (YYYY-MM-DD format) + return statistics.reduce((acc, stat) => { + // Format the date as YYYY-MM-DD + const day = stat.timestamp.toISOString().split('T')[0]; + acc[day] = (acc[day] || 0) + 1; + return acc; + }, {} as Record); + } catch (error) { + this.logger.error(`Error calculating replay distribution by day: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Get replay distribution by hour + * @param workspaceId The ID of the workspace + * @returns Object with hours (0-23) as keys and replay counts as values + */ + async getReplayDistributionByHour(workspaceId: number): Promise> { + try { + const statistics = await this.getReplayStatistics(workspaceId); + + // Initialize all hours with 0 count + const hourDistribution: Record = {}; + for (let i = 0; i < 24; i++) { + hourDistribution[i.toString()] = 0; + } + + // Count replays by hour + statistics.forEach(stat => { + const hour = stat.timestamp.getHours().toString(); + hourDistribution[hour] += 1; + }); + + return hourDistribution; + } catch (error) { + this.logger.error(`Error calculating replay distribution by hour: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Get replay error statistics + * @param workspaceId The ID of the workspace + * @returns Object with error statistics + */ + async getReplayErrorStatistics(workspaceId: number): Promise<{ + successRate: number; + totalReplays: number; + successfulReplays: number; + failedReplays: number; + commonErrors: Array<{ message: string; count: number }>; + }> { + try { + const statistics = await this.getReplayStatistics(workspaceId); + + if (statistics.length === 0) { + return { + successRate: 0, + totalReplays: 0, + successfulReplays: 0, + failedReplays: 0, + commonErrors: [] + }; + } + + const totalReplays = statistics.length; + const successfulReplays = statistics.filter(stat => stat.success).length; + const failedReplays = totalReplays - successfulReplays; + const successRate = (successfulReplays / totalReplays) * 100; + + // Count occurrences of each error message + const errorCounts: Record = {}; + statistics.forEach(stat => { + if (!stat.success && stat.error_message) { + errorCounts[stat.error_message] = (errorCounts[stat.error_message] || 0) + 1; + } + }); + + // Convert to array and sort by count (descending) + const commonErrors = Object.entries(errorCounts) + .map(([message, count]) => ({ message, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); // Get top 10 most common errors + + return { + successRate, + totalReplays, + successfulReplays, + failedReplays, + commonErrors + }; + } catch (error) { + this.logger.error(`Error calculating replay error statistics: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Get failure distribution by unit + * @param workspaceId The ID of the workspace + * @returns Object with units as keys and failure counts as values + */ + async getFailureDistributionByUnit(workspaceId: number): Promise> { + try { + const statistics = await this.getReplayStatistics(workspaceId); + + // Filter for failed replays only + const failedReplays = statistics.filter(stat => !stat.success); + + // Group failures by unit + return failedReplays.reduce((acc, stat) => { + const unitId = stat.unit_id; + acc[unitId] = (acc[unitId] || 0) + 1; + return acc; + }, {} as Record); + } catch (error) { + this.logger.error(`Error calculating failure distribution by unit: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Get failure distribution by day + * @param workspaceId The ID of the workspace + * @returns Object with days as keys and failure counts as values + */ + async getFailureDistributionByDay(workspaceId: number): Promise> { + try { + const statistics = await this.getReplayStatistics(workspaceId); + + // Filter for failed replays only + const failedReplays = statistics.filter(stat => !stat.success); + + // Group failures by day (YYYY-MM-DD format) + return failedReplays.reduce((acc, stat) => { + // Format the date as YYYY-MM-DD + const day = stat.timestamp.toISOString().split('T')[0]; + acc[day] = (acc[day] || 0) + 1; + return acc; + }, {} as Record); + } catch (error) { + this.logger.error(`Error calculating failure distribution by day: ${error.message}`, error.stack); + throw error; + } + } + + /** + * Get failure distribution by hour + * @param workspaceId The ID of the workspace + * @returns Object with hours (0-23) as keys and failure counts as values + */ + async getFailureDistributionByHour(workspaceId: number): Promise> { + try { + const statistics = await this.getReplayStatistics(workspaceId); + + // Filter for failed replays only + const failedReplays = statistics.filter(stat => !stat.success); + + // Initialize all hours with 0 count + const hourDistribution: Record = {}; + for (let i = 0; i < 24; i++) { + hourDistribution[i.toString()] = 0; + } + + // Count failures by hour + failedReplays.forEach(stat => { + const hour = stat.timestamp.getHours().toString(); + hourDistribution[hour] += 1; + }); + + return hourDistribution; + } catch (error) { + this.logger.error(`Error calculating failure distribution by hour: ${error.message}`, error.stack); + throw error; + } + } +} diff --git a/apps/backend/src/app/database/services/unit-info.service.ts b/apps/backend/src/app/database/services/unit-info.service.ts new file mode 100644 index 000000000..296676402 --- /dev/null +++ b/apps/backend/src/app/database/services/unit-info.service.ts @@ -0,0 +1,451 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as xml2js from 'xml2js'; +import { UnitInfoDto } from '../../../../../../api-dto/unit-info/unit-info.dto'; +import { UnitMetadataDto } from '../../../../../../api-dto/unit-info/unit-metadata.dto'; +import { UnitDefinitionDto } from '../../../../../../api-dto/unit-info/unit-definition.dto'; +import { UnitVariableDto } from '../../../../../../api-dto/unit-info/unit-variable.dto'; +import { UnitVariableValueDto } from '../../../../../../api-dto/unit-info/unit-variable-value.dto'; +import { UnitCodingSchemeRefDto } from '../../../../../../api-dto/unit-info/unit-coding-scheme-ref.dto'; +import { UnitDependencyDto } from '../../../../../../api-dto/unit-info/unit-dependency.dto'; +import FileUpload from '../entities/file_upload.entity'; + +// XML element interfaces for parsing +interface XmlAttributes { + [key: string]: string; +} + +interface XmlElement { + $?: XmlAttributes; + _?: string; // Element text content +} + +interface MetadataElement extends XmlElement { + Id?: string[]; + Label?: string[]; + Description?: string[]; + Transcript?: string[]; + Reference?: string[]; + Lastchange?: string[]; +} + +interface DefinitionElement extends XmlElement { + // $ contains player, editor, lastChange attributes +} + +interface CodingSchemeRefElement extends XmlElement { + // $ contains schemer, schemeType, lastChange attributes +} + +interface DependencyElement extends XmlElement { + // $ contains for attribute +} + +interface DependenciesElement extends XmlElement { + File?: DependencyElement[]; + file?: DependencyElement[]; // Deprecated lowercase version + Service?: DependencyElement[]; +} + +interface ValueElement extends XmlElement { + label?: string[]; + value?: string[]; +} + +interface ValuesElement extends XmlElement { + Value?: ValueElement[]; +} + +interface ValuePositionLabelsElement extends XmlElement { + ValuePositionLabel?: string[]; +} + +interface VariableElement extends XmlElement { + // $ contains id, alias, type, format, multiple, nullable, page attributes + Values?: ValuesElement[]; + ValuePositionLabels?: ValuePositionLabelsElement[]; +} + +interface BaseVariablesElement extends XmlElement { + Variable?: VariableElement[]; +} + +interface DerivedVariablesElement extends XmlElement { + Variable?: VariableElement[]; +} + +interface UnitElement extends XmlElement { + Metadata?: MetadataElement[]; + Definition?: DefinitionElement[]; + DefinitionRef?: DefinitionElement[]; + CodingSchemeRef?: CodingSchemeRefElement[]; + Dependencies?: DependenciesElement[]; + BaseVariables?: BaseVariablesElement[]; + DerivedVariables?: DerivedVariablesElement[]; +} + +@Injectable() +export class UnitInfoService { + constructor( + @InjectRepository(FileUpload) + private fileUploadRepository: Repository + ) {} + + async getUnitInfo(workspaceId: number, unitId: string): Promise { + const unitFile = await this.fileUploadRepository.findOne({ + where: { + workspace_id: workspaceId, + file_id: unitId + } + }); + + if (!unitFile) { + throw new Error(`Unit with ID ${unitId} not found in workspace ${workspaceId}`); + } + + const unitXml = unitFile.data; + return this.parseUnitXml(unitXml); + } + + /** + * Validate unit XML structure + * @param result Parsed XML result + */ + private validateUnitStructure(result: { Unit: UnitElement }): void { + if (!result || !result.Unit) { + throw new Error('Invalid unit XML: Missing Unit element'); + } + + if (!result.Unit.Metadata || !result.Unit.Metadata.length) { + throw new Error('Invalid unit XML: Missing Metadata element'); + } + + const metadataElement = result.Unit.Metadata[0]; + + if (!metadataElement.Id || !Array.isArray(metadataElement.Id) || metadataElement.Id.length === 0) { + throw new Error('Invalid unit XML: Missing required Id in Metadata'); + } + + if (!metadataElement.Label || !Array.isArray(metadataElement.Label) || metadataElement.Label.length === 0) { + throw new Error('Invalid unit XML: Missing required Label in Metadata'); + } + + // Validate definition (required by schema) + if (!result.Unit.Definition && !result.Unit.DefinitionRef) { + throw new Error('Invalid unit XML: Missing Definition or DefinitionRef element'); + } + } + + /** + * Validate definition element + * @param element Definition or DefinitionRef element + * @param elementType Type of element ('Definition' or 'DefinitionRef') + */ + private validateDefinitionElement(element: XmlElement, elementType: string): void { + if (!element.$ || !element.$.player) { + throw new Error(`Invalid unit XML: Missing required player attribute in ${elementType}`); + } + } + + /** + * Validate coding scheme reference element + * @param element CodingSchemeRef element + */ + private validateCodingSchemeElement(element: XmlElement): void { + if (!element.$ || !element.$.schemer) { + throw new Error('Invalid unit XML: Missing required schemer attribute in CodingSchemeRef'); + } + } + + private async parseUnitXml(unitXml: string): Promise { + // Validate input before parsing + if (!unitXml || typeof unitXml !== 'string') { + throw new Error('Invalid unit XML: XML data is empty or not a string'); + } + + const parser = new xml2js.Parser({ + explicitArray: true, // Always return arrays for elements that can occur multiple times + mergeAttrs: false, // Don't merge attributes into the element + attrkey: '$', // Use $ for attributes + charkey: '_' // Use _ for element text content + }); + + try { + const result = await parser.parseStringPromise(unitXml) as { Unit: UnitElement }; + + // Validate parsed result outside the try block + this.validateUnitStructure(result); + + const metadataElement = result.Unit.Metadata[0]; + + // Extract metadata + const metadata: UnitMetadataDto = { + id: metadataElement.Id[0] as string || '', + label: metadataElement.Label[0] as string || '', + description: metadataElement.Description && Array.isArray(metadataElement.Description) ? + metadataElement.Description[0] as string : undefined, + transcript: metadataElement.Transcript && Array.isArray(metadataElement.Transcript) ? + metadataElement.Transcript[0] as string : undefined, + reference: metadataElement.Reference && Array.isArray(metadataElement.Reference) ? + metadataElement.Reference[0] as string : undefined + }; + + // Extract lastChange from metadata attributes if present + if (metadataElement.$ && metadataElement.$.lastChange) { + metadata.lastChange = new Date(metadataElement.$.lastChange as string); + } else if (metadataElement.Lastchange && Array.isArray(metadataElement.Lastchange) && metadataElement.Lastchange.length > 0) { + // Handle deprecated Lastchange element + metadata.lastChange = new Date(metadataElement.Lastchange[0] as string); + } + + // Definition validation is now handled in validateUnitStructure + + let definition: UnitDefinitionDto; + if (result.Unit.Definition && Array.isArray(result.Unit.Definition) && result.Unit.Definition.length > 0) { + const definitionElement = result.Unit.Definition[0]; + // Validation moved to validateDefinitionElement method + this.validateDefinitionElement(definitionElement, 'Definition'); + + definition = { + type: 'Definition', + player: definitionElement.$.player as string, + editor: definitionElement.$.editor as string, + content: definitionElement._ as string || '' + }; + + if (definitionElement.$.lastChange) { + definition.lastChange = new Date(definitionElement.$.lastChange as string); + } + } else if (result.Unit.DefinitionRef && Array.isArray(result.Unit.DefinitionRef) && result.Unit.DefinitionRef.length > 0) { + const definitionRefElement = result.Unit.DefinitionRef[0]; + // Validation moved to validateDefinitionElement method + this.validateDefinitionElement(definitionRefElement, 'DefinitionRef'); + + definition = { + type: 'DefinitionRef', + player: definitionRefElement.$.player as string, + editor: definitionRefElement.$.editor as string, + content: definitionRefElement._ as string || '' + }; + + if (definitionRefElement.$.lastChange) { + definition.lastChange = new Date(definitionRefElement.$.lastChange as string); + } + } + + let codingSchemeRef: UnitCodingSchemeRefDto | undefined; + if (result.Unit.CodingSchemeRef && Array.isArray(result.Unit.CodingSchemeRef) && result.Unit.CodingSchemeRef.length > 0) { + const codingSchemeRefElement = result.Unit.CodingSchemeRef[0]; + // Validation moved to validateCodingSchemeElement method + this.validateCodingSchemeElement(codingSchemeRefElement); + + codingSchemeRef = { + content: codingSchemeRefElement._ as string || '', + schemer: codingSchemeRefElement.$.schemer as string, + schemeType: codingSchemeRefElement.$.schemeType as string + }; + + if (codingSchemeRefElement.$.lastChange) { + codingSchemeRef.lastChange = new Date(codingSchemeRefElement.$.lastChange as string); + } + } + + // Extract dependencies (optional) + const dependencies: UnitDependencyDto[] = []; + if (result.Unit.Dependencies && Array.isArray(result.Unit.Dependencies) && result.Unit.Dependencies.length > 0) { + const dependenciesElement = result.Unit.Dependencies[0] as DependenciesElement; + + // Process File dependencies + if (dependenciesElement.File && Array.isArray(dependenciesElement.File)) { + dependenciesElement.File.forEach((fileElement: DependencyElement) => { + const dependency: UnitDependencyDto = { + type: 'File', + content: fileElement._ as string || '', + for: (fileElement.$ && fileElement.$.for) ? + (fileElement.$.for as 'player' | 'editor' | 'schemer' | 'coder') : 'player' + }; + dependencies.push(dependency); + }); + } + + // Process deprecated 'file' dependencies (lowercase) + if (dependenciesElement.file && Array.isArray(dependenciesElement.file)) { + dependenciesElement.file.forEach((fileElement: DependencyElement) => { + const dependency: UnitDependencyDto = { + type: 'File', + content: fileElement._ as string || '', + for: (fileElement.$ && fileElement.$.for) ? + (fileElement.$.for as 'player' | 'editor' | 'schemer' | 'coder') : 'player' + }; + dependencies.push(dependency); + }); + } + + // Process Service dependencies + if (dependenciesElement.Service && Array.isArray(dependenciesElement.Service)) { + dependenciesElement.Service.forEach((serviceElement: DependencyElement) => { + const dependency: UnitDependencyDto = { + type: 'Service', + content: serviceElement._ as string || '', + for: (serviceElement.$ && serviceElement.$.for) ? + (serviceElement.$.for as 'player' | 'editor' | 'schemer' | 'coder') : 'player' + }; + dependencies.push(dependency); + }); + } + } + + // Extract base variables (optional) + const baseVariables: UnitVariableDto[] = []; + if (result.Unit.BaseVariables && Array.isArray(result.Unit.BaseVariables) && result.Unit.BaseVariables.length > 0) { + const baseVariablesElement = result.Unit.BaseVariables[0] as BaseVariablesElement; + if (baseVariablesElement.Variable && Array.isArray(baseVariablesElement.Variable)) { + baseVariablesElement.Variable.forEach((variableElement: VariableElement) => { + if (!variableElement.$ || !variableElement.$.id || !variableElement.$.type) { + return; // Skip invalid variables + } + + const variable: UnitVariableDto = { + id: variableElement.$.id as string, + alias: variableElement.$.alias as string, + type: variableElement.$.type as 'string' | 'integer' | 'number' | 'boolean' | 'attachment' | 'json' | 'no-value', + format: variableElement.$.format as string, + multiple: variableElement.$.multiple === 'true', + nullable: variableElement.$.nullable === 'true', + page: variableElement.$.page as string + }; + + // Extract values + if (variableElement.Values && Array.isArray(variableElement.Values) && variableElement.Values.length > 0) { + const valuesElement = variableElement.Values[0] as ValuesElement; + variable.valuesComplete = valuesElement.$ && valuesElement.$.complete === 'true'; + + if (valuesElement.Value && Array.isArray(valuesElement.Value)) { + variable.values = []; + valuesElement.Value.forEach((valueElement: ValueElement) => { + if (!valueElement.label || !valueElement.value || + !Array.isArray(valueElement.label) || !Array.isArray(valueElement.value)) { + return; // Skip invalid values + } + + const value: UnitVariableValueDto = { + label: valueElement.label[0] as string || '', + value: valueElement.value[0] as string || '' + }; + variable.values.push(value); + }); + } + } + + // Extract value position labels + if (variableElement.ValuePositionLabels && Array.isArray(variableElement.ValuePositionLabels) && + variableElement.ValuePositionLabels.length > 0) { + const valuePositionLabelsElement = variableElement.ValuePositionLabels[0] as ValuePositionLabelsElement; + if (valuePositionLabelsElement.ValuePositionLabel && + Array.isArray(valuePositionLabelsElement.ValuePositionLabel)) { + variable.valuePositionLabels = valuePositionLabelsElement.ValuePositionLabel.map( + (label: unknown) => label as string + ); + } + } + + baseVariables.push(variable); + }); + } + } + + // Extract derived variables (optional) + const derivedVariables: UnitVariableDto[] = []; + if (result.Unit.DerivedVariables && Array.isArray(result.Unit.DerivedVariables) && result.Unit.DerivedVariables.length > 0) { + const derivedVariablesElement = result.Unit.DerivedVariables[0] as DerivedVariablesElement; + if (derivedVariablesElement.Variable && Array.isArray(derivedVariablesElement.Variable)) { + derivedVariablesElement.Variable.forEach((variableElement: VariableElement) => { + if (!variableElement.$ || !variableElement.$.id || !variableElement.$.type) { + return; // Skip invalid variables + } + + const variable: UnitVariableDto = { + id: variableElement.$.id as string, + alias: variableElement.$.alias as string, + type: variableElement.$.type as 'string' | 'integer' | 'number' | 'boolean' | 'attachment' | 'json' | 'no-value', + format: variableElement.$.format as string, + multiple: variableElement.$.multiple === 'true', + nullable: variableElement.$.nullable === 'true', + page: variableElement.$.page as string + }; + + // Extract values + if (variableElement.Values && Array.isArray(variableElement.Values) && variableElement.Values.length > 0) { + const valuesElement = variableElement.Values[0] as ValuesElement; + variable.valuesComplete = valuesElement.$ && valuesElement.$.complete === 'true'; + + if (valuesElement.Value && Array.isArray(valuesElement.Value)) { + variable.values = []; + valuesElement.Value.forEach((valueElement: ValueElement) => { + if (!valueElement.label || !valueElement.value || + !Array.isArray(valueElement.label) || !Array.isArray(valueElement.value)) { + return; // Skip invalid values + } + + const value: UnitVariableValueDto = { + label: valueElement.label[0] as string || '', + value: valueElement.value[0] as string || '' + }; + variable.values.push(value); + }); + } + } + + // Extract value position labels + if (variableElement.ValuePositionLabels && Array.isArray(variableElement.ValuePositionLabels) && + variableElement.ValuePositionLabels.length > 0) { + const valuePositionLabelsElement = variableElement.ValuePositionLabels[0] as ValuePositionLabelsElement; + if (valuePositionLabelsElement.ValuePositionLabel && + Array.isArray(valuePositionLabelsElement.ValuePositionLabel)) { + variable.valuePositionLabels = valuePositionLabelsElement.ValuePositionLabel.map( + (label: unknown) => label as string + ); + } + } + + derivedVariables.push(variable); + }); + } + } + + // Build the response object + const response: UnitInfoDto = { + metadata, + definition, + rawXml: unitXml + }; + + // Add optional properties if they exist + if (codingSchemeRef) { + response.codingSchemeRef = codingSchemeRef; + } + + if (dependencies.length > 0) { + response.dependencies = dependencies; + } + + if (baseVariables.length > 0) { + response.baseVariables = baseVariables; + } + + if (derivedVariables.length > 0) { + response.derivedVariables = derivedVariables; + } + + return response; + } catch (error) { + // Error will be thrown and can be handled by the caller + if (error instanceof Error) { + throw new Error(`Failed to parse unit XML: ${error.message}`); + } else { + throw new Error('Failed to parse unit XML: Unknown error'); + } + } + } +} diff --git a/apps/backend/src/app/database/services/workspace-coding.service.ts b/apps/backend/src/app/database/services/workspace-coding.service.ts index 9db6f3f05..8ed343129 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -10,9 +10,21 @@ import Persons from '../entities/persons.entity'; import { Unit } from '../entities/unit.entity'; import { Booklet } from '../entities/booklet.entity'; import { ResponseEntity } from '../entities/response.entity'; -import { TestPersonCodingJob } from '../entities/test-person-coding-job.entity'; +import { Setting } from '../entities/setting.entity'; import { CodingStatistics, CodingStatisticsWithJob } from './shared-types'; import { extractVariableLocation } from '../../utils/voud/extractVariableLocation'; +import { CodebookGenerator } from '../../admin/code-book/codebook-generator.class'; +import { CodeBookContentSetting, UnitPropertiesForCodebook, Missing } from '../../admin/code-book/codebook.interfaces'; +import { MissingsProfilesDto } from '../../../../../../api-dto/coding/missings-profiles.dto'; +import { VariableAnalysisItemDto } from '../../../../../../api-dto/coding/variable-analysis-item.dto'; +import { JobQueueService } from '../../job-queue/job-queue.service'; + +interface CodedResponse { + id: number; + code?: string; + codedstatus?: string; + score?: number; +} @Injectable() export class WorkspaceCodingService { @@ -29,8 +41,9 @@ export class WorkspaceCodingService { private bookletRepository: Repository, @InjectRepository(ResponseEntity) private responseRepository: Repository, - @InjectRepository(TestPersonCodingJob) - private jobRepository: Repository + @InjectRepository(Setting) + private settingRepository: Repository, + private jobQueueService: JobQueueService ) {} private codingSchemeCache: Map = new Map(); @@ -159,7 +172,7 @@ export class WorkspaceCodingService { } } - private jobStatus: Map = new Map(); + // In-memory job status map removed as we now use only Bull for job management async getAllJobs(workspaceId?: number): Promise<{ jobId: string; @@ -173,317 +186,373 @@ export class WorkspaceCodingService { durationMs?: number; completedAt?: Date; }[]> { - const jobs: { - jobId: string; - status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; - progress: number; - result?: CodingStatistics; - error?: string; - workspaceId?: number; - createdAt?: Date; - groupNames?: string; - durationMs?: number; - completedAt?: Date; - }[] = []; - - // First get jobs from the in-memory map for backward compatibility - this.jobStatus.forEach((status, jobId) => { - // If workspaceId is provided, filter jobs by workspace - if (workspaceId !== undefined && status.workspaceId !== workspaceId) { - return; - } + // Use getBullJobs for all workspaces + if (workspaceId !== undefined) { + return this.getBullJobs(workspaceId); + } - jobs.push({ - jobId, - ...status - }); - }); + // If no workspaceId is provided, we need to get jobs for all workspaces + // Since we don't have a way to get all jobs from Bull without a workspaceId, + // we'll return an empty array for now + this.logger.warn('getAllJobs called without workspaceId, returning empty array'); + return []; + } + async getJobStatus(jobId: string): Promise<{ status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: CodingStatistics; error?: string } | null> { try { - const whereClause = workspaceId !== undefined ? { workspace_id: workspaceId } : {}; - const dbJobs = await this.jobRepository.find({ - where: whereClause, - order: { created_at: 'DESC' } - }); + // Get job from Bull queue + const bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); + if (bullJob) { + // Get job state and progress + const state = await bullJob.getState(); + const progress = await bullJob.progress() || 0; + + // Map Bull job state to our job status + let status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; + switch (state) { + case 'active': + status = 'processing'; + break; + case 'completed': + status = 'completed'; + break; + case 'failed': + status = 'failed'; + break; + case 'delayed': + case 'waiting': + status = 'pending'; + break; + case 'paused': + status = 'paused'; + break; + default: + status = 'pending'; + } - for (const job of dbJobs) { + // Get result from job return value if completed let result: CodingStatistics | undefined; - if (job.result) { - try { - result = JSON.parse(job.result) as CodingStatistics; - } catch (error) { - this.logger.error(`Error parsing job result: ${error.message}`, error.stack); - } - } + let error: string | undefined; - // Check if this is a TestPersonCodingJob to get additional fields - const isTestPersonCodingJob = job.type === 'test-person-coding'; + if (state === 'completed' && bullJob.returnvalue) { + result = bullJob.returnvalue as CodingStatistics; + } else if (state === 'failed' && bullJob.failedReason) { + error = bullJob.failedReason; + } - jobs.push({ - jobId: job.id.toString(), - status: job.status as 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused', - progress: job.progress || 0, + return { + status, + progress: typeof progress === 'number' ? progress : 0, result, - error: job.error, - workspaceId: job.workspace_id, - createdAt: job.created_at, - groupNames: isTestPersonCodingJob ? (job as TestPersonCodingJob).group_names : undefined, - durationMs: isTestPersonCodingJob ? (job as TestPersonCodingJob).duration_ms : undefined, - completedAt: job.status === 'completed' ? job.updated_at : undefined - }); + error + }; } + + return null; } catch (error) { - this.logger.error(`Error getting jobs from database: ${error.message}`, error.stack); + this.logger.error(`Error getting job status: ${error.message}`, error.stack); + return null; } - - // Sort jobs by creation date (newest first) - return jobs.sort((a, b) => { - if (!a.createdAt) return 1; - if (!b.createdAt) return -1; - return b.createdAt.getTime() - a.createdAt.getTime(); - }); } - private async processTestPersonsInBackground(jobId: number, workspace_id: number, personIds: string[]): Promise { + async cancelJob(jobId: string): Promise<{ success: boolean; message: string }> { try { - const job = await this.jobRepository.findOne({ where: { id: jobId } }); - if (!job) { - this.logger.error(`Job with ID ${jobId} not found`); - return; + const bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); + if (!bullJob) { + return { success: false, message: `Job with ID ${jobId} not found` }; } - job.status = 'processing'; - job.progress = 0; - await this.jobRepository.save(job); + // Check if job can be cancelled + const state = await bullJob.getState(); + if (state === 'completed' || state === 'failed') { + return { + success: false, + message: `Job with ID ${jobId} cannot be cancelled because it is already ${state}` + }; + } - const BATCH_SIZE = 500; - const totalPersons = personIds.length; - let processedPersons = 0; - const combinedResult: CodingStatistics = { totalResponses: 0, statusCounts: {} }; + // Cancel the job + const result = await this.jobQueueService.cancelTestPersonCodingJob(jobId); + if (result) { + this.logger.log(`Job ${jobId} has been cancelled successfully`); + return { success: true, message: `Job ${jobId} has been cancelled successfully` }; + } + return { success: false, message: `Failed to cancel job ${jobId}` }; + } catch (error) { + this.logger.error(`Error cancelling job: ${error.message}`, error.stack); + return { success: false, message: `Error cancelling job: ${error.message}` }; + } + } - this.logger.log(`Processing ${totalPersons} test persons in batches of ${BATCH_SIZE}`); + async deleteJob(jobId: string): Promise<{ success: boolean; message: string }> { + try { + const bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); + if (!bullJob) { + return { success: false, message: `Job with ID ${jobId} not found` }; + } + + // Delete the job + const result = await this.jobQueueService.deleteTestPersonCodingJob(jobId); + if (result) { + this.logger.log(`Job ${jobId} has been deleted successfully`); + return { success: true, message: `Job ${jobId} has been deleted successfully` }; + } + return { success: false, message: `Failed to delete job ${jobId}` }; + } catch (error) { + this.logger.error(`Error deleting job: ${error.message}`, error.stack); + return { success: false, message: `Error deleting job: ${error.message}` }; + } + } - // Process each batch sequentially - for (let i = 0; i < personIds.length; i += BATCH_SIZE) { - const currentJobStatus = await this.jobRepository.findOne({ where: { id: jobId } }); - if (!currentJobStatus || currentJobStatus.status === 'cancelled' || currentJobStatus.status === 'paused') { - this.logger.log(`Job ${jobId} was ${currentJobStatus ? currentJobStatus.status : 'cancelled'} before processing batch ${(i / BATCH_SIZE) + 1}`); - return; + private async isJobCancelled(jobId: string | number): Promise { + try { + // Check Redis queue + const bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId.toString()); + if (bullJob) { + // Check if job is paused via our custom isPaused property + if (bullJob.data.isPaused) { + return true; } - const batchPersonIds = personIds.slice(i, i + BATCH_SIZE); - const batchNumber = (i / BATCH_SIZE) + 1; - const totalBatches = Math.ceil(totalPersons / BATCH_SIZE); - this.logger.log(`Processing batch ${batchNumber} of ${totalBatches} (${batchPersonIds.length} persons)`); + // Also check Bull's native state + const state = await bullJob.getState(); + return state === 'paused'; + } + return false; + } catch (error) { + this.logger.error(`Error checking job cancellation or pause: ${error.message}`, error.stack); + return false; // Assume not cancelled or paused on error + } + } - // Capture the current processed count for this batch's progress calculation - const currentProcessedCount = processedPersons; + private async updateResponsesInDatabase( + allCodedResponses: CodedResponse[], + queryRunner: import('typeorm').QueryRunner, + jobId?: string, + progressCallback?: (progress: number) => void, + metrics?: { [key: string]: number } + ): Promise { + if (allCodedResponses.length === 0) { + await queryRunner.release(); + return true; + } - const batchResult = await this.processTestPersonsBatch(workspace_id, batchPersonIds, async progress => { - // Calculate overall progress based on completed batches and current batch progress - const overallProgress = Math.min( - Math.floor(((currentProcessedCount + (batchPersonIds.length * (progress / 100))) / totalPersons) * 100), - 99 // Cap at 99% until fully complete - ); + const updateStart = Date.now(); + try { + const updateBatchSize = 500; + const batches = []; + for (let i = 0; i < allCodedResponses.length; i += updateBatchSize) { + batches.push(allCodedResponses.slice(i, i + updateBatchSize)); + } - // Update job progress - try { - const currentJob = await this.jobRepository.findOne({ where: { id: jobId } }); - if (!currentJob) { - this.logger.error(`Job with ID ${jobId} not found when updating progress`); - return; - } + this.logger.log(`Starte die Aktualisierung von ${allCodedResponses.length} Responses in ${batches.length} Batches (sequential).`); - if (currentJob.status === 'cancelled' || currentJob.status === 'paused') { - return; - } + for (let index = 0; index < batches.length; index++) { + const batch = batches[index]; + this.logger.log(`Starte Aktualisierung für Batch #${index + 1} (Größe: ${batch.length}).`); - // Update progress - currentJob.progress = overallProgress; - await this.jobRepository.save(currentJob); - } catch (error) { - this.logger.error(`Error updating job progress: ${error.message}`, error.stack); - } - }, jobId.toString()); + // Check for cancellation or pause before updating batch + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused before updating batch #${index + 1}`); + await queryRunner.rollbackTransaction(); + await queryRunner.release(); + return false; + } - // Merge batch results into combined results - combinedResult.totalResponses += batchResult.totalResponses; + try { + if (batch.length > 0) { + const updatePromises = batch.map(response => queryRunner.manager.update( + ResponseEntity, + response.id, + { + code: response.code, + codedstatus: response.codedstatus, + score: response.score + } + )); - // Merge status counts - Object.entries(batchResult.statusCounts).forEach(([status, count]) => { - if (!combinedResult.statusCounts[status]) { - combinedResult.statusCounts[status] = 0; + await Promise.all(updatePromises); } - combinedResult.statusCounts[status] += count; - }); - // Update processed count - processedPersons += batchPersonIds.length; + this.logger.log(`Batch #${index + 1} (Größe: ${batch.length}) erfolgreich aktualisiert.`); - // Force garbage collection between batches if available - if (global.gc) { - this.logger.log('Forcing garbage collection between batches'); - global.gc(); + // Update progress during batch updates + if (progressCallback) { + const batchProgress = 95 + (5 * ((index + 1) / batches.length)); + progressCallback(Math.round(Math.min(batchProgress, 99))); // Cap at 99% until fully complete and round to integer + } + } catch (error) { + this.logger.error(`Fehler beim Aktualisieren von Batch #${index + 1} (Größe: ${batch.length}):`, error.message); + // Rollback transaction on error + await queryRunner.rollbackTransaction(); + await queryRunner.release(); + throw error; } } - // Use the combined result from all batches + // Commit transaction if all updates were successful + await queryRunner.commitTransaction(); + this.logger.log(`${allCodedResponses.length} Responses wurden erfolgreich aktualisiert.`); - // Check if job was cancelled during processing - const currentJob = await this.jobRepository.findOne({ where: { id: jobId } }); - if (!currentJob) { - this.logger.error(`Job with ID ${jobId} not found when checking cancellation`); - return; + if (metrics) { + metrics.update = Date.now() - updateStart; } - if (currentJob.status === 'cancelled' || currentJob.status === 'paused') { - this.logger.log(`Background job ${jobId} was ${currentJob.status}`); - return; + // Always release the query runner + await queryRunner.release(); + return true; + } catch (error) { + this.logger.error('Fehler beim Aktualisieren der Responses:', error.message); + // Ensure transaction is rolled back on error + try { + await queryRunner.rollbackTransaction(); + } catch (rollbackError) { + this.logger.error('Fehler beim Rollback der Transaktion:', rollbackError.message); } + // Always release the query runner + await queryRunner.release(); + return false; + } + } - // Update job status to completed with result - currentJob.status = 'completed'; - currentJob.progress = 100; - currentJob.result = JSON.stringify(combinedResult); + private async processAndCodeResponses( + units: Unit[], + unitToResponsesMap: Map, + unitToCodingSchemeRefMap: Map, + fileIdToCodingSchemeMap: Map, + allResponses: ResponseEntity[], + statistics: CodingStatistics, + jobId?: string, + queryRunner?: import('typeorm').QueryRunner, + progressCallback?: (progress: number) => void + ): Promise<{ allCodedResponses: CodedResponse[]; statistics: CodingStatistics }> { + const allCodedResponses = []; + const estimatedResponseCount = allResponses.length; + allCodedResponses.length = estimatedResponseCount; + let responseIndex = 0; + const batchSize = 50; + const emptyScheme = new Autocoder.CodingScheme({}); - // Calculate and store job duration if it's a TestPersonCodingJob - if (currentJob.type === 'test-person-coding' && currentJob.created_at) { - const durationMs = Date.now() - currentJob.created_at.getTime(); - (currentJob as TestPersonCodingJob).duration_ms = durationMs; - this.logger.log(`Job ${jobId} completed in ${durationMs}ms`); - } + for (let i = 0; i < units.length; i += batchSize) { + const unitBatch = units.slice(i, i + batchSize); - await this.jobRepository.save(currentJob); - this.statisticsCache.delete(workspace_id); - this.logger.log(`Invalidated coding statistics cache for workspace ${workspace_id}`); + for (const unit of unitBatch) { + const responses = unitToResponsesMap.get(unit.id) || []; + if (responses.length === 0) continue; - this.logger.log(`Background job ${jobId} completed successfully`); - } catch (error) { - try { - // Get the job from the database - const job = await this.jobRepository.findOne({ where: { id: jobId } }); - if (!job) { - this.logger.error(`Job with ID ${jobId} not found when handling error`); - return; - } + statistics.totalResponses += responses.length; - // Don't update if job has been cancelled or paused - if (job.status === 'cancelled' || job.status === 'paused') { - this.logger.log(`Background job ${jobId} was ${job.status}`); - return; - } + const codingSchemeRef = unitToCodingSchemeRefMap.get(unit.id); + const scheme = codingSchemeRef ? + (fileIdToCodingSchemeMap.get(codingSchemeRef) || emptyScheme) : + emptyScheme; - // Update job status to failed with error - job.status = 'failed'; - job.progress = 0; - job.error = error.message; - await this.jobRepository.save(job); - } catch (innerError) { - this.logger.error(`Error updating job status: ${innerError.message}`, innerError.stack); - } + for (const response of responses) { + const codedResult = scheme.code([{ + id: response.variableid, + value: response.value, + status: response.status as ResponseStatusType + }]); - this.logger.error(`Background job ${jobId} failed: ${error.message}`, error.stack); - } - } - - async getJobStatus(jobId: string): Promise<{ status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; progress: number; result?: CodingStatistics; error?: string } | null> { - try { - // First check the in-memory job status map for backward compatibility - const inMemoryStatus = this.jobStatus.get(jobId); - if (inMemoryStatus) { - return inMemoryStatus; - } + const codedStatus = codedResult[0]?.status; + if (!statistics.statusCounts[codedStatus]) { + statistics.statusCounts[codedStatus] = 0; + } + statistics.statusCounts[codedStatus] += 1; - // If not found in memory, check the database - const job = await this.jobRepository.findOne({ where: { id: parseInt(jobId, 10) } }); - if (!job) { - return null; + allCodedResponses[responseIndex] = { + id: response.id, + code: codedResult[0]?.code, + codedstatus: codedStatus, + score: codedResult[0]?.score + }; + responseIndex += 1; + } } - // Parse the result if it exists - let result: CodingStatistics | undefined; - if (job.result) { - try { - result = JSON.parse(job.result) as CodingStatistics; - } catch (error) { - this.logger.error(`Error parsing job result: ${error.message}`, error.stack); + // Check for cancellation or pause during response processing + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused during response processing`); + if (queryRunner) { + await queryRunner.release(); } + return { allCodedResponses, statistics }; } + } - return { - status: job.status as 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused', - progress: job.progress || 0, - result, - error: job.error - }; - } catch (error) { - this.logger.error(`Error getting job status: ${error.message}`, error.stack); - return null; + allCodedResponses.length = responseIndex; + + // Report progress after processing + if (progressCallback) { + progressCallback(95); } - } - async cancelJob(jobId: string): Promise<{ success: boolean; message: string }> { - try { - // First check the in-memory job status map for backward compatibility - const inMemoryJob = this.jobStatus.get(jobId); - if (inMemoryJob) { - // Only pending or processing jobs can be cancelled - if (inMemoryJob.status !== 'pending' && inMemoryJob.status !== 'processing') { - return { - success: false, - message: `Job with ID ${jobId} cannot be cancelled because it is already ${inMemoryJob.status}` - }; - } + return { allCodedResponses, statistics }; + } - // Update job status to cancelled - this.jobStatus.set(jobId, { ...inMemoryJob, status: 'cancelled' }); - this.logger.log(`In-memory job ${jobId} has been cancelled`); + private async getCodingSchemeFiles( + codingSchemeRefs: Set, + jobId?: string, + queryRunner?: import('typeorm').QueryRunner + ): Promise> { + // Use cache for coding schemes + const fileIdToCodingSchemeMap = await this.getCodingSchemesWithCache([...codingSchemeRefs]); - return { success: true, message: `Job ${jobId} has been cancelled successfully` }; + // Check for cancellation or pause + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused after getting coding scheme files`); + if (queryRunner) { + await queryRunner.release(); } + return fileIdToCodingSchemeMap; + } - // If not found in memory, check the database - const job = await this.jobRepository.findOne({ where: { id: parseInt(jobId, 10) } }); - if (!job) { - return { success: false, message: `Job with ID ${jobId} not found` }; - } + return fileIdToCodingSchemeMap; + } - // Only pending or processing jobs can be cancelled - if (job.status !== 'pending' && job.status !== 'processing') { - return { - success: false, - message: `Job with ID ${jobId} cannot be cancelled because it is already ${job.status}` - }; - } + private async extractCodingSchemeReferences( + units: Unit[], + fileIdToTestFileMap: Map, + jobId?: string, + queryRunner?: import('typeorm').QueryRunner + ): Promise<{ codingSchemeRefs: Set; unitToCodingSchemeRefMap: Map }> { + const codingSchemeRefs = new Set(); + const unitToCodingSchemeRefMap = new Map(); + const batchSize = 50; - // Update job status to cancelled - job.status = 'cancelled'; - await this.jobRepository.save(job); - this.logger.log(`Job ${jobId} has been cancelled`); + for (let i = 0; i < units.length; i += batchSize) { + const unitBatch = units.slice(i, i + batchSize); - return { success: true, message: `Job ${jobId} has been cancelled successfully` }; - } catch (error) { - this.logger.error(`Error cancelling job: ${error.message}`, error.stack); - return { success: false, message: `Error cancelling job: ${error.message}` }; - } - } + for (const unit of unitBatch) { + const testFile = fileIdToTestFileMap.get(unit.alias.toUpperCase()); + if (!testFile) continue; - private async isJobCancelled(jobId: string | number): Promise { - try { - const inMemoryStatus = this.jobStatus.get(jobId.toString()); - if (inMemoryStatus && (inMemoryStatus.status === 'cancelled' || inMemoryStatus.status === 'paused')) { - return true; + try { + const $ = cheerio.load(testFile.data); + const codingSchemeRefText = $('codingSchemeRef').text(); + if (codingSchemeRefText) { + codingSchemeRefs.add(codingSchemeRefText.toUpperCase()); + unitToCodingSchemeRefMap.set(unit.id, codingSchemeRefText.toUpperCase()); + } + } catch (error) { + this.logger.error(`--- Fehler beim Verarbeiten der Datei ${testFile.filename}: ${error.message}`); + } } - const job = await this.jobRepository.findOne({ where: { id: Number(jobId) } }); - return job && (job.status === 'cancelled' || job.status === 'paused'); - } catch (error) { - this.logger.error(`Error checking job cancellation or pause: ${error.message}`, error.stack); - return false; // Assume not cancelled or paused on error + // Check for cancellation or pause during scheme extraction + if (jobId && await this.isJobCancelled(jobId)) { + this.logger.log(`Job ${jobId} was cancelled or paused during scheme extraction`); + if (queryRunner) { + await queryRunner.release(); + } + return { codingSchemeRefs, unitToCodingSchemeRefMap }; + } } + + return { codingSchemeRefs, unitToCodingSchemeRefMap }; } - private async processTestPersonsBatch( + async processTestPersonsBatch( workspace_id: number, personIds: string[], progressCallback?: (progress: number) => void, @@ -495,13 +564,11 @@ export class WorkspaceCodingService { const startTime = Date.now(); const metrics: { [key: string]: number } = {}; - // Initialize statistics const statistics: CodingStatistics = { totalResponses: 0, statusCounts: {} }; - // Report initial progress if (progressCallback) { progressCallback(0); } @@ -693,35 +760,12 @@ export class WorkspaceCodingService { // Step 8: Extract coding scheme references - 80% progress const schemeExtractStart = Date.now(); - const codingSchemeRefs = new Set(); - const unitToCodingSchemeRefMap = new Map(); - const batchSize = 50; - for (let i = 0; i < units.length; i += batchSize) { - const unitBatch = units.slice(i, i + batchSize); - - for (const unit of unitBatch) { - const testFile = fileIdToTestFileMap.get(unit.alias.toUpperCase()); - if (!testFile) continue; - - try { - const $ = cheerio.load(testFile.data); - const codingSchemeRefText = $('codingSchemeRef').text(); - if (codingSchemeRefText) { - codingSchemeRefs.add(codingSchemeRefText.toUpperCase()); - unitToCodingSchemeRefMap.set(unit.id, codingSchemeRefText.toUpperCase()); - } - } catch (error) { - this.logger.error(`--- Fehler beim Verarbeiten der Datei ${testFile.filename}: ${error.message}`); - } - } - - // Check for cancellation or pause during scheme extraction - if (jobId && await this.isJobCancelled(jobId)) { - this.logger.log(`Job ${jobId} was cancelled or paused during scheme extraction`); - await queryRunner.release(); - return statistics; - } - } + const { codingSchemeRefs, unitToCodingSchemeRefMap } = await this.extractCodingSchemeReferences( + units, + fileIdToTestFileMap, + jobId, + queryRunner + ); metrics.schemeExtract = Date.now() - schemeExtractStart; // Report progress after step 8 @@ -738,8 +782,11 @@ export class WorkspaceCodingService { // Step 9: Get coding scheme files - 85% progress const schemeQueryStart = Date.now(); - // Use cache for coding schemes - const fileIdToCodingSchemeMap = await this.getCodingSchemesWithCache([...codingSchemeRefs]); + const fileIdToCodingSchemeMap = await this.getCodingSchemeFiles( + codingSchemeRefs, + jobId, + queryRunner + ); metrics.schemeQuery = Date.now() - schemeQueryStart; // No separate parsing step needed as it's handled by the cache helper metrics.schemeParsing = 0; @@ -749,16 +796,6 @@ export class WorkspaceCodingService { progressCallback(85); } - // Check for cancellation or pause after step 9 - if (jobId && await this.isJobCancelled(jobId)) { - this.logger.log(`Job ${jobId} was cancelled or paused after getting coding scheme files`); - await queryRunner.release(); - return statistics; - } - - // Skip to step 11 (step 10 is now part of getCodingSchemesWithCache) - const emptyScheme = new Autocoder.CodingScheme({}); - // Report progress after step 10 if (progressCallback) { progressCallback(90); @@ -774,64 +811,20 @@ export class WorkspaceCodingService { // Step 11: Process and code responses - 95% progress const processingStart = Date.now(); - const allCodedResponses = []; - const estimatedResponseCount = allResponses.length; - allCodedResponses.length = estimatedResponseCount; - let responseIndex = 0; - - for (let i = 0; i < units.length; i += batchSize) { - const unitBatch = units.slice(i, i + batchSize); - - for (const unit of unitBatch) { - const responses = unitToResponsesMap.get(unit.id) || []; - if (responses.length === 0) continue; - - statistics.totalResponses += responses.length; - - const codingSchemeRef = unitToCodingSchemeRefMap.get(unit.id); - const scheme = codingSchemeRef ? - (fileIdToCodingSchemeMap.get(codingSchemeRef) || emptyScheme) : - emptyScheme; - - for (const response of responses) { - const codedResult = scheme.code([{ - id: response.variableid, - value: response.value, - status: response.status as ResponseStatusType - }]); - - const codedStatus = codedResult[0]?.status; - if (!statistics.statusCounts[codedStatus]) { - statistics.statusCounts[codedStatus] = 0; - } - statistics.statusCounts[codedStatus] += 1; - - allCodedResponses[responseIndex] = { - id: response.id, - code: codedResult[0]?.code, - codedstatus: codedStatus, - score: codedResult[0]?.score - }; - responseIndex += 1; - } - } - - // Check for cancellation or pause during response processing - if (jobId && await this.isJobCancelled(jobId)) { - this.logger.log(`Job ${jobId} was cancelled or paused during response processing`); - await queryRunner.release(); - return statistics; - } - } + const { allCodedResponses } = await this.processAndCodeResponses( + units, + unitToResponsesMap, + unitToCodingSchemeRefMap, + fileIdToCodingSchemeMap, + allResponses, + statistics, + jobId, + queryRunner, + progressCallback + ); - allCodedResponses.length = responseIndex; metrics.processing = Date.now() - processingStart; - // Report progress after step 11 - if (progressCallback) { - progressCallback(95); - } - // Check for cancellation or pause after step 11 if (jobId && await this.isJobCancelled(jobId)) { this.logger.log(`Job ${jobId} was cancelled or paused after processing responses`); @@ -840,79 +833,17 @@ export class WorkspaceCodingService { } // Step 12: Update responses in database - 100% progress - if (allCodedResponses.length > 0) { - const updateStart = Date.now(); - try { - const updateBatchSize = 500; - const batches = []; - for (let i = 0; i < allCodedResponses.length; i += updateBatchSize) { - batches.push(allCodedResponses.slice(i, i + updateBatchSize)); - } - - this.logger.log(`Starte die Aktualisierung von ${allCodedResponses.length} Responses in ${batches.length} Batches (sequential).`); - - for (let index = 0; index < batches.length; index++) { - const batch = batches[index]; - this.logger.log(`Starte Aktualisierung für Batch #${index + 1} (Größe: ${batch.length}).`); - - // Check for cancellation or pause before updating batch - if (jobId && await this.isJobCancelled(jobId)) { - this.logger.log(`Job ${jobId} was cancelled or paused before updating batch #${index + 1}`); - await queryRunner.rollbackTransaction(); - await queryRunner.release(); - return statistics; - } - - try { - if (batch.length > 0) { - const updatePromises = batch.map(response => queryRunner.manager.update( - ResponseEntity, - response.id, - { - code: response.code, - codedstatus: response.codedstatus, - score: response.score - } - )); - - await Promise.all(updatePromises); - } - - this.logger.log(`Batch #${index + 1} (Größe: ${batch.length}) erfolgreich aktualisiert.`); - - // Update progress during batch updates - if (progressCallback) { - const batchProgress = 95 + (5 * ((index + 1) / batches.length)); - progressCallback(Math.round(Math.min(batchProgress, 99))); // Cap at 99% until fully complete and round to integer - } - } catch (error) { - this.logger.error(`Fehler beim Aktualisieren von Batch #${index + 1} (Größe: ${batch.length}):`, error.message); - // Rollback transaction on error - await queryRunner.rollbackTransaction(); - await queryRunner.release(); - throw error; - } - } + const updateSuccess = await this.updateResponsesInDatabase( + allCodedResponses, + queryRunner, + jobId, + progressCallback, + metrics + ); - // Commit transaction if all updates were successful - await queryRunner.commitTransaction(); - this.logger.log(`${allCodedResponses.length} Responses wurden erfolgreich aktualisiert.`); - } catch (error) { - this.logger.error('Fehler beim Aktualisieren der Responses:', error.message); - // Ensure transaction is rolled back on error - try { - await queryRunner.rollbackTransaction(); - } catch (rollbackError) { - this.logger.error('Fehler beim Rollback der Transaktion:', rollbackError.message); - } - } finally { - // Always release the query runner - await queryRunner.release(); - } - metrics.update = Date.now() - updateStart; - } else { - // Release query runner if no updates were performed - await queryRunner.release(); + if (!updateSuccess) { + // If update failed, return early + return statistics; } // Report completion @@ -1013,25 +944,20 @@ export class WorkspaceCodingService { // Always process as a job, regardless of the number of test persons this.logger.log(`Starting job for ${personIds.length} test persons in workspace ${workspace_id}`); - const job = this.jobRepository.create({ - workspace_id, - person_ids: personIds.join(','), - status: 'pending', - progress: 0, - // Store group names if groups were provided (not person IDs) - group_names: !areAllNumbers ? groupsOrIds.join(',') : undefined + // Add the job to the Redis queue + const bullJob = await this.jobQueueService.addTestPersonCodingJob({ + workspaceId: workspace_id, + personIds, + groupNames: !areAllNumbers ? groupsOrIds.join(',') : undefined }); - const savedJob = await this.jobRepository.save(job); - this.logger.log(`Created test person coding job with ID ${savedJob.id}`); - - this.processTestPersonsInBackground(savedJob.id, workspace_id, personIds); + this.logger.log(`Added job to Redis queue with ID ${bullJob.id}`); return { totalResponses: 0, statusCounts: {}, - jobId: savedJob.id.toString(), - message: `Processing ${personIds.length} test persons in the background. Check job status with jobId: ${savedJob.id}` + jobId: bullJob.id.toString(), + message: `Processing ${personIds.length} test persons in the background. Check job status with jobId: ${bullJob.id}` }; } @@ -1425,48 +1351,359 @@ export class WorkspaceCodingService { } /** - * Pause a running job - * @param jobId Job ID to pause - * @returns Object with success flag and message + * Get all missings profiles + * @param workspaceId Workspace ID (not used, profiles are global) + * @returns Array of missings profiles with labels */ - async pauseJob(jobId: string): Promise<{ success: boolean; message: string }> { + async getMissingsProfiles(workspaceId: number): Promise<{ label: string }[]> { try { - // First check the in-memory job status map for backward compatibility - const inMemoryJob = this.jobStatus.get(jobId); - if (inMemoryJob) { - // Only processing jobs can be paused - if (inMemoryJob.status !== 'processing') { - return { - success: false, - message: `Job with ID ${jobId} cannot be paused because it is ${inMemoryJob.status}` - }; + this.logger.log(`Getting missings profiles for workspace ${workspaceId}`); + + // Get the setting with key 'missings-profile-iqb-standard' + const setting = await this.settingRepository.findOne({ + where: { key: 'missings-profile-iqb-standard' } + }); + + if (!setting) { + // If no profiles exist yet, create a default one + const defaultProfiles = this.createDefaultMissingsProfiles(); + await this.saveMissingsProfiles(defaultProfiles); + + // Return just the labels + return defaultProfiles.map(profile => ({ label: profile.label })); + } + + // Parse the profiles from the setting content + try { + const profiles: MissingsProfilesDto[] = JSON.parse(setting.content); + return profiles.map(profile => ({ label: profile.label })); + } catch (parseError) { + this.logger.error(`Error parsing missings profiles: ${parseError.message}`, parseError.stack); + return []; + } + } catch (error) { + this.logger.error(`Error getting missings profiles for workspace ${workspaceId}: ${error.message}`, error.stack); + return []; + } + } + + private async getMissingsProfileByLabel(label: string): Promise { + try { + // Get the setting with key 'missings-profile-iqb-standard' + const setting = await this.settingRepository.findOne({ + where: { key: 'missings-profile-iqb-standard' } + }); + + if (!setting) { + return null; + } + + // Parse the profiles from the setting content + try { + const profiles: MissingsProfilesDto[] = JSON.parse(setting.content); + const profile = profiles.find(p => p.label === label); + return profile || null; + } catch (parseError) { + this.logger.error(`Error parsing missings profiles: ${parseError.message}`, parseError.stack); + return null; + } + } catch (error) { + this.logger.error(`Error getting missings profile by label: ${error.message}`, error.stack); + return null; + } + } + + /** + * Create default missings profiles + * @returns Array of default missings profiles + */ + private createDefaultMissingsProfiles(): MissingsProfilesDto[] { + // Create default profiles + const defaultProfile = new MissingsProfilesDto(); + defaultProfile.label = 'Default'; + defaultProfile.setMissings([ + { + id: 'missing', + label: 'Missing', + description: 'Value is missing', + code: 999 + } + ]); + + const standardProfile = new MissingsProfilesDto(); + standardProfile.label = 'Standard'; + standardProfile.setMissings([ + { + id: 'missing', + label: 'Missing', + description: 'Value is missing', + code: 999 + }, + { + id: 'not-reached', + label: 'Not Reached', + description: 'Item was not reached by the test taker', + code: 998 + } + ]); + + const extendedProfile = new MissingsProfilesDto(); + extendedProfile.label = 'Extended'; + extendedProfile.setMissings([ + { + id: 'missing', + label: 'Missing', + description: 'Value is missing', + code: 999 + }, + { + id: 'not-reached', + label: 'Not Reached', + description: 'Item was not reached by the test taker', + code: 998 + }, + { + id: 'not-applicable', + label: 'Not Applicable', + description: 'Item is not applicable for this test taker', + code: 997 + }, + { + id: 'invalid', + label: 'Invalid', + description: 'Response is invalid', + code: 996 + } + ]); + + return [defaultProfile, standardProfile, extendedProfile]; + } + + private async saveMissingsProfiles(profiles: MissingsProfilesDto[]): Promise { + try { + // Create or update the setting + let setting = await this.settingRepository.findOne({ + where: { key: 'missings-profile-iqb-standard' } + }); + + if (!setting) { + setting = new Setting(); + setting.key = 'missings-profile-iqb-standard'; + } + + setting.content = JSON.stringify(profiles); + await this.settingRepository.save(setting); + } catch (error) { + this.logger.error(`Error saving missings profiles: ${error.message}`, error.stack); + throw error; + } + } + + async createMissingsProfile(workspaceId: number, profile: MissingsProfilesDto): Promise { + try { + this.logger.log(`Creating missings profile for workspace ${workspaceId}`); + + // Get all existing profiles + const setting = await this.settingRepository.findOne({ + where: { key: 'missings-profile-iqb-standard' } + }); + + let profiles: MissingsProfilesDto[] = []; + + if (setting) { + try { + profiles = JSON.parse(setting.content); + } catch (parseError) { + this.logger.error(`Error parsing missings profiles: ${parseError.message}`, parseError.stack); + profiles = []; } + } + + // Check if a profile with the same label already exists + const existingProfile = profiles.find(p => p.label === profile.label); + if (existingProfile) { + throw new Error(`A missings profile with label '${profile.label}' already exists`); + } + + // Add the new profile + profiles.push(profile); + + // Save the updated profiles + await this.saveMissingsProfiles(profiles); + + return profile; + } catch (error) { + this.logger.error(`Error creating missings profile: ${error.message}`, error.stack); + throw error; + } + } + + async updateMissingsProfile(workspaceId: number, label: string, profile: MissingsProfilesDto): Promise { + try { + this.logger.log(`Updating missings profile '${label}' for workspace ${workspaceId}`); + + // Get all existing profiles + const setting = await this.settingRepository.findOne({ + where: { key: 'missings-profile-iqb-standard' } + }); + + if (!setting) { + throw new Error('No missings profiles found'); + } + + let profiles: MissingsProfilesDto[] = []; + + try { + profiles = JSON.parse(setting.content); + } catch (parseError) { + this.logger.error(`Error parsing missings profiles: ${parseError.message}`, parseError.stack); + } + + const index = profiles.findIndex(p => p.label === label); + if (index === -1) { + throw new Error(`Missings profile with label '${label}' not found`); + } + profiles[index] = profile; + await this.saveMissingsProfiles(profiles); + + return profile; + } catch (error) { + this.logger.error(`Error updating missings profile: ${error.message}`, error.stack); + throw error; + } + } + + async deleteMissingsProfile(workspaceId: number, label: string): Promise { + try { + this.logger.log(`Deleting missings profile '${label}' for workspace ${workspaceId}`); + + // Get all existing profiles + const setting = await this.settingRepository.findOne({ + where: { key: 'missings-profile-iqb-standard' } + }); + + if (!setting) { + return false; + } + + let profiles: MissingsProfilesDto[] = []; + + try { + profiles = JSON.parse(setting.content); + } catch (parseError) { + this.logger.error(`Error parsing missings profiles: ${parseError.message}`, parseError.stack); + return false; + } + + // Find the profile to delete + const index = profiles.findIndex(p => p.label === label); + if (index === -1) { + return false; + } + + // Remove the profile + profiles.splice(index, 1); + + // Save the updated profiles + await this.saveMissingsProfiles(profiles); + + return true; + } catch (error) { + this.logger.error(`Error deleting missings profile: ${error.message}`, error.stack); + return false; + } + } + + async getMissingsProfileDetails(workspaceId: number, label: string): Promise { + try { + this.logger.log(`Getting missings profile details for '${label}' in workspace ${workspaceId}`); + return await this.getMissingsProfileByLabel(label); + } catch (error) { + this.logger.error(`Error getting missings profile details: ${error.message}`, error.stack); + return null; + } + } + + async generateCodebook( + workspaceId: number, + missingsProfile: string, + contentOptions: CodeBookContentSetting, + unitIds: number[] + ): Promise { + try { + this.logger.log(`Generating codebook for workspace ${workspaceId} with ${unitIds.length} units`); + const units = await this.fileUploadRepository.findBy({ + id: In(unitIds) + }); + + if (!units || units.length === 0) { + this.logger.warn(`No units found for workspace ${workspaceId} with IDs ${unitIds}`); + return null; + } - // Update job status to paused - this.jobStatus.set(jobId, { ...inMemoryJob, status: 'paused' }); - this.logger.log(`In-memory job ${jobId} has been paused`); + const unitProperties: UnitPropertiesForCodebook[] = units.map(unit => ({ + id: unit.id, + key: unit.file_id, + name: unit.filename, + scheme: unit.data || '' + })); - return { success: true, message: `Job ${jobId} has been paused successfully` }; + // Get the missings from the selected profile + let missings: Missing[] = [ + { + code: '999', + label: 'Missing', + description: 'Value is missing' + } + ]; + + if (missingsProfile) { + const profile = await this.getMissingsProfileByLabel(missingsProfile); + if (profile) { + // Convert MissingDto[] to Missing[] + const profileMissings = profile.parseMissings(); + if (profileMissings.length > 0) { + missings = profileMissings.map(m => ({ + code: m.code.toString(), + label: m.label, + description: m.description + })); + } + } } - // If not found in memory, check the database - const job = await this.jobRepository.findOne({ where: { id: parseInt(jobId, 10) } }); - if (!job) { + return await CodebookGenerator.generateCodebook(unitProperties, contentOptions, missings); + } catch (error) { + this.logger.error(`Error generating codebook for workspace ${workspaceId}: ${error.message}`, error.stack); + return null; + } + } + + async pauseJob(jobId: string): Promise<{ success: boolean; message: string }> { + try { + // Get job from Bull queue + const bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); + if (!bullJob) { return { success: false, message: `Job with ID ${jobId} not found` }; } - // Only processing jobs can be paused - if (job.status !== 'processing') { + // Check if job can be paused + const state = await bullJob.getState(); + if (state !== 'active' && state !== 'waiting' && state !== 'delayed') { return { success: false, - message: `Job with ID ${jobId} cannot be paused because it is ${job.status}` + message: `Job with ID ${jobId} cannot be paused because it is ${state}` }; } - // Update job status to paused - job.status = 'paused'; - await this.jobRepository.save(job); - this.logger.log(`Job ${jobId} has been paused`); + // Update job data to mark it as paused + const updatedData = { + ...bullJob.data, + isPaused: true + }; + + await bullJob.update(updatedData); + this.logger.log(`Job ${jobId} has been paused successfully`); return { success: true, message: `Job ${jobId} has been paused successfully` }; } catch (error) { @@ -1477,41 +1714,459 @@ export class WorkspaceCodingService { async resumeJob(jobId: string): Promise<{ success: boolean; message: string }> { try { - const inMemoryJob = this.jobStatus.get(jobId); - if (inMemoryJob) { - if (inMemoryJob.status !== 'paused') { - return { - success: false, - message: `Job with ID ${jobId} cannot be resumed because it is ${inMemoryJob.status}` - }; - } - - this.jobStatus.set(jobId, { ...inMemoryJob, status: 'processing' }); - this.logger.log(`In-memory job ${jobId} has been resumed`); - - return { success: true, message: `Job ${jobId} has been resumed successfully` }; - } - - const job = await this.jobRepository.findOne({ where: { id: parseInt(jobId, 10) } }); - if (!job) { + // Get job from Bull queue + const bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); + if (!bullJob) { return { success: false, message: `Job with ID ${jobId} not found` }; } - if (job.status !== 'paused') { + // Check if job is paused + if (!bullJob.data.isPaused) { return { success: false, - message: `Job with ID ${jobId} cannot be resumed because it is ${job.status}` + message: `Job with ID ${jobId} is not paused and cannot be resumed` }; } - job.status = 'processing'; - await this.jobRepository.save(job); - this.logger.log(`Job ${jobId} has been resumed`); + // Update job data to remove the isPaused flag + const { isPaused, ...restData } = bullJob.data; + await bullJob.update(restData); + this.logger.log(`Job ${jobId} has been resumed successfully`); return { success: true, message: `Job ${jobId} has been resumed successfully` }; } catch (error) { this.logger.error(`Error resuming job: ${error.message}`, error.stack); return { success: false, message: `Error resuming job: ${error.message}` }; } } + + /** + * Get jobs only from Redis Bull queue for a workspace + * @param workspaceId The workspace ID + * @returns Array of jobs from Redis Bull + */ + async getBullJobs(workspaceId: number): Promise<{ + jobId: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; + progress: number; + result?: CodingStatistics; + error?: string; + workspaceId?: number; + createdAt?: Date; + groupNames?: string; + durationMs?: number; + completedAt?: Date; + }[]> { + const jobs: { + jobId: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; + progress: number; + result?: CodingStatistics; + error?: string; + workspaceId?: number; + createdAt?: Date; + groupNames?: string; + durationMs?: number; + completedAt?: Date; + }[] = []; + + try { + const bullJobs = await this.jobQueueService.getTestPersonCodingJobs(workspaceId); + for (const bullJob of bullJobs) { + // Get job state and progress + const state = await bullJob.getState(); + const progress = await bullJob.progress() || 0; + + // Map Bull job state to our job status + let status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; + switch (state) { + case 'active': + status = 'processing'; + break; + case 'completed': + status = 'completed'; + break; + case 'failed': + status = 'failed'; + break; + case 'delayed': + case 'waiting': + status = 'pending'; + break; + case 'paused': + status = 'paused'; + break; + default: + status = 'pending'; + } + + // Get result from job return value if completed + let result: CodingStatistics | undefined; + let error: string | undefined; + + if (state === 'completed' && bullJob.returnvalue) { + result = bullJob.returnvalue as CodingStatistics; + } else if (state === 'failed' && bullJob.failedReason) { + error = bullJob.failedReason; + } + + // Add job to the list + jobs.push({ + jobId: bullJob.id.toString(), + status, + progress: typeof progress === 'number' ? progress : 0, + result, + error, + workspaceId: bullJob.data.workspaceId, + createdAt: new Date(bullJob.timestamp), + groupNames: bullJob.data.groupNames, + completedAt: state === 'completed' ? new Date(bullJob.finishedOn || Date.now()) : undefined, + durationMs: state === 'completed' && bullJob.finishedOn && bullJob.timestamp ? + bullJob.finishedOn - bullJob.timestamp : + undefined + }); + } + } catch (bullError) { + this.logger.error(`Error getting jobs from Redis queue: ${bullError.message}`, bullError.stack); + } + + // Sort jobs by creation date (newest first) + return jobs.sort((a, b) => { + if (!a.createdAt) return 1; + if (!b.createdAt) return -1; + return b.createdAt.getTime() - a.createdAt.getTime(); + }); + } + + /** + * Get variable analysis data for a workspace + * This method retrieves and analyzes responses grouped by unit, variable, and code + * It also fetches coding scheme information to populate derivation and description fields + * + * The analysis includes: + * - Grouping responses by unit, variable, and code + * - Calculating occurrence counts and relative occurrences + * - Generating replay URLs for each combination + * - Fetching coding scheme information from file_upload table (file_id = unitId+.VOCS) + * + * @param workspace_id The workspace ID + * @param authToken Authentication token for generating replay URLs + * @param serverUrl Base server URL for replay links + * @param page Page number for pagination (default: 1) + * @param limit Number of items per page (default: 100) + * @returns Paginated array of variable analysis items with all required information + */ + async getVariableAnalysis( + workspace_id: number, + authToken: string, + serverUrl?: string, + page: number = 1, + limit: number = 100, + unitIdFilter?: string, + variableIdFilter?: string, + derivationFilter?: string + ): Promise<{ + data: VariableAnalysisItemDto[]; + total: number; + page: number; + limit: number; + }> { + try { + this.logger.log(`Getting variable analysis for workspace ${workspace_id} (page ${page}, limit ${limit})`); + const startTime = Date.now(); + + // Step 1: Pre-fetch all coding schemes for the workspace to avoid individual queries + this.logger.log('Pre-fetching coding schemes...'); + const codingSchemes = await this.fileUploadRepository.find({ + where: { + workspace_id, + file_type: 'Resource', + file_id: Like('%.VOCS') + } + }); + + // Create a map of unitId to parsed coding scheme for quick lookup + interface CodingScheme { + variableCodings?: { + id: string; + sourceType?: string; + label?: string; + }[]; + [key: string]: unknown; + } + + const codingSchemeMap = new Map(); + for (const scheme of codingSchemes) { + try { + const unitId = scheme.file_id.replace('.VOCS', ''); + const parsedScheme = JSON.parse(scheme.data) as CodingScheme; + codingSchemeMap.set(unitId, parsedScheme); + } catch (error) { + this.logger.error(`Error parsing coding scheme ${scheme.file_id}: ${error.message}`, error.stack); + } + } + this.logger.log(`Pre-fetched ${codingSchemeMap.size} coding schemes in ${Date.now() - startTime}ms`); + + // Step 2: Count total number of unique unit-variable-code combinations + const countQuery = this.responseRepository.createQueryBuilder('response') + .select('COUNT(DISTINCT CONCAT(unit.name, response.variableid, response.code))', 'count') + .leftJoin('response.unit', 'unit') + .leftJoin('unit.booklet', 'booklet') + .leftJoin('booklet.person', 'person') + .where('person.workspace_id = :workspace_id', { workspace_id }); + + // Add filters if provided + if (unitIdFilter) { + countQuery.andWhere('unit.name LIKE :unitId', { unitId: `%${unitIdFilter}%` }); + } + + if (variableIdFilter) { + countQuery.andWhere('response.variableid LIKE :variableId', { variableId: `%${variableIdFilter}%` }); + } + + const totalCountResult = await countQuery.getRawOne(); + const totalCount = parseInt(totalCountResult?.count || '0', 10); + this.logger.log(`Total unique combinations: ${totalCount}`); + + // Step 3: Use direct SQL aggregation to get counts and other data + // This avoids loading complete response objects and processing them in memory + const aggregationQuery = this.responseRepository.createQueryBuilder('response') + .select('unit.name', 'unitId') + .addSelect('response.variableid', 'variableId') + .addSelect('response.code', 'code') + .addSelect('COUNT(response.id)', 'occurrenceCount') + .addSelect('MAX(response.score)', 'score') // Use MAX as a sample score + .leftJoin('response.unit', 'unit') + .leftJoin('unit.booklet', 'booklet') + .leftJoin('booklet.person', 'person') + .leftJoin('booklet.bookletinfo', 'bookletinfo') + .where('person.workspace_id = :workspace_id', { workspace_id }); + + // Add filters if provided + if (unitIdFilter) { + aggregationQuery.andWhere('unit.name LIKE :unitId', { unitId: `%${unitIdFilter}%` }); + } + + if (variableIdFilter) { + aggregationQuery.andWhere('response.variableid LIKE :variableId', { variableId: `%${variableIdFilter}%` }); + } + + aggregationQuery + .groupBy('unit.name') + .addGroupBy('response.variableid') + .addGroupBy('response.code') + .orderBy('unit.name', 'ASC') + .addOrderBy('response.variableid', 'ASC') + .addOrderBy('response.code', 'ASC') + .offset((page - 1) * limit) + .limit(limit); + + const aggregatedResults = await aggregationQuery.getRawMany(); + this.logger.log(`Retrieved ${aggregatedResults.length} aggregated combinations for page ${page}`); + + // If no combinations found, return empty result + if (aggregatedResults.length === 0) { + return { + data: [], + total: totalCount, + page, + limit + }; + } + + // Step 4: Get total counts for each unit-variable combination + // We need this to calculate relative occurrences + const unitVariableCounts = new Map>(); + + // Extract unique unit-variable combinations from the aggregated results + const unitVariableCombinations = Array.from( + new Set(aggregatedResults.map(item => `${item.unitId}|${item.variableId}`)) + ).map(combined => { + const [unitId, variableId] = combined.split('|'); + return { unitId, variableId }; + }); + + // Query to get total counts for each unit-variable combination + const totalCountsQuery = this.responseRepository.createQueryBuilder('response') + .select('unit.name', 'unitId') + .addSelect('response.variableid', 'variableId') + .addSelect('COUNT(response.id)', 'totalCount') + .leftJoin('response.unit', 'unit') + .leftJoin('unit.booklet', 'booklet') + .leftJoin('booklet.person', 'person') + .where('person.workspace_id = :workspace_id', { workspace_id }); + + // Add filters if provided + if (unitIdFilter) { + totalCountsQuery.andWhere('unit.name LIKE :unitId', { unitId: `%${unitIdFilter}%` }); + } + + if (variableIdFilter) { + totalCountsQuery.andWhere('response.variableid LIKE :variableId', { variableId: `%${variableIdFilter}%` }); + } + + // Add conditions for the specific unit-variable combinations we need + if (unitVariableCombinations.length > 0) { + unitVariableCombinations.forEach((combo, index) => { + totalCountsQuery.orWhere( + `(unit.name = :unitId${index} AND response.variableid = :variableId${index})`, + { + [`unitId${index}`]: combo.unitId, + [`variableId${index}`]: combo.variableId + } + ); + }); + } + + totalCountsQuery.groupBy('unit.name') + .addGroupBy('response.variableid'); + + const totalCountsResults = await totalCountsQuery.getRawMany(); + + // Build a map for quick lookup of total counts + for (const result of totalCountsResults) { + if (!unitVariableCounts.has(result.unitId)) { + unitVariableCounts.set(result.unitId, new Map()); + } + unitVariableCounts.get(result.unitId)?.set(result.variableId, parseInt(result.totalCount, 10)); + } + + // Step 5: Get sample login information for replay URLs + // We need one sample per unit-variable combination + const sampleInfoQuery = this.responseRepository.createQueryBuilder('response') + .select('unit.name', 'unitId') + .addSelect('response.variableid', 'variableId') + .addSelect('person.login', 'loginName') + .addSelect('person.code', 'loginCode') + .addSelect('bookletinfo.name', 'bookletId') + .leftJoin('response.unit', 'unit') + .leftJoin('unit.booklet', 'booklet') + .leftJoin('booklet.person', 'person') + .leftJoin('booklet.bookletinfo', 'bookletinfo') + .where('person.workspace_id = :workspace_id', { workspace_id }); + + // Add conditions for the specific unit-variable combinations we need + if (unitVariableCombinations.length > 0) { + unitVariableCombinations.forEach((combo, index) => { + sampleInfoQuery.orWhere( + `(unit.name = :unitId${index} AND response.variableid = :variableId${index})`, + { + [`unitId${index}`]: combo.unitId, + [`variableId${index}`]: combo.variableId + } + ); + }); + } + + // Limit to one sample per combination + sampleInfoQuery.groupBy('unit.name') + .addGroupBy('response.variableid') + .addGroupBy('person.login') + .addGroupBy('person.code') + .addGroupBy('bookletinfo.name'); + + const sampleInfoResults = await sampleInfoQuery.getRawMany(); + + // Build a map for quick lookup of sample info + const sampleInfoMap = new Map(); + for (const result of sampleInfoResults) { + const key = `${result.unitId}|${result.variableId}`; + sampleInfoMap.set(key, { + loginName: result.loginName || '', + loginCode: result.loginCode || '', + bookletId: result.bookletId || '' + }); + } + + // Step 6: Convert aggregated data to the required format + const result: VariableAnalysisItemDto[] = []; + + for (const item of aggregatedResults) { + const unitId = item.unitId; + const variableId = item.variableId; + const code = item.code; + const occurrenceCount = parseInt(item.occurrenceCount, 10); + const score = parseFloat(item.score) || 0; + + // Get total count for this unit-variable combination + const variableTotalCount = unitVariableCounts.get(unitId)?.get(variableId) || 0; + + // Calculate relative occurrence + const relativeOccurrence = variableTotalCount > 0 ? occurrenceCount / variableTotalCount : 0; + + // Get coding scheme information + let derivation = ''; + let description = ''; + const codingScheme = codingSchemeMap.get(unitId); + if (codingScheme && codingScheme.variableCodings && Array.isArray(codingScheme.variableCodings)) { + const variableCoding = codingScheme.variableCodings.find(vc => vc.id === variableId); + if (variableCoding) { + derivation = variableCoding.sourceType || ''; + description = variableCoding.label || ''; + } + } + + // Skip items where derivation is BASE_NO_VALUE or empty + if (derivation === 'BASE_NO_VALUE' || derivation === '') { + continue; + } + + // Get sample info for replay URL + const sampleInfo = sampleInfoMap.get(`${unitId}|${variableId}`); + const loginName = sampleInfo?.loginName || ''; + const loginCode = sampleInfo?.loginCode || ''; + const bookletId = sampleInfo?.bookletId || ''; + + // Generate replay URL + const variablePage = '0'; + const variableAnchor = variableId; + const replayUrl = `${serverUrl}/#/replay/${loginName}@${loginCode}@${bookletId}/${unitId}/${variablePage}/${variableAnchor}?auth=${authToken}`; + + // Add to result + result.push({ + replayUrl, + unitId, + variableId, + derivation, + code, + description, + score, + occurrenceCount, + totalCount: variableTotalCount, + relativeOccurrence + }); + } + + // Apply derivation filter if provided + if (derivationFilter && derivationFilter.trim() !== '') { + const filteredResult = result.filter(item => item.derivation.toLowerCase().includes(derivationFilter.toLowerCase())); + + const filteredCount = filteredResult.length; + this.logger.log(`Applied derivation filter: ${derivationFilter}, filtered from ${result.length} to ${filteredCount} items`); + + const endTime = Date.now(); + this.logger.log(`Variable analysis completed in ${endTime - startTime}ms`); + + return { + data: filteredResult, + total: filteredCount, // Update total count to reflect filtered results + page, + limit + }; + } + + const endTime = Date.now(); + this.logger.log(`Variable analysis completed in ${endTime - startTime}ms`); + + return { + data: result, + total: totalCount, + page, + limit + }; + } catch (error) { + this.logger.error(`Error getting variable analysis: ${error.message}`, error.stack); + throw new Error('Could not retrieve variable analysis data. Please check the database connection or query.'); + } + } } diff --git a/apps/backend/src/app/database/services/workspace-files.service.ts b/apps/backend/src/app/database/services/workspace-files.service.ts index d2772dc20..6a8457ad0 100644 --- a/apps/backend/src/app/database/services/workspace-files.service.ts +++ b/apps/backend/src/app/database/services/workspace-files.service.ts @@ -1,13 +1,14 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { In, Like, Repository } from 'typeorm'; import * as cheerio from 'cheerio'; import AdmZip = require('adm-zip'); import * as fs from 'fs'; import * as path from 'path'; import * as libxmljs from 'libxmljs2'; import { parseStringPromise } from 'xml2js'; -import FileUpload from '../entities/file_upload.entity'; +import { VariableInfo } from '@iqb/responses'; +import FileUpload, { StructuredFileData } from '../entities/file_upload.entity'; import { FilesDto } from '../../../../../../api-dto/files/files.dto'; import { FileIo } from '../../admin/workspace/file-io.interface'; import { FileDownloadDto } from '../../../../../../api-dto/files/file-download.dto'; @@ -449,6 +450,29 @@ ${bookletRefs} } } + async getUnitsWithFileIds(workspaceId: number): Promise<{ id: number; unitId: string; fileName: string; data: string }[]> { + try { + const units = await this.fileUploadRepository.find({ + where: { workspace_id: workspaceId, file_type: 'Resource', file_id: Like('%.VOCS') } + }); + + if (!units || units.length === 0) { + this.logger.warn(`No schmemes found in workspace with ID ${workspaceId}.`); + return []; + } + + return units.map(unit => ({ + id: unit.id, + unitId: unit.file_id, + fileName: unit.filename, + data: unit.data + })); + } catch (error) { + this.logger.error(`Error getting units with file IDs for workspace ${workspaceId}: ${error.message}`, error.stack); + return []; + } + } + private async processTestTaker(testTaker: FileUpload, unusedBooklets: string[] = []): Promise { const xmlDocument = cheerio.load(testTaker.data, { xml: true }); const bookletTags = xmlDocument('Booklet'); @@ -672,6 +696,225 @@ ${bookletRefs} } } + private async extractUnitInfo(xmlDocument: cheerio.CheerioAPI): Promise> { + try { + const result: Record = {}; + const metadata = xmlDocument('Metadata'); + if (metadata.length) { + const metadataInfo: Record = {}; + + const id = metadata.find('Id'); + if (id.length) { + metadataInfo.id = id.text().trim(); + } + + const label = metadata.find('Label'); + if (label.length) { + metadataInfo.label = label.text().trim(); + } + + const description = metadata.find('Description'); + if (description.length) { + metadataInfo.description = description.text().trim(); + } + + result.metadata = metadataInfo; + } + + const baseVariables = xmlDocument('BaseVariables Variable'); + if (baseVariables.length) { + const variables: Array> = []; + + baseVariables.each((index, element) => { + const variable = xmlDocument(element); + const variableInfo: Record = {}; + + const attrs = variable.attr(); + if (attrs) { + variableInfo.id = attrs.id; + variableInfo.alias = attrs.alias; + variableInfo.type = attrs.type; + variableInfo.format = attrs.format; + variableInfo.multiple = attrs.multiple === 'true'; + variableInfo.nullable = attrs.nullable !== 'false'; + + if (attrs.values) { + variableInfo.values = attrs.values.split('|'); + } + + if (attrs.valuesComplete) { + variableInfo.valuesComplete = attrs.valuesComplete === 'true'; + } + + if (attrs.page) { + variableInfo.page = attrs.page; + } + } + + const alias = variable.text().trim(); + if (alias) { + variableInfo.alias = alias; + } + + variables.push(variableInfo); + }); + + result.variables = variables; + } + + const definitions = xmlDocument('Definition'); + if (definitions.length) { + const definitionsArray: Array> = []; + + definitions.each((index, element) => { + const definition = xmlDocument(element); + const definitionInfo: Record = {}; + + const attrs = definition.attr(); + if (attrs) { + definitionInfo.id = attrs.id; + definitionInfo.type = attrs.type; + } + + definitionsArray.push(definitionInfo); + }); + + result.definitions = definitionsArray; + } + + return result; + } catch (error) { + this.logger.error(`Error extracting Unit information: ${error.message}`); + return {}; + } + } + + private async extractBookletInfo(xmlDocument: cheerio.CheerioAPI): Promise> { + try { + const result: Record = {}; + const metadata = xmlDocument('Metadata'); + if (metadata.length) { + const metadataInfo: Record = {}; + const id = metadata.find('Id'); + if (id.length) { + metadataInfo.id = id.text().trim(); + } + const label = metadata.find('Label'); + if (label.length) { + metadataInfo.label = label.text().trim(); + } + const description = metadata.find('Description'); + if (description.length) { + metadataInfo.description = description.text().trim(); + } + + result.metadata = metadataInfo; + } + + const units = xmlDocument('Units Unit'); + if (units.length) { + const unitsArray: Array> = []; + + units.each((index, element) => { + const unit = xmlDocument(element); + const unitInfo: Record = {}; + + const attrs = unit.attr(); + if (attrs) { + unitInfo.id = attrs.id; + unitInfo.label = attrs.label; + unitInfo.labelShort = attrs.labelshort; + } + + unitsArray.push(unitInfo); + }); + + result.units = unitsArray; + } + + return result; + } catch (error) { + this.logger.error(`Error extracting Booklet information: ${error.message}`); + return {}; + } + } + + private async extractTestTakersInfo(xmlDocument: cheerio.CheerioAPI): Promise> { + try { + const result: Record = {}; + + const testTakers = xmlDocument('Testtaker'); + if (testTakers.length) { + const testTakersArray: Array> = []; + + testTakers.each((index, element) => { + const testTaker = xmlDocument(element); + const testTakerInfo: Record = {}; + + const attrs = testTaker.attr(); + if (attrs) { + testTakerInfo.id = attrs.id; + testTakerInfo.login = attrs.login; + testTakerInfo.code = attrs.code; + } + + const booklets = testTaker.find('Booklet'); + if (booklets.length) { + const bookletsArray: string[] = []; + + booklets.each((bookletIndex, bookletElement) => { + const booklet = xmlDocument(bookletElement); + bookletsArray.push(booklet.text().trim()); + }); + + testTakerInfo.booklets = bookletsArray; + } + + testTakersArray.push(testTakerInfo); + }); + + result.testTakers = testTakersArray; + } + + const groups = xmlDocument('Group'); + if (groups.length) { + const groupsArray: Array> = []; + + groups.each((groupIndex, element) => { + const group = xmlDocument(element); + const groupInfo: Record = {}; + + const attrs = group.attr(); + if (attrs) { + groupInfo.id = attrs.id; + groupInfo.label = attrs.label; + } + + const members = group.find('Member'); + if (members.length) { + const membersArray: string[] = []; + + members.each((memberIndex, memberElement) => { + const member = xmlDocument(memberElement); + membersArray.push(member.text().trim()); + }); + + groupInfo.members = membersArray; + } + + groupsArray.push(groupInfo); + }); + + result.groups = groupsArray; + } + + return result; + } catch (error) { + this.logger.error(`Error extracting TestTakers information: ${error.message}`); + return {}; + } + } + private async handleXmlFile(workspaceId: number, file: FileIo): Promise { try { if (!file.buffer || !file.buffer.length) { @@ -730,13 +973,33 @@ ${bookletRefs} }; } + let extractedInfo: Record = {}; + try { + if (fileType === 'Unit') { + extractedInfo = await this.extractUnitInfo(xmlDocument); + } else if (fileType === 'Booklet') { + extractedInfo = await this.extractBookletInfo(xmlDocument); + } else if (fileType === 'TestTakers') { + extractedInfo = await this.extractTestTakersInfo(xmlDocument); + } + this.logger.log(`Extracted information from ${fileType} file: ${JSON.stringify(extractedInfo)}`); + } catch (extractError) { + this.logger.error(`Error extracting information from ${fileType} file: ${extractError.message}`); + // Continue with upload even if extraction fails + } + + const structuredData: StructuredFileData = { + extractedInfo + }; + return await this.fileUploadRepository.upsert({ workspace_id: workspaceId, filename: file.originalname, file_type: fileType, file_size: file.size, data: file.buffer.toString(), - file_id: resolvedFileId + file_id: resolvedFileId, + structured_data: structuredData // Store extracted information in the structured_data column }, ['file_id', 'workspace_id']); } catch (error) { this.logger.error(`Error processing XML file: ${error.message}`); @@ -745,16 +1008,62 @@ ${bookletRefs} } private async handleHtmlFile(workspaceId: number, file: FileIo): Promise { - const resourceFileId = WorkspaceFilesService.getPlayerId(file); - - return this.fileUploadRepository.upsert({ - filename: file.originalname, - workspace_id: workspaceId, - file_type: 'Resource', - file_size: file.size, - file_id: resourceFileId, - data: file.buffer.toString() - }, ['file_id', 'workspace_id']); + try { + const playerCode = file.buffer.toString(); + const playerContent = cheerio.load(playerCode); + const metaDataElement = playerContent('script[type="application/ld+json"]'); + let metadata = {}; + + try { + metadata = JSON.parse(metaDataElement.text()); + } catch (metadataError) { + this.logger.warn(`Error parsing metadata from HTML file: ${metadataError.message}`); + } + const structuredData: StructuredFileData = { + metadata + }; + + // Check if this is a schemer HTML file + if (metadata['@type'] === 'schemer') { + const resourceFileId = WorkspaceFilesService.getSchemerId(file); + const result = await this.fileUploadRepository.upsert({ + filename: file.originalname, + workspace_id: workspaceId, + file_type: 'Schemer', + file_size: file.size, + file_id: resourceFileId, + data: file.buffer.toString(), + structured_data: structuredData + }, ['file_id', 'workspace_id']); + return result; + } + + // Handle as player HTML file + const resourceFileId = WorkspaceFilesService.getPlayerId(file); + const result = await this.fileUploadRepository.upsert({ + filename: file.originalname, + workspace_id: workspaceId, + file_type: 'Resource', + file_size: file.size, + file_id: resourceFileId, + data: file.buffer.toString(), + structured_data: structuredData + }, ['file_id', 'workspace_id']); + return result; + } catch (error) { + // If there's an error parsing the metadata, handle as a regular resource + const resourceFileId = WorkspaceFilesService.getResourceId(file); + const result = await this.fileUploadRepository.upsert({ + filename: file.originalname, + workspace_id: workspaceId, + file_type: 'Resource', + file_size: file.size, + file_id: resourceFileId, + data: file.buffer.toString(), + structured_data: { metadata: {} } + }, ['file_id', 'workspace_id']); + return result; + } } private async handleOctetStreamFile(workspaceId: number, file: FileIo): Promise { @@ -763,6 +1072,7 @@ ${bookletRefs} const fileExtension = path.extname(file.originalname).toLowerCase(); let fileType = 'Resource'; let fileContent: string | Buffer = file.buffer; + let extractedInfo = {}; if (['.xml', '.html', '.htm', '.xhtml', '.txt', '.json', '.csv'].includes(fileExtension)) { fileContent = file.buffer.toString('utf8'); @@ -773,18 +1083,38 @@ ${bookletRefs} const $ = cheerio.load(fileContent as string, { xmlMode: true }); if ($('Testtakers').length > 0) { fileType = 'TestTakers'; + extractedInfo = { + rootElement: 'Testtakers', + detectedVia: 'octet-stream-handler' + }; } else if ($('Booklet').length > 0) { fileType = 'Booklet'; + extractedInfo = { + rootElement: 'Booklet', + detectedVia: 'octet-stream-handler' + }; } else if ($('Unit').length > 0) { fileType = 'Unit'; + extractedInfo = { + rootElement: 'Unit', + detectedVia: 'octet-stream-handler' + }; } else if ($('SysCheck').length > 0) { fileType = 'SysCheck'; + extractedInfo = { + rootElement: 'SysCheck', + detectedVia: 'octet-stream-handler' + }; } } catch (error) { this.logger.warn(`Could not parse XML content for ${file.originalname}: ${error.message}`); } } + const structuredData: StructuredFileData = { + extractedInfo + }; + // @ts-expect-error: not exact match const fileUpload = this.fileUploadRepository.create({ workspace_id: workspaceId, @@ -792,7 +1122,8 @@ ${bookletRefs} file_id: file.originalname.toUpperCase(), file_type: fileType, file_size: file.size, - data: fileContent + data: fileContent, + structured_data: structuredData }); await this.fileUploadRepository.save(fileUpload); @@ -1138,6 +1469,18 @@ ${bookletRefs} } } + private static getSchemerId(file: FileIo): string { + try { + const schemerCode = file.buffer.toString(); + const schemerContent = cheerio.load(schemerCode); + const metaDataElement = schemerContent('script[type="application/ld+json"]'); + const metadata = JSON.parse(metaDataElement.text()); + return WorkspaceFilesService.normalizePlayerId(`${metadata['@id']}-${metadata.version}`); + } catch (error) { + return WorkspaceFilesService.getResourceId(file); + } + } + private static getResourceId(file: FileIo): string { if (!file?.originalname) { throw new Error('Invalid file: originalname is required.'); @@ -1215,6 +1558,70 @@ ${bookletRefs} } } + async getVariableInfoForScheme(workspaceId: number, schemeFileId: string): Promise { + try { + const unitFiles = await this.fileUploadRepository.find({ + where: { + workspace_id: workspaceId, + file_type: 'Unit' + } + }); + + if (!unitFiles || unitFiles.length === 0) { + this.logger.warn(`No Unit files found in workspace ${workspaceId}`); + return []; + } + + const filteredUnitFiles = unitFiles.filter(file => file.file_id === schemeFileId && !file.file_id.includes('VOCS')); + + if (filteredUnitFiles.length === 0) { + this.logger.warn(`No Unit files with file_id ${schemeFileId} (without VOCS) found in workspace ${workspaceId}`); + return []; + } + + const variableInfoArray: VariableInfo[] = []; + + for (const unitFile of filteredUnitFiles) { + try { + const xmlContent = unitFile.data.toString(); + const parsedXml = await parseStringPromise(xmlContent, { explicitArray: false }); + + if (parsedXml.Unit && parsedXml.Unit.BaseVariables && parsedXml.Unit.BaseVariables.Variable) { + const baseVariables = Array.isArray(parsedXml.Unit.BaseVariables.Variable) ? + parsedXml.Unit.BaseVariables.Variable : + [parsedXml.Unit.BaseVariables.Variable]; + + for (const variable of baseVariables) { + if (variable.$ && variable.$.alias && variable.$.type) { + const variableInfo: VariableInfo = { + id: variable.$.id, + alias: variable.$.alias, + type: variable.$.type, + multiple: variable.$.multiple === 'true' || variable.$.multiple === true, + nullable: variable.$.nullable !== 'false' && variable.$.nullable !== false, // Default to true if not specified + values: variable.$.values ? variable.$.values.split('|') : undefined, + valuesComplete: variable.$.valuesComplete === 'true' || variable.$.valuesComplete === true, + page: variable.$.page, + format: '', + valuePositionLabels: [] + }; + + variableInfoArray.push(variableInfo); + } + } + } + } catch (e) { + this.logger.error(`Error parsing XML for unit file ${unitFile.file_id}: ${e.message}`); + } + } + + return variableInfoArray; + } catch (error) { + this.logger.error(`Error retrieving variable info: ${error.message}`, error.stack); + return []; + } + } + async validateVariables(workspaceId: number, page: number = 1, limit: number = 10): Promise<{ data: InvalidVariableDto[]; total: number; page: number; limit: number }> { if (!workspaceId) { this.logger.error('Workspace ID is required'); diff --git a/apps/backend/src/app/database/services/workspace-users.service.ts b/apps/backend/src/app/database/services/workspace-users.service.ts index 268248748..f658d0625 100644 --- a/apps/backend/src/app/database/services/workspace-users.service.ts +++ b/apps/backend/src/app/database/services/workspace-users.service.ts @@ -94,4 +94,24 @@ export class WorkspaceUsersService { throw new Error('Could not retrieve workspace users'); } } + + async findCoders(workspaceId: number): Promise<[WorkspaceUser[], number]> { + this.logger.log(`Retrieving coders (users with accessLevel 1) for workspace ID: ${workspaceId}`); + + try { + const users = await this.workspaceUsersRepository.find({ + where: { + workspaceId, + accessLevel: 1 + }, + order: { userId: 'ASC' } + }); + + this.logger.log(`Found ${users.length} coder(s) for workspace ID: ${workspaceId}`); + return [users, users.length]; + } catch (error) { + this.logger.error(`Failed to retrieve coders for workspace ID: ${workspaceId}`, error.stack); + throw new Error('Could not retrieve workspace coders'); + } + } } diff --git a/apps/backend/src/app/health/health.controller.ts b/apps/backend/src/app/health/health.controller.ts new file mode 100644 index 000000000..5f521c69d --- /dev/null +++ b/apps/backend/src/app/health/health.controller.ts @@ -0,0 +1,88 @@ +import { Controller, Get, Logger } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { JobQueueService, RedisConnectionStatus } from '../job-queue/job-queue.service'; + +@ApiTags('Health') +@Controller('health') +export class HealthController { + private readonly logger = new Logger(HealthController.name); + + constructor(private readonly jobQueueService: JobQueueService) {} + + /** + * Check if Redis is connected and jobs can be managed + * @returns Redis connection status + */ + @Get('redis') + @ApiOkResponse({ + description: 'Redis connection status', + type: Object, + schema: { + properties: { + connected: { + type: 'boolean', + description: 'Whether Redis is connected' + }, + message: { + type: 'string', + description: 'Status message' + }, + details: { + type: 'object', + properties: { + pingLatency: { + type: 'number', + description: 'Redis ping latency in milliseconds' + }, + queueStatus: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Queue name' + }, + isReady: { + type: 'boolean', + description: 'Whether the queue is ready' + }, + jobCounts: { + type: 'object', + properties: { + waiting: { + type: 'number', + description: 'Number of waiting jobs' + }, + active: { + type: 'number', + description: 'Number of active jobs' + }, + completed: { + type: 'number', + description: 'Number of completed jobs' + }, + failed: { + type: 'number', + description: 'Number of failed jobs' + }, + delayed: { + type: 'number', + description: 'Number of delayed jobs' + }, + paused: { + type: 'number', + description: 'Number of paused jobs' + } + } + } + } + } + } + } + } + } + }) + async checkRedisConnection(): Promise { + this.logger.log('Health check: Checking Redis connection'); + return this.jobQueueService.checkRedisConnection(); + } +} diff --git a/apps/backend/src/app/health/health.module.ts b/apps/backend/src/app/health/health.module.ts new file mode 100644 index 000000000..cb0b4d6ed --- /dev/null +++ b/apps/backend/src/app/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; +import { JobQueueModule } from '../job-queue/job-queue.module'; + +@Module({ + imports: [JobQueueModule], + controllers: [HealthController] +}) +export class HealthModule {} diff --git a/apps/backend/src/app/job-queue/job-queue.module.ts b/apps/backend/src/app/job-queue/job-queue.module.ts new file mode 100644 index 000000000..0e67785d8 --- /dev/null +++ b/apps/backend/src/app/job-queue/job-queue.module.ts @@ -0,0 +1,30 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { BullModule } from '@nestjs/bull'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JobQueueService } from './job-queue.service'; +import { TestPersonCodingProcessor } from './processors/test-person-coding.processor'; +// eslint-disable-next-line import/no-cycle +import { DatabaseModule } from '../database/database.module'; + +@Module({ + imports: [ + BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + redis: { + host: configService.get('REDIS_HOST', 'redis'), + port: parseInt(configService.get('REDIS_PORT', '6379'), 10) + }, + prefix: configService.get('REDIS_PREFIX', 'coding-box') + }) + }), + BullModule.registerQueue({ + name: 'test-person-coding' + }), + forwardRef(() => DatabaseModule) + ], + providers: [JobQueueService, TestPersonCodingProcessor], + exports: [JobQueueService] +}) +export class JobQueueModule {} diff --git a/apps/backend/src/app/job-queue/job-queue.service.ts b/apps/backend/src/app/job-queue/job-queue.service.ts new file mode 100644 index 000000000..e63aae154 --- /dev/null +++ b/apps/backend/src/app/job-queue/job-queue.service.ts @@ -0,0 +1,195 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue, JobOptions, Job } from 'bull'; + +export interface TestPersonCodingJobData { + workspaceId: number; + personIds: string[]; + groupNames?: string; + isPaused?: boolean; +} + +export interface RedisConnectionStatus { + connected: boolean; + message: string; + details?: { + pingLatency?: number; + queueStatus?: { + name: string; + isReady: boolean; + jobCounts?: { + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + paused: number; + }; + }; + }; +} + +@Injectable() +export class JobQueueService { + private readonly logger = new Logger(JobQueueService.name); + + constructor( + @InjectQueue('test-person-coding') private testPersonCodingQueue: Queue + ) {} + + /** + * Add a test person coding job to the queue + * @param data Job data + * @param options Job options + * @returns The created job + */ + async addTestPersonCodingJob( + data: TestPersonCodingJobData, + options?: JobOptions + ): Promise> { + this.logger.log(`Adding test person coding job for workspace ${data.workspaceId}`); + return this.testPersonCodingQueue.add(data, options); + } + + /** + * Get a test person coding job by ID + * @param jobId The job ID + * @returns The job + */ + async getTestPersonCodingJob(jobId: string): Promise> { + return this.testPersonCodingQueue.getJob(jobId); + } + + /** + * Get all test person coding jobs for a workspace + * @param workspaceId The workspace ID + * @returns Array of jobs + */ + async getTestPersonCodingJobs(workspaceId: number): Promise[]> { + console.log(`Fetching all test person coding jobs for workspace ${workspaceId}`); + const jobs = await this.testPersonCodingQueue.getJobs(['completed', 'failed', 'active', 'waiting', 'delayed']); + console.log(`Found ${jobs.length} jobs in total`); + return jobs.filter(job => job.data.workspaceId === workspaceId); + } + + /** + * Cancel a test person coding job + * @param jobId The job ID + * @returns True if the job was cancelled, false otherwise + */ + async cancelTestPersonCodingJob(jobId: string): Promise { + const job = await this.testPersonCodingQueue.getJob(jobId); + if (!job) { + this.logger.warn(`Job with ID ${jobId} not found`); + return false; + } + + try { + await job.remove(); + this.logger.log(`Job ${jobId} has been cancelled`); + return true; + } catch (error) { + this.logger.error(`Error cancelling job: ${error.message}`, error.stack); + return false; + } + } + + /** + * Clean completed and failed jobs + * @returns The number of jobs cleaned + */ + async cleanJobs(): Promise { + // Keep jobs for 24 hours + const grace = 24 * 60 * 60 * 1000; + const completedCount = await this.testPersonCodingQueue.clean(grace, 'completed'); + const failedCount = await this.testPersonCodingQueue.clean(grace, 'failed'); + + this.logger.log(`Cleaned ${completedCount} completed jobs and ${failedCount} failed jobs`); + return completedCount.length + failedCount.length; + } + + /** + * Delete a test person coding job + * @param jobId The job ID + * @returns True if the job was deleted, false otherwise + */ + async deleteTestPersonCodingJob(jobId: string): Promise { + const job = await this.testPersonCodingQueue.getJob(jobId); + if (!job) { + this.logger.warn(`Job with ID ${jobId} not found`); + return false; + } + + try { + await job.remove(); + this.logger.log(`Job ${jobId} has been deleted`); + return true; + } catch (error) { + this.logger.error(`Error deleting job: ${error.message}`, error.stack); + return false; + } + } + + /** + * Check if Redis is connected and jobs can be managed + * @returns Redis connection status + */ + async checkRedisConnection(): Promise { + try { + this.logger.log('Checking Redis connection status...'); + + // Access the Redis client from the Bull queue + const client = this.testPersonCodingQueue.client; + + if (!client) { + return { + connected: false, + message: 'Redis client is not available' + }; + } + + // Measure ping latency + const startTime = Date.now(); + await client.ping(); + const pingLatency = Date.now() - startTime; + + // Get queue job counts to verify job management + const originalJobCounts = await this.testPersonCodingQueue.getJobCounts(); + + // Add the missing 'paused' property to match our RedisConnectionStatus interface + const jobCounts = { + ...originalJobCounts, + paused: 0 // Default value since JobCounts doesn't include this property + }; + + // Check if queue is ready + let isReady = false; + try { + await this.testPersonCodingQueue.isReady(); + isReady = true; + } catch (readyError) { + this.logger.warn(`Queue ready check failed: ${readyError.message}`); + } + + return { + connected: true, + message: 'Redis is connected and jobs can be managed', + details: { + pingLatency, + queueStatus: { + name: 'test-person-coding', + isReady, + jobCounts + } + } + }; + } catch (error) { + this.logger.error(`Redis connection check failed: ${error.message}`, error.stack); + + return { + connected: false, + message: `Redis connection failed: ${error.message}` + }; + } + } +} diff --git a/apps/backend/src/app/job-queue/processors/test-person-coding.processor.ts b/apps/backend/src/app/job-queue/processors/test-person-coding.processor.ts new file mode 100644 index 000000000..f7f787b94 --- /dev/null +++ b/apps/backend/src/app/job-queue/processors/test-person-coding.processor.ts @@ -0,0 +1,101 @@ +import { Processor, Process } from '@nestjs/bull'; +import { + Injectable, + Logger, + Inject, + forwardRef +} from '@nestjs/common'; +import { Job } from 'bull'; +import { TestPersonCodingJobData } from '../job-queue.service'; +import { CodingStatistics } from '../../database/services/shared-types'; +import { WorkspaceCodingService } from '../../database/services/workspace-coding.service'; + +@Injectable() +@Processor('test-person-coding') +export class TestPersonCodingProcessor { + private readonly logger = new Logger(TestPersonCodingProcessor.name); + + constructor( + @Inject(forwardRef(() => WorkspaceCodingService)) + private workspaceCodingService: WorkspaceCodingService + ) {} + + @Process() + async process(job: Job): Promise { + this.logger.log(`Processing test person coding job ${job.id} for workspace ${job.data.workspaceId}`); + + try { + // Process test persons in batches + const BATCH_SIZE = 500; + const totalPersons = job.data.personIds.length; + const combinedResult: CodingStatistics = { totalResponses: 0, statusCounts: {} }; + + // Update job progress to 0% + await job.progress(0); + + // Process each batch sequentially + for (let i = 0; i < job.data.personIds.length; i += BATCH_SIZE) { + // Check if job has been cancelled or paused + const currentJob = await job.getState(); + if (currentJob === 'failed' || currentJob === 'paused') { + this.logger.log(`Job ${job.id} was ${currentJob} before processing batch ${(i / BATCH_SIZE) + 1}`); + return combinedResult; + } + + // Check if job is marked as paused in the job data + if (job.data.isPaused) { + this.logger.log(`Job ${job.id} was paused before processing batch ${(i / BATCH_SIZE) + 1}`); + return combinedResult; + } + + const batchPersonIds = job.data.personIds.slice(i, i + BATCH_SIZE); + const batchNumber = (i / BATCH_SIZE) + 1; + const totalBatches = Math.ceil(totalPersons / BATCH_SIZE); + this.logger.log(`Processing batch ${batchNumber} of ${totalBatches} (${batchPersonIds.length} persons)`); + + // Process the batch using the actual processing logic + const progressCallback = async (progress: number) => { + // Calculate overall progress based on completed batches and current batch progress + const batchProgress = (i / job.data.personIds.length) * 100; // Progress from completed batches + const currentBatchProgress = (progress / 100) * (batchPersonIds.length / job.data.personIds.length) * 100; // Progress from current batch + const overallProgress = Math.min( + Math.floor(batchProgress + currentBatchProgress), + 99 // Cap at 99% until fully complete + ); + + await job.progress(overallProgress); + }; + + // Call the actual processing logic + const batchResult = await this.workspaceCodingService.processTestPersonsBatch( + job.data.workspaceId, + batchPersonIds, + progressCallback, + job.id.toString() + ); + + // Merge batch results into combined results + combinedResult.totalResponses += batchResult.totalResponses; + + // Merge status counts + Object.entries(batchResult.statusCounts).forEach(([status, count]) => { + if (!combinedResult.statusCounts[status]) { + combinedResult.statusCounts[status] = 0; + } + combinedResult.statusCounts[status] += count; + }); + } + + // Set job progress to 100% + await job.progress(100); + + this.logger.log(`Job ${job.id} completed successfully`); + return combinedResult; + } catch (error) { + this.logger.error(`Error processing job ${job.id}: ${error.message}`, error.stack); + + // Re-throw the error to mark the job as failed in Bull + throw error; + } + } +} diff --git a/apps/frontend/project.json b/apps/frontend/project.json index b54a4e33f..c3254796a 100755 --- a/apps/frontend/project.json +++ b/apps/frontend/project.json @@ -14,7 +14,8 @@ "allowedCommonJsDependencies": [ "xml2js", "base64-js", - "js-sha256" + "js-sha256", + "nx/src/utils/logger" ], "outputPath": "dist/apps/frontend", "index": "apps/frontend/src/index.html", @@ -36,12 +37,12 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kb", - "maximumError": "2mb" + "maximumWarning": "2mb", + "maximumError": "3mb" }, { "type": "anyComponentStyle", - "maximumWarning": "2kb", + "maximumWarning": "50kb", "maximumError": "100kb" } ], diff --git a/apps/frontend/src/app/app.component.scss b/apps/frontend/src/app/app.component.scss index 6fa9a82c9..ca0344284 100755 --- a/apps/frontend/src/app/app.component.scss +++ b/apps/frontend/src/app/app.component.scss @@ -3,36 +3,49 @@ display: flex; justify-content: center; align-items: center; + width: 100%; } .box-left{ - min-width: 50%; + width: 100%; + max-width: 50%; height: 100%; background-color: #f1f1f1; display: flex; justify-content: center; align-items: center; } + .app{ background: linear-gradient(180deg, rgba(7,70,94,1) 0%, rgba(6,112,123,1) 24%, rgba(1,192,229,1) 85%); height: 100%; + width: 100%; + overflow-x: hidden; } img{ - width: 100px; - height: 70px; - object-fit: cover; + max-width: 100px; + max-height: 70px; + width: auto; + height: auto; + object-fit: contain; } .app-header{ - padding:10px; + padding: 10px; + display: flex; + flex-wrap: wrap; + align-items: center; } .app-title { - color: lightgrey ; + color: lightgrey; text-align: center; margin: 0 auto; - + font-size: 1.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; &.margin-logged-in { margin-left: 25px; @@ -44,3 +57,68 @@ img{ margin-right: 100px; } } + +.admin-section { + display: flex; + align-items: center; + gap: 10px; +} + +/* Spinner container */ +.spinner-container { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +/* Media queries for responsive layout */ +@media (max-width: 768px) { + .app-header { + padding: 8px; + justify-content: space-between; + } + + img { + max-width: 80px; + max-height: 56px; + } + + .app-title { + font-size: 1.3rem; + + &.margin-logged-in, + &.margin-logged-out { + margin-left: 10px; + margin-right: 10px; + } + } +} + +@media (max-width: 576px) { + .app-header { + padding: 5px; + } + + img { + max-width: 60px; + max-height: 42px; + } + + .app-title { + font-size: 1.1rem; + max-width: 40%; + } + + .admin-section { + gap: 5px; + } +} + +@media (max-width: 400px) { + .app-title { + font-size: 1rem; + max-width: 35%; + } +} diff --git a/apps/frontend/src/app/app.component.spec.ts b/apps/frontend/src/app/app.component.spec.ts deleted file mode 100755 index 787dd9946..000000000 --- a/apps/frontend/src/app/app.component.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { provideHttpClient } from '@angular/common/http'; -import { InjectionToken } from '@angular/core'; -import { KeycloakService } from 'keycloak-angular'; -import { AppComponent } from './app.component'; -import { environment } from '../environments/environment'; -import { AuthService } from './core/services/auth.service'; - -export const AUTH_TOKEN = new InjectionToken('AUTH_TOKEN'); -const mockAuthService = { - isLoggedIn: jest.fn(() => true) -}; - -const mockKeycloakService = { - isLoggedIn: () => true, - getToken: () => 'mocked-jwt-token', - login: jest.fn(), - logout: jest.fn() -}; - -describe('AppComponent', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - providers: [provideHttpClient(), { provide: AUTH_TOKEN, useValue: 'dummy-auth-token' }, - { provide: AuthService, useValue: mockAuthService }, - { provide: KeycloakService, useValue: mockKeycloakService }, - - { - provide: 'SERVER_URL', - useValue: environment.backendUrl - }], - imports: [AppComponent] - }).compileComponents(); - }); - - it('should create the app', () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); -}); diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html index 05261d01b..49df0c89f 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.html @@ -1,37 +1,32 @@ -
-
-

Manuelle Kodierung

-

Verwalten Sie Kodierer und Kodierjobs für die manuelle Kodierung von Antworten.

-
+
-
- - - Organisation für manuelle Kodierungen - - - - - + -
-

Manuelle Kodierung planen

-
-
- -
-
- +
+
+

Manuelle Kodierung planen

+

Verwalten Sie Kodierer und Kodierjobs für die manuelle Kodierung von Antworten.

+
+
+
+

Kodierer

+ +
+
+

Kodierjobs

+ +
diff --git a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss index e69de29bb..80c25f552 100755 --- a/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss +++ b/apps/frontend/src/app/coding/components/coding-management-manual/coding-management-manual.component.scss @@ -0,0 +1,144 @@ +// Main container styles +.coding-container { + padding: 24px; + width: 100%; + max-width: 1600px; + height: auto; + min-height: 60vh; + box-sizing: border-box; + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +// Action buttons styles +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: flex-start; + margin: 20px 0 10px; + + a[mat-raised-button] { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 20px; + height: 44px; + line-height: 44px; + border-radius: 22px; + font-weight: 500; + letter-spacing: 0.3px; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.12); + transition: all 0.2s ease; + min-width: auto; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18); + } + + &:active { + transform: translateY(0); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + } + + mat-icon { + margin-right: 10px; + font-size: 20px; + height: 20px; + width: 20px; + vertical-align: middle; + } + } +} + +// Card styles +.statistics-card { + background-color: white; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + padding: 24px; + margin-bottom: 24px; + width: 100%; + transition: box-shadow 0.3s ease, transform 0.2s ease; + border: 1px solid rgba(0, 0, 0, 0.03); + + &:hover { + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); + } + + .section-title { + font-size: 22px; + font-weight: 500; + color: #1976d2; + margin: 0 0 10px 0; + letter-spacing: -0.3px; + } + + .section-description { + font-size: 14px; + color: #666; + margin: 0 0 20px 0; + line-height: 1.5; + } + + hr { + margin-bottom: 20px; + opacity: 0.7; + border: 0; + border-top: 1px solid rgba(0, 0, 0, 0.1); + } + + .statistics-content { + margin: 20px 0; + + .coder-list-container, .coding-jobs-container { + margin-bottom: 20px; + } + + h3 { + font-size: 18px; + font-weight: 500; + color: #1976d2; + margin: 0 0 15px 0; + } + } +} + +// Responsive styles +@media (max-width: 768px) { + .coding-container { + padding: 16px; + } + + .action-buttons { + gap: 12px; + } + + .statistics-card { + padding: 16px; + } +} + +@media (max-width: 576px) { + .coding-container { + padding: 12px; + } + + .action-buttons { + flex-direction: column; + align-items: stretch; + } + + .action-buttons a[mat-raised-button] { + width: 100%; + } + + .statistics-card { + padding: 12px; + } +} diff --git a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.html b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.html index 6c1f179ae..090620c72 100755 --- a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.html +++ b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.html @@ -2,181 +2,192 @@ -
- @if (!isLoadingStatistics) { -
-

Kodierstatistiken

-

Übersicht über den Status der Kodierung und Antworten

- -
-
- Gesamtanzahl der gegebenen Antworten: - {{ codingStatistics.totalResponses }} -
- @for (status of getStatuses(); track status) { + + @if (!showManualCoding) { +
+ @if (!isLoadingStatistics) { +
+

Kodierstatistiken

+

Übersicht über den Status der Kodierung und Antworten

+ +
- - @if (status === 'null') { - unkodierte Antworten: - warning - } @else { - Anzahl der Antworten mit Status {{ status }}: - } - - - {{ codingStatistics.statusCounts[status] }} - ({{ getStatusPercentage(status) }}%) - - - visibility - Anzeigen - + Gesamtanzahl der gegebenen Antworten: + {{ codingStatistics.totalResponses }}
- } + @for (status of getStatuses(); track status) { +
+ + @if (status === 'null') { + unkodierte Antworten: + warning + } @else { + Anzahl der Antworten mit Status {{ status }}: + } + + + {{ codingStatistics.statusCounts[status] }} + ({{ getStatusPercentage(status) }}%) + + + visibility + Anzeigen + +
+ } +
+
-
- - refresh - Statistiken aktualisieren - + } + + @if (isLoadingStatistics) { +
+ +

Lade Kodierstatistiken...

-
- } + } - @if (isLoadingStatistics) { -
- -

Lade Kodierstatistiken...

-
- } + @if (isAutoCoding) { +
+ +

Antworten werden kodiert...

+
+ } + @if (isLoading && !isAutoCoding) { +
+ +

Daten werden verarbeitet...

+
+ } + @if (!isLoading && this.data.length > 0) { +
+
+

Kodierdaten

+ @if (currentStatusFilter) { +
+

+ @if (currentStatusFilter === 'null') { + Unkodierte Antworten + } @else { + Antworten mit Status: {{ currentStatusFilter }} + } +

+
+ } +
+ +
+ + @for (column of displayedColumns; track column) { + + + + + } + + +
{{ column | titlecase }} + @if (element.id === 0) { + + {{ element.unitname || element.variableid }} + + } + @if (element.id !== 0) { + @if (column === 'actions') { +
+ + +
+ } @else if (column === 'unitname') { + + {{ element[column] }} + + } @else { + + {{ element[column] }} + + } + } +
+ + +
+
+ } - @if (isAutoCoding) { -
- -

Antworten werden kodiert...

-
- } + @if (!isLoading && this.data.length === 0) { +
+ code +

Noch keine Kodierdaten angezeigt

+

Klicken Sie auf "Anzeigen" um Daten zu laden.

+
+ } - @if (isLoading && !isAutoCoding) { -
- -

Daten werden verarbeitet...

-
- } +
+ } - @if (!isLoading && this.data.length > 0) { -
-
-

Kodierdaten

- @if (currentStatusFilter) { -
-

- @if (currentStatusFilter === 'null') { - Unkodierte Antworten - } @else { - Antworten mit Status: {{ currentStatusFilter }} - } -

-
- } -
- - - - - - - - - - - - - - - - -
- - @for (column of displayedColumns; track column) { - - - - - } - - -
{{ column | titlecase }} - @if (element.id === 0) { - - {{ element.unitname || element.variableid }} - - } - @if (element.id !== 0) { - @if (column === 'actions') { -
- - -
- } @else if (column === 'unitname') { - - {{ element[column] }} - - } @else { - - {{ element[column] }} - - } - } -
- - -
-
-} + + @if (showManualCoding) { + + } -@if (!isLoading && this.data.length === 0) { -
- code -

Noch keine Kodierdaten angezeigt

-

Klicken Sie auf "Anzeigen" um Daten zu laden.

-
-} -
diff --git a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.scss b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.scss index e89fa5955..255d8ff66 100755 --- a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.scss +++ b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.scss @@ -716,6 +716,40 @@ } } +// Variable Analysis Bar Chart styles +.bar-chart-container { + display: flex; + align-items: center; + width: 100%; + gap: 10px; + + .bar-chart-value { + min-width: 50px; + font-weight: 500; + color: #1976d2; + font-size: 13px; + text-align: right; + } + + .bar-chart { + flex: 1; + height: 20px; + background-color: #f0f0f0; + border-radius: 10px; + overflow: hidden; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); + + .bar-chart-bar { + height: 100%; + background-color: #1976d2; + border-radius: 10px; + transition: width 0.5s ease-in-out; + min-width: 5px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + } + } +} + // Dialog styles for test person coding ::ng-deep .full-screen-dialog { .mat-mdc-dialog-container { diff --git a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts index b1eb0c930..18c2a01c1 100755 --- a/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts +++ b/apps/frontend/src/app/coding/components/coding-managment/coding-management.component.ts @@ -1,8 +1,8 @@ import { Component, ViewChild, AfterViewInit, OnInit, OnDestroy, inject } from '@angular/core'; -import { RouterLink } from '@angular/router'; import { NgClass, TitleCasePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { catchError, finalize, @@ -29,6 +29,7 @@ import { MatAnchor, MatButton, MatIconButton } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ScrollingModule } from '@angular/cdk/scrolling'; import { MatDivider } from '@angular/material/divider'; +import { MatSelectModule } from '@angular/material/select'; import { MatDialog } from '@angular/material/dialog'; import { ContentDialogComponent } from '../../../shared/dialogs/content-dialog/content-dialog.component'; import { BackendService } from '../../../services/backend.service'; @@ -38,12 +39,14 @@ import { ExportDialogComponent, ExportFormat } from '../export-dialog/export-dia import { Success } from '../../models/success.model'; import { CodingListItem } from '../../models/coding-list-item.model'; import { TestPersonCodingDialogComponent } from '../test-person-coding-dialog/test-person-coding-dialog.component'; +import { ExportCodingBookComponent } from '../export-coding-book/export-coding-book.component'; +import { CodingManagementManualComponent } from '../coding-management-manual/coding-management-manual.component'; +import { VariableAnalysisDialogComponent } from '../variable-analysis-dialog/variable-analysis-dialog.component'; @Component({ selector: 'app-coding-management', templateUrl: './coding-management.component.html', imports: [ - RouterLink, NgClass, MatTable, MatColumnDef, @@ -69,7 +72,10 @@ import { TestPersonCodingDialogComponent } from '../test-person-coding-dialog/te MatIconButton, MatTooltipModule, MatDivider, - MatButton + MatButton, + MatSelectModule, + CodingManagementManualComponent, + FormsModule ], styleUrls: ['./coding-management.component.scss'] }) @@ -86,10 +92,12 @@ export class CodingManagementComponent implements AfterViewInit, OnInit, OnDestr data: any[] = []; dataSource = new MatTableDataSource(this.data); displayedColumns: string[] = ['unitname', 'variableid', 'value', 'codedstatus', 'actions']; + isLoading = false; isFilterLoading = false; isLoadingStatistics = false; isAutoCoding = false; + showManualCoding = false; currentStatusFilter: string | null = null; pageSizeOptions = [100, 200, 500]; pageSize = 100; @@ -306,18 +314,13 @@ export class CodingManagementComponent implements AfterViewInit, OnInit, OnDestr } onAutoCode(): void { - // Open the test person coding dialog const dialogRef = this.dialog.open(TestPersonCodingDialogComponent, { - width: '90vw', height: '90vh', maxWidth: '100vw', - maxHeight: '100vh', - panelClass: 'full-screen-dialog' + maxHeight: '100vh' }); - // Handle dialog close event if needed dialogRef.afterClosed().subscribe(() => { - // Refresh statistics after dialog is closed this.fetchCodingStatistics(); }); } @@ -619,4 +622,32 @@ export class CodingManagementComponent implements AfterViewInit, OnInit, OnDestr }); }); } + + /** + * Opens the export coding book dialog + */ + openExportCodingBook(): void { + this.dialog.open(ExportCodingBookComponent, { + width: '80%', + height: '80%' + }); + } + + toggleManualCoding(): void { + this.showManualCoding = !this.showManualCoding; + } + + fetchVariableAnalysis(): void { + const workspaceId = this.appService.selectedWorkspaceId; + + this.dialog.open(VariableAnalysisDialogComponent, { + width: '90%', + height: '90%', + maxWidth: '1400px', + maxHeight: '900px', + data: { + workspaceId + } + }); + } } diff --git a/apps/frontend/src/app/coding/components/edit-missings-profiles-dialog/edit-missings-profiles-dialog.component.html b/apps/frontend/src/app/coding/components/edit-missings-profiles-dialog/edit-missings-profiles-dialog.component.html new file mode 100644 index 000000000..8b70db89e --- /dev/null +++ b/apps/frontend/src/app/coding/components/edit-missings-profiles-dialog/edit-missings-profiles-dialog.component.html @@ -0,0 +1,179 @@ +

{{ 'workspace.edit-missings-profiles' | translate }}

+ + +
+ +
+

{{ 'workspace.missings-profiles' | translate }}

+ + @for (profile of missingsProfiles; track profile.label) { + + {{ profile.label }} + + } + +
+ +
+
+ + +
+

+ @if (editMode) { + {{ 'workspace.edit-profile' | translate }} + } @else { + {{ selectedProfile.label }} + } +

+ + @if (editMode) { +
+ + {{ 'workspace.profile-name' | translate }} + + + +

{{ 'workspace.missing-values' | translate }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'workspace.missing-id' | translate }} + + + + {{ 'workspace.missing-label' | translate }} + + + + {{ 'workspace.missing-description' | translate }} + + + + {{ 'workspace.missing-code' | translate }} + + + + {{ 'workspace.actions' | translate }} + +
+ +
+ +
+
+ } @else { +
+

{{ 'workspace.missing-values' | translate }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'workspace.missing-id' | translate }}{{ missing.id }}{{ 'workspace.missing-label' | translate }}{{ missing.label }}{{ 'workspace.missing-description' | translate }}{{ missing.description }}{{ 'workspace.missing-code' | translate }}{{ missing.code }}
+
+ } +
+
+
+ + + @if (selectedProfile) { + @if (editMode) { + + + } @else { + + + } + } + + diff --git a/apps/frontend/src/app/coding/components/edit-missings-profiles-dialog/edit-missings-profiles-dialog.component.scss b/apps/frontend/src/app/coding/components/edit-missings-profiles-dialog/edit-missings-profiles-dialog.component.scss new file mode 100644 index 000000000..f9b4af0ba --- /dev/null +++ b/apps/frontend/src/app/coding/components/edit-missings-profiles-dialog/edit-missings-profiles-dialog.component.scss @@ -0,0 +1,120 @@ +.dialog-content { + min-width: 800px; + max-width: 1200px; + max-height: 80vh; + overflow: auto; +} + +.dialog-layout { + display: flex; + flex-direction: row; + gap: 24px; + + @media (max-width: 768px) { + flex-direction: column; + } +} + +.profiles-list { + width: 250px; + min-width: 200px; + + @media (max-width: 768px) { + width: 100%; + } + + h3 { + margin-top: 0; + margin-bottom: 16px; + } + + mat-list { + max-height: 300px; + overflow-y: auto; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + + mat-list-item { + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + + &.selected { + background-color: rgba(0, 0, 0, 0.08); + font-weight: 500; + } + } + } + + .list-actions { + margin-top: 16px; + } +} + +.profile-details { + flex: 1; + + h3 { + margin-top: 0; + margin-bottom: 16px; + } + + h4 { + margin-top: 16px; + margin-bottom: 8px; + } + + .profile-form, .profile-info { + mat-form-field { + width: 100%; + } + } + + .missings-table { + width: 100%; + margin-bottom: 16px; + + .mat-column-id { + width: 15%; + } + + .mat-column-label { + width: 20%; + } + + .mat-column-description { + width: 45%; + } + + .mat-column-code { + width: 10%; + } + + .mat-column-actions { + width: 10%; + text-align: center; + } + + mat-form-field { + width: 100%; + margin: -16px 0; + } + } + + .add-missing-button { + margin-top: 16px; + display: flex; + justify-content: center; + } +} + +mat-dialog-actions { + padding: 16px 0; + + button { + margin-left: 8px; + } +} diff --git a/apps/frontend/src/app/coding/components/edit-missings-profiles-dialog/edit-missings-profiles-dialog.component.ts b/apps/frontend/src/app/coding/components/edit-missings-profiles-dialog/edit-missings-profiles-dialog.component.ts new file mode 100644 index 000000000..babc75bdc --- /dev/null +++ b/apps/frontend/src/app/coding/components/edit-missings-profiles-dialog/edit-missings-profiles-dialog.component.ts @@ -0,0 +1,265 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatListModule } from '@angular/material/list'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateModule } from '@ngx-translate/core'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import { MissingDto, MissingsProfilesDto } from '../../../../../../../api-dto/coding/missings-profiles.dto'; + +@Component({ + selector: 'app-edit-missings-profiles-dialog', + templateUrl: './edit-missings-profiles-dialog.component.html', + styleUrls: ['./edit-missings-profiles-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatButtonModule, + MatCardModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatListModule, + MatSnackBarModule, + MatTableModule, + MatTooltipModule, + TranslateModule + ] +}) +export class EditMissingsProfilesDialogComponent implements OnInit { + missingsProfiles: { label: string }[] = []; + selectedProfile: MissingsProfilesDto | null = null; + editMode = false; + loading = false; + saving = false; + displayedColumns: string[] = ['id', 'label', 'description', 'code', 'actions']; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { workspaceId: number }, + private backendService: BackendService, + private appService: AppService, + private snackBar: MatSnackBar + ) {} + + ngOnInit(): void { + this.loadMissingsProfiles(); + } + + loadMissingsProfiles(): void { + const workspaceId = this.data.workspaceId; + if (workspaceId) { + this.loading = true; + this.backendService.getMissingsProfiles(workspaceId).subscribe({ + next: profiles => { + this.missingsProfiles = profiles; + this.loading = false; + }, + error: () => { + this.loading = false; + this.snackBar.open('Error loading missings profiles', 'Close', { duration: 3000 }); + } + }); + } + } + + selectProfile(label: string): void { + const workspaceId = this.data.workspaceId; + if (workspaceId) { + this.loading = true; + this.backendService.getMissingsProfileDetails(workspaceId, label).subscribe({ + next: profile => { + const missingsProfile = new MissingsProfilesDto(); + if (profile) { + missingsProfile.id = profile.id; + missingsProfile.label = profile.label; + missingsProfile.missings = profile.missings; + } + this.selectedProfile = missingsProfile; + this.loading = false; + }, + error: () => { + this.loading = false; + this.snackBar.open('Error loading missings profile details', 'Close', { duration: 3000 }); + } + }); + } + } + + createProfile(): void { + this.selectedProfile = new MissingsProfilesDto(); + this.selectedProfile.label = ''; + // this.selectedProfile.setMissings([ + // { + // id: 'missing', + // label: 'Missing', + // description: 'Value is missing', + // code: 999 + // } + // ]); + this.editMode = true; + } + + editProfile(): void { + this.editMode = true; + } + + saveProfile(): void { + const workspaceId = this.data.workspaceId; + if (workspaceId && this.selectedProfile) { + this.saving = true; + + const existingProfile = this.missingsProfiles.find(p => p.label === this.selectedProfile?.label); + + if (existingProfile) { + this.backendService.updateMissingsProfile(workspaceId, existingProfile.label, this.selectedProfile).subscribe({ + next: profile => { + const missingsProfile = new MissingsProfilesDto(); + if (profile) { + missingsProfile.id = profile.id; + missingsProfile.label = profile.label; + missingsProfile.missings = profile.missings; + } + this.selectedProfile = missingsProfile; + this.saving = false; + this.editMode = false; + this.loadMissingsProfiles(); + this.snackBar.open('Profile updated successfully', 'Close', { duration: 3000 }); + }, + error: () => { + this.saving = false; + this.snackBar.open('Error updating missings profile', 'Close', { duration: 3000 }); + } + }); + } else { + this.backendService.createMissingsProfile(workspaceId, this.selectedProfile).subscribe({ + next: profile => { + // Convert plain object to MissingsProfilesDto instance + const missingsProfile = new MissingsProfilesDto(); + if (profile) { + missingsProfile.id = profile.id; + missingsProfile.label = profile.label; + missingsProfile.missings = profile.missings; + } + this.selectedProfile = missingsProfile; + this.saving = false; + this.editMode = false; + this.loadMissingsProfiles(); + this.snackBar.open('Profile created successfully', 'Close', { duration: 3000 }); + }, + error: () => { + this.saving = false; + this.snackBar.open('Error creating missings profile', 'Close', { duration: 3000 }); + } + }); + } + } + } + + deleteProfile(): void { + const workspaceId = this.data.workspaceId; + if (workspaceId && this.selectedProfile) { + this.saving = true; + this.backendService.deleteMissingsProfile(workspaceId, this.selectedProfile.label).subscribe({ + next: success => { + if (success) { + this.selectedProfile = null; + this.saving = false; + this.editMode = false; + this.loadMissingsProfiles(); + this.snackBar.open('Profile deleted successfully', 'Close', { duration: 3000 }); + } else { + this.saving = false; + this.snackBar.open('Error deleting missings profile', 'Close', { duration: 3000 }); + } + }, + error: () => { + this.saving = false; + this.snackBar.open('Error deleting missings profile', 'Close', { duration: 3000 }); + } + }); + } + } + + cancelEdit(): void { + this.editMode = false; + + // If this was a new profile, clear the selection + if (!this.missingsProfiles.find(p => p.label === this.selectedProfile?.label)) { + this.selectedProfile = null; + } + } + + addMissing(): void { + if (this.selectedProfile) { + const missings = this.selectedProfile.parseMissings(); + + const highestCode = missings.reduce((max, missing) => Math.max(max, missing.code), 0); + + missings.push({ + id: `missing-${Date.now()}`, + label: 'New Missing', + description: 'Description', + code: highestCode > 900 ? highestCode - 1 : 998 + }); + + // this.selectedProfile.setMissings(missings); + } + } + + removeMissing(index: number): void { + if (this.selectedProfile) { + const missings = this.selectedProfile.parseMissings(); + missings.splice(index, 1); + this.selectedProfile.setMissings(missings); + } + } + + getMissings(): MissingDto[] { + if (!this.selectedProfile) { + return []; + } + + try { + const missings = this.selectedProfile.parseMissings(); + return Array.isArray(missings) ? missings : []; + } catch (error) { + // Error occurred while parsing missings + return []; + } + } + + updateMissing(index: number, field: keyof MissingDto, value: string | number): void { + if (this.selectedProfile) { + const missings = this.selectedProfile.parseMissings(); + + if (field === 'code') { + missings[index][field] = typeof value === 'string' ? parseInt(value, 10) : value; + } else if (field === 'id' || field === 'label' || field === 'description') { + missings[index][field] = String(value); + } + + this.selectedProfile.setMissings(missings); + } + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.html b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.html new file mode 100644 index 000000000..876378eaf --- /dev/null +++ b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.html @@ -0,0 +1,211 @@ +
+

{{ 'workspace.export-coding-book' | translate }}

+ +
+ +
+
+

{{'coding.select-units' | translate}}

+ {{ unitList.length }} / {{ availableUnits.length }} +
+ +
+ + {{'coding.select-all-units' | translate}} + +
+ + +
+ + {{'search' | translate}} + + search + @if (filterValue) { + + } + +
+ + + @if (isLoading) { +
+ +

{{'loading-units' | translate}}

+
+ } + + + @if (!isLoading) { +
+ + + + + + + + + + + + + + + + + + + + +
+ + + {{'coding.unit-name' | translate}}{{formatUnitName(unit.unitName)}}
+ @if (filterValue) { + {{'no-units-matching' | translate}} "{{filterValue}}" + } @else { + {{'no-units-available' | translate}} + } +
+
+ } +
+ + +
+ +
+

{{'coding.codebook-content' | translate}}

+
+ + {{'coding.has-only-vars-with-codes' | translate}} + + + + {{'coding.has-general-instructions' | translate}} + + + + {{'coding.hide-item-var-relation' | translate}} + + + + {{'coding.has-derived-vars' | translate}} + + + + {{'coding.has-only-manual-coding' | translate}} + + + + {{'coding.has-closed-vars' | translate}} + + + + {{'coding.show-score' | translate}} + + + + {{'coding.code-label-to-upper' | translate}} + +
+
+ + + + +
+

{{ 'workspace.coding-missing-profiles' | translate }}

+ + {{'workspace.select-missings-profile' | translate }} + + @for (missingsProfile of missingsProfiles; track missingsProfile) { + + @if (missingsProfile === '') { + {{ 'Keines' }} + } @else { + {{missingsProfile}} + } + + } + + +
+ + + + +
+

{{'coding.export-format' | translate}}

+ + JSON + DOCX + +
+ + @if(workspaceChanges) { + +
+ {{'coding.error-save-changes' | translate}} +
+ } +
+
+
+ + + + + +
diff --git a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.scss b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.scss new file mode 100644 index 000000000..b5c3bc8c9 --- /dev/null +++ b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.scss @@ -0,0 +1,240 @@ +// Main container styles +.export-codebook-container { + display: flex; + flex-direction: column; + height: 100%; +} + +// Dialog content styles +.dialog-content { + min-height: 500px; + padding: 0; + overflow: hidden; +} + +// Layout for the export panels +.export-layout { + display: flex; + flex-direction: row; + gap: 20px; + height: 100%; + padding: 16px; + + @media (max-width: 960px) { + flex-direction: column; + } +} + +// Shared panel styles +.unit-selection-panel, +.settings-panel { + border-radius: 8px; + padding: 16px; + background-color: white; +} + +// Unit selection panel styles +.unit-selection-panel { + flex: 1; + display: flex; + flex-direction: column; + max-height: 600px; + min-width: 300px; + + @media (max-width: 960px) { + max-height: 400px; + } + + @media (max-height: 768px) { + max-height: 450px; + } + + @media (max-height: 600px) { + max-height: 350px; + } +} + +// Panel header with title and count +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + + h3 { + margin: 0; + font-weight: 500; + } + + .selection-count { + background-color: #f0f0f0; + padding: 4px 8px; + border-radius: 16px; + font-size: 14px; + color: rgba(0, 0, 0, 0.7); + } +} + +// Select all container +.select-all-container { + margin-bottom: 16px; +} + +// Search container +.search-container { + margin-bottom: 16px; + + .search-field { + width: 100%; + } +} + +// Loading container +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + + .loading-text { + margin-top: 16px; + color: rgba(0, 0, 0, 0.6); + } +} + +// Units table container +.units-table-container { + overflow-y: auto; // Enable vertical scrolling + flex: 1; + border: 1px solid #e0e0e0; + border-radius: 4px; + display: flex; + flex-direction: column; + max-height: 500px; // Fixed height to enable scrolling when content exceeds this height + + // Ensure smooth scrolling on touch devices + -webkit-overflow-scrolling: touch; + + // Add some bottom padding to ensure last row is fully visible when scrolled to bottom + padding-bottom: 4px; +} + +// Units table styles +.units-table { + width: 100%; + // Ensure table takes up available space in the container + flex: 1; + overflow: auto; + + .mat-mdc-header-cell { + background-color: #f5f5f5; + font-weight: 500; + padding: 12px 16px; + position: sticky; + top: 0; + z-index: 1; + } + + .mat-mdc-cell { + padding: 12px 16px; + height: 48px; // Match row height + vertical-align: middle; // Center content vertically + box-sizing: border-box; // Include padding in height calculation + } + + .mat-mdc-row { + height: 48px; // Fixed height for all rows + min-height: 48px; // Ensure minimum height + + &.selected { + background-color: rgba(33, 150, 243, 0.08); + } + + &:hover { + background-color: #f9f9f9; + } + } + + .mat-column-select { + width: 60px; + text-align: center; + } + + // No data row + .mat-mdc-no-data-row { + height: 48px; // Match regular row height + min-height: 48px; + + .mat-mdc-cell { + text-align: center; + padding: 12px 16px; // Match regular cell padding + height: 48px; // Match regular cell height + color: rgba(0, 0, 0, 0.6); + vertical-align: middle; + } + } +} + +// Settings panel styles +.settings-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 300px; + max-width: 500px; + overflow-y: auto; + + @media (max-width: 960px) { + max-width: none; + } +} + +// Settings section styles +.settings-section { + padding: 16px 0; + + h3 { + margin-top: 0; + margin-bottom: 16px; + font-weight: 500; + } + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } +} + +// Warning section styles +.warning-section { + padding: 16px; + background-color: rgba(244, 67, 54, 0.08); + border-radius: 4px; +} + +// Options grid for checkboxes +.options-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 12px; + + mat-checkbox { + margin-bottom: 8px; + } +} + +// Full width form field +.full-width { + width: 100%; +} + +// Export format radio group +.export-format-group { + display: flex; + flex-direction: row; + gap: 16px; +} diff --git a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.ts b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.ts new file mode 100644 index 000000000..74a71548c --- /dev/null +++ b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.ts @@ -0,0 +1,244 @@ +import { + Component, OnInit +} from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatOptionModule } from '@angular/material/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslateModule } from '@ngx-translate/core'; +import { Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { CodeBookContentSetting } from '../../../../../../../api-dto/coding/codebook-content-setting'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; + +@Component({ + selector: 'shared-export-coding-book', + templateUrl: './export-coding-book.component.html', + styleUrls: ['./export-coding-book.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatCheckboxModule, + MatRadioModule, + MatSelectModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatOptionModule, + MatIconModule, + MatTooltipModule, + MatDividerModule, + MatTableModule, + MatProgressSpinnerModule, + TranslateModule + ], + providers: [ + DatePipe + ] +}) +export class ExportCodingBookComponent implements OnInit { + unitList: number[] = []; + availableUnits: { + unitId: number; + unitName: string; + unitAlias: string | null; + }[] = []; + + dataSource = new MatTableDataSource<{ + unitId: number; + unitName: string; + unitAlias: string | null; + }>([]); + + filterValue = ''; + filterTextChanged = new Subject(); + isLoading = false; + + selectedMissingsProfile: string = ''; + missingsProfiles: string[] = ['']; + workspaceChanges = false; + + displayedColumns: string[] = ['select', 'unitName']; + + contentOptions: CodeBookContentSetting = { + exportFormat: 'docx', + missingsProfile: '', + hasOnlyManualCoding: true, + hasGeneralInstructions: true, + hasDerivedVars: true, + hasOnlyVarsWithCodes: true, + hasClosedVars: true, + codeLabelToUpper: true, + showScore: true, + hideItemVarRelation: true + }; + + constructor( + private backendService: BackendService, + private appService: AppService, + private datePipe: DatePipe + ) {} + + ngOnInit(): void { + this.workspaceChanges = this.checkWorkspaceChanges(); + this.loadMissingsProfiles(); + this.loadUnitsWithFileIds(); + + this.filterTextChanged + .pipe( + debounceTime(300), + distinctUntilChanged() + ) + .subscribe(event => { + this.applyFilter(event); + }); + } + + applyFilter(event: Event): void { + const filterValue = (event.target as HTMLInputElement).value; + // Apply the filter to the data source (will use the filterPredicate defined in loadUnitsWithFileIds) + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + private loadUnitsWithFileIds(): void { + const workspaceId = this.appService.selectedWorkspaceId; + if (workspaceId) { + // Show loading indicator while fetching data + this.isLoading = true; + + this.backendService.getUnitsWithFileIds(workspaceId).subscribe({ + next: units => { + if (units && units.length > 0) { + this.availableUnits = units.map(unit => ({ + unitId: unit.id, + unitName: unit.fileName, + unitAlias: null + })); + + // Initialize the data source with all units + // MatTableDataSource will handle filtering internally + this.dataSource.data = this.availableUnits; + + // Set up custom filter predicate to filter by formatted unit name + // This allows users to search for units without the .vocs extension + this.dataSource.filterPredicate = (data, filter: string) => { + const formattedName = this.formatUnitName(data.unitName).toLowerCase(); + return formattedName.includes(filter); + }; + + this.unitList = []; + } + this.isLoading = false; + }, + error: () => { + // Error occurred while loading units with file IDs + this.isLoading = false; + } + }); + } + } + + toggleUnitSelection(unitId: number, isSelected: boolean): void { + if (isSelected) { + if (!this.unitList.includes(unitId)) { + this.unitList.push(unitId); + } + } else { + this.unitList = this.unitList.filter(id => id !== unitId); + } + } + + isUnitSelected(unitId: number): boolean { + return this.unitList.includes(unitId); + } + + formatUnitName(unitName: string): string { + if (unitName && unitName.toLowerCase().endsWith('.vocs')) { + return unitName.substring(0, unitName.length - 5); + } + return unitName; + } + + toggleAllUnits(isSelected: boolean): void { + if (isSelected) { + this.unitList = this.availableUnits.map(unit => unit.unitId); + } else { + this.unitList = []; + } + } + + private checkWorkspaceChanges(): boolean { + return false; + } + + private loadMissingsProfiles(): void { + const workspaceId = this.appService.selectedWorkspaceId; + if (workspaceId) { + this.backendService.getMissingsProfiles(workspaceId).subscribe({ + next: profiles => { + this.missingsProfiles = ['']; + const profileLabels = profiles.map(profile => profile.label); + this.missingsProfiles = [...this.missingsProfiles, ...profileLabels]; + this.selectedMissingsProfile = ''; + this.contentOptions.missingsProfile = this.selectedMissingsProfile; + }, + error: () => { + // Error occurred while loading missings profiles + } + }); + } + } + + exportCodingBook(): void { + const workspaceId = this.appService.selectedWorkspaceId; + if (!workspaceId) { + return; + } + if (this.unitList.length === 0) { + return; + } + + this.contentOptions.missingsProfile = this.selectedMissingsProfile; + this.appService.dataLoading = true; + this.backendService.getCodingBook( + workspaceId, + this.selectedMissingsProfile, + this.contentOptions, + this.unitList + ).subscribe({ + next: blob => { + if (blob) { + // Create a download link for the blob + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + const timestamp = this.datePipe.transform(new Date(), 'yyyyMMdd_HHmmss'); + const fileExtension = this.contentOptions.exportFormat.toLowerCase(); + a.href = url; + a.download = `codebook_${timestamp}.${fileExtension}`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } + this.appService.dataLoading = false; + }, + error: () => { + this.appService.dataLoading = false; + } + }); + } +} diff --git a/apps/frontend/src/app/coding/components/scheme-editor-dialog/scheme-editor-dialog.component.ts b/apps/frontend/src/app/coding/components/scheme-editor-dialog/scheme-editor-dialog.component.ts new file mode 100644 index 000000000..4b870fff7 --- /dev/null +++ b/apps/frontend/src/app/coding/components/scheme-editor-dialog/scheme-editor-dialog.component.ts @@ -0,0 +1,270 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + MAT_DIALOG_DATA, MatDialog, MatDialogRef, MatDialogTitle, MatDialogContent, MatDialogActions +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { MatDivider } from '@angular/material/divider'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { StandaloneUnitSchemerComponent } from '../schemer/unit-schemer.component'; +import { UnitScheme } from '../schemer/unit-scheme.interface'; +import { BackendService } from '../../../services/backend.service'; +import { ConfirmDialogComponent } from '../../../shared/dialogs/confirm-dialog.component'; + +export interface SchemeEditorDialogData { + workspaceId: number; + fileId: string; + fileName: string; + content: string; +} + +@Component({ + selector: 'app-scheme-editor-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatButton, + MatDivider, + StandaloneUnitSchemerComponent + ], + template: ` +

{{ data.fileName }}

+ +
+ @if (schemerHtml && !isLoading) { + + + } @else { +
Loading schemer...
+ } +
+
+ + + + + + `, + styles: [` + .editor-container { + height: 80vh; + width: 100%; + overflow: hidden; + } + + .loading { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + font-size: 18px; + color: #666; + } + + unit-schemer-standalone { + display: block; + height: 100%; + width: 100%; + } + `] +}) +export class SchemeEditorDialogComponent implements OnInit { + schemerHtml = ''; + isLoading = true; + hasChanges = false; + + unitScheme: UnitScheme = { + scheme: '', + schemeType: 'iqb-standard@3.2' + }; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: SchemeEditorDialogData, + private snackBar: MatSnackBar, + private backendService: BackendService, + private dialog: MatDialog + ) {} + + ngOnInit(): void { + this.loadSchemerHtml(); + + this.unitScheme = { + scheme: this.data.content, + schemeType: 'iqb-standard@3.2' + }; + console.log(this.unitScheme); + this.backendService.getVariableInfoForScheme(this.data.workspaceId, this.data.fileName) + .subscribe({ + next: variables => { + if (variables && variables.length > 0) { + this.unitScheme = { + ...this.unitScheme, + variables + }; + } + }, + error: () => { + this.snackBar.open( + 'Failed to load variable information for the scheme. The schemer will work without variable validation.', + 'OK', + { duration: 5000 } + ); + } + }); + } + + loadSchemerHtml(): void { + this.isLoading = true; + + this.backendService.getFilesList(this.data.workspaceId, 1, 10000, 'Schemer') + .subscribe({ + next: response => { + if (response.data && response.data.length > 0) { + const sortedFiles = [...response.data].sort((a, b) => { + if (!a.created_at || !b.created_at) return 0; + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + + const latestFile = sortedFiles[0]; + + this.backendService.downloadFile(this.data.workspaceId, latestFile.id) + .subscribe({ + next: fileDownload => { + try { + const decodedContent = atob(fileDownload.base64Data); + this.schemerHtml = decodedContent; + this.isLoading = false; + } catch (error) { + this.snackBar.open('Failed to decode schemer HTML', 'Error', { duration: 3000 }); + } + }, + error: () => { + this.snackBar.open('Failed to download schemer HTML', 'Error', { duration: 3000 }); + } + }); + } + }, + error: () => { + this.snackBar.open('Failed to fetch schemer files', 'Error', { duration: 3000 }); + } + }); + } + + onSchemeChanged(scheme: UnitScheme): void { + if (!scheme.variables && this.unitScheme.variables) { + scheme.variables = this.unitScheme.variables; + } + this.unitScheme = scheme; + this.hasChanges = true; + } + + onError(error: string): void { + this.snackBar.open(`Schemer error: ${error}`, 'Error', { duration: 3000 }); + } + + close(): void { + if (this.hasChanges) { + const confirmRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Unsaved Changes', + content: 'You have unsaved changes. Are you sure you want to close?', + confirmButtonLabel: 'Yes', + showCancel: true + } + }); + + confirmRef.afterClosed().subscribe(result => { + if (result === true) { + this.dialogRef.close(false); + } + }); + } else { + this.dialogRef.close(false); + } + } + + + save(): void { + if (!this.hasChanges) { + this.dialogRef.close(false); + return; + } + + this.backendService.getFilesList(this.data.workspaceId, 1, 10000, 'Resource') + .subscribe({ + next: response => { + if (response.data && response.data.length > 0) { + const vocsFiles = response.data.filter(file => file.filename.endsWith('.VOCS')); + + if (vocsFiles.length > 0) { + // Delete the existing VOCS file + this.backendService.deleteFiles(this.data.workspaceId, [vocsFiles[0].id]) + .subscribe(deleteSuccess => { + if (deleteSuccess) { + const blob = new Blob([this.unitScheme.scheme], { type: 'application/json' }); + const file = new File([blob], vocsFiles[0].filename, { type: 'application/json' }); + + const formData = new FormData(); + formData.append('files', file); + + this.backendService.uploadTestFiles(this.data.workspaceId, formData) + .subscribe(uploadSuccess => { + if (uploadSuccess) { + this.snackBar.open('Scheme saved successfully', 'Success', { duration: 3000 }); + this.dialogRef.close(true); + } else { + this.snackBar.open('Failed to save scheme', 'Error', { duration: 3000 }); + } + }); + } else { + this.snackBar.open('Failed to update scheme', 'Error', { duration: 3000 }); + } + }); + } else { + this.saveOriginalFile(); + } + } else { + this.saveOriginalFile(); + } + }, + error: () => { + this.snackBar.open('Failed to fetch Resource files', 'Error', { duration: 3000 }); + this.saveOriginalFile(); + } + }); + } + + private saveOriginalFile(): void { + this.backendService.deleteFiles(this.data.workspaceId, [+this.data.fileId]) + .subscribe(deleteSuccess => { + if (deleteSuccess) { + const blob = new Blob([this.unitScheme.scheme], { type: 'application/json' }); + const file = new File([blob], this.data.fileName, { type: 'application/json' }); + + const formData = new FormData(); + formData.append('files', file); + + this.backendService.uploadTestFiles(this.data.workspaceId, formData) + .subscribe(uploadSuccess => { + if (uploadSuccess) { + this.snackBar.open('Scheme saved successfully', 'Success', { duration: 3000 }); + this.dialogRef.close(true); + } else { + this.snackBar.open('Failed to save scheme', 'Error', { duration: 3000 }); + } + }); + } else { + this.snackBar.open('Failed to update scheme', 'Error', { duration: 3000 }); + } + }); + } +} diff --git a/apps/frontend/src/app/coding/components/schemer/message-types.interface.ts b/apps/frontend/src/app/coding/components/schemer/message-types.interface.ts new file mode 100644 index 000000000..b3ba305a5 --- /dev/null +++ b/apps/frontend/src/app/coding/components/schemer/message-types.interface.ts @@ -0,0 +1,40 @@ +import { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; +import { SchemerConfig } from './schemer-config.interface'; + +export interface VosReadyNotification { + type: 'vosReadyNotification'; +} + +export interface VosStartCommand { + type: 'vosStartCommand'; + sessionId: string; + schemerConfig: SchemerConfig; + codingScheme: string; + codingSchemeType: string; + variables?: VariableInfo[]; +} + +export interface VosSchemeChangedNotification { + type: 'vosSchemeChangedNotification'; + sessionId: string; + codingScheme: string; + codingSchemeType: string; +} + +export interface VosGetSchemeRequest { + type: 'vosGetSchemeRequest'; + sessionId: string; +} + +export interface VosReadNotification { + type: 'vosReadNotification'; + sessionId: string; + message?: string; +} + +export type VosMessage = + | VosReadyNotification + | VosStartCommand + | VosSchemeChangedNotification + | VosGetSchemeRequest + | VosReadNotification; diff --git a/apps/frontend/src/app/coding/components/schemer/schemer-config.interface.ts b/apps/frontend/src/app/coding/components/schemer/schemer-config.interface.ts new file mode 100644 index 000000000..e9782108b --- /dev/null +++ b/apps/frontend/src/app/coding/components/schemer/schemer-config.interface.ts @@ -0,0 +1,19 @@ +/** + * Interface representing the configuration options for the unit schemer + */ +export interface SchemerConfig { + /** + * The policy for reporting definition changes + * - 'eager': Report changes immediately + * - 'onDemand': Report changes only when requested + */ + definitionReportPolicy: 'eager' | 'onDemand'; + + /** + * The role of the user + * - 'editor': Can edit the scheme + * - 'viewer': Can only view the scheme + * - 'admin': Has full access to the scheme + */ + role: 'editor' | 'viewer' | 'admin'; +} diff --git a/apps/frontend/src/app/coding/components/schemer/unit-scheme.interface.ts b/apps/frontend/src/app/coding/components/schemer/unit-scheme.interface.ts new file mode 100644 index 000000000..9e8c166f8 --- /dev/null +++ b/apps/frontend/src/app/coding/components/schemer/unit-scheme.interface.ts @@ -0,0 +1,7 @@ +import { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; + +export interface UnitScheme { + scheme: string; + schemeType: string; + variables?: VariableInfo[]; +} diff --git a/apps/frontend/src/app/coding/components/schemer/unit-schemer.component.html b/apps/frontend/src/app/coding/components/schemer/unit-schemer.component.html new file mode 100644 index 000000000..b64126aeb --- /dev/null +++ b/apps/frontend/src/app/coding/components/schemer/unit-schemer.component.html @@ -0,0 +1,4 @@ +
+
{{ message }}
+ +
diff --git a/apps/frontend/src/app/coding/components/schemer/unit-schemer.component.scss b/apps/frontend/src/app/coding/components/schemer/unit-schemer.component.scss new file mode 100644 index 000000000..75d138ba0 --- /dev/null +++ b/apps/frontend/src/app/coding/components/schemer/unit-schemer.component.scss @@ -0,0 +1,24 @@ +.unit-schemer-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; +} + +.message { + padding: 10px; + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + margin-bottom: 10px; + color: #495057; +} + +.schemer-iframe { + flex: 1; + border: none; + width: 100%; + height: 100%; + min-height: 400px; +} diff --git a/apps/frontend/src/app/coding/components/schemer/unit-schemer.component.ts b/apps/frontend/src/app/coding/components/schemer/unit-schemer.component.ts new file mode 100644 index 000000000..fa5a82327 --- /dev/null +++ b/apps/frontend/src/app/coding/components/schemer/unit-schemer.component.ts @@ -0,0 +1,133 @@ +import { + AfterViewInit, Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Subject, takeUntil } from 'rxjs'; +import { UnitScheme } from './unit-scheme.interface'; +import { SchemerConfig } from './schemer-config.interface'; +import { + VosReadNotification, + VosStartCommand +} from './message-types.interface'; +import { PostMessageService } from '../../../services/post-message.service'; +import { SchemerMessage } from '../../../services/post-message-types'; + +@Component({ + selector: 'unit-schemer-standalone', + templateUrl: './unit-schemer.component.html', + styleUrls: ['./unit-schemer.component.scss'], + standalone: true, + imports: [CommonModule] +}) +export class StandaloneUnitSchemerComponent implements AfterViewInit, OnDestroy { + @ViewChild('hostingIframe') hostingIframe!: ElementRef; + @Input() schemerId = ''; + @Input() schemerHtml = ''; + @Input() unitScheme: UnitScheme = { + scheme: '', + schemeType: '' + }; + + @Input() schemerConfig: SchemerConfig = { + definitionReportPolicy: 'eager', + role: 'editor' + }; + + @Output() schemeChanged = new EventEmitter(); + @Output() error = new EventEmitter(); + @Output() ready = new EventEmitter(); + @Output() readNotification = new EventEmitter(); + + private iFrameElement: HTMLIFrameElement | undefined; + private sessionId = ''; + private destroy$ = new Subject(); + message = ''; + + constructor(private postMessageService: PostMessageService) {} + + ngAfterViewInit(): void { + this.iFrameElement = this.hostingIframe.nativeElement; + + this.subscribeToSchemerMessages(); + + if (this.schemerHtml) { + this.setupSchemerIFrame(this.schemerHtml); + } else if (this.schemerId) { + this.error.emit(`Schemer HTML content not provided for ID: ${this.schemerId}`); + } else { + this.error.emit('Neither schemer ID nor HTML content provided'); + } + } + + private subscribeToSchemerMessages(): void { + this.postMessageService.getMessages('vosReadyNotification') + .pipe(takeUntil(this.destroy$)) + .subscribe(event => { + if (event.source === this.iFrameElement?.contentWindow) { + this.sessionId = this.postMessageService.generateSessionId(); + this.sendUnitScheme(); + this.ready.emit(); + } + }); + + this.postMessageService.getMessages('vosSchemeChangedNotification') + .pipe(takeUntil(this.destroy$)) + .subscribe(event => { + if (event.source === this.iFrameElement?.contentWindow && event.message.sessionId === this.sessionId) { + if (event.message.codingScheme) { + const updatedScheme: UnitScheme = { + scheme: event.message.codingScheme, + schemeType: event.message.codingSchemeType || this.unitScheme.schemeType, + variables: this.unitScheme.variables + }; + this.unitScheme = updatedScheme; + this.schemeChanged.emit(updatedScheme); + } + } + }); + + this.postMessageService.getMessages('vosReadNotification') + .pipe(takeUntil(this.destroy$)) + .subscribe(event => { + if (event.source === this.iFrameElement?.contentWindow && event.message.sessionId === this.sessionId) { + this.readNotification.emit(event.message as VosReadNotification); + + // Optionally display a message in the component + if (event.message.message) { + this.message = event.message.message; + // Clear the message after a few seconds + setTimeout(() => { + this.message = ''; + }, 3000); + } + } + }); + } + + sendUnitScheme(): void { + if (this.iFrameElement?.contentWindow) { + const variables = this.unitScheme.variables || []; + const message: VosStartCommand = { + type: 'vosStartCommand', + sessionId: this.sessionId, + schemerConfig: this.schemerConfig, + codingScheme: this.unitScheme.scheme || '', + codingSchemeType: this.unitScheme.schemeType || '', + variables: variables + }; + + this.postMessageService.sendMessageToIframe(message, this.iFrameElement); + } + } + + private setupSchemerIFrame(schemerHtml: string): void { + if (this.iFrameElement && this.iFrameElement.parentElement) { + this.iFrameElement.srcdoc = schemerHtml; + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html index ca7aab1ae..aab12f13a 100644 --- a/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html +++ b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.html @@ -1,12 +1,15 @@

Testpersonen Kodierung

-
+ +
diff --git a/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.scss b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.scss index 49a95ce38..f29ce792b 100644 --- a/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.scss +++ b/apps/frontend/src/app/coding/components/test-person-coding-dialog/test-person-coding-dialog.component.scss @@ -3,14 +3,14 @@ flex-direction: column; height: 100%; width: 100%; - max-width: 1200px; + max-width: 900px; max-height: 90vh; overflow: hidden; } .dialog-header { display: flex; - justify-content: space-between; + justify-content: center; align-items: center; padding: 16px 24px; border-bottom: 1px solid rgba(0, 0, 0, 0.12); @@ -22,16 +22,23 @@ font-weight: 500; } -.close-button { - margin-left: 16px; -} - .dialog-content { flex: 1; overflow: auto; padding: 0; } +.dialog-footer { + display: flex; + justify-content: flex-end; + padding: 16px 24px; + border-top: 1px solid rgba(0, 0, 0, 0.12); +} + +.close-button { + min-width: 100px; +} + ::ng-deep .mat-mdc-dialog-container { padding: 0 !important; } diff --git a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html index 508ed9ca4..5d2116570 100644 --- a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html +++ b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html @@ -99,18 +99,21 @@ Aktionen
- - + + + + + + Variablen-ID Filter + + + + + + + + +
+
+ + @if (isLoadingVariableAnalysis) { +
+ +

Lade Variablenanalyse...

+
+ } + + @if (!isLoadingVariableAnalysis && variableAnalysisData.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Replay + + play_circle_filled + + Aufgaben-ID{{ element.unitId }}Variablen-ID{{ element.variableId }}Code{{ element.code }}Beschreibung{{ element.description }}Score{{ element.score }}Anzahl Vorkommen{{ element.occurrenceCount }}Gesamtanzahl{{ element.totalCount }}Relatives Vorkommen +
+
{{ (element.relativeOccurrence * 100).toFixed(1) }}%
+
+
+
+
+
+ + + + +
+ } + + @if (!isLoadingVariableAnalysis && variableAnalysisData.length === 0) { +
+ analytics +

Keine Variablenanalyse-Daten verfügbar

+

Es wurden keine Daten für die Variablenanalyse gefunden.

+
+ } +
+ +
+ +
+
diff --git a/apps/frontend/src/app/coding/components/variable-analysis-dialog/variable-analysis-dialog.component.scss b/apps/frontend/src/app/coding/components/variable-analysis-dialog/variable-analysis-dialog.component.scss new file mode 100644 index 000000000..b44e9b4e3 --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-analysis-dialog/variable-analysis-dialog.component.scss @@ -0,0 +1,272 @@ +// Dialog container styles +.dialog-container { + display: flex; + flex-direction: column; + height: 100%; + max-height: 90vh; + width: 100%; + padding: 0; + overflow: hidden; +} + +.dialog-title { + font-size: 22px; + font-weight: 500; + color: #1976d2; + margin: 16px 24px; + letter-spacing: -0.3px; +} + +.dialog-content { + flex: 1; + overflow-y: auto; + padding: 0 24px 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + padding: 16px 24px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + background-color: #f9fafc; +} + +// Filter container styles +.filter-container { + position: sticky; + top: 0; + z-index: 2; + width: 100%; + background-color: white; + padding: 16px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + margin-bottom: 16px; + + .filter-row { + display: flex; + align-items: center; + width: 100%; + flex-wrap: wrap; + gap: 16px; + } + + .filter-field { + flex: 1; + margin-bottom: 0; + + input { + font-size: 15px; + } + } +} + +// Table container styles +.table-container { + position: relative; + overflow-x: auto; + overflow-y: auto; + max-height: 500px; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); + background-color: white; + + /* Custom scrollbar styling */ + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #c1d5e8; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb:hover { + background: #a3c0e0; + } + + mat-paginator { + margin-top: 0; + background-color: #f9fafc; + position: sticky; + bottom: 0; + z-index: 2; + border-top: 1px solid rgba(0, 0, 0, 0.08); + padding: 4px 8px; + border-radius: 0 0 8px 8px; + } +} + +// Table styles +.coding-table { + width: 100%; + min-width: 600px; /* Ensures table doesn't get too narrow */ + + th, td { + padding: 14px 18px; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; /* Prevents cells from getting too wide */ + } + + th { + font-weight: 600; + color: #1976d2; + background-color: #f5f9ff; + letter-spacing: 0.2px; + font-size: 14px; + border-bottom: 2px solid rgba(25, 118, 210, 0.1); + position: sticky; + top: 0; + z-index: 1; + } + + td { + font-size: 14px; + color: #444; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + } + + tr { + transition: all 0.25s ease; + height: 54px; + + &:hover { + background-color: #e8f4ff; + cursor: pointer; + transform: translateY(-1px); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); + } + + &:nth-child(even) { + background-color: #fafafa; + + &:hover { + background-color: #e8f4ff; + } + } + } +} + +// Loading container styles +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 0; + width: 100%; + background-color: white; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + margin-bottom: 24px; + height: 200px; + border: 1px solid rgba(0, 0, 0, 0.03); + + .loading-text { + margin-top: 16px; + font-size: 16px; + color: #666; + } +} + +// Empty state styles +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 0; + text-align: center; + background-color: white; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + margin-bottom: 24px; + border: 1px solid rgba(0, 0, 0, 0.03); + animation: fadeIn 0.3s ease-in-out; + + .empty-icon { + font-size: 64px; + height: 64px; + width: 64px; + color: #bbdefb; + margin-bottom: 16px; + } + + h3 { + font-size: 20px; + font-weight: 500; + color: #1976d2; + margin: 0 0 8px 0; + } + + p { + font-size: 16px; + color: #666; + max-width: 400px; + line-height: 1.5; + margin: 0; + } +} + +// Variable Analysis Bar Chart styles +.bar-chart-container { + display: flex; + align-items: center; + width: 100%; + gap: 10px; + + .bar-chart-value { + min-width: 50px; + font-weight: 500; + color: #1976d2; + font-size: 13px; + text-align: right; + } + + .bar-chart { + flex: 1; + height: 20px; + background-color: #f0f0f0; + border-radius: 10px; + overflow: hidden; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); + + .bar-chart-bar { + height: 100%; + background-color: #1976d2; + border-radius: 10px; + transition: width 0.5s ease-in-out; + min-width: 5px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + } + } +} + +// Responsive styles +@media (max-width: 768px) { + .filter-container .filter-row { + flex-direction: column; + align-items: stretch; + } + + .table-container { + max-height: 350px; + } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/apps/frontend/src/app/coding/components/variable-analysis-dialog/variable-analysis-dialog.component.ts b/apps/frontend/src/app/coding/components/variable-analysis-dialog/variable-analysis-dialog.component.ts new file mode 100644 index 000000000..8d2e5a6a7 --- /dev/null +++ b/apps/frontend/src/app/coding/components/variable-analysis-dialog/variable-analysis-dialog.component.ts @@ -0,0 +1,186 @@ +import { + Component, + Inject, + OnInit, + ViewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatSort, MatSortModule } from '@angular/material/sort'; +import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import { VariableAnalysisItemDto } from '../../../../../../../api-dto/coding/variable-analysis-item.dto'; + +export interface VariableAnalysisDialogData { + workspaceId: number; + initialData?: { + data: VariableAnalysisItemDto[]; + total: number; + page: number; + limit: number; + }; +} + +@Component({ + selector: 'coding-box-variable-analysis-dialog', + templateUrl: './variable-analysis-dialog.component.html', + styleUrls: ['./variable-analysis-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatDividerModule, + MatProgressSpinnerModule, + MatTableModule, + MatSortModule, + MatPaginatorModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatTooltipModule + ] +}) +export class VariableAnalysisDialogComponent implements OnInit { + // Variable analysis data + variableAnalysisData: VariableAnalysisItemDto[] = []; + variableAnalysisDataSource = new MatTableDataSource([]); + variableAnalysisColumns: string[] = [ + 'replayUrl', 'unitId', 'variableId', + 'code', 'description', 'score', 'occurrenceCount', + 'totalCount', 'relativeOccurrence' + ]; + + totalVariableAnalysisRecords = 0; + + variableAnalysisPageIndex = 0; + + variableAnalysisPageSize = 100; + + variableAnalysisPageSizeOptions = [10, 25, 50, 100, 200]; + + // Filters + unitIdFilter = ''; + + variableIdFilter = ''; + + // Loading state + isLoadingVariableAnalysis = false; + + // Filter debounce + variableAnalysisFilterChanged = new Subject(); + + @ViewChild(MatSort) sort!: MatSort; + + @ViewChild(MatPaginator) paginator!: MatPaginator; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: VariableAnalysisDialogData, + private backendService: BackendService, + private appService: AppService, + private snackBar: MatSnackBar + ) {} + + ngOnInit(): void { + // Set up filter debounce + this.variableAnalysisFilterChanged.pipe( + debounceTime(500), + distinctUntilChanged() + ).subscribe(() => { + this.fetchVariableAnalysis(1, this.variableAnalysisPageSize); + }); + + // If initial data is provided, use it + if (this.data.initialData) { + this.variableAnalysisData = this.data.initialData.data; + this.variableAnalysisDataSource.data = this.data.initialData.data; + this.totalVariableAnalysisRecords = this.data.initialData.total; + this.variableAnalysisPageIndex = this.data.initialData.page - 1; // MatPaginator uses 0-based index + this.variableAnalysisPageSize = this.data.initialData.limit; + } else { + // Otherwise fetch the data + this.fetchVariableAnalysis(1, this.variableAnalysisPageSize); + } + } + + fetchVariableAnalysis(page: number = 1, limit: number = 100): void { + const workspaceId = this.data.workspaceId; + this.isLoadingVariableAnalysis = true; + + // Get filter values, trimming whitespace and only passing non-empty values + const unitId = this.unitIdFilter.trim() || undefined; + const variableId = this.variableIdFilter.trim() || undefined; + + this.backendService.getVariableAnalysis( + workspaceId, + page, + limit, + unitId, + variableId + ) + .subscribe({ + next: response => { + this.variableAnalysisData = response.data; + this.variableAnalysisDataSource.data = response.data; + this.totalVariableAnalysisRecords = response.total; + this.variableAnalysisPageIndex = response.page - 1; // MatPaginator uses 0-based index + this.variableAnalysisPageSize = response.limit; + + // Set up sorting for the variable analysis table + setTimeout(() => { + if (this.sort) { + this.variableAnalysisDataSource.sort = this.sort; + } + }); + + this.isLoadingVariableAnalysis = false; + }, + error: () => { + this.isLoadingVariableAnalysis = false; + this.snackBar.open('Fehler beim Abrufen der Variablenanalyse', 'Schließen', { + duration: 5000, + panelClass: ['error-snackbar'] + }); + } + }); + } + + onVariableAnalysisPaginatorChange(event: PageEvent): void { + const page = event.pageIndex + 1; // Convert from 0-based to 1-based index + const limit = event.pageSize; + this.fetchVariableAnalysis(page, limit); + } + + clearVariableAnalysisFilters(): void { + this.unitIdFilter = ''; + this.variableIdFilter = ''; + + // Reset to first page and refresh data + this.fetchVariableAnalysis(1, this.variableAnalysisPageSize); + } + + onVariableAnalysisFilterChange(): void { + this.variableAnalysisFilterChanged.next(); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/apps/frontend/src/app/coding/models/variable-analysis-item.model.ts b/apps/frontend/src/app/coding/models/variable-analysis-item.model.ts new file mode 100644 index 000000000..05b309053 --- /dev/null +++ b/apps/frontend/src/app/coding/models/variable-analysis-item.model.ts @@ -0,0 +1,31 @@ +export interface VariableAnalysisItem { + // Link to the replay of unit with its responses + replayUrl: string; + + // Unit ID + unitId: string; + + // Variable ID + variableId: string; + + // Derivation + derivation: string; + + // Code + code: string; + + // Description + description: string; + + // Score + score: number; + + // How often this unitId in combination with variableId with that code is in responses + occurrenceCount: number; + + // Total amount of that combination variableId and unit Id + totalCount: number; + + // Relative occurrence (for bar chart) + relativeOccurrence: number; +} diff --git a/apps/frontend/src/app/coding/services/coder.service.ts b/apps/frontend/src/app/coding/services/coder.service.ts index 30a88b200..4f861b217 100644 --- a/apps/frontend/src/app/coding/services/coder.service.ts +++ b/apps/frontend/src/app/coding/services/coder.service.ts @@ -1,42 +1,60 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { BehaviorSubject, Observable, of } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; import { Coder } from '../models/coder.model'; +import { SERVER_URL } from '../../injection-tokens'; @Injectable({ providedIn: 'root' }) export class CoderService { - // Sample data for demonstration - private sampleCoders: Coder[] = [ - { - id: 1, - name: 'Kodierer 1', - displayName: 'Max Mustermann', - email: 'max.mustermann@example.com', - assignedJobs: [1] - }, - { - id: 2, - name: 'Kodierer 2', - displayName: 'Anna Schmidt', - email: 'anna.schmidt@example.com', - assignedJobs: [2] - }, - { - id: 3, - name: 'Kodierer 3', - displayName: 'Tom Meyer', - email: 'tom.meyer@example.com', - assignedJobs: [3] - } - ]; + private http = inject(HttpClient); + private readonly serverUrl = inject(SERVER_URL); - private codersSubject = new BehaviorSubject(this.sampleCoders); + // Initialize with empty array + private codersSubject = new BehaviorSubject([]); /** - * Gets all coders + * Gets all coders (users with accessLevel 1) for the current workspace */ getCoders(): Observable { + // Get the current workspace ID from localStorage + const workspaceId = localStorage.getItem('workspace_id'); + + if (!workspaceId) { + console.error('No workspace ID found in localStorage'); + return of([]); + } + + // Fetch coders from the API + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/coders`; + + interface WorkspaceUser { + userId: number; + workspaceId: number; + accessLevel: number; + } + + this.http.get<{ data: WorkspaceUser[], total: number }>(url).subscribe({ + next: response => { + // Map the workspace users with accessLevel 1 to Coder objects + const coders: Coder[] = response.data.map(user => ({ + id: user.userId, + name: `User ${user.userId}`, // Default name if user details not available + displayName: `Coder ${user.userId}`, // Default display name + assignedJobs: [] + })); + + // Update the subject with the fetched coders + this.codersSubject.next(coders); + }, + error: error => { + console.error('Error fetching coders:', error); + // Keep the current value in case of error + } + }); + + // Return the observable from the subject return this.codersSubject.asObservable(); } diff --git a/apps/frontend/src/app/coding/services/test-person-coding.service.spec.ts b/apps/frontend/src/app/coding/services/test-person-coding.service.spec.ts new file mode 100644 index 000000000..bff244ce0 --- /dev/null +++ b/apps/frontend/src/app/coding/services/test-person-coding.service.spec.ts @@ -0,0 +1,351 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { + TestPersonCodingService, CodingStatistics, PaginatedCodingList, JobStatus, JobInfo +} from './test-person-coding.service'; +import { SERVER_URL } from '../../injection-tokens'; + +describe('TestPersonCodingService', () => { + let service: TestPersonCodingService; + let httpMock: HttpTestingController; + const mockServerUrl = 'http://localhost:3000/'; + const mockWorkspaceId = 123; + const mockAuthToken = 'test-token'; + + beforeEach(() => { + // Mock localStorage using Object.defineProperty + Object.defineProperty(window, 'localStorage', { + value: { + getItem: jest.fn().mockReturnValue(mockAuthToken) + }, + writable: true + }); + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + TestPersonCodingService, + { provide: SERVER_URL, useValue: mockServerUrl } + ] + }); + + service = TestBed.inject(TestPersonCodingService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + try { + httpMock.verify(); + } catch (error) { + console.error('Error in httpMock.verify():', error); + } + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('codeTestPersons', () => { + it('should send a GET request to code test persons', () => { + const mockTestPersonIds = '1,2,3'; + const mockResponse: CodingStatistics = { + totalResponses: 3, + statusCounts: { coded: 3 } + }; + + service.codeTestPersons(mockWorkspaceId, mockTestPersonIds).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding?testPersons=${mockTestPersonIds}`); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockAuthToken}`); + req.flush(mockResponse); + }); + + it('should handle errors and return empty statistics', () => { + const mockTestPersonIds = '1,2,3'; + + service.codeTestPersons(mockWorkspaceId, mockTestPersonIds).subscribe(response => { + expect(response).toEqual({ totalResponses: 0, statusCounts: {} }); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding?testPersons=${mockTestPersonIds}`); + req.error(new ProgressEvent('error')); + }); + }); + + describe('getManualTestPersons', () => { + it('should send a GET request to get manual test persons', () => { + const mockResponse = [{ id: 1, name: 'Test Person 1' }]; + + service.getManualTestPersons(mockWorkspaceId).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/manual`); + expect(req.request.method).toBe('GET'); + req.flush(mockResponse); + }); + + it('should include testPersons parameter when provided', () => { + const mockTestPersonIds = '1,2,3'; + const mockResponse = [{ id: 1, name: 'Test Person 1' }]; + + service.getManualTestPersons(mockWorkspaceId, mockTestPersonIds).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/manual?testPersons=${mockTestPersonIds}`); + expect(req.request.method).toBe('GET'); + req.flush(mockResponse); + }); + + it('should handle errors and return empty array', () => { + service.getManualTestPersons(mockWorkspaceId).subscribe(response => { + expect(response).toEqual([]); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/manual`); + req.error(new ProgressEvent('error')); + }); + }); + + describe('getCodingList', () => { + it('should send a GET request to get coding list with correct parameters', () => { + const mockServerUrlParam = 'http://test-server.com'; + const mockPage = 2; + const mockLimit = 10; + const mockResponse: PaginatedCodingList = { + data: [{ + unit_key: 'key1', + unit_alias: 'alias1', + login_name: 'user1', + login_code: 'code1', + booklet_id: 'book1', + variable_id: 'var1', + variable_page: 'page1', + variable_anchor: 'anchor1', + url: 'url1' + }], + total: 1, + page: mockPage, + limit: mockLimit + }; + + service.getCodingList(mockWorkspaceId, mockAuthToken, mockServerUrlParam, mockPage, mockLimit).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(request => request.url === `${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/coding-list` && + request.params.get('authToken') === mockAuthToken && + request.params.get('serverUrl') === mockServerUrlParam && + request.params.get('page') === mockPage.toString() && + request.params.get('limit') === mockLimit.toString() + ); + expect(req.request.method).toBe('GET'); + req.flush(mockResponse); + }); + + it('should handle errors and return empty paginated list', () => { + service.getCodingList(mockWorkspaceId, mockAuthToken).subscribe(response => { + expect(response).toEqual({ + data: [], + total: 0, + page: 1, + limit: 20 + }); + }); + + const req = httpMock.expectOne(request => request.url === `${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/coding-list` + ); + req.error(new ProgressEvent('error')); + }); + }); + + describe('getCodingStatistics', () => { + it('should send a GET request to get coding statistics', () => { + const mockResponse: CodingStatistics = { + totalResponses: 10, + statusCounts: { coded: 7, pending: 3 } + }; + + service.getCodingStatistics(mockWorkspaceId).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/statistics`); + expect(req.request.method).toBe('GET'); + req.flush(mockResponse); + }); + + it('should handle errors and return empty statistics', () => { + service.getCodingStatistics(mockWorkspaceId).subscribe(response => { + expect(response).toEqual({ totalResponses: 0, statusCounts: {} }); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/statistics`); + req.error(new ProgressEvent('error')); + }); + }); + + describe('getJobStatus', () => { + it('should send a GET request to get job status', () => { + const mockJobId = 'job-123'; + const mockResponse: JobStatus = { + status: 'processing', + progress: 50 + }; + + service.getJobStatus(mockWorkspaceId, mockJobId).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/job/${mockJobId}`); + expect(req.request.method).toBe('GET'); + req.flush(mockResponse); + }); + + it('should handle errors and return error object', () => { + const mockJobId = 'job-123'; + + service.getJobStatus(mockWorkspaceId, mockJobId).subscribe(response => { + expect(response).toEqual({ error: `Failed to get status for job ${mockJobId}` }); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/job/${mockJobId}`); + req.error(new ProgressEvent('error')); + }); + }); + + describe('cancelJob', () => { + it('should send a GET request to cancel job', () => { + const mockJobId = 'job-123'; + const mockResponse = { success: true, message: 'Job cancelled successfully' }; + + service.cancelJob(mockWorkspaceId, mockJobId).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/job/${mockJobId}/cancel`); + expect(req.request.method).toBe('GET'); + req.flush(mockResponse); + }); + + it('should handle errors and return failure object', () => { + const mockJobId = 'job-123'; + + service.cancelJob(mockWorkspaceId, mockJobId).subscribe(response => { + expect(response).toEqual({ success: false, message: `Failed to cancel job ${mockJobId}` }); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/job/${mockJobId}/cancel`); + req.error(new ProgressEvent('error')); + }); + }); + + describe('getAllJobs', () => { + it('should send a GET request to get all jobs', () => { + const mockResponse: JobInfo[] = [ + { + jobId: 'job-123', + status: 'completed', + progress: 100, + result: { totalResponses: 5, statusCounts: { coded: 5 } } + } + ]; + + service.getAllJobs(mockWorkspaceId).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/jobs`); + expect(req.request.method).toBe('GET'); + req.flush(mockResponse); + }); + + it('should handle errors and return empty array', () => { + service.getAllJobs(mockWorkspaceId).subscribe(response => { + expect(response).toEqual([]); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/jobs`); + req.error(new ProgressEvent('error')); + }); + }); + + describe('getWorkspaceGroups', () => { + it('should send a GET request to get workspace groups', () => { + const mockResponse = ['Group1', 'Group2', 'Group3']; + + service.getWorkspaceGroups(mockWorkspaceId).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/groups`); + expect(req.request.method).toBe('GET'); + req.flush(mockResponse); + }); + + it('should handle errors and return empty array', () => { + service.getWorkspaceGroups(mockWorkspaceId).subscribe(response => { + expect(response).toEqual([]); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/groups`); + req.error(new ProgressEvent('error')); + }); + }); + + describe('exportCodingListAsCsv', () => { + it('should send a GET request to export coding list as CSV', () => { + const mockBlob = new Blob(['test,data'], { type: 'text/csv' }); + + service.exportCodingListAsCsv(mockWorkspaceId).subscribe(response => { + expect(response).toEqual(mockBlob); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/coding-list/csv`); + expect(req.request.method).toBe('GET'); + expect(req.request.responseType).toBe('blob'); + req.flush(mockBlob); + }); + + it('should handle errors and return empty blob', () => { + service.exportCodingListAsCsv(mockWorkspaceId).subscribe(response => { + expect(response).toBeInstanceOf(Blob); + expect(response.type).toBe('text/csv'); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/coding-list/csv`); + req.error(new ProgressEvent('error')); + }); + }); + + describe('exportCodingListAsExcel', () => { + it('should send a GET request to export coding list as Excel', () => { + const mockBlob = new Blob(['test data'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + + service.exportCodingListAsExcel(mockWorkspaceId).subscribe(response => { + expect(response).toEqual(mockBlob); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/coding-list/excel`); + expect(req.request.method).toBe('GET'); + expect(req.request.responseType).toBe('blob'); + req.flush(mockBlob); + }); + + it('should handle errors and return empty blob', () => { + service.exportCodingListAsExcel(mockWorkspaceId).subscribe(response => { + expect(response).toBeInstanceOf(Blob); + expect(response.type).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + }); + + const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/coding-list/excel`); + req.error(new ProgressEvent('error')); + }); + }); +}); diff --git a/apps/frontend/src/app/coding/services/test-person-coding.service.ts b/apps/frontend/src/app/coding/services/test-person-coding.service.ts index 78d1b2daf..91ecc1fe7 100644 --- a/apps/frontend/src/app/coding/services/test-person-coding.service.ts +++ b/apps/frontend/src/app/coding/services/test-person-coding.service.ts @@ -181,6 +181,22 @@ export class TestPersonCodingService { ); } + /** + * Delete job + * @param workspaceId Workspace ID + * @param jobId Job ID + */ + deleteJob(workspaceId: number, jobId: string): Observable<{ success: boolean; message: string }> { + return this.http + .get<{ success: boolean; message: string }>( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/job/${jobId}/delete`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of({ success: false, message: `Failed to delete job ${jobId}` })) + ); + } + /** * Export coding list as CSV * @param workspaceId Workspace ID diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index 1a38e71e1..e15d176e8 100755 --- a/apps/frontend/src/app/components/home/home.component.html +++ b/apps/frontend/src/app/components/home/home.component.html @@ -9,7 +9,7 @@ [appTitle]="'Web application for coding'" [introHtml]="'appService.appConfig.introHtml'" [appName]="'IQB-Kodierbox'" - [appVersion]="'0.10.0'" + [appVersion]="'0.11.0'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" diff --git a/apps/frontend/src/app/components/home/home.component.scss b/apps/frontend/src/app/components/home/home.component.scss index a16db9cdf..37957c815 100755 --- a/apps/frontend/src/app/components/home/home.component.scss +++ b/apps/frontend/src/app/components/home/home.component.scss @@ -12,11 +12,35 @@ } coding-box-user-workspaces-area{ - min-width:400px; + min-width: 300px; max-width: 500px; + width: 100%; } coding-box-app-info{ - min-width:400px; + min-width: 300px; max-width: 500px; + width: 100%; +} + +/* Media queries for responsive layout */ +@media (max-width: 768px) { + .app-container { + flex-direction: column; + align-items: center; + margin: 10px; + } + + coding-box-user-workspaces-area, + coding-box-app-info { + min-width: 100%; + max-width: 100%; + margin-bottom: 20px; + } +} + +@media (max-width: 480px) { + .app-container { + margin: 5px; + } } diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts b/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts index f7c160962..5f4a8a5de 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts @@ -45,7 +45,6 @@ const fakeActivatedRoute = { describe('ReplayComponent', () => { let component: ReplayComponent; let fixture: ComponentFixture; - let backendService: BackendServiceMock; let snackBar: MatSnackBarMock; beforeEach(async () => { @@ -70,7 +69,6 @@ describe('ReplayComponent', () => { fixture = TestBed.createComponent(ReplayComponent); component = fixture.componentInstance; - backendService = TestBed.inject(BackendService) as unknown as BackendServiceMock; snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarMock; fixture.detectChanges(); }); diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index f81169972..e617188cd 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -64,8 +64,11 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { readonly unitIdInput = input(); private bookletData: BookletReplay | null = null; @ViewChild(UnitPlayerComponent) unitPlayerComponent: UnitPlayerComponent | undefined; + private replayStartTime: number = 0; // Track when replay viewing starts ngOnInit(): void { + // Record the start time when the component is initialized + this.replayStartTime = performance.now(); this.subscribeRouter(); } @@ -104,7 +107,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { // Parse the JSON string to get the BookletReplay object return JSON.parse(jsonString) as BookletReplay; } catch (error) { - console.error('Error deserializing booklet data:', error); + // Error occurred while deserializing booklet data return null; } } @@ -123,7 +126,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { if (this.isBookletMode && queryParams.bookletData) { const deserializedBooklet = this.deserializeBookletData(queryParams.bookletData); if (deserializedBooklet) { - console.log('Deserialized booklet data from URL:', deserializedBooklet); + // Successfully deserialized booklet data from URL this.bookletData = deserializedBooklet; // Update the component state this.currentUnitIndex = deserializedBooklet.currentUnitIndex; @@ -237,7 +240,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { return Promise.resolve(); } - // eslint-disable-next-line @typescript-eslint/dot-notation if (changes.unitIdInput) { this.resetUnitData(); this.resetSnackBars(); @@ -372,8 +374,62 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { })) ])); const endTime = performance.now(); - const duration = endTime - startTime; + const duration = Math.floor(endTime - startTime); logger.log(`Replay-Dauer: ${duration.toFixed(2)}ms`); + + if (duration) { + if (duration >= 1) { + try { + let testPersonLogin: string | undefined; + let testPersonCode: string | undefined; + let bookletId: string | undefined; + + if (this.testPerson) { + const parts = this.testPerson.split('@'); + console.log('parts', parts); + if (parts.length > 0) { + testPersonLogin = parts[0]; + testPersonCode = parts[1]; + bookletId = parts[2]; + } + } + if (authToken) { + try { + const decoded: JwtPayload & { workspace: string } = jwtDecode(authToken); + const workspaceId = Number(decoded?.workspace); + if (workspaceId) { + const replayUrl = window.location.href; + + this.backendService.storeReplayStatistics(workspaceId, { + unitId: this.unitId, + bookletId, + testPersonLogin, + testPersonCode, + durationMilliseconds: duration, + replayUrl, + success: true + }).subscribe({ + next: () => { + logger.log(`Replay statistics stored successfully. Duration: ${duration}ms`); + }, + error: error => { + logger.error(`Error storing replay statistics: ${error}`); + } + }); + } + } catch (error) { + logger.error(`Error decoding auth token: ${error}`); + } + } + } catch (error) { + logger.error(`Error storing replay statistics: ${error}`); + } + } + + // Reset the start time for the next unit + this.replayStartTime = performance.now(); + } + this.setIsLoaded(); return { unitDef: unitData[0], response: unitData[1], player: unitData[2] }; } @@ -387,8 +443,8 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { TestPersonError: 'Ungültige ID für Testperson', PlayerError: 'Ungültiger Player-Name', ResponsesError: `Fehler beim Laden der Antworten für Aufgabe "${this.unitId}" von Testperson "${this.testPerson}"`, - notInList: `Keine valide Seite mit ID "${this.page}" gefunden`, - notCurrent: `Seite mit ID "${this.page}" kann nicht ausgewählt werden`, + notInList: `Keine valide Seite mit der ID "${this.page || ''}" gefunden`, + notCurrent: `Seite mit der ID "${this.page || ''}" kann nicht ausgewählt werden`, tokenExpired: 'Das Authentisierungs-Token ist abgelaufen', tokenInvalid: 'Das Authentisierungs-Token ist ungültig', unknown: 'Unbekannter Fehler' @@ -409,6 +465,62 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { const message = this.getErrorMessages()[messageKey] || this.getErrorMessages().unknown; this.openErrorSnackBar(message, 'Schließen'); + + this.storeErrorInStatistics(message); + } + + private storeErrorInStatistics(errorMessage: string): void { + // Calculate duration from start time to now + const duration = this.replayStartTime ? Math.round(performance.now() - this.replayStartTime) : 0; + + // Get auth token from local storage + const authToken = localStorage.getItem('authToken'); + if (!authToken) return; + + try { + // Extract workspace ID from token + const decoded: JwtPayload & { workspace: string } = jwtDecode(authToken); + const workspaceId = Number(decoded?.workspace); + if (!workspaceId) return; + + // Extract test person information + let testPersonLogin = ''; + let testPersonCode = ''; + let bookletId = ''; + + if (this.testPerson) { + const parts = this.testPerson.split(':'); + if (parts.length > 0) { + testPersonLogin = parts[0]; + testPersonCode = parts[1]; + bookletId = parts[2]; + } + } + + // Construct the replay URL + const replayUrl = window.location.href; + + // Store the replay statistics with error information + this.backendService.storeReplayStatistics(workspaceId, { + unitId: this.unitId || 'unknown', + bookletId, + testPersonLogin, + testPersonCode, + durationMilliseconds: duration, + replayUrl, + success: false, + errorMessage: errorMessage + }).subscribe({ + next: () => { + logger.log('Error replay statistics stored successfully.'); + }, + error: error => { + logger.error(`Error storing replay error statistics: ${error}`); + } + }); + } catch (error) { + logger.error(`Error storing replay error statistics: ${error}`); + } } checkPageError(pageError: 'notInList' | 'notCurrent' | null): void { diff --git a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts index 53ac63edc..26177ee69 100755 --- a/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts +++ b/apps/frontend/src/app/replay/components/unit-player/unit-player.component.ts @@ -40,6 +40,8 @@ export class UnitPlayerComponent implements AfterViewInit, OnChanges, OnDestroy readonly printMode = input(false); iFrameHeight = input(); readonly invalidPage = output<'notInList' | 'notCurrent' | null>(); + // Track the last emitted page error to prevent flickering + private lastPageError: 'notInList' | 'notCurrent' | null = null; @ViewChild('hostingIframe') hostingIframe!: ElementRef; private validPages: Subject<{ pages: string[], current: string }> = new Subject(); private iFrameElement: HTMLIFrameElement | undefined; @@ -146,7 +148,8 @@ export class UnitPlayerComponent implements AfterViewInit, OnChanges, OnDestroy observer.next(this.pageId() || ''); }; - const interval = setInterval(callback, 500); + // Use a longer interval to reduce unnecessary checks + const interval = setInterval(callback, 2000); return () => { clearInterval(interval); @@ -158,22 +161,38 @@ export class UnitPlayerComponent implements AfterViewInit, OnChanges, OnDestroy this.validPages.pipe(debounceTime(2000)) ]).subscribe({ next: ([pageId, validPages]) => { + // Don't emit error if pageId is empty - it might still be initializing if (!pageId) { - this.invalidPage.emit('notInList'); return; } - if (!validPages.pages.includes(pageId)) { - this.invalidPage.emit('notInList'); - } else if (validPages.current !== pageId) { - this.invalidPage.emit('notCurrent'); - } else { - this.invalidPage.emit(null); - this.cleanupValidPagesSubscription(); + // Only emit errors if we have valid pages to compare against + if (validPages.pages.length > 0) { + let newPageError: 'notInList' | 'notCurrent' | null = null; + + if (!validPages.pages.includes(pageId)) { + newPageError = 'notInList'; + } else if (validPages.current !== pageId) { + newPageError = 'notCurrent'; + } + + // Only emit if the error state has changed to prevent flickering + if (newPageError !== this.lastPageError) { + this.lastPageError = newPageError; + this.invalidPage.emit(newPageError); + + if (newPageError === null) { + this.cleanupValidPagesSubscription(); + } + } } }, error: () => { - this.invalidPage.emit('notInList'); + // Only emit if the error state has changed + if (this.lastPageError !== 'notInList') { + this.lastPageError = 'notInList'; + this.invalidPage.emit('notInList'); + } } }); } diff --git a/apps/frontend/src/app/replay/utils/dom-utils.ts b/apps/frontend/src/app/replay/utils/dom-utils.ts index 6287b740c..fe64705e3 100644 --- a/apps/frontend/src/app/replay/utils/dom-utils.ts +++ b/apps/frontend/src/app/replay/utils/dom-utils.ts @@ -80,7 +80,7 @@ export function findElementsByDataAlias(iframe: HTMLIFrameElement): Record { data: T[]; @@ -96,8 +119,7 @@ interface ResponseEntity { export class BackendService { readonly serverUrl = inject(SERVER_URL); appService = inject(AppService); - - // Inject specialized services + private http = inject(HttpClient); private userService = inject(UserService); private workspaceService = inject(WorkspaceService); private fileService = inject(FileService); @@ -244,6 +266,17 @@ export class BackendService { return this.codingService.getCodingStatistics(workspace_id); } + getVariableAnalysis( + workspace_id: number, + page: number = 1, + limit: number = 100, + unitId?: string, + variableId?: string, + derivation?: string + ): Observable> { + return this.codingService.getVariableAnalysis(workspace_id, page, limit, unitId, variableId, derivation); + } + getResponsesByStatus(workspace_id: number, status: string, page: number = 1, limit: number = 100): Observable> { return this.codingService.getResponsesByStatus(workspace_id, status, page, limit); } @@ -252,7 +285,7 @@ export class BackendService { return this.workspaceService.changeWorkspace(workspaceData); } - uploadTestFiles(workspaceId: number, files: FileList | null): Observable { + uploadTestFiles(workspaceId: number, files: FileList | FormData | null): Observable { return this.fileService.uploadTestFiles(workspaceId, files); } @@ -354,6 +387,10 @@ export class BackendService { return this.fileService.getBookletInfo(workspaceId, bookletId, authToken); } + getUnitInfo(workspaceId: number, unitId: string, authToken?: string): Observable { + return this.fileService.getUnitInfo(workspaceId, unitId, authToken); + } + getTestPersons(workspaceId: number): Observable { return this.testResultService.getTestPersons(workspaceId); } @@ -638,4 +675,152 @@ export class BackendService { createDummyTestTakerFile(workspaceId: number): Observable { return this.fileService.createDummyTestTakerFile(workspaceId); } + + getMissingsProfiles(workspaceId: number): Observable<{ label: string }[]> { + return this.codingService.getMissingsProfiles(workspaceId); + } + + getMissingsProfileDetails(workspaceId: number, label: string): Observable { + return this.codingService.getMissingsProfileDetails(workspaceId, label); + } + + createMissingsProfile(workspaceId: number, profile: MissingsProfilesDto): Observable { + return this.codingService.createMissingsProfile(workspaceId, profile); + } + + updateMissingsProfile(workspaceId: number, label: string, profile: MissingsProfilesDto): Observable { + return this.codingService.updateMissingsProfile(workspaceId, label, profile); + } + + deleteMissingsProfile(workspaceId: number, label: string): Observable { + return this.codingService.deleteMissingsProfile(workspaceId, label); + } + + getCodingBook( + workspaceId: number, + missingsProfile: string, + contentOptions: CodeBookContentSetting, + unitList: number[] + ): Observable { + return this.codingService.getCodingBook(workspaceId, missingsProfile, contentOptions, unitList); + } + + getUnitsWithFileIds(workspaceId: number): Observable<{ id: number; unitId: string; fileName: string; data: string }[]> { + return this.fileService.getUnitsWithFileIds(workspaceId); + } + + getVariableInfoForScheme(workspaceId: number, schemeFileId: string): Observable { + const fileId = schemeFileId.endsWith('.vocs') ? + schemeFileId.slice(0, -5) : + schemeFileId; + + return this.fileService.getVariableInfoForScheme(workspaceId, fileId); + } + + storeReplayStatistics( + workspaceId: number, + data: { + unitId: string; + bookletId?: string; + testPersonLogin?: string; + testPersonCode?: string; + durationMilliseconds: number; + replayUrl?: string; + success?: boolean; + errorMessage?: string; + } + ): Observable { + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics`; + return this.http.post(url, data); + } + + getReplayFrequencyByUnit(workspaceId: number): Observable> { + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/frequency`; + return this.http.get>(url); + } + + getReplayDurationStatistics( + workspaceId: number, + unitId?: string + ): Observable<{ + min: number; + max: number; + average: number; + distribution: Record; + unitAverages?: Record; + }> { + let url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/duration`; + if (unitId) { + url += `?unitId=${encodeURIComponent(unitId)}`; + } + return this.http.get<{ + min: number; + max: number; + average: number; + distribution: Record; + unitAverages?: Record; + }>(url); + } + + getReplayDistributionByDay(workspaceId: number): Observable> { + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/distribution/day`; + return this.http.get>(url); + } + + getReplayDistributionByHour(workspaceId: number): Observable> { + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/distribution/hour`; + return this.http.get>(url); + } + + /** + * Get replay error statistics + * @param workspaceId The ID of the workspace + * @returns Observable of replay error statistics + */ + getReplayErrorStatistics(workspaceId: number): Observable<{ + successRate: number; + totalReplays: number; + successfulReplays: number; + failedReplays: number; + commonErrors: Array<{ message: string; count: number }>; + }> { + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/errors`; + return this.http.get<{ + successRate: number; + totalReplays: number; + successfulReplays: number; + failedReplays: number; + commonErrors: Array<{ message: string; count: number }>; + }>(url); + } + + /** + * Get failure distribution by unit + * @param workspaceId The ID of the workspace + * @returns Observable of failure distribution by unit + */ + getFailureDistributionByUnit(workspaceId: number): Observable> { + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/failures/unit`; + return this.http.get>(url); + } + + /** + * Get failure distribution by day + * @param workspaceId The ID of the workspace + * @returns Observable of failure distribution by day + */ + getFailureDistributionByDay(workspaceId: number): Observable> { + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/failures/day`; + return this.http.get>(url); + } + + /** + * Get failure distribution by hour + * @param workspaceId The ID of the workspace + * @returns Observable of failure distribution by hour + */ + getFailureDistributionByHour(workspaceId: number): Observable> { + const url = `${this.serverUrl}/admin/workspace/${workspaceId}/replay-statistics/failures/hour`; + return this.http.get>(url); + } } diff --git a/apps/frontend/src/app/services/booklet-replay.service.ts b/apps/frontend/src/app/services/booklet-replay.service.ts index a33671b58..ed425544c 100644 --- a/apps/frontend/src/app/services/booklet-replay.service.ts +++ b/apps/frontend/src/app/services/booklet-replay.service.ts @@ -43,13 +43,13 @@ export class BookletReplayService { bookletName = bookletFile.file_id; } } catch (error) { - console.error('Error extracting basic booklet information:', error); + // Error occurred while extracting basic booklet information } return this.backendService.getBookletUnits(workspaceId, bookletFileId).pipe( map(units => { if (!units || units.length === 0) { - console.warn(`No units found in booklet ${bookletFileId}`); + // No units found in the specified booklet return null; } diff --git a/apps/frontend/src/app/services/coding.service.ts b/apps/frontend/src/app/services/coding.service.ts index 920f81348..d56795e67 100644 --- a/apps/frontend/src/app/services/coding.service.ts +++ b/apps/frontend/src/app/services/coding.service.ts @@ -9,6 +9,9 @@ import { import { CodingStatistics } from '../../../../../api-dto/coding/coding-statistics'; import { SERVER_URL } from '../injection-tokens'; import { AppService } from './app.service'; +import { CodeBookContentSetting } from '../../../../../api-dto/coding/codebook-content-setting'; +import { MissingsProfilesDto } from '../../../../../api-dto/coding/missings-profiles.dto'; +import { VariableAnalysisItemDto } from '../../../../../api-dto/coding/variable-analysis-item.dto'; interface PaginatedResponse { data: T[]; @@ -118,14 +121,11 @@ export class CodingService { { headers: this.authHeader } ) .pipe( - catchError(error => { - console.error('Error getting job status:', error); - return of({ - status: 'failed' as const, - progress: 0, - error: 'Failed to get job status' - }); - }) + catchError(() => of({ + status: 'failed' as const, + progress: 0, + error: 'Failed to get job status' + })) ); } @@ -142,13 +142,10 @@ export class CodingService { { headers: this.authHeader } ) .pipe( - catchError(error => { - console.error('Error cancelling job:', error); - return of({ - success: false, - message: 'Failed to cancel job' - }); - }) + catchError(() => of({ + success: false, + message: 'Failed to cancel job' + })) ); } @@ -185,10 +182,7 @@ export class CodingService { { headers: this.authHeader } ) .pipe( - catchError(error => { - console.error('Error getting all jobs:', error); - return of([]); - }) + catchError(() => of([])) ); } @@ -269,4 +263,135 @@ export class CodingService { })) ); } + + getMissingsProfiles(workspaceId: number): Observable<{ label: string }[]> { + return this.http + .get<{ label: string }[]>( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/missings-profiles`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of([])) + ); + } + + getMissingsProfileDetails(workspaceId: number, label: string): Observable { + return this.http + .get( + `${this.serverUrl}admin/workspace/${workspaceId}/missings-profiles/${encodeURIComponent(label)}`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of(null)) + ); + } + + createMissingsProfile(workspaceId: number, profile: MissingsProfilesDto): Observable { + return this.http + .post( + `${this.serverUrl}admin/workspace/${workspaceId}/missings-profiles`, + profile, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of(null)) + ); + } + + updateMissingsProfile(workspaceId: number, label: string, profile: MissingsProfilesDto): Observable { + return this.http + .put( + `${this.serverUrl}admin/workspace/${workspaceId}/missings-profiles/${encodeURIComponent(label)}`, + profile, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of(null)) + ); + } + + deleteMissingsProfile(workspaceId: number, label: string): Observable { + return this.http + .delete( + `${this.serverUrl}admin/workspace/${workspaceId}/missings-profiles/${encodeURIComponent(label)}`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of(false)) + ); + } + + getCodingBook( + workspaceId: number, + missingsProfile: string, + contentOptions: CodeBookContentSetting, + unitList: number[] + ): Observable { + // Ensure unitList is an array of numbers + const payload = { + missingsProfile, + contentOptions, + unitList: Array.isArray(unitList) ? unitList : [unitList] + }; + + return this.http + .post( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/codebook`, + payload, + { + headers: this.authHeader, + responseType: 'blob' + } + ) + .pipe( + catchError(() => of(null)) + ); + } + + getVariableAnalysis( + workspace_id: number, + page: number = 1, + limit: number = 100, + unitId?: string, + variableId?: string, + derivation?: string + ): Observable> { + const identity = this.appService.loggedUser?.sub || ''; + return this.appService.createToken(workspace_id, identity, 60).pipe( + catchError(() => of('')), + switchMap(token => { + let params = new HttpParams() + .set('authToken', token) + .set('serverUrl', window.location.origin) + .set('page', page.toString()) + .set('limit', limit.toString()); + + if (unitId) { + params = params.set('unitId', unitId); + } + + if (variableId) { + params = params.set('variableId', variableId); + } + + if (derivation) { + params = params.set('derivation', derivation); + } + + return this.http + .get>( + `${this.serverUrl}admin/workspace/${workspace_id}/coding/variable-analysis`, + { headers: this.authHeader, params } + ) + .pipe( + catchError(() => of({ + data: [], + total: 0, + page, + limit + })) + ); + }) + ); + } } diff --git a/apps/frontend/src/app/services/file.service.ts b/apps/frontend/src/app/services/file.service.ts index ac30ef076..6e086e3ce 100644 --- a/apps/frontend/src/app/services/file.service.ts +++ b/apps/frontend/src/app/services/file.service.ts @@ -5,14 +5,17 @@ import { map, Observable, of, - switchMap, throwError + switchMap, throwError, tap } from 'rxjs'; +import { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; import { FilesInListDto } from '../../../../../api-dto/files/files-in-list.dto'; import { FilesDto } from '../../../../../api-dto/files/files.dto'; import { FileValidationResultDto } from '../../../../../api-dto/files/file-validation-result.dto'; import { FileDownloadDto } from '../../../../../api-dto/files/file-download.dto'; import { BookletInfoDto } from '../../../../../api-dto/booklet-info/booklet-info.dto'; +import { UnitInfoDto } from '../../../../../api-dto/unit-info/unit-info.dto'; import { SERVER_URL } from '../injection-tokens'; +import { TestResultService } from './test-result.service'; export interface BookletUnit { id: number; @@ -34,6 +37,7 @@ interface PaginatedResponse { export class FileService { readonly serverUrl = inject(SERVER_URL); private http = inject(HttpClient); + private testResultService = inject(TestResultService); get authHeader() { return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; @@ -101,13 +105,20 @@ export class FileService { ); } - uploadTestFiles(workspaceId: number, files: FileList | null): Observable { - const formData = new FormData(); - if (files) { - for (let i = 0; i < files.length; i++) { - formData.append('files', files[i]); + uploadTestFiles(workspaceId: number, files: FileList | FormData | null): Observable { + let formData: FormData; + + if (files instanceof FormData) { + formData = files; + } else { + formData = new FormData(); + if (files) { + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } } } + return this.http.post(`${this.serverUrl}admin/workspace/${workspaceId}/upload`, formData, { headers: this.authHeader }); @@ -128,7 +139,12 @@ export class FileService { const url = `${this.serverUrl}admin/workspace/${workspaceId}/upload/results/${resultType}?overwriteExisting=${overwriteExisting}`; return this.http.post(url, formData, { headers: this.authHeader - }); + }).pipe( + tap(() => { + // Invalidate cache after uploading test results + this.testResultService.invalidateCache(workspaceId); + }) + ); } getUnitDef(workspaceId: number, unit: string, authToken?: string): Observable { @@ -190,10 +206,7 @@ export class FileService { `${this.serverUrl}admin/workspace/${workspaceId}/booklet/${bookletId}/units`, { headers } ).pipe( - catchError(error => { - console.error(`Error retrieving booklet units for ${bookletId}:`, error); - return of([]); - }) + catchError(() => of([])) ); } @@ -203,10 +216,35 @@ export class FileService { `${this.serverUrl}admin/workspace/${workspaceId}/booklet/${bookletId}/info`, { headers } ).pipe( - catchError(error => { - console.error(`Error retrieving booklet info for ${bookletId}:`, error); - return throwError(() => error); - }) + catchError(error => throwError(() => error)) + ); + } + + getUnitInfo(workspaceId: number, unitId: string, authToken?: string): Observable { + const headers = authToken ? { Authorization: `Bearer ${authToken}` } : this.authHeader; + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/unit/${unitId}/info`, + { headers } + ).pipe( + catchError(error => throwError(() => error)) + ); + } + + getUnitsWithFileIds(workspaceId: number): Observable<{ id: number; unitId: string; fileName: string; data: string }[]> { + return this.http.get<{ id: number; unitId: string; fileName: string; data: string }[]>( + `${this.serverUrl}admin/workspace/${workspaceId}/files/units-with-file-ids`, + { headers: this.authHeader } + ).pipe( + catchError(() => of([])) + ); + } + + getVariableInfoForScheme(workspaceId: number, schemeFileId: string): Observable { + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/files/variable-info/${schemeFileId}`, + { headers: this.authHeader } + ).pipe( + catchError(() => of([])) ); } } diff --git a/apps/frontend/src/app/services/post-message-types.ts b/apps/frontend/src/app/services/post-message-types.ts new file mode 100644 index 000000000..91dd580bc --- /dev/null +++ b/apps/frontend/src/app/services/post-message-types.ts @@ -0,0 +1,83 @@ +import { PostMessage } from './post-message.service'; + +export interface NavigationMessage extends PostMessage { + type: 'navigation'; + data: { + route: string; + queryParams?: Record; + replaceUrl?: boolean; + }; +} + +export interface DataTransferMessage extends PostMessage { + type: 'dataTransfer'; + data: { + key: string; + value: Record; + metadata?: Record; + }; +} + +export interface AuthenticationMessage extends PostMessage { + type: 'authentication'; + data: { + event: 'login' | 'logout' | 'sessionExpired' | 'tokenRefresh'; + user?: { + id: string; + username: string; + roles?: string[]; + }; + token?: { + value: string; + expiresAt: number; + }; + }; +} + +export interface UiEventMessage extends PostMessage { + type: 'uiEvent'; + data: { + event: 'modalOpen' | 'modalClose' | 'notification' | 'themeChange' | 'resize'; + payload?: Record; + }; +} + +export interface ErrorMessage extends PostMessage { + type: 'error'; + data: { + code: string; + message: string; + details?: Record; + stack?: string; + }; +} + +export interface IframeMessage extends PostMessage { + type: 'iframe'; + data: { + action: 'load' | 'unload' | 'resize' | 'refresh' | 'getData' | 'setData'; + targetId?: string; + payload?: Record; + }; +} + +export interface SchemerMessage extends PostMessage { + type: 'vosReadyNotification' | 'vosStartCommand' | 'vosSchemeChangedNotification' | 'vosReadNotification' | 'vosGetSchemeRequest'; + sessionId: string; + codingScheme?: string; + codingSchemeType?: string; + schemerConfig?: { + definitionReportPolicy: 'eager' | 'onDemand'; + role: 'editor' | 'viewer' | 'admin'; + }; + message?: string; +} + +export type ApplicationMessage = + | NavigationMessage + | DataTransferMessage + | AuthenticationMessage + | UiEventMessage + | ErrorMessage + | IframeMessage + | SchemerMessage; diff --git a/apps/frontend/src/app/services/post-message.service.ts b/apps/frontend/src/app/services/post-message.service.ts new file mode 100644 index 000000000..cbf742b1d --- /dev/null +++ b/apps/frontend/src/app/services/post-message.service.ts @@ -0,0 +1,89 @@ +import { Injectable, NgZone } from '@angular/core'; +import { + Observable, + Subject, + filter, + map +} from 'rxjs'; + +export interface PostMessage { + type: string; + sessionId?: string; + data?: Record; +} + +@Injectable({ + providedIn: 'root' +}) +export class PostMessageService { + private readonly messageSubject: Subject<{ message: PostMessage, source: MessageEventSource | null }> = + new Subject<{ message: PostMessage, source: MessageEventSource | null }>(); + + readonly messages$: Observable<{ message: PostMessage, source: MessageEventSource | null }> = + this.messageSubject.asObservable(); + + constructor(private readonly zone: NgZone) { + this.setupMessageListener(); + } + + private setupMessageListener(): void { + // Use NgZone.runOutsideAngular to avoid unnecessary change detection + this.zone.runOutsideAngular(() => { + window.addEventListener('message', (event: MessageEvent) => { + // Run inside Angular zone when a message is received + this.zone.run(() => { + try { + const message = event.data as PostMessage; + this.messageSubject.next({ + message, + source: event.source + }); + } catch (error) { + console.error('Error processing postMessage:', error); + } + }); + }); + }); + } + + sendMessage( + message: PostMessage, + target: Window = window.parent, + targetOrigin = '*' + ): boolean { + try { + target.postMessage(message, targetOrigin); + return true; + } catch (error) { + console.error('Error sending postMessage:', error); + return false; + } + } + + sendMessageToIframe( + message: PostMessage, + iframe: HTMLIFrameElement, + targetOrigin = '*' + ): boolean { + if (!iframe || !iframe.contentWindow) { + console.error('Invalid iframe or contentWindow is null'); + return false; + } + + return this.sendMessage(message, iframe.contentWindow, targetOrigin); + } + + getMessages(type: string): Observable<{ message: T, source: MessageEventSource | null }> { + return this.messages$.pipe( + filter(event => event.message.type === type), + map(event => ({ + message: event.message as T, + source: event.source + })) + ); + } + + generateSessionId(): string { + return Math.floor(Math.random() * 20000000 + 10000000).toString(); + } +} diff --git a/apps/frontend/src/app/services/response.service.ts b/apps/frontend/src/app/services/response.service.ts index 91a3208e2..230b5f029 100644 --- a/apps/frontend/src/app/services/response.service.ts +++ b/apps/frontend/src/app/services/response.service.ts @@ -5,11 +5,13 @@ import { map, Observable, of, - forkJoin + forkJoin, + tap } from 'rxjs'; import { logger } from 'nx/src/utils/logger'; import { ResponseDto } from '../../../../../api-dto/responses/response-dto'; import { SERVER_URL } from '../injection-tokens'; +import { TestResultService } from './test-result.service'; @Injectable({ providedIn: 'root' @@ -17,6 +19,7 @@ import { SERVER_URL } from '../injection-tokens'; export class ResponseService { readonly serverUrl = inject(SERVER_URL); private http = inject(HttpClient); + private testResultService = inject(TestResultService); get authHeader() { return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; @@ -37,7 +40,13 @@ export class ResponseService { { headers: this.authHeader, params }) .pipe( catchError(() => of(false)), - map(() => true) + map(() => true), + tap(success => { + // Invalidate cache if deletion was successful + if (success) { + this.testResultService.invalidateCache(workspace_id); + } + }) ); } @@ -67,6 +76,12 @@ export class ResponseService { catchError(() => { logger.error(`Error deleting response with ID: ${responseId}`); return of({ success: false, report: { deletedResponse: null, warnings: ['Failed to delete response'] } }); + }), + tap(result => { + // Invalidate cache if deletion was successful + if (result.success) { + this.testResultService.invalidateCache(workspaceId); + } }) ); } @@ -116,6 +131,12 @@ export class ResponseService { warnings: ['Failed to delete responses'] } }); + }), + tap(result => { + // Invalidate cache if any responses were deleted successfully + if (result.success) { + this.testResultService.invalidateCache(workspaceId); + } }) ); } diff --git a/apps/frontend/src/app/services/test-result-cache.service.ts b/apps/frontend/src/app/services/test-result-cache.service.ts new file mode 100644 index 000000000..35182d8ea --- /dev/null +++ b/apps/frontend/src/app/services/test-result-cache.service.ts @@ -0,0 +1,165 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; +import { tap, catchError, map } from 'rxjs/operators'; +import { SERVER_URL } from '../injection-tokens'; + +interface CacheEntry { + data: T; + expires: number; +} + +interface TestResultsResponse { + data: TestResultItem[]; + total: number; +} + +interface TestResultItem { + id: number; + code: string; + group: string; + login: string; + uploaded_at: Date; + [key: string]: unknown; +} + +interface PersonTestResult { + [key: string]: unknown; +} + +@Injectable({ + providedIn: 'root' +}) +export class TestResultCacheService { + private readonly serverUrl = inject(SERVER_URL); + private readonly http = inject(HttpClient); + private cache = new Map>(); + + // Cache expiration time in milliseconds (5 minutes) + private readonly CACHE_EXPIRATION = 5 * 60 * 1000; + + get authHeader() { + return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + } + + /** + * Get test results with caching + * @param workspaceId The workspace ID + * @param page Page number + * @param limit Items per page + * @param searchText Optional search text + * @returns Observable with test results + */ + getTestResults(workspaceId: number, page: number, limit: number, searchText?: string): Observable { + const cacheKey = this.generateCacheKey(workspaceId, page, limit, searchText); + const cachedData = this.getFromCache(cacheKey); + + if (cachedData) { + return of(cachedData); + } + + const params: { [key: string]: string } = { + page: page.toString(), + limit: limit.toString() + }; + + if (searchText && searchText.trim() !== '') { + params.searchText = searchText.trim(); + } + + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/test-results/`, + { + headers: this.authHeader, + params: params + } + ).pipe( + catchError(() => { + console.error('Error fetching test data'); + return of({ data: [], total: 0 }); + }), + map(result => result || { data: [], total: 0 }), + tap(result => this.addToCache(cacheKey, result)) + ); + } + + /** + * Get test results for a specific person with caching + * @param workspaceId The workspace ID + * @param personId The person ID + * @returns Observable with person test results + */ + getPersonTestResults(workspaceId: number, personId: number): Observable { + const cacheKey = this.generateCacheKey(workspaceId, personId); + const cachedData = this.getFromCache(cacheKey); + + if (cachedData) { + return of(cachedData); + } + + return this.http.get( + `${this.serverUrl}admin/workspace/${workspaceId}/test-results/${personId}`, + { headers: this.authHeader } + ).pipe( + tap(result => this.addToCache(cacheKey, result)) + ); + } + + /** + * Invalidate cache for a specific workspace + * @param workspaceId The workspace ID to invalidate + */ + invalidateWorkspaceCache(workspaceId: number): void { + const keysToRemove: string[] = []; + + this.cache.forEach((_, key) => { + if (key.startsWith(`workspace_${workspaceId}`)) { + keysToRemove.push(key); + } + }); + + keysToRemove.forEach(key => this.cache.delete(key)); + } + + /** + * Clear the entire cache + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Generate a cache key based on parameters + */ + private generateCacheKey(workspaceId: number, ...params: unknown[]): string { + return `workspace_${workspaceId}_${params.join('_')}`; + } + + /** + * Get data from cache if it exists and is not expired + */ + private getFromCache(key: string): T | null { + const entry = this.cache.get(key); + + if (!entry) { + return null; + } + + if (entry.expires < Date.now()) { + this.cache.delete(key); + return null; + } + + return entry.data as T; + } + + /** + * Add data to cache with expiration + */ + private addToCache(key: string, data: T): void { + this.cache.set(key, { + data, + expires: Date.now() + this.CACHE_EXPIRATION + }); + } +} diff --git a/apps/frontend/src/app/services/test-result.service.ts b/apps/frontend/src/app/services/test-result.service.ts index 8e1c7b709..09bf7fbf0 100644 --- a/apps/frontend/src/app/services/test-result.service.ts +++ b/apps/frontend/src/app/services/test-result.service.ts @@ -9,6 +9,7 @@ import { } from 'rxjs'; import { logger } from 'nx/src/utils/logger'; import { SERVER_URL } from '../injection-tokens'; +import { TestResultCacheService } from './test-result-cache.service'; @Injectable({ providedIn: 'root' @@ -16,6 +17,7 @@ import { SERVER_URL } from '../injection-tokens'; export class TestResultService { readonly serverUrl = inject(SERVER_URL); private http = inject(HttpClient); + private cacheService = inject(TestResultCacheService); get authHeader() { return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; @@ -29,38 +31,23 @@ export class TestResultService { // eslint-disable-next-line @typescript-eslint/no-explicit-any getTestResults(workspaceId: number, page: number, limit: number, searchText?: string): Observable { - const params: { [key: string]: string } = { - page: page.toString(), - limit: limit.toString() - }; - - if (searchText && searchText.trim() !== '') { - params.searchText = searchText.trim(); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/test-results/`, - { - headers: this.authHeader, - params: params - } - ).pipe( - catchError(() => { - logger.error('Error fetching test data'); - return of({ results: [], total: 0 }); - }), - map(result => result || { results: [], total: 0 }) - ); + // Use the cache service to get test results + return this.cacheService.getTestResults(workspaceId, page, limit, searchText); } // eslint-disable-next-line @typescript-eslint/no-explicit-any getPersonTestResults(workspaceId: number, personId: number): Observable { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.http.get( - `${this.serverUrl}admin/workspace/${workspaceId}/test-results/${personId}`, - { headers: this.authHeader } - ); + // Use the cache service to get person test results + return this.cacheService.getPersonTestResults(workspaceId, personId); + } + + /** + * Invalidate the cache for a specific workspace + * This should be called whenever test results are modified + * @param workspaceId The workspace ID + */ + invalidateCache(workspaceId: number): void { + this.cacheService.invalidateWorkspaceCache(workspaceId); } searchUnitsByName( diff --git a/apps/frontend/src/app/services/validation.service.ts b/apps/frontend/src/app/services/validation.service.ts index dda7905f9..3cac1f39d 100644 --- a/apps/frontend/src/app/services/validation.service.ts +++ b/apps/frontend/src/app/services/validation.service.ts @@ -276,7 +276,7 @@ export class ValidationService { { headers: this.authHeader } ).pipe( catchError(error => { - console.error(`Error getting validation tasks: ${error.message}`); + // Error occurred while getting validation tasks throw error; }) ); @@ -340,9 +340,7 @@ export class ValidationService { map( result => [type, { task, result }] as [string, { task: ValidationTaskDto; result: unknown }] ), - catchError(() => { - return of([type, { task, result: null }] as [string, { task: ValidationTaskDto; result: unknown }]); - }) + catchError(() => of([type, { task, result: null }] as [string, { task: ValidationTaskDto; result: unknown }])) ) ); } @@ -362,10 +360,7 @@ export class ValidationService { }) ); }), - catchError(error => { - console.error(`Error getting last validation results: ${error.message}`); - return of>({}); - }) + catchError(() => of>({})) ); } } diff --git a/apps/frontend/src/app/shared/utils/common-utils.ts b/apps/frontend/src/app/shared/utils/common-utils.ts index 4cd61b378..d24b27374 100644 --- a/apps/frontend/src/app/shared/utils/common-utils.ts +++ b/apps/frontend/src/app/shared/utils/common-utils.ts @@ -62,7 +62,7 @@ export function debounce unknown>( ): (...args: Parameters) => void { let timeout: number | null = null; - return function (...args: Parameters): void { + return function debouncedFunction(...args: Parameters): void { const later = () => { timeout = null; func(...args); diff --git a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.html b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.html index 19233c9ed..55c9c3f79 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.html +++ b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.html @@ -23,10 +23,11 @@

Arbeitsbereiche

}@else {

- Bitte loggen Sie sich ein, um auf Ihre Arbeitsbereiche zuzugreifen. + Bitte melden Sie sich an, um auf Ihre Arbeitsbereiche zuzugreifen.

-
} diff --git a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.scss b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.scss index 6ff5f8139..f4cf7ab74 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.scss +++ b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.scss @@ -30,3 +30,25 @@ p{ font-size: 18px; line-height: 20pt; } + +.login-button { + padding: 12px 24px; + font-size: 16px; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + transition: all 0.3s ease; + min-width: 180px; + margin-top: 16px; + + mat-icon { + margin-right: 8px; + font-size: 20px; + height: 20px; + width: 20px; + } + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3); + } +} diff --git a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts index 54140b758..7f3dd651e 100755 --- a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.ts @@ -2,6 +2,7 @@ import { Component, Input, inject } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { RouterLink } from '@angular/router'; import { MatAnchor, MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; import { WorkspaceFullDto } from '../../../../../../../api-dto/workspaces/workspace-full-dto'; import { AuthService } from '../../../core/services/auth.service'; @@ -9,7 +10,7 @@ import { AuthService } from '../../../core/services/auth.service'; selector: 'coding-book-user-workspaces', templateUrl: './user-workspaces.component.html', styleUrls: ['./user-workspaces.component.scss'], - imports: [MatAnchor, RouterLink, TranslateModule, MatButton] + imports: [MatAnchor, RouterLink, TranslateModule, MatButton, MatIcon] }) export class UserWorkspacesComponent { diff --git a/apps/frontend/src/app/ws-admin/components/cleaning/cleaning.component.html b/apps/frontend/src/app/ws-admin/components/cleaning/cleaning.component.html new file mode 100644 index 000000000..2386befc1 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/cleaning/cleaning.component.html @@ -0,0 +1,21 @@ +
+ + + {{ 'ws-admin.cleaning-title' | translate }} + {{ 'ws-admin.cleaning-subtitle' | translate }} + + +

{{ 'ws-admin.cleaning-description' | translate }}

+ + +
+

{{ 'ws-admin.cleaning-placeholder' | translate }}

+
+
+ + + +
+
diff --git a/apps/frontend/src/app/ws-admin/components/cleaning/cleaning.component.scss b/apps/frontend/src/app/ws-admin/components/cleaning/cleaning.component.scss new file mode 100644 index 000000000..cdffebc83 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/cleaning/cleaning.component.scss @@ -0,0 +1,31 @@ +.cleaning-container { + padding: 20px; + + mat-card { + max-width: 800px; + margin: 0 auto; + } + + mat-card-content { + margin-top: 20px; + } + + .placeholder-message { + margin: 30px 0; + padding: 20px; + background-color: #f5f5f5; + border-radius: 4px; + border-left: 4px solid #2196f3; + + p { + margin: 0; + color: #616161; + } + } + + mat-card-actions { + padding: 16px; + display: flex; + justify-content: flex-end; + } +} diff --git a/apps/frontend/src/app/ws-admin/components/cleaning/cleaning.component.ts b/apps/frontend/src/app/ws-admin/components/cleaning/cleaning.component.ts new file mode 100644 index 000000000..786a902ef --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/cleaning/cleaning.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; + +@Component({ + selector: 'coding-box-cleaning', + templateUrl: './cleaning.component.html', + styleUrls: ['./cleaning.component.scss'], + standalone: true, + imports: [ + TranslateModule, + MatCardModule, + MatButtonModule + ] +}) +export class CleaningComponent { + // This component will be responsible for cleaning of the data + // after automatic and manual coding in the future +} diff --git a/apps/frontend/src/app/ws-admin/components/export/export.component.html b/apps/frontend/src/app/ws-admin/components/export/export.component.html new file mode 100644 index 000000000..c05e47f8f --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/export/export.component.html @@ -0,0 +1,21 @@ +
+ + + {{ 'ws-admin.export-title' | translate }} + {{ 'ws-admin.export-subtitle' | translate }} + + +

{{ 'ws-admin.export-description' | translate }}

+ + +
+

{{ 'ws-admin.export-placeholder' | translate }}

+
+
+ + + +
+
diff --git a/apps/frontend/src/app/ws-admin/components/export/export.component.scss b/apps/frontend/src/app/ws-admin/components/export/export.component.scss new file mode 100644 index 000000000..c185f7de4 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/export/export.component.scss @@ -0,0 +1,31 @@ +.export-container { + padding: 20px; + + mat-card { + max-width: 800px; + margin: 0 auto; + } + + mat-card-content { + margin-top: 20px; + } + + .placeholder-message { + margin: 30px 0; + padding: 20px; + background-color: #f5f5f5; + border-radius: 4px; + border-left: 4px solid #2196f3; + + p { + margin: 0; + color: #616161; + } + } + + mat-card-actions { + padding: 16px; + display: flex; + justify-content: flex-end; + } +} diff --git a/apps/frontend/src/app/ws-admin/components/export/export.component.ts b/apps/frontend/src/app/ws-admin/components/export/export.component.ts new file mode 100644 index 000000000..e8438d970 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/export/export.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; + +@Component({ + selector: 'coding-box-export', + templateUrl: './export.component.html', + styleUrls: ['./export.component.scss'], + standalone: true, + imports: [ + TranslateModule, + MatCardModule, + MatButtonModule + ] +}) +export class ExportComponent { + // This component will be responsible for export of the data + // after coding and cleaning in the future +} diff --git a/apps/frontend/src/app/ws-admin/components/files-validation-result/files-validation.component.spec.ts b/apps/frontend/src/app/ws-admin/components/files-validation-result/files-validation.component.spec.ts index 009b76aab..26818d797 100644 --- a/apps/frontend/src/app/ws-admin/components/files-validation-result/files-validation.component.spec.ts +++ b/apps/frontend/src/app/ws-admin/components/files-validation-result/files-validation.component.spec.ts @@ -1,7 +1,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { provideHttpClient } from '@angular/common/http'; import { FilesValidationDialogComponent } from './files-validation.component'; +import { SERVER_URL } from '../../../injection-tokens'; +import { environment } from '../../../../environments/environment'; describe('FilesValidationComponent', () => { let component: FilesValidationDialogComponent; @@ -14,6 +17,11 @@ describe('FilesValidationComponent', () => { TranslateModule.forRoot() ], providers: [ + provideHttpClient(), + { + provide: SERVER_URL, + useValue: environment.backendUrl + }, { provide: MatDialogRef, useValue: [] diff --git a/apps/frontend/src/app/ws-admin/components/replay-statistics-dialog/replay-statistics-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/replay-statistics-dialog/replay-statistics-dialog.component.ts new file mode 100644 index 000000000..8e96286a6 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/replay-statistics-dialog/replay-statistics-dialog.component.ts @@ -0,0 +1,589 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgxChartsModule } from '@swimlane/ngx-charts'; +import { BackendService } from '../../../services/backend.service'; + +interface ReplayFrequencyData { + name: string; + value: number; +} + +@Component({ + selector: 'coding-box-replay-statistics-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatCardModule, + MatTabsModule, + MatProgressSpinnerModule, + TranslateModule, + NgxChartsModule + ], + template: ` +

{{ 'workspace.replay-statistics' | translate }}

+ +
+ +

{{ 'workspace.loading-statistics' | translate }}

+
+ +
+ + + +
+

{{ 'workspace.replay-frequency-by-unit' | translate }}

+ +
+
+ + + +
+
+ + +
+ {{ 'workspace.min-duration' | translate }}: + {{ formatMilliseconds(durationStats.min) }} +
+
+ {{ 'workspace.max-duration' | translate }}: + {{ formatMilliseconds(durationStats.max) }} +
+
+ {{ 'workspace.avg-duration' | translate }}: + {{ formatMilliseconds(durationStats.average) }} +
+
+
+
+ +
+
+

{{ 'workspace.replay-duration-distribution' | translate }}

+ +
+ +
+

{{ 'workspace.avg-duration-by-unit' | translate }}

+ +
+
+
+
+ + + +
+

{{ 'workspace.replay-distribution-by-day' | translate }}

+ +
+
+ + + +
+

{{ 'workspace.replay-distribution-by-hour' | translate }}

+ +
+
+ + + +
+
+ + +
+ {{ 'workspace.success-rate' | translate }}: + {{ errorStats.successRate.toFixed(2) }}% +
+
+ {{ 'workspace.total-replays' | translate }}: + {{ errorStats.totalReplays }} +
+
+ {{ 'workspace.successful-replays' | translate }}: + {{ errorStats.successfulReplays }} +
+
+ {{ 'workspace.failed-replays' | translate }}: + {{ errorStats.failedReplays }} +
+
+
+
+ +
+

{{ 'workspace.common-errors' | translate }}

+ + +
+
{{ error.count }}
+
{{ error.message }}
+
+
+
+
+ +
+

{{ 'workspace.no-error-messages' | translate }}

+
+
+
+ + + +
+

{{ 'workspace.failure-distribution-by-unit' | translate }}

+
+

{{ 'workspace.no-failures' | translate }}

+
+ +
+
+ + + +
+

{{ 'workspace.failure-distribution-by-day' | translate }}

+
+

{{ 'workspace.no-failures' | translate }}

+
+ +
+
+ + + +
+

{{ 'workspace.failure-distribution-by-hour' | translate }}

+
+

{{ 'workspace.no-failures' | translate }}

+
+ +
+
+
+
+
+ + + + `, + styles: [` + .dialog-content { + min-height: 400px; + min-width: 900px; + max-width: 1200px; + } + + .loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 400px; + } + + .chart-container { + padding: 20px 0; + } + + .charts-row { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; + } + + .chart-column { + flex: 1; + height: 350px; + } + + .stats-container { + margin: 20px 0; + } + + .stat-item { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + } + + .stat-label { + font-weight: bold; + } + + .error-item { + display: flex; + margin-bottom: 12px; + padding: 8px; + border-bottom: 1px solid #eee; + } + + .error-count { + font-weight: bold; + min-width: 40px; + margin-right: 16px; + color: #d32f2f; + } + + .error-message { + flex: 1; + word-break: break-word; + } + `] +}) +export class ReplayStatisticsDialogComponent implements OnInit { + private backendService = inject(BackendService); + private data = inject(MAT_DIALOG_DATA); + + workspaceId: number; + loading = true; + + // Chart data + frequencyData: ReplayFrequencyData[] = []; + durationDistributionData: { name: string; value: number }[] = []; + unitDurationData: ReplayFrequencyData[] = []; + dayDistributionData: ReplayFrequencyData[] = []; + hourDistributionData: ReplayFrequencyData[] = []; + + // Failure distribution data + failureByUnitData: ReplayFrequencyData[] = []; + failureByDayData: ReplayFrequencyData[] = []; + failureByHourData: ReplayFrequencyData[] = []; + + // Error statistics data + errorStats = { + successRate: 0, + totalReplays: 0, + successfulReplays: 0, + failedReplays: 0, + commonErrors: [] as Array<{ message: string; count: number }> + }; + + // Duration statistics + durationStats = { + min: 0, + max: 0, + average: 0 + }; + + // Chart configuration + colorScheme = 'vivid'; + + /** + * Format milliseconds to a more readable format + * @param milliseconds The duration in milliseconds + * @returns Formatted string (e.g., "5.00 s" for 5000 milliseconds) + */ + formatMilliseconds(milliseconds: number): string { + // Convert to seconds with 2 decimal places for better readability + return `${(milliseconds / 1000).toFixed(2)} s`; + } + + constructor() { + this.workspaceId = this.data.workspaceId; + } + + ngOnInit(): void { + this.loadReplayStatistics(); + } + + private loadReplayStatistics(): void { + this.loading = true; + + // Load replay frequency data + this.backendService.getReplayFrequencyByUnit(this.workspaceId) + .subscribe({ + next: data => { + this.frequencyData = Object.entries(data).map(([unitId, count]) => ({ + name: unitId, + value: count + })); + + // Sort by frequency (highest first) + this.frequencyData.sort((a, b) => b.value - a.value); + + // Load day distribution data + this.loadDayDistribution(); + }, + error: () => { + this.loading = false; + } + }); + } + + private loadDayDistribution(): void { + this.backendService.getReplayDistributionByDay(this.workspaceId) + .subscribe({ + next: data => { + this.dayDistributionData = Object.entries(data).map(([day, count]) => ({ + name: day, + value: count + })); + + // Sort by date (oldest first) + this.dayDistributionData.sort((a, b) => a.name.localeCompare(b.name)); + + // Load hour distribution data + this.loadHourDistribution(); + }, + error: () => { + // Continue with duration statistics even if day distribution fails + this.loadHourDistribution(); + } + }); + } + + private loadHourDistribution(): void { + this.backendService.getReplayDistributionByHour(this.workspaceId) + .subscribe({ + next: data => { + this.hourDistributionData = Object.entries(data).map(([hour, count]) => ({ + name: `${hour}:00`, + value: count + })); + + // Sort by hour (earliest first) + this.hourDistributionData.sort((a, b) => { + const hourA = parseInt(a.name.split(':')[0], 10); + const hourB = parseInt(b.name.split(':')[0], 10); + return hourA - hourB; + }); + + // Load duration statistics + this.loadDurationStatistics(); + }, + error: () => { + // Continue with duration statistics even if hour distribution fails + this.loadDurationStatistics(); + } + }); + } + + private loadDurationStatistics(): void { + this.backendService.getReplayDurationStatistics(this.workspaceId) + .subscribe({ + next: data => { + // Set duration statistics + this.durationStats = { + min: data.min, + max: data.max, + average: data.average + }; + + // Set duration distribution data + this.durationDistributionData = Object.entries(data.distribution).map(([range, count]) => ({ + name: range, + value: count + })); + + // Sort by duration range + this.durationDistributionData.sort((a, b) => { + const aStart = parseInt(a.name.split('-')[0], 10); + const bStart = parseInt(b.name.split('-')[0], 10); + return aStart - bStart; + }); + + // Set unit duration data + if (data.unitAverages) { + this.unitDurationData = Object.entries(data.unitAverages).map(([unitId, avgDuration]) => ({ + name: unitId, + value: avgDuration as number + })); + + // Sort by unit ID + this.unitDurationData.sort((a, b) => a.name.localeCompare(b.name)); + } + + // Load error statistics + this.loadErrorStatistics(); + }, + error: () => { + // Continue with error statistics even if duration statistics fails + this.loadErrorStatistics(); + } + }); + } + + private loadErrorStatistics(): void { + this.backendService.getReplayErrorStatistics(this.workspaceId) + .subscribe({ + next: data => { + this.errorStats = data; + + // Load failure distributions + this.loadFailureDistributions(); + }, + error: () => { + // Continue with failure distributions even if error statistics fails + this.loadFailureDistributions(); + } + }); + } + + private loadFailureDistributions(): void { + // Load failure distribution by unit + this.backendService.getFailureDistributionByUnit(this.workspaceId) + .subscribe({ + next: data => { + this.failureByUnitData = Object.entries(data).map(([unitId, count]) => ({ + name: unitId, + value: count + })); + + // Sort by unit ID + this.failureByUnitData.sort((a, b) => a.name.localeCompare(b.name)); + + // Load failure distribution by day + this.loadFailureDistributionByDay(); + }, + error: () => { + // Continue with day distribution even if unit distribution fails + this.loadFailureDistributionByDay(); + } + }); + } + + private loadFailureDistributionByDay(): void { + this.backendService.getFailureDistributionByDay(this.workspaceId) + .subscribe({ + next: data => { + this.failureByDayData = Object.entries(data).map(([day, count]) => ({ + name: day, + value: count + })); + + // Sort by date (oldest first) + this.failureByDayData.sort((a, b) => a.name.localeCompare(b.name)); + + // Load failure distribution by hour + this.loadFailureDistributionByHour(); + }, + error: () => { + // Continue with hour distribution even if day distribution fails + this.loadFailureDistributionByHour(); + } + }); + } + + private loadFailureDistributionByHour(): void { + this.backendService.getFailureDistributionByHour(this.workspaceId) + .subscribe({ + next: data => { + this.failureByHourData = Object.entries(data).map(([hour, count]) => ({ + name: `${hour}:00`, + value: count + })); + + // Sort by hour (earliest first) + this.failureByHourData.sort((a, b) => { + const hourA = parseInt(a.name.split(':')[0], 10); + const hourB = parseInt(b.name.split(':')[0], 10); + return hourA - hourB; + }); + + // Complete loading + this.loading = false; + }, + error: () => { + this.loading = false; + } + }); + } +} diff --git a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts index 04f551d1f..52bd36fad 100755 --- a/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-files/test-files.component.ts @@ -29,6 +29,7 @@ import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { FilesValidationDialogComponent } from '../files-validation-result/files-validation.component'; import { TestCenterImportComponent } from '../test-center-import/test-center-import.component'; import { ResourcePackagesDialogComponent } from '../resource-packages-dialog/resource-packages-dialog.component'; +import { SchemeEditorDialogComponent } from '../../../coding/components/scheme-editor-dialog/scheme-editor-dialog.component'; import { AppService } from '../../../services/app.service'; import { BackendService } from '../../../services/backend.service'; import { HasSelectionValuePipe } from '../../../shared/pipes/hasSelectionValue.pipe'; @@ -175,6 +176,7 @@ export class TestFilesComponent implements OnInit, OnDestroy { applyFilters(): void { this.page = 1; + this.tableCheckboxSelection.clear(); this.loadTestFiles(); } @@ -267,6 +269,7 @@ export class TestFilesComponent implements OnInit, OnDestroy { { duration: 1000 } ); if (success) { + this.tableCheckboxSelection.clear(); this.loadTestFiles(); } } @@ -367,20 +370,41 @@ export class TestFilesComponent implements OnInit, OnDestroy { onPageChange(event: PageEvent): void { this.page = event.pageIndex + 1; this.limit = event.pageSize; + this.tableCheckboxSelection.clear(); this.loadTestFiles(); } showFileContent(file: FilesInListDto): void { this.backendService.downloadFile(this.appService.selectedWorkspaceId, file.id).subscribe(fileData => { const decodedContent = atob(fileData.base64Data); - this.dialog.open(ContentDialogComponent, { - width: '800px', - height: '800px', - data: { - title: file.filename, - content: decodedContent - } - }); + + if (file.file_type === 'Resource' && file.filename.toLowerCase().endsWith('.vocs')) { + const dialogRef = this.dialog.open(SchemeEditorDialogComponent, { + width: '90%', + height: '90%', + data: { + workspaceId: this.appService.selectedWorkspaceId, + fileId: file.id, + fileName: file.filename, + content: decodedContent + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result === true) { + this.loadTestFiles(); + } + }); + } else { + this.dialog.open(ContentDialogComponent, { + width: '800px', + height: '800px', + data: { + title: file.filename, + content: decodedContent + } + }); + } }); } diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html index 48b065044..70ce17be6 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html @@ -266,6 +266,10 @@

Antworten

list Logs + + + + +
+ +

Loading unit information...

+
+ +
+ error +

{{ errorMessage }}

+
+ +
+ + + +
+
+

Basic Information

+
+ ID: + {{ data.unitInfo.metadata.id }} +
+
+ Label: + {{ data.unitInfo.metadata.label }} +
+
+ Description: + {{ data.unitInfo.metadata.description }} +
+
+ Transcript: + {{ data.unitInfo.metadata.transcript }} +
+
+ Reference: + {{ data.unitInfo.metadata.reference }} +
+
+ Last Change: + {{ data.unitInfo.metadata.lastChange | date:'medium' }} +
+
+
+
+ + + +
+
+

Definition Information

+
+ Type: + {{ data.unitInfo.definition.type }} +
+
+ Player: + {{ data.unitInfo.definition.player }} +
+
+ Editor: + {{ data.unitInfo.definition.editor }} +
+
+ Last Change: + {{ data.unitInfo.definition.lastChange | date:'medium' }} +
+
+ Content: +
{{ data.unitInfo.definition.content }}
+
+
+
+
+ + + +
+ +
+

Base Variables

+
+
+ ID: + {{ variable.id }} +
+
+ Alias: + {{ variable.alias }} +
+
+ Type: + {{ variable.type }} +
+
+ Format: + {{ variable.format }} +
+
+ Multiple: + {{ variable.multiple ? 'Yes' : 'No' }} +
+
+ Nullable: + {{ variable.nullable ? 'Yes' : 'No' }} +
+
+ Page: + {{ variable.page }} +
+ + +
+

Values

+
+
+ Label: + {{ value.label }} +
+
+ Value: + {{ value.value }} +
+
+
+ + +
+
+ + +
+

Derived Variables

+
+
+ ID: + {{ variable.id }} +
+
+ Alias: + {{ variable.alias }} +
+
+ Type: + {{ variable.type }} +
+
+ Format: + {{ variable.format }} +
+
+ Multiple: + {{ variable.multiple ? 'Yes' : 'No' }} +
+
+ Nullable: + {{ variable.nullable ? 'Yes' : 'No' }} +
+
+ Page: + {{ variable.page }} +
+ + +
+

Values

+
+
+ Label: + {{ value.label }} +
+
+ Value: + {{ value.value }} +
+
+
+ + +
+
+
+
+ + + +
+
+

Dependencies

+
+
+ Type: + {{ dependency.type }} +
+
+ For: + {{ dependency.for }} +
+
+ Content: + {{ dependency.content }} +
+ +
+
+
+
+ + + +
+
+

Coding Scheme Reference

+
+ Schemer: + {{ data.unitInfo.codingSchemeRef.schemer }} +
+
+ Scheme Type: + {{ data.unitInfo.codingSchemeRef.schemeType }} +
+
+ Last Change: + {{ data.unitInfo.codingSchemeRef.lastChange | date:'medium' }} +
+
+ Content: + {{ data.unitInfo.codingSchemeRef.content }} +
+
+
+
+ + + +
+
+
{{ data.unitInfo.rawXml }}
+
+
+
+
+
+
+ + + + +
diff --git a/apps/frontend/src/app/ws-admin/components/unit-info-dialog/unit-info-dialog.component.scss b/apps/frontend/src/app/ws-admin/components/unit-info-dialog/unit-info-dialog.component.scss new file mode 100644 index 000000000..be1899de2 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/unit-info-dialog/unit-info-dialog.component.scss @@ -0,0 +1,137 @@ +.dialog-container { + min-width: 600px; + max-width: 800px; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +h2 { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + + .close-button { + margin-left: auto; + } +} + +mat-dialog-content { + flex: 1; + overflow: auto; +} + +.loading-container, .error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + text-align: center; + + mat-spinner { + margin-bottom: 16px; + } + + mat-icon { + font-size: 48px; + height: 48px; + width: 48px; + margin-bottom: 16px; + } +} + +.info-container { + .tab-content { + padding: 16px 0; + } + + .info-section { + margin-bottom: 24px; + + h3 { + margin-bottom: 16px; + font-weight: 500; + color: #333; + } + + h4 { + margin: 16px 0 8px; + font-weight: 500; + color: #555; + } + } + + .info-row { + display: flex; + margin-bottom: 8px; + + .info-label { + min-width: 120px; + font-weight: 500; + color: #555; + } + + .info-value { + flex: 1; + } + } + + .content-row { + flex-direction: column; + + .info-label { + margin-bottom: 8px; + } + + .content-value { + background-color: #f5f5f5; + padding: 8px; + border-radius: 4px; + overflow: auto; + max-height: 200px; + white-space: pre-wrap; + word-break: break-word; + } + } + + .variable-item, .dependency-item { + padding: 16px 0; + + &:not(:last-child) { + border-bottom: 1px solid #eee; + } + } + + .variable-values { + margin-left: 16px; + padding-left: 16px; + border-left: 2px solid #eee; + } + + .value-item { + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } + } + + .xml-content { + background-color: #f5f5f5; + padding: 16px; + border-radius: 4px; + overflow: auto; + max-height: 400px; + white-space: pre-wrap; + word-break: break-word; + font-family: monospace; + font-size: 12px; + } +} + +mat-dialog-actions { + padding: 16px 0; + margin-bottom: 0; +} diff --git a/apps/frontend/src/app/ws-admin/components/unit-info-dialog/unit-info-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/unit-info-dialog/unit-info-dialog.component.ts new file mode 100644 index 000000000..b05e483ad --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/unit-info-dialog/unit-info-dialog.component.ts @@ -0,0 +1,47 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { UnitInfoDto } from '../../../../../../../api-dto/unit-info/unit-info.dto'; + +@Component({ + selector: 'coding-box-unit-info-dialog', + templateUrl: './unit-info-dialog.component.html', + styleUrls: ['./unit-info-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatTabsModule, + MatDividerModule, + MatProgressSpinnerModule + ] +}) +export class UnitInfoDialogComponent implements OnInit { + isLoading = true; + errorMessage: string | null = null; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { + unitInfo: UnitInfoDto; + unitId: string; + } + ) {} + + ngOnInit(): void { + if (this.data.unitInfo) { + this.isLoading = false; + } + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts index 408f79d98..1b4443f22 100644 --- a/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts +++ b/apps/frontend/src/app/ws-admin/components/validation-dialog/validation-dialog.component.ts @@ -2860,8 +2860,8 @@ export class ValidationDialogComponent implements AfterViewInit, OnInit, OnDestr this.validationTaskStateService.setValidationResult(workspaceId, 'groupResponses', validationResult); } }, - error: error => { - console.error('Error loading previous validation results:', error); + error: () => { + // Error occurred while loading previous validation results } }); diff --git a/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.ts b/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.ts index c3288f769..c18e4f38a 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.ts +++ b/apps/frontend/src/app/ws-admin/components/ws-admin/ws-admin.component.ts @@ -30,7 +30,7 @@ export class WsAdminComponent implements OnInit { private appService = inject(AppService); private backendService = inject(BackendService); - navLinks: string[] = ['test-files', 'test-results', 'coding', 'settings']; + navLinks: string[] = ['test-files', 'test-results', 'coding', 'cleaning', 'export', 'settings']; accessLevel:number = 0; authData = AppService.defaultAuthData; diff --git a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.html b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.html index 6a7d75b68..f84542eec 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.html +++ b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.html @@ -39,6 +39,38 @@

Generiertes Token

+ + + {{ 'workspace.coding-missing-profiles' | translate }} + + +
+

{{ 'workspace.edit-missings-profiles-description' | translate }}

+
+ +
+
+
+
+ + + + Replay-Statistiken + + +
+

Visualisieren Sie Statistiken über die Nutzung der Replay-Funktion, einschließlich Häufigkeit und Dauer.

+
+ +
+
+
+
+ Nutzerrechte diff --git a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.scss b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.scss index bb0a32819..7c9dc8a59 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.scss +++ b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.scss @@ -10,7 +10,8 @@ .token-settings-card, .access-rights-card, -.journal-card { +.journal-card, +.missings-profiles-card { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border-radius: 8px; overflow: hidden; @@ -97,6 +98,19 @@ h4 { justify-content: flex-end; } +.missings-profiles-container { + background-color: #f9f9f9; + border-radius: 6px; + padding: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.missings-profiles-actions { + display: flex; + margin-top: 20px; + justify-content: center; +} + button mat-icon { margin-right: 5px; } @@ -109,7 +123,8 @@ button mat-icon { .token-settings-card, .access-rights-card, - .journal-card { + .journal-card, + .missings-profiles-card { margin-bottom: 20px; } } @@ -130,7 +145,8 @@ button mat-icon { @media (max-width: 480px) { .token-actions, - .token-copy-action { + .token-copy-action, + .missings-profiles-actions { flex-direction: column; align-items: stretch; } diff --git a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts index 43ed05a5f..f0cd7a534 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts +++ b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts @@ -7,17 +7,21 @@ import { MatInputModule } from '@angular/material/input'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { Clipboard } from '@angular/cdk/clipboard'; import { AppService } from '../../../services/app.service'; import { WsAccessRightsComponent } from '../ws-access-rights/ws-access-rights.component'; import { JournalComponent } from '../journal/journal.component'; +import { EditMissingsProfilesDialogComponent } from '../../../coding/components/edit-missings-profiles-dialog/edit-missings-profiles-dialog.component'; +import { ReplayStatisticsDialogComponent } from '../replay-statistics-dialog/replay-statistics-dialog.component'; @Component({ selector: 'coding-box-ws-settings', templateUrl: './ws-settings.component.html', styleUrls: ['./ws-settings.component.scss'], + standalone: true, imports: [ FormsModule, TranslateModule, @@ -26,6 +30,7 @@ import { JournalComponent } from '../journal/journal.component'; MatInputModule, MatCardModule, MatIconModule, + MatDialogModule, CdkTextareaAutosize, WsAccessRightsComponent, JournalComponent @@ -35,10 +40,21 @@ export class WsSettingsComponent { private appService = inject(AppService); private clipboard = inject(Clipboard); private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); authToken: string | null = null; duration = 60; + openReplayStatistics(): void { + const workspaceId = this.appService.selectedWorkspaceId; + if (workspaceId) { + this.dialog.open(ReplayStatisticsDialogComponent, { + width: '900px', + data: { workspaceId } + }); + } + } + createToken(): void { this.appService .createToken(this.appService.selectedWorkspaceId, this.appService.loggedUser?.sub || '', this.duration) @@ -54,4 +70,14 @@ export class WsSettingsComponent { this.snackBar.open('Token in die Zwischenablage kopiert', 'Schließen', { duration: 3000 }); } } + + editMissingsProfiles(): void { + const workspaceId = this.appService.selectedWorkspaceId; + if (workspaceId) { + this.dialog.open(EditMissingsProfilesDialogComponent, { + width: '900px', + data: { workspaceId } + }); + } + } } diff --git a/apps/frontend/src/app/ws-admin/ws-admin.routes.ts b/apps/frontend/src/app/ws-admin/ws-admin.routes.ts index 18346d3c9..a5d0dbf23 100644 --- a/apps/frontend/src/app/ws-admin/ws-admin.routes.ts +++ b/apps/frontend/src/app/ws-admin/ws-admin.routes.ts @@ -12,6 +12,8 @@ export const wsAdminRoutes: Routes = [ { path: 'test-results', loadComponent: () => import('./components/test-groups/test-groups.component').then(m => m.TestGroupsComponent) }, { path: 'users', loadComponent: () => import('./components/ws-users/ws-users.component').then(m => m.WsUsersComponent) }, { path: 'coding', loadComponent: () => import('../coding/components/coding-managment/coding-management.component').then(m => m.CodingManagementComponent) }, + { path: 'cleaning', loadComponent: () => import('./components/cleaning/cleaning.component').then(m => m.CleaningComponent) }, + { path: 'export', loadComponent: () => import('./components/export/export.component').then(m => m.ExportComponent) }, { path: 'settings', loadComponent: () => import('./components/ws-settings/ws-settings.component').then(m => m.WsSettingsComponent) }, { path: '**', loadComponent: () => import('./components/test-files/test-files.component').then(m => m.TestFilesComponent) } ] diff --git a/apps/frontend/src/assets/i18n/de.json b/apps/frontend/src/assets/i18n/de.json index daa0b3837..0793e6e82 100755 --- a/apps/frontend/src/assets/i18n/de.json +++ b/apps/frontend/src/assets/i18n/de.json @@ -20,6 +20,11 @@ "filter-by": "Filtern nach...", "hour": "Uhr", "files-import": "Dateien importieren", + "search": "Suchen", + "search-units": "Nach Aufgaben suchen", + "loading-units": "Aufgaben werden geladen...", + "no-units-matching": "Keine Aufgaben gefunden, die übereinstimmen mit", + "no-units-available": "Keine Aufgaben verfügbar", "home": { "welcome": "Willkommen!", @@ -153,7 +158,19 @@ "test-groups": "Testgruppen", "test-results": "Testergebnisse", "select-unit-play": "Replay", - "coding": "Kodierung" + "coding": "Kodierung", + "cleaning": "Datenbereinigung", + "cleaning-title": "Datenbereinigung", + "cleaning-subtitle": "Bereinigung der Daten nach automatischer und manueller Kodierung", + "cleaning-description": "Diese Komponente wird für die Bereinigung der Daten nach automatischer und manueller Kodierung verwendet.", + "cleaning-placeholder": "Die Funktionalität zur Datenbereinigung wird in einer zukünftigen Version implementiert.", + "cleaning-action-button": "Datenbereinigung starten", + "export": "Datenexport", + "export-title": "Datenexport", + "export-subtitle": "Export der Daten nach Kodierung und Bereinigung", + "export-description": "Diese Komponente wird für den Export der Daten nach Kodierung und Bereinigung verwendet.", + "export-placeholder": "Die Funktionalität zum Datenexport wird in einer zukünftigen Version implementiert.", + "export-action-button": "Datenexport starten" }, "search-filter": { "filter-users": "Suche nach Nutzer:innen", @@ -190,6 +207,37 @@ "created_at": "Hochgeladen am" }, + "coding": { + "id": "Variablen Id", + "result": "Kodierergebnis", + "variable": "Variable", + "value": "Wert", + "state": "Status", + "status": "Status", + "code": "Code", + "score": "Bewertung", + "subform": "Unterformular", + "raw-responses": "Rohdaten anzeigen", + "select-units": "Aufgaben auswählen", + "select-all-units": "Alle Aufgaben auswählen", + "select": "Auswählen", + "unit-id": "Aufgaben-ID", + "unit-name": "Aufgabe", + "unit-alias": "Einheiten-Alias", + "has-only-vars-with-codes": "Nur Variablen mit Codes", + "vars-with-codes-only": "Nur Variablen mit Codes", + "has-general-instructions": "Allgemeine Hinweise für jede Variable", + "has-derived-vars": "Abgeleitete Variablen", + "has-only-manual-coding": "Manuell kodierte Variablen", + "has-closed-vars": "Geschlossen kodierte Variablen", + "show-score": "Bewertung anzeigen", + "codebook-content": "Codebook Inhalte", + "export-format": "Exportformat", + "code-label-to-upper": "Code-Label in Großbuchstaben", + "hide-item-var-relation": "Item-Variable-Relation ausblenden", + "error-save-changes": "Änderungen im Arbeitsbereich müssen erst noch gespeichert werden!" + }, + "workspace": { "title": "Arbeitsbereich", "workspace-not-loaded": "Konnte Daten für Arbeitsbereich nicht laden", @@ -229,6 +277,55 @@ "player-send-unit-navigation-request": "Player sendet UnitNavigationRequest: \"{{target}}\"", "page-selection": "Seitenauswahl", "validate": "Validieren", - "send-navigation-denied": "Sende die Nachricht \"Verweigerte Navigation\"" + "send-navigation-denied": "Sende die Nachricht \"Verweigerte Navigation\"", + "edit-missings-profiles": "Missings-Profile verwalten", + "edit-missings-profiles-description": "Hier können Sie Missings-Profile erstellen, bearbeiten und löschen. Diese Profile werden beim Export des Codebooks verwendet.", + "missings-profiles": "Missings-Profile", + "create-profile": "Profil erstellen", + "edit-profile": "Profil bearbeiten", + "profile-name": "Profilname", + "missing-values": "Missing-Werte", + "missing-id": "ID", + "missing-label": "Label", + "missing-description": "Beschreibung", + "missing-code": "Code", + "actions": "Aktionen", + "remove-missing": "Missing-Wert entfernen", + "add-missing": "Missing-Wert hinzufügen", + "save-profile": "Profil speichern", + "delete-profile": "Profil löschen", + "close": "Schließen", + "select-missings-profile": "Missings-Profil auswählen", + "replay-statistics": "Replay-Statistiken", + "loading-statistics": "Statistiken werden geladen...", + "replay-frequency": "Replay-Häufigkeit", + "replay-frequency-by-unit": "Replay-Häufigkeit nach Aufgabe", + "unit": "Aufgabe", + "replay-count": "Anzahl der Replays", + "replay-duration": "Replay-Dauer", + "replay-duration-distribution": "Verteilung der Replay-Dauer", + "duration-milliseconds": "Dauer (Millisekunden)", + "min-duration": "Minimale Dauer", + "max-duration": "Maximale Dauer", + "avg-duration": "Durchschnittliche Dauer", + "milliseconds": "Millisekunden", + "avg-duration-by-unit": "Durchschnittliche Dauer nach Aufgabe", + "avg-duration-milliseconds": "Durchschnittliche Dauer (Millisekunden)", + "replay-distribution-by-day": "Verteilung der Replays nach Tag", + "replay-distribution-by-hour": "Verteilung der Replays nach Stunde", + "date": "Datum", + "hour": "Stunde", + "replay-errors": "Replay-Fehler", + "success-rate": "Erfolgsrate", + "total-replays": "Gesamtzahl der Replays", + "successful-replays": "Erfolgreiche Replays", + "failed-replays": "Fehlgeschlagene Replays", + "common-errors": "Häufige Fehler", + "no-error-messages": "Keine Fehlermeldungen verfügbar", + "failure-distribution-by-unit": "Verteilung der Fehler nach Aufgabe", + "failure-distribution-by-day": "Verteilung der Fehler nach Tag", + "failure-distribution-by-hour": "Verteilung der Fehler nach Stunde", + "failure-count": "Anzahl der Fehler", + "no-failures": "Keine Fehler vorhanden" } } diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index 6bfebb43e..f5ed97800 100755 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -4,8 +4,17 @@ $iqb-primary: mat.m2-define-palette(mat.$m2-cyan-palette, 900); $iqb-accent: mat.m2-define-palette(mat.$m2-light-green-palette, A200); -$iqb-app-theme: mat.m2-define-light-theme($iqb-primary, $iqb-accent); $iqb-warn: mat.m2-define-palette(mat.$m2-red-palette); + +$iqb-app-theme: mat.m2-define-light-theme(( + color: ( + primary: $iqb-primary, + accent: $iqb-accent, + warn: $iqb-warn, + ), + typography: mat.m2-define-typography-config(), + density: 0 +)); @include mat.all-component-themes($iqb-app-theme); diff --git a/database/changelog/coding-box.changelog-0.11.0.sql b/database/changelog/coding-box.changelog-0.11.0.sql new file mode 100644 index 000000000..2a426f618 --- /dev/null +++ b/database/changelog/coding-box.changelog-0.11.0.sql @@ -0,0 +1,38 @@ +-- liquibase formatted sql + +-- changeset jurei733:1 +CREATE TABLE setting ( + key VARCHAR(255) PRIMARY KEY NOT NULL, + content TEXT NOT NULL +); + +-- rollback DROP TABLE IF EXISTS setting; + +-- changeset jurei733:2 +ALTER TABLE "public"."file_upload" ADD COLUMN "structured_data" JSONB NULL; +-- rollback ALTER TABLE "public"."file_upload" DROP COLUMN "structured_data"; + +-- changeset jurei733:3 +CREATE TABLE replay_statistics ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + workspace_id INTEGER NOT NULL, + unit_id VARCHAR(255) NOT NULL, + booklet_id VARCHAR(255) NULL, + test_person_login VARCHAR(255) NULL, + test_person_code VARCHAR(255) NULL, + duration_milliseconds INTEGER NOT NULL, + replay_url VARCHAR(2000) NULL +); + +-- rollback DROP TABLE IF EXISTS replay_statistics; + +-- changeset jurei733:4 +ALTER TABLE "public"."replay_statistics" ADD COLUMN "success" BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE "public"."replay_statistics" ADD COLUMN "error_message" VARCHAR(2000) NULL; +-- rollback ALTER TABLE "public"."replay_statistics" DROP COLUMN "success"; +-- rollback ALTER TABLE "public"."replay_statistics" DROP COLUMN "error_message"; + +-- changeset jurei733:5 +CREATE INDEX IF NOT EXISTS response_id_idx ON response (id); +-- rollback DROP INDEX IF EXISTS response_id_idx; diff --git a/database/changelog/coding-box.changelog-root.xml b/database/changelog/coding-box.changelog-root.xml index e87f9d0c7..8d861d409 100644 --- a/database/changelog/coding-box.changelog-root.xml +++ b/database/changelog/coding-box.changelog-root.xml @@ -19,4 +19,5 @@ + diff --git a/database/config/postgresql.conf b/database/config/postgresql.conf index 7c7549cf0..3db6fa55d 100644 --- a/database/config/postgresql.conf +++ b/database/config/postgresql.conf @@ -1,14 +1,23 @@ # Memory settings -shared_buffers = '1GB' # Increase shared buffer size for better caching -work_mem = '16MB' # Increase work memory for complex operations -maintenance_work_mem = '256MB' # Increase memory for maintenance operations like bulk loading +shared_buffers = 1GB; # ca. 20% des RAM (z.B. 1-4GB für 8-16GB RAM Server) +work_mem = 16MB; # pro Verbindung; erhöht für große Abfragen +maintenance_work_mem = 256MB; # für VACUUM, CREATE INDEX usw. +effective_cache_size = 3GB; # ca. 70% des RAM (z.B. 3-4GB für 8GB RAM) # Write-Ahead Log (WAL) settings -wal_buffers = '8MB' # Increase WAL buffer size -synchronous_commit = 'off' # Disable synchronous commit for faster inserts (use with caution in production) +wal_buffers = 16MB; # Increase WAL buffer size +synchronous_commit = on; # Disable synchronous commit for faster inserts (use with caution in production) # Checkpoint settings -checkpoint_timeout = '15min' # Increase checkpoint timeout -max_wal_size = '2GB' # Increase max WAL size -checkpoint_completion_target = 0.9 # Spread checkpoint completion +checkpoint_timeout = 15min; # Increase checkpoint timeout +max_wal_size = 2GB; # Increase max WAL size +checkpoint_completion_target = 0.9; # Spread checkpoint completion + +# Parallelization settings (ab Version 9.6+) +max_parallel_workers_per_gather = 2; # Maximale Anzahl paralleler Worker pro Gather-Knoten +max_worker_processes = 4; # Maximale Anzahl von Hintergrundprozessen +max_parallel_workers = 4; # Maximale Anzahl paralleler Worker + +# Weitere Empfehlungen +default_statistics_target = 200; # Erhöht die Statistikgenauigkeit für den Planer diff --git a/docker-compose.coding-box.prod.yaml b/docker-compose.coding-box.prod.yaml index f970a7847..36f323c05 100644 --- a/docker-compose.coding-box.prod.yaml +++ b/docker-compose.coding-box.prod.yaml @@ -20,6 +20,13 @@ services: - "traefik.http.services.backend.loadbalancer.server.port=3333" - "traefik.docker.network=app-net" image: ${REGISTRY_PATH}iqbberlin/coding-box-backend:${TAG} + depends_on: + redis: + condition: service_healthy + environment: + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PREFIX: ${REDIS_PREFIX:-coding-box} volumes: - "backend_vol:/usr/src/coding-box-backend/packages" restart: always diff --git a/docker-compose.override.yaml b/docker-compose.override.yaml index d7a41893f..bc272e812 100644 --- a/docker-compose.override.yaml +++ b/docker-compose.override.yaml @@ -7,6 +7,9 @@ services: REGISTRY_PATH: ${REGISTRY_PATH} ports: - "${POSTGRES_PORT}:5432" + volumes: + - "./database/config/postgresql.conf:/etc/postgresql/postgresql.conf" + command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"] liquibase: build: diff --git a/docker-compose.yaml b/docker-compose.yaml index 5c0400a4d..41e4714ec 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -6,6 +6,20 @@ x-env-postgres: &env-postgres POSTGRES_DB: ${POSTGRES_DB} POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS} services: + redis: + image: redis:alpine + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - "redis_data:/data" + networks: + - application-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + db: environment: <<: *env-postgres @@ -38,9 +52,14 @@ services: condition: service_healthy liquibase: condition: service_completed_successfully + redis: + condition: service_healthy environment: API_HOST: backend JWT_SECRET: ${JWT_SECRET} + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PREFIX: ${REDIS_PREFIX:-coding-box} <<: *env-postgres networks: - application-network @@ -64,6 +83,7 @@ services: volumes: db_vol: backend_vol: + redis_data: networks: application-network: diff --git a/package-lock.json b/package-lock.json index 9531c4d7b..f67e8def9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.10.0", + "version": "0.11.0", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", @@ -20,7 +20,10 @@ "@angular/platform-browser-dynamic": "20.0.3", "@angular/router": "20.0.3", "@iqb/responses": "^3.6.0", + "@iqbspecs/response": "1.4.0", + "@iqbspecs/variable-info": "1.3.0", "@nestjs/axios": "^4.0.1", + "@nestjs/bull": "^11.0.3", "@nestjs/common": "^11.1.5", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.5", @@ -32,15 +35,20 @@ "@nestjs/typeorm": "^11.0.0", "@ngx-translate/core": "^15.0.0", "@ngx-translate/http-loader": "^8.0.0", + "@swimlane/ngx-charts": "^23.0.0-alpha.0", "@types/adm-zip": "^0.5.0", + "@types/file-saver-es": "^2.0.1", "adm-zip": "^0.5.9", "ajv": "^8.17.1", "ajv-keywords": "^5.1.0", "axios": "^1.3.1", + "bull": "^4.16.5", "cheerio": "^1.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "docx": "^9.5.1", "fast-csv": "^5.0.1", + "file-saver-es": "^2.0.5", "jwt-decode": "^4.0.0", "keycloak-angular": "20.0.0", "keycloak-js": "^23.0.6", @@ -75,7 +83,6 @@ "@nx/node": "21.2.0", "@nx/workspace": "21.2.0", "@schematics/angular": "19.1.9", - "@types/jasmine": "~5.1.0", "@types/jest": "29.5.14", "@types/multer": "^1.4.7", "@types/passport-jwt": "^4.0.1", @@ -85,15 +92,9 @@ "eslint-plugin-html": "^8.1.1", "eslint-plugin-import": "^2.29.1", "express": "4.21.2", - "jasmine-core": "~5.1.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", "jest-preset-angular": "14.6.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.1.0", "nx": "21.2.0", "prisma": "^5.10.2", "ts-jest": "29.1.1", @@ -101,13 +102,6 @@ "typescript": "5.8.3" } }, - "node_modules/@adobe/css-tools": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", - "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -4480,15 +4474,6 @@ "dev": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -5546,6 +5531,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.0.tgz", + "integrity": "sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==", + "license": "MIT" + }, "node_modules/@iqb/eslint-config": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@iqb/eslint-config/-/eslint-config-2.2.0.tgz", @@ -5567,6 +5558,18 @@ "mathjs": "^12.4.2" } }, + "node_modules/@iqbspecs/response": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@iqbspecs/response/-/response-1.4.0.tgz", + "integrity": "sha512-GRpKAl0EUcMvJlniUrViRYdFigZ3UR26RE/BinRhbFR4p3FNWdbC7stAhUPkOio/FRBGdPRh+QL7ScCA5B1lDg==", + "license": "CC0 1.0" + }, + "node_modules/@iqbspecs/variable-info": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@iqbspecs/variable-info/-/variable-info-1.3.0.tgz", + "integrity": "sha512-rVm6xChJ4j0h8kW9jKli6Wle+9RnYFnJJBgTI5o2+g29o485ZZSPYy/DL6ajvCNI8/72Ak17PFsBfjQfMhW+IQ==", + "license": "CC0 1.0" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -7154,7 +7157,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7168,7 +7170,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7182,7 +7183,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7196,7 +7196,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7210,7 +7209,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7224,7 +7222,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7559,6 +7556,34 @@ "rxjs": "^7.0.0" } }, + "node_modules/@nestjs/bull": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.3.tgz", + "integrity": "sha512-VyH823Klc7OgsU0FyuKItgRefgrQOQHN5uW9lHxNih6LRtw2Vpi9fENjPdOwjQqKexxZxjNDGKZoDCyK+UGclg==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^11.0.3", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "bull": "^3.3 || ^4.0.0" + } + }, + "node_modules/@nestjs/bull-shared": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.3.tgz", + "integrity": "sha512-CaHniPkLAxis6fAB1DB8WZELQv8VPCLedbj7iP0VQ1pz74i6NSzG9mBg6tOomXq/WW4la4P4OMGEQ48UAJh20A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/common": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.5.tgz", @@ -11266,8 +11291,6 @@ "sass-loader": "^16.0.4", "source-map-loader": "^5.0.0", "style-loader": "^3.3.0", - "stylus": "^0.64.0", - "stylus-loader": "^7.1.0", "terser-webpack-plugin": "^5.3.3", "ts-loader": "^9.3.1", "tsconfig-paths-webpack-plugin": "4.0.0", @@ -13290,12 +13313,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true - }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -13311,6 +13328,39 @@ "tslib": "^2.8.0" } }, + "node_modules/@swimlane/ngx-charts": { + "version": "23.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-23.0.0-alpha.0.tgz", + "integrity": "sha512-3ENgHscwVrTR1ARIGktZbfpx+MIMM/ikwrioOFDhj4crXU3ZvT9xAn59gn3dYOvfuiKIgWNC+TxlRnoPeCvoAw==", + "license": "MIT", + "dependencies": { + "d3-array": "^3.2.0", + "d3-brush": "^3.0.0", + "d3-color": "^3.1.0", + "d3-ease": "^3.0.1", + "d3-format": "^3.1.0", + "d3-hierarchy": "^3.1.2", + "d3-interpolate": "^3.0.1", + "d3-sankey": "^0.12.3", + "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-time-format": "^4.1.0", + "d3-transition": "^3.0.1", + "gradient-path": "^2.3.0", + "tslib": "^2.3.1" + }, + "peerDependencies": { + "@angular/animations": "18.x || 19.x || 20.x", + "@angular/cdk": "18.x || 19.x || 20.x", + "@angular/common": "18.x || 19.x || 20.x", + "@angular/core": "18.x || 19.x || 20.x", + "@angular/forms": "18.x || 19.x || 20.x", + "@angular/platform-browser": "18.x || 19.x || 20.x", + "@angular/platform-browser-dynamic": "18.x || 19.x || 20.x", + "rxjs": "7.x" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -13544,16 +13594,6 @@ "@types/node": "*" } }, - "node_modules/@types/cors": { - "version": "2.8.17", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", - "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -13607,6 +13647,12 @@ "@types/send": "*" } }, + "node_modules/@types/file-saver-es": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/file-saver-es/-/file-saver-es-2.0.3.tgz", + "integrity": "sha512-1N7YkjKDfSSlBq9TCbNelivW+CkqEGh6HWzOP2w8znKsyASufdp8ymxnmKs67hyO9r175xMUu1e2w50xfBD4Ew==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -13656,12 +13702,6 @@ "@types/istanbul-lib-report": "*" } }, - "node_modules/@types/jasmine": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.4.tgz", - "integrity": "sha512-px7OMFO/ncXxixDe1zR13V1iycqWae0MxTaw62RpFlksUi5QuNWgQJFkTQjIOvrmutJbI7Fp2Y2N1F6D2R4G6w==", - "dev": true - }, "node_modules/@types/jest": { "version": "29.5.14", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", @@ -13721,12 +13761,12 @@ } }, "node_modules/@types/node": { - "version": "22.13.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", - "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.8.0" } }, "node_modules/@types/node-forge": { @@ -13848,6 +13888,12 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -15126,16 +15172,6 @@ } ] }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -15419,6 +15455,33 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "node_modules/bull": { + "version": "4.16.5", + "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", + "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.9.0", + "get-port": "^5.1.1", + "ioredis": "^5.3.2", + "lodash": "^4.17.21", + "msgpackr": "^1.11.2", + "semver": "^7.5.2", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/bull/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -16080,6 +16143,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -16306,21 +16378,6 @@ "license": "MIT", "peer": true }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10.0" - } - }, "node_modules/connect-history-api-fallback": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", @@ -16331,60 +16388,6 @@ "node": ">=0.8" } }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/connect/node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/connect/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dev": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -16516,8 +16519,7 @@ "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/cors": { "version": "2.8.5", @@ -16600,7 +16602,6 @@ "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", - "dev": true, "license": "MIT", "dependencies": { "luxon": "^3.2.1" @@ -16909,123 +16910,353 @@ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "dev": true }, - "node_modules/custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", - "dev": true - }, - "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "dev": true, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "internmap": "1 - 2" }, "engines": { "node": ">=12" } }, - "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", - "dev": true, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", - "dev": true, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "d3-dispatch": "1 - 3", + "d3-selection": "3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/date-format": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", - "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", - "dev": true, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", "engines": { - "node": ">=4.0" + "node": ">=12" } }, - "node_modules/dayjs": { - "version": "1.11.11", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", - "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==" - }, - "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dependencies": { - "ms": "2.1.2" - }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=12" } }, - "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", "dependencies": { - "mimic-response": "^3.1.0" + "d3-color": "1 - 3" }, "engines": { - "node": ">=10" - }, + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/dayjs": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", + "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==" + }, + "node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -17165,6 +17396,15 @@ "dev": true, "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -17225,12 +17465,6 @@ "node": ">= 4.0.0" } }, - "node_modules/di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", - "dev": true - }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -17287,16 +17521,39 @@ "node": ">=6.0.0" } }, - "node_modules/dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", - "dev": true, + "node_modules/docx": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz", + "integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==", + "license": "MIT", "dependencies": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" + "@types/node": "^24.0.1", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" } }, "node_modules/dom-serializer": { @@ -17558,47 +17815,6 @@ "once": "^1.4.0" } }, - "node_modules/engine.io": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", - "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.17.1" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/engine.io/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/enhanced-resolve": { "version": "5.18.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", @@ -17626,12 +17842,6 @@ "node": ">=8.6" } }, - "node_modules/ent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", - "integrity": "sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==", - "dev": true - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -17800,14 +18010,15 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -18672,12 +18883,6 @@ "dev": true, "license": "MIT" }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -18853,6 +19058,12 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-saver-es": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver-es/-/file-saver-es-2.0.5.tgz", + "integrity": "sha512-Kg0lt+is9nOyi/VDms9miScNGot25jVFbjFccXuCL/shd2Q+rt70MALxHVkXllsX83JEBLiHQNjDPGd/6FIOoQ==", + "license": "MIT" + }, "node_modules/file-type": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.0.0.tgz", @@ -19376,12 +19587,15 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -19619,6 +19833,18 @@ "node": ">=8.0.0" } }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -19853,6 +20079,15 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/gradient-path": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/gradient-path/-/gradient-path-2.3.0.tgz", + "integrity": "sha512-vZdF/Z0EpqUztzWXFjFC16lqcialHacYoRonslk/bC6CuujkuIrqx7etlzdYHY4SnUU94LRWESamZKfkGh7yYQ==", + "license": "MIT", + "dependencies": { + "tinygradient": "^1.0.0" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -19939,7 +20174,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -19950,6 +20184,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -20422,6 +20666,12 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", @@ -20521,6 +20771,39 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ioredis": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", @@ -21057,20 +21340,7 @@ "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, - "node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, "node_modules/isexe": { "version": "2.0.0", @@ -21255,12 +21525,6 @@ "node": "*" } }, - "node_modules/jasmine-core": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.1.2.tgz", - "integrity": "sha512-2oIUMGn00FdUiqz6epiiJr7xcFyNYj3rDcfmnzfkBnHyBQ3cBQUs4mmyGsOb7TTLb9kxk7dBcmEmqhDKkBoDyA==", - "dev": true - }, "node_modules/javascript-natural-sort": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", @@ -22204,364 +22468,73 @@ ], "license": "MIT" }, - "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "dependencies": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jwt-decode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", - "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", - "engines": { - "node": ">=18" - } - }, - "node_modules/karma": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.3.tgz", - "integrity": "sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q==", - "dev": true, - "dependencies": { - "@colors/colors": "1.5.0", - "body-parser": "^1.19.0", - "braces": "^3.0.2", - "chokidar": "^3.5.1", - "connect": "^3.7.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.1", - "glob": "^7.1.7", - "graceful-fs": "^4.2.6", - "http-proxy": "^1.18.1", - "isbinaryfile": "^4.0.8", - "lodash": "^4.17.21", - "log4js": "^6.4.1", - "mime": "^2.5.2", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.5", - "qjobs": "^1.2.0", - "range-parser": "^1.2.1", - "rimraf": "^3.0.2", - "socket.io": "^4.7.2", - "source-map": "^0.6.1", - "tmp": "^0.2.1", - "ua-parser-js": "^0.7.30", - "yargs": "^16.1.1" - }, - "bin": { - "karma": "bin/karma" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/karma-chrome-launcher": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", - "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", - "dev": true, - "dependencies": { - "which": "^1.2.1" - } - }, - "node_modules/karma-chrome-launcher/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/karma-coverage": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", - "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.0.5", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/karma-coverage/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma-coverage/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma-coverage/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/karma-jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", - "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", - "dev": true, - "dependencies": { - "jasmine-core": "^4.1.0" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "karma": "^6.0.0" - } - }, - "node_modules/karma-jasmine-html-reporter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", - "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", - "dev": true, - "peerDependencies": { - "jasmine-core": "^4.0.0 || ^5.0.0", - "karma": "^6.0.0", - "karma-jasmine": "^5.0.0" - } - }, - "node_modules/karma-jasmine/node_modules/jasmine-core": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", - "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", - "dev": true - }, - "node_modules/karma-source-map-support": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", - "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", - "dev": true, - "dependencies": { - "source-map-support": "^0.5.5" - } - }, - "node_modules/karma/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/karma/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/karma/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/karma/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/karma/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/karma/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", "dependencies": { - "picomatch": "^2.2.1" + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" }, "engines": { - "node": ">=8.10.0" + "node": ">=12", + "npm": ">=6" } }, - "node_modules/karma/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" } }, - "node_modules/karma/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" } }, - "node_modules/karma/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/karma/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "node_modules/karma-source-map-support": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", + "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", "dev": true, - "engines": { - "node": ">=10" + "dependencies": { + "source-map-support": "^0.5.5" } }, "node_modules/keycloak-angular": { @@ -22883,6 +22856,15 @@ } } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -23114,6 +23096,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", @@ -23131,6 +23119,12 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -23470,7 +23464,6 @@ "version": "3.7.1", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -23787,7 +23780,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, "license": "ISC" }, "node_modules/minimatch": { @@ -24039,9 +24031,7 @@ "version": "1.11.4", "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.4.tgz", "integrity": "sha512-uaff7RG9VIC4jacFW9xzL3jc0iM32DNHe4jYVycBcjUePT/Klnfj7pqtWJt9khvDFizmjN2TlYniYmSS2LIaZg==", - "dev": true, "license": "MIT", - "optional": true, "optionalDependencies": { "msgpackr-extract": "^3.0.2" } @@ -24050,7 +24040,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -24317,7 +24306,6 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -25325,6 +25313,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -26583,8 +26577,7 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "node_modules/promise-retry": { "version": "2.0.1", @@ -26679,15 +26672,6 @@ } ] }, - "node_modules/qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", - "dev": true, - "engines": { - "node": ">=0.9" - } - }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -26865,7 +26849,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -26879,8 +26862,7 @@ "node_modules/readable-stream/node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/readdirp": { "version": "4.1.2", @@ -26895,6 +26877,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -28057,6 +28060,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -28334,48 +28343,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/socket.io": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", - "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", - "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", - "dev": true, - "dependencies": { - "debug": "~4.3.4", - "ws": "~8.17.1" - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", - "dev": true, - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -28640,6 +28607,12 @@ "dev": true, "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -28919,88 +28892,6 @@ "postcss": "^8.4.31" } }, - "node_modules/stylus": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.64.0.tgz", - "integrity": "sha512-ZIdT8eUv8tegmqy1tTIdJv9We2DumkNZFdCF5mz/Kpq3OcTaxSuCAYZge6HKK2CmNC02G1eJig2RV7XTw5hQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "~4.3.3", - "debug": "^4.3.2", - "glob": "^10.4.5", - "sax": "~1.4.1", - "source-map": "^0.7.3" - }, - "bin": { - "stylus": "bin/stylus" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://opencollective.com/stylus" - } - }, - "node_modules/stylus-loader": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/stylus-loader/-/stylus-loader-7.1.3.tgz", - "integrity": "sha512-TY0SKwiY7D2kMd3UxaWKSf3xHF0FFN/FAfsSqfrhxRT/koXTwffq2cgEWDkLQz7VojMu7qEEHt5TlMjkPx9UDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "^3.2.12", - "normalize-path": "^3.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "stylus": ">=0.52.4", - "webpack": "^5.0.0" - } - }, - "node_modules/stylus/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/stylus/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -29470,6 +29361,12 @@ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", @@ -29486,6 +29383,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinygradient": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", + "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "^1.4.0", + "tinycolor2": "^1.0.0" + } + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -30272,29 +30179,6 @@ "node": ">=14.17" } }, - "node_modules/ua-parser-js": { - "version": "0.7.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.38.tgz", - "integrity": "sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "engines": { - "node": "*" - } - }, "node_modules/uid": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", @@ -30343,9 +30227,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -30685,15 +30569,6 @@ } } }, - "node_modules/void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -31317,6 +31192,24 @@ } } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/package.json b/package.json index 0a2d051db..b2a4c9364 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.10.0", + "version": "0.11.0", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": { @@ -31,7 +31,10 @@ "@angular/platform-browser-dynamic": "20.0.3", "@angular/router": "20.0.3", "@iqb/responses": "^3.6.0", + "@iqbspecs/response": "1.4.0", + "@iqbspecs/variable-info": "1.3.0", "@nestjs/axios": "^4.0.1", + "@nestjs/bull": "^11.0.3", "@nestjs/common": "^11.1.5", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.5", @@ -46,12 +49,16 @@ "@types/adm-zip": "^0.5.0", "adm-zip": "^0.5.9", "ajv": "^8.17.1", - "axios": "^1.3.1", "ajv-keywords": "^5.1.0", + "axios": "^1.3.1", + "bull": "^4.16.5", "cheerio": "^1.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "docx": "^9.5.1", "fast-csv": "^5.0.1", + "file-saver-es": "^2.0.5", + "@types/file-saver-es": "^2.0.1", "jwt-decode": "^4.0.0", "keycloak-angular": "20.0.0", "keycloak-js": "^23.0.6", @@ -63,6 +70,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "~7.8.0", "stream": "^0.0.2", + "@swimlane/ngx-charts": "^23.0.0-alpha.0", "timers": "^0.1.1", "tslib": "^2.3.0", "typeorm": "^0.3.20", @@ -86,7 +94,6 @@ "@nx/node": "21.2.0", "@nx/workspace": "21.2.0", "@schematics/angular": "19.1.9", - "@types/jasmine": "~5.1.0", "@types/jest": "29.5.14", "@types/multer": "^1.4.7", "@types/passport-jwt": "^4.0.1", @@ -96,21 +103,18 @@ "eslint-plugin-html": "^8.1.1", "eslint-plugin-import": "^2.29.1", "express": "4.21.2", - "jasmine-core": "~5.1.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", "jest-preset-angular": "14.6.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.1.0", "nx": "21.2.0", "prisma": "^5.10.2", "ts-jest": "29.1.1", "ts-node": "^10.9.2", "typescript": "5.8.3" }, + "overrides": { + "form-data": "4.0.4" + }, "eslintConfig": { "extends": "@iqb/eslint-config", "rules": { diff --git a/scripts/make/dev-redis.mk b/scripts/make/dev-redis.mk new file mode 100644 index 000000000..906a16859 --- /dev/null +++ b/scripts/make/dev-redis.mk @@ -0,0 +1,81 @@ +SHELL:=/bin/bash -O extglob +CODING_BOX_BASE_DIR := $(shell git rev-parse --show-toplevel) + +include $(CODING_BOX_BASE_DIR)/.env.dev + +## exports all variables (especially those of the included .env.dev file!) +.EXPORT_ALL_VARIABLES: + +## prevents collisions of make target names with possible file names +.PHONY: dev-redis-registry-login dev-redis-registry-logout dev-redis-build dev-redis-up dev-redis-down dev-redis-volumes-clean\ + dev-redis-images-clean dev-redis-monitor dev-redis-info dev-redis-stats dev-redis-ping dev-redis-flush-all dev-redis-flush-db dev-redis-cli + +## disables printing the recipe of a make target before executing it +.SILENT: dev-redis-registry-login dev-redis-registry-logout dev-redis-volumes-clean dev-redis-images-clean + +## Log in to selected registry (see .env.dev file) +dev-redis-registry-login: + if test $(REGISTRY_PATH); then printf "Login %s\n" $(REGISTRY_PATH); docker login $(REGISTRY_PATH); fi + +## Log out of selected registry (see .env.dev file) +dev-redis-registry-logout: + if test $(REGISTRY_PATH); then docker logout $(REGISTRY_PATH); fi + +## Pull redis docker image +dev-redis-build: + docker compose --progress plain --env-file $(CODING_BOX_BASE_DIR)/.env.dev pull redis + +## Start redis container (e.g. for a localhost dev environment with non containerized frontend and backend servers) +dev-redis-up: + @if ! test $(shell docker network ls -q --filter name=app-net);\ + then docker network create app-net;\ + fi + docker compose --env-file $(CODING_BOX_BASE_DIR)/.env.dev up --no-build --pull never -d redis + +## Stop and remove redis container +dev-redis-down: + docker compose --env-file $(CODING_BOX_BASE_DIR)/.env.dev down + @if test $(shell docker network ls -q --filter name=app-net);\ + then docker network rm $(shell docker network ls -q -f name=app-net);\ + fi + +## Remove all unused redis volumes +# Be very careful, all data could be lost!!! +dev-redis-volumes-clean: + if test "$(shell docker volume ls -f name=coding-box_redis_data -q)";\ + then docker volume rm $(shell docker volume ls -f name=coding-box_redis_data -q);\ + fi + +## Remove all unused (not just dangling) redis images! +dev-redis-images-clean: + if test "$(shell docker images -f reference=redis:alpine -q)";\ + then docker rmi $(shell docker images -f reference=redis:alpine -q);\ + fi + +## Monitor Redis in real-time +dev-redis-monitor: + docker compose --env-file $(CODING_BOX_BASE_DIR)/.env.dev exec redis redis-cli monitor + +## Display Redis server information +dev-redis-info: + docker compose --env-file $(CODING_BOX_BASE_DIR)/.env.dev exec redis redis-cli info + +## Check Redis connection status +dev-redis-ping: + docker compose --env-file $(CODING_BOX_BASE_DIR)/.env.dev exec redis redis-cli ping + +## Display Redis statistics +dev-redis-stats: + docker compose --env-file $(CODING_BOX_BASE_DIR)/.env.dev exec redis redis-cli info stats + +## Flush all Redis databases +dev-redis-flush-all: + docker compose --env-file $(CODING_BOX_BASE_DIR)/.env.dev exec redis redis-cli flushall + +## Flush the current Redis database +dev-redis-flush-db: + docker compose --env-file $(CODING_BOX_BASE_DIR)/.env.dev exec redis redis-cli flushdb + +## Open Redis CLI +dev-redis-cli: + docker compose --env-file $(CODING_BOX_BASE_DIR)/.env.dev exec -it redis redis-cli diff --git a/scripts/make/prod.mk b/scripts/make/prod.mk index 1d4d762a6..dd9d53c7a 100644 --- a/scripts/make/prod.mk +++ b/scripts/make/prod.mk @@ -10,7 +10,9 @@ include $(CODING_BOX_BASE_DIR)/.env.coding-box .PHONY: coding-box-up coding-box-down coding-box-start coding-box-stop coding-box-status coding-box-logs\ coding-box-config coding-box-system-prune coding-box-volumes-prune coding-box-images-clean\ coding-box-liquibase-status coding-box-connect-db coding-box-dump-all coding-box-restore-all coding-box-dump-db\ - coding-box-restore-db coding-box-dump-db-data-only coding-box-restore-db-data-only coding-box-update + coding-box-restore-db coding-box-dump-db-data-only coding-box-restore-db-data-only coding-box-update\ + coding-box-redis-monitor coding-box-redis-info coding-box-redis-stats coding-box-redis-ping\ + coding-box-redis-flush-all coding-box-redis-flush-db coding-box-redis-cli ## disables printing the recipe of a make target before executing it .SILENT: prod-images-clean @@ -295,5 +297,61 @@ coding-box-restore-db-data-only: coding-box-down .EXPORT_ALL_VARIABLES --env-file $(CODING_BOX_BASE_DIR)/.env.coding-box\ down +## Monitor Redis in real-time +coding-box-redis-monitor: + docker compose\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.yaml\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.prod.yaml\ + --env-file $(CODING_BOX_BASE_DIR)/.env.coding-box\ + exec redis redis-cli monitor + +## Display Redis server information +coding-box-redis-info: + docker compose\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.yaml\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.prod.yaml\ + --env-file $(CODING_BOX_BASE_DIR)/.env.coding-box\ + exec redis redis-cli info + +## Display Redis statistics +coding-box-redis-stats: + docker compose\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.yaml\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.prod.yaml\ + --env-file $(CODING_BOX_BASE_DIR)/.env.coding-box\ + exec redis redis-cli info stats + +## Check Redis connection status +coding-box-redis-ping: + docker compose\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.yaml\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.prod.yaml\ + --env-file $(CODING_BOX_BASE_DIR)/.env.coding-box\ + exec redis redis-cli ping + +## Flush all Redis databases +coding-box-redis-flush-all: + docker compose\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.yaml\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.prod.yaml\ + --env-file $(CODING_BOX_BASE_DIR)/.env.coding-box\ + exec redis redis-cli flushall + +## Flush the current Redis database +coding-box-redis-flush-db: + docker compose\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.yaml\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.prod.yaml\ + --env-file $(CODING_BOX_BASE_DIR)/.env.coding-box\ + exec redis redis-cli flushdb + +## Open Redis CLI +coding-box-redis-cli: + docker compose\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.yaml\ + --file $(CODING_BOX_BASE_DIR)/docker-compose.coding-box.prod.yaml\ + --env-file $(CODING_BOX_BASE_DIR)/.env.coding-box\ + exec -it redis redis-cli + coding-box-update: bash $(CODING_BOX_BASE_DIR)/scripts/update.sh