From b279e6cc177a6fa9bd71c52a391a237d0fe500f4 Mon Sep 17 00:00:00 2001 From: Denis Lugowski Date: Sat, 31 Jan 2026 08:58:35 +0100 Subject: [PATCH] feat(fullstack): Add test application form creation --- api/legalconsenthub.yml | 25 ++++ .../TestDataApplicationFormService.kt | 107 ++++++++++++++++++ .../test_data/TestDataController.kt | 24 ++++ .../seed/initial_application_form.yaml | 35 ++++-- .../components/FormStepperWithNavigation.vue | 32 +++++- .../testing/useSeededSapS4HanaDuplicator.ts | 73 ++++++++++++ .../app/composables/testing/useTestDataApi.ts | 24 ++++ legalconsenthub/app/layouts/default.vue | 20 +++- 8 files changed, 326 insertions(+), 14 deletions(-) create mode 100644 legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/test_data/TestDataApplicationFormService.kt create mode 100644 legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/test_data/TestDataController.kt create mode 100644 legalconsenthub/app/composables/testing/useSeededSapS4HanaDuplicator.ts create mode 100644 legalconsenthub/app/composables/testing/useTestDataApi.ts diff --git a/api/legalconsenthub.yml b/api/legalconsenthub.yml index 75e62e0..3cf17ca 100644 --- a/api/legalconsenthub.yml +++ b/api/legalconsenthub.yml @@ -63,6 +63,31 @@ paths: "503": $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable" + /test-data/application-form: + post: + summary: Creates a test application form based on seeded data + operationId: createTestDataApplicationForm + tags: + - test-data + parameters: + - in: query + name: organizationId + required: true + schema: + type: string + description: The organization ID to create the test form in + responses: + "201": + description: Successfully created test application form + content: + application/json: + schema: + $ref: "#/components/schemas/ApplicationFormDto" + "401": + $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized" + "500": + $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError" + /application-forms/{id}: parameters: - name: id diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/test_data/TestDataApplicationFormService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/test_data/TestDataApplicationFormService.kt new file mode 100644 index 0000000..877f9c7 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/test_data/TestDataApplicationFormService.kt @@ -0,0 +1,107 @@ +package com.betriebsratkanzlei.legalconsenthub.test_data + +import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm +import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormService +import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto +import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus +import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSectionDto +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import org.slf4j.LoggerFactory +import org.springframework.core.io.ClassPathResource +import org.springframework.stereotype.Service + +@Service +class TestDataApplicationFormService( + private val applicationFormService: ApplicationFormService, +) { + private val logger = LoggerFactory.getLogger(TestDataApplicationFormService::class.java) + private val yamlMapper = ObjectMapper(YAMLFactory()).findAndRegisterModules() + + fun createTestDataApplicationForm(organizationId: String): ApplicationForm { + val seededDto = loadInitialFormDto() + + // Load template sections from seed template YAML (deterministic, no DB dependency) + val templateDto = loadInitialFormTemplateDto() + val templateSections = + templateDto.formElementSections.filter { it.isTemplate == true && !it.templateReference.isNullOrBlank() } + + // Merge seeded sections with template sections + // Add template sections that don't already exist as templates in the seeded form + // (templates are needed for reactive section spawning even if spawned instances exist) + val finalSections = + seededDto.formElementSections + + templateSections.filter { ts -> + seededDto.formElementSections.none { seeded -> + seeded.isTemplate == true && seeded.templateReference == ts.templateReference + } + } + + val newFormDto = + seededDto.copy( + id = null, + organizationId = organizationId, + status = ApplicationFormStatus.DRAFT, + name = "SAP S/4HANA (Copy ${System.currentTimeMillis()})", + isTemplate = false, + formElementSections = finalSections, + ) + + validateSectionSpawningInvariants(newFormDto.formElementSections) + + return applicationFormService.createApplicationForm(newFormDto) + } + + private fun loadInitialFormDto(): ApplicationFormDto = + ClassPathResource(INITIAL_FORM_RESOURCE_PATH).inputStream.use { inputStream -> + yamlMapper.readValue(inputStream, ApplicationFormDto::class.java) + } + + private fun loadInitialFormTemplateDto(): ApplicationFormDto = + ClassPathResource(INITIAL_FORM_TEMPLATE_RESOURCE_PATH).inputStream.use { inputStream -> + yamlMapper.readValue(inputStream, ApplicationFormDto::class.java) + } + + private fun validateSectionSpawningInvariants(sections: List) { + val templateRefs = sections.filter { it.isTemplate == true }.mapNotNull { it.templateReference }.toSet() + + val spawnedWithoutTemplateRef = + sections + .filter { + it.isTemplate != true && + !it.spawnedFromElementReference.isNullOrBlank() && + it.templateReference.isNullOrBlank() + }.mapNotNull { it.title } + + if (spawnedWithoutTemplateRef.isNotEmpty()) { + val msg = + "Invalid seeded form: spawned sections missing templateReference: " + + spawnedWithoutTemplateRef.joinToString(", ") + logger.error(msg) + throw IllegalStateException(msg) + } + + val triggerTemplateRefs = + sections + .flatMap { it.formElementSubSections } + .flatMap { it.formElements } + .flatMap { it.sectionSpawnTriggers ?: emptyList() } + .map { it.templateReference } + .filter { it.isNotBlank() } + .toSet() + + val missingTemplateSections = triggerTemplateRefs.minus(templateRefs) + if (missingTemplateSections.isNotEmpty()) { + val msg = + "Invalid seeded form: missing template sections for triggers: " + + missingTemplateSections.joinToString(", ") + logger.error(msg) + throw IllegalStateException(msg) + } + } + + private companion object { + private const val INITIAL_FORM_RESOURCE_PATH = "seed/initial_application_form.yaml" + private const val INITIAL_FORM_TEMPLATE_RESOURCE_PATH = "seed/initial_application_form_template.yaml" + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/test_data/TestDataController.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/test_data/TestDataController.kt new file mode 100644 index 0000000..9ab2a01 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/test_data/TestDataController.kt @@ -0,0 +1,24 @@ +package com.betriebsratkanzlei.legalconsenthub.test_data + +import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormMapper +import com.betriebsratkanzlei.legalconsenthub_api.api.TestDataApi +import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.RestController + +@RestController +class TestDataController( + private val testDataApplicationFormService: TestDataApplicationFormService, + private val applicationFormMapper: ApplicationFormMapper, +) : TestDataApi { + @PreAuthorize( + "hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')", + ) + override fun createTestDataApplicationForm(organizationId: String): ResponseEntity = + ResponseEntity.status(201).body( + applicationFormMapper.toApplicationFormDto( + testDataApplicationFormService.createTestDataApplicationForm(organizationId), + ), + ) +} diff --git a/legalconsenthub-backend/src/main/resources/seed/initial_application_form.yaml b/legalconsenthub-backend/src/main/resources/seed/initial_application_form.yaml index 6a63528..17f408c 100644 --- a/legalconsenthub-backend/src/main/resources/seed/initial_application_form.yaml +++ b/legalconsenthub-backend/src/main/resources/seed/initial_application_form.yaml @@ -403,7 +403,7 @@ formElementSections: formElementExpectedValue: Einführung formElementOperator: EQUALS sectionSpawnTriggers: - - templateReference: ki_details_template + - templateReference: ki_informationen_template sectionSpawnConditionType: SHOW sectionSpawnExpectedValue: Ja sectionSpawnOperator: EQUALS @@ -444,6 +444,8 @@ formElementSections: - title: Rollen und Berechtigungen shortTitle: Rollen/Berechtigungen description: Vollständiges Rollen- und Berechtigungskonzept für das IT-System + templateReference: rollen_berechtigungen_template + titleTemplate: Rollen und Berechtigungen spawnedFromElementReference: art_der_massnahme formElementSubSections: @@ -642,6 +644,8 @@ formElementSections: - title: Verarbeitung von Mitarbeiterdaten shortTitle: Mitarbeiterdaten description: Angaben zur Verarbeitung von personenbezogenen Arbeitnehmerdaten + templateReference: verarbeitung_mitarbeiterdaten_template + titleTemplate: Verarbeitung von Mitarbeiterdaten spawnedFromElementReference: art_der_massnahme formElementSubSections: @@ -727,7 +731,7 @@ formElementSections: label: Export/Weitergabe (Ja/Nein + Ziel) processingPurpose: DATA_ANALYSIS employeeDataCategory: REVIEW_REQUIRED - - value: '["false", "true", "false", "true", "true"]' + - value: '[false, true, false, true, true]' label: Leistungs-/Verhaltenskontrolle beabsichtigt? processingPurpose: DATA_ANALYSIS employeeDataCategory: SENSITIVE @@ -805,7 +809,7 @@ formElementSections: label: Schutzmaßnahmen/Governance processingPurpose: SYSTEM_OPERATION employeeDataCategory: REVIEW_REQUIRED - - value: '["true", "true", "true"]' + - value: '[true, true, true]' label: Audit-Logging erforderlich processingPurpose: SYSTEM_OPERATION employeeDataCategory: REVIEW_REQUIRED @@ -843,7 +847,7 @@ formElementSections: columnConfig: sourceTableReference: rollenstamm_tabelle sourceColumnIndex: 0 - - value: '["true", "true", "true", "true", "true"]' + - value: '[true, true, true, true, true]' label: Sichtbar (Ja/Nein) processingPurpose: SYSTEM_OPERATION employeeDataCategory: NON_CRITICAL @@ -918,6 +922,8 @@ formElementSections: - title: Löschkonzept shortTitle: Löschkonzept description: Angaben zum Löschkonzept für Verarbeitungsvorgänge, Datenkategorien und Arbeitnehmerdaten + templateReference: loeschkonzept_template + titleTemplate: Löschkonzept spawnedFromElementReference: art_der_massnahme formElementSubSections: @@ -1080,6 +1086,8 @@ formElementSections: - title: Schnittstellen shortTitle: Schnittstellen description: Angaben zu Schnittstellen zwischen IT-Systemen + templateReference: schnittstellen_template + titleTemplate: Schnittstellen spawnedFromElementReference: art_der_massnahme formElementSubSections: @@ -1152,6 +1160,8 @@ formElementSections: - title: Datenschutz shortTitle: Datenschutz description: Datenschutzrechtliche Angaben zur Datenverarbeitung + templateReference: datenschutz_template + titleTemplate: Datenschutz spawnedFromElementReference: art_der_massnahme formElementSubSections: @@ -1488,6 +1498,8 @@ formElementSections: - title: Modul SAP Finance and Controlling (FI/CO) shortTitle: SAP Finance and Controlling (FI/CO) description: Detaillierte Informationen zum Modul + templateReference: module_details_template + titleTemplate: 'Modul: {{triggerValue}}' spawnedFromElementReference: modul_1 formElementSubSections: @@ -1532,7 +1544,7 @@ formElementSections: label: Analytische Funktionen processingPurpose: DATA_ANALYSIS employeeDataCategory: REVIEW_REQUIRED - - value: '["true", "true", "true", "false"]' + - value: '[true, true, true, false]' label: In Nutzung processingPurpose: DATA_ANALYSIS employeeDataCategory: REVIEW_REQUIRED @@ -1626,6 +1638,8 @@ formElementSections: - title: Modul SAP Human Capital Management (HCM) shortTitle: SAP Human Capital Management (HCM) description: Detaillierte Informationen zum Modul + templateReference: module_details_template + titleTemplate: 'Modul: {{triggerValue}}' spawnedFromElementReference: modul_2 formElementSubSections: @@ -1670,7 +1684,7 @@ formElementSections: label: Analytische Funktionen processingPurpose: DATA_ANALYSIS employeeDataCategory: REVIEW_REQUIRED - - value: '["true", "true", "true", "true", "true", "false"]' + - value: '[true, true, true, true, true, false]' label: In Nutzung processingPurpose: DATA_ANALYSIS employeeDataCategory: REVIEW_REQUIRED @@ -1764,6 +1778,8 @@ formElementSections: - title: Modul SAP Supply Chain Management (SCM) shortTitle: SAP Supply Chain Management (SCM) description: Detaillierte Informationen zum Modul + templateReference: module_details_template + titleTemplate: 'Modul: {{triggerValue}}' spawnedFromElementReference: modul_3 formElementSubSections: @@ -1808,7 +1824,7 @@ formElementSections: label: Analytische Funktionen processingPurpose: DATA_ANALYSIS employeeDataCategory: REVIEW_REQUIRED - - value: '["true", "true", "true", "false", "true"]' + - value: '[true, true, true, false, true]' label: In Nutzung processingPurpose: DATA_ANALYSIS employeeDataCategory: REVIEW_REQUIRED @@ -1902,6 +1918,8 @@ formElementSections: - title: Auswirkungen auf Arbeitnehmer shortTitle: Auswirkungen auf AN description: Auswirkungen des IT-Systems auf Arbeitnehmer, Arbeitsabläufe und Arbeitsbedingungen + templateReference: auswirkungen_arbeitnehmer_template + titleTemplate: Auswirkungen auf Arbeitnehmer spawnedFromElementReference: art_der_massnahme formElementSubSections: @@ -2187,6 +2205,9 @@ formElementSections: - title: Informationen zur Künstlichen Intelligenz shortTitle: KI-Informationen description: Detaillierte Angaben zum Einsatz von Künstlicher Intelligenz gemäß EU-KI-Verordnung + templateReference: ki_informationen_template + titleTemplate: Informationen zur Künstlichen Intelligenz + spawnedFromElementReference: ki_einsatz formElementSubSections: # Risk class selection diff --git a/legalconsenthub/app/components/FormStepperWithNavigation.vue b/legalconsenthub/app/components/FormStepperWithNavigation.vue index 86dd5af..48fabc7 100644 --- a/legalconsenthub/app/components/FormStepperWithNavigation.vue +++ b/legalconsenthub/app/components/FormStepperWithNavigation.vue @@ -203,9 +203,10 @@ const { isSwiping } = usePointerSwipe(stepperScrollEl, { const previousVisibilityMap = ref>(new Map()) const allFormElements = computed(() => { - return props.formElementSections - .filter((section) => section.isTemplate !== true) - .flatMap((section) => section.formElementSubSections?.flatMap((subsection) => subsection.formElements) ?? []) + const nonTemplateSections = props.formElementSections.filter((section) => section.isTemplate !== true) + return nonTemplateSections.flatMap( + (section) => section.formElementSubSections?.flatMap((subsection) => subsection.formElements) ?? [] + ) }) const visibilityMap = computed(() => { @@ -407,15 +408,34 @@ function handleFormElementUpdate(updatedFormElements: FormElementDto[], subsecti } function clearNewlyHiddenFormElements(sections: FormElementSectionDto[]): FormElementSectionDto[] { - const allElements = sections.flatMap( + // Only evaluate visibility for non-template sections to avoid duplicate reference issues + const nonTemplateSections = sections.filter((section) => section.isTemplate !== true) + const allElements = nonTemplateSections.flatMap( (section) => section.formElementSubSections?.flatMap((subsection) => subsection.formElements) ?? [] ) const newVisibilityMap = evaluateFormElementVisibility(allElements) - const clearedSections = clearHiddenFormElementValues(sections, previousVisibilityMap.value, newVisibilityMap) + // Only clear values in non-template sections, preserve template sections unchanged + const clearedNonTemplateSections = clearHiddenFormElementValues( + nonTemplateSections, + previousVisibilityMap.value, + newVisibilityMap + ) previousVisibilityMap.value = newVisibilityMap - return clearedSections + // Create a map of cleared sections by their identity for lookup + const clearedSectionMap = new Map() + nonTemplateSections.forEach((original, index) => { + clearedSectionMap.set(original, clearedNonTemplateSections[index]!) + }) + + // Preserve original order: replace non-template sections with cleared versions, keep templates unchanged + return sections.map((section) => { + if (section.isTemplate === true) { + return section + } + return clearedSectionMap.get(section) ?? section + }) } function getSubsectionKey( diff --git a/legalconsenthub/app/composables/testing/useSeededSapS4HanaDuplicator.ts b/legalconsenthub/app/composables/testing/useSeededSapS4HanaDuplicator.ts new file mode 100644 index 0000000..d6253c4 --- /dev/null +++ b/legalconsenthub/app/composables/testing/useSeededSapS4HanaDuplicator.ts @@ -0,0 +1,73 @@ +import { useUserStore } from '~~/stores/useUserStore' +import { useTestDataApi } from '~/composables/testing/useTestDataApi' + +/** + * TESTING-ONLY FEATURE + * + * One-click duplicator for the seeded demo application form "SAP S/4HANA". + * + * This version uses a dedicated backend endpoint to ensure reliability: + * 1. Loads the seeded YAML on the backend. + * 2. Creates a fresh ApplicationForm entity with new IDs. + * 3. Sets current user as creator. + * 4. Assigns the form to the currently selected organization. + */ +export function useSeededSapS4HanaDuplicator() { + const { t } = useI18n() + const logger = useLogger().withTag('seeded-sap-duplicator') + const toast = useToast() + + const { canWriteApplicationForms } = usePermissions() + const userStore = useUserStore() + const { selectedOrganization } = storeToRefs(userStore) + + const isDuplicating = ref(false) + + const showButton = computed(() => canWriteApplicationForms.value) + + async function duplicateSapS4HanaForTesting() { + if (isDuplicating.value) return + + const organizationId = selectedOrganization.value?.id + if (!organizationId) { + toast.add({ + title: t('common.error'), + description: 'Please select an organization first.', + color: 'error' + }) + return + } + + isDuplicating.value = true + try { + const { createTestDataApplicationForm } = useTestDataApi() + + const created = await createTestDataApplicationForm(organizationId) + + toast.add({ + title: t('common.success'), + description: 'Created a new test application form.', + color: 'success' + }) + + if (created?.id) { + await navigateTo(`/application-forms/${created.id}/0`) + } + } catch (e: unknown) { + logger.error('Failed creating test application form via backend:', e) + toast.add({ + title: t('common.error'), + description: 'Failed to create test application form. Check backend logs.', + color: 'error' + }) + } finally { + isDuplicating.value = false + } + } + + return { + showButton, + isDuplicating, + duplicateSapS4HanaForTesting + } +} diff --git a/legalconsenthub/app/composables/testing/useTestDataApi.ts b/legalconsenthub/app/composables/testing/useTestDataApi.ts new file mode 100644 index 0000000..49b10b3 --- /dev/null +++ b/legalconsenthub/app/composables/testing/useTestDataApi.ts @@ -0,0 +1,24 @@ +import { TestDataApi, Configuration, type ApplicationFormDto } from '~~/.api-client' +import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo' +import { wrappedFetchWrap } from '~/utils/wrappedFetch' + +export function useTestDataApi() { + const appBaseUrl = useRuntimeConfig().app.baseURL + const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public + + const basePath = withoutTrailingSlash( + cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath) + ) + + const testDataApiClient = new TestDataApi( + new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) }) + ) + + async function createTestDataApplicationForm(organizationId: string): Promise { + return testDataApiClient.createTestDataApplicationForm({ organizationId }) + } + + return { + createTestDataApplicationForm + } +} diff --git a/legalconsenthub/app/layouts/default.vue b/legalconsenthub/app/layouts/default.vue index e06af88..9002cff 100644 --- a/legalconsenthub/app/layouts/default.vue +++ b/legalconsenthub/app/layouts/default.vue @@ -25,7 +25,18 @@ @@ -37,6 +48,7 @@