feat(fullstack): Add application form status, add submissions of forms, update DB schema

This commit is contained in:
2025-08-02 18:00:59 +02:00
parent f9851f01d9
commit a5eae07eaf
13 changed files with 278 additions and 91 deletions

View File

@@ -193,6 +193,37 @@ paths:
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
/application-forms/{id}/submit:
post:
summary: Submit an application form
operationId: submitApplicationForm
tags:
- application-form
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Application form successfully submitted
content:
application/json:
schema:
$ref: "#/components/schemas/ApplicationFormDto"
"400":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"404":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/NotFound"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
####### Application Form Templates #######
/application-form-templates:
get:
@@ -672,6 +703,7 @@ components:
- lastModifiedBy
- createdAt
- modifiedAt
- status
properties:
id:
type: string
@@ -696,6 +728,8 @@ components:
modifiedAt:
type: string
format: date-time
status:
$ref: "#/components/schemas/ApplicationFormStatus"
CreateApplicationFormDto:
required:
@@ -715,6 +749,9 @@ components:
default: false
organizationId:
type: string
status:
$ref: "#/components/schemas/ApplicationFormStatus"
default: DRAFT
PagedApplicationFormDto:
type: object
@@ -1009,6 +1046,15 @@ components:
- WARNING
- CRITICAL
ApplicationFormStatus:
type: string
enum:
- DRAFT
- SUBMITTED
- APPROVED
- REJECTED
- SIGNED
####### Supporting components #######
Page:
type: object

View File

@@ -2,12 +2,15 @@ package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection
import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import jakarta.persistence.AttributeOverride
import jakarta.persistence.AttributeOverrides
import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.Enumerated
import jakarta.persistence.EnumType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.OneToMany
@@ -36,6 +39,10 @@ class ApplicationForm(
var organizationId: String = "",
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var status: ApplicationFormStatus = ApplicationFormStatus.DRAFT,
@Embedded
@AttributeOverrides(
AttributeOverride(name = "id", column = Column(name = "created_by_id", nullable = false)),

View File

@@ -77,4 +77,12 @@ class ApplicationFormController(
applicationFormService.deleteApplicationFormByID(id)
return ResponseEntity.noContent().build()
}
override fun submitApplicationForm(id: UUID): ResponseEntity<ApplicationFormDto> {
return ResponseEntity.ok(
applicationFormMapper.toApplicationFormDto(
applicationFormService.submitApplicationForm(id)
)
)
}
}

View File

@@ -22,7 +22,8 @@ class ApplicationFormMapper(private val formElementSectionMapper: FormElementSec
createdBy = userMapper.toUserDto(applicationForm.createdBy),
lastModifiedBy = userMapper.toUserDto(applicationForm.lastModifiedBy),
createdAt = applicationForm.createdAt ?: LocalDateTime.now(),
modifiedAt = applicationForm.modifiedAt ?: LocalDateTime.now()
modifiedAt = applicationForm.modifiedAt ?: LocalDateTime.now(),
status = applicationForm.status
)
}
@@ -33,6 +34,7 @@ class ApplicationFormMapper(private val formElementSectionMapper: FormElementSec
formElementSections = applicationForm.formElementSections.map { formElementSectionMapper.toFormElementSection(it) }.toMutableList(),
isTemplate = applicationForm.isTemplate,
organizationId = applicationForm.organizationId,
status = applicationForm.status,
createdBy = userMapper.toUser(applicationForm.createdBy),
lastModifiedBy = userMapper.toUser(applicationForm.lastModifiedBy),
createdAt = applicationForm.createdAt,
@@ -50,6 +52,7 @@ class ApplicationFormMapper(private val formElementSectionMapper: FormElementSec
name = createApplicationFormDto.name,
isTemplate = createApplicationFormDto.isTemplate,
organizationId = createApplicationFormDto.organizationId ?: "",
status = createApplicationFormDto.status ?: com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus.DRAFT,
createdBy = createdBy,
lastModifiedBy = lastModifiedBy,
)

View File

@@ -1,10 +1,12 @@
package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
@@ -59,4 +61,25 @@ class ApplicationFormService(
throw ApplicationFormNotDeletedException(e)
}
}
fun submitApplicationForm(id: UUID): ApplicationForm {
val applicationForm = getApplicationFormById(id)
if (applicationForm.status != ApplicationFormStatus.DRAFT) {
throw ApplicationFormInvalidStateException(
applicationFormId = id,
currentState = applicationForm.status,
expectedState = ApplicationFormStatus.DRAFT,
operation = "submit"
)
}
applicationForm.status = ApplicationFormStatus.SUBMITTED
return try {
applicationFormRepository.save(applicationForm)
} catch (e: Exception) {
throw ApplicationFormNotUpdatedException(e, id)
}
}
}

View File

@@ -0,0 +1,11 @@
package com.betriebsratkanzlei.legalconsenthub.error
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import java.util.UUID
class ApplicationFormInvalidStateException(
val applicationFormId: UUID,
val currentState: ApplicationFormStatus,
val expectedState: ApplicationFormStatus,
val operation: String
) : RuntimeException("Cannot $operation application form with ID $applicationFormId. Current state: $currentState, expected state: $expectedState")

View File

@@ -31,6 +31,22 @@ class ExceptionHandler {
)
}
@ResponseBody
@ExceptionHandler(ApplicationFormInvalidStateException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleInvalidStateError(e: ApplicationFormInvalidStateException): ResponseEntity<ProblemDetails> {
logger.warn(e.message, e)
return ResponseEntity.status(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(
ProblemDetails(
title = "Invalid State",
status = HttpStatus.BAD_REQUEST.value(),
type = URI.create("about:blank"),
detail = e.message ?: "Operation not allowed in current state"
)
)
}
@ResponseBody
@ExceptionHandler(
ApplicationFormNotCreatedException::class,

View File

@@ -10,18 +10,20 @@ create table application_form
last_modified_by_name varchar(255) not null,
name varchar(255) not null,
organization_id varchar(255),
status enum ('APPROVED','DRAFT','REJECTED','SIGNED','SUBMITTED') not null,
primary key (id)
);
create table comment
(
created_at timestamp(6) not null,
modified_at timestamp(6) not null,
form_element_id uuid not null,
id uuid not null,
created_by_id varchar(255) not null,
created_by_name varchar(255) not null,
message varchar(255) not null,
created_at timestamp(6) not null,
modified_at timestamp(6) not null,
application_form_id uuid not null,
form_element_id uuid not null,
id uuid not null,
created_by_id varchar(255) not null,
created_by_name varchar(255) not null,
message varchar(255) not null,
primary key (id)
);
@@ -36,12 +38,29 @@ create table form_element_options
create table form_element
(
type tinyint not null check (type between 0 and 4),
application_form_id uuid not null,
id uuid not null,
type tinyint not null check (type between 0 and 4),
form_element_section_id uuid not null,
id uuid not null,
description varchar(255),
title varchar(255),
primary key (id)
);
create table form_element_section
(
application_form_id uuid not null,
id uuid not null,
description varchar(255),
short_title varchar(255),
title varchar(255) not null,
primary key (id)
);
alter table if exists comment
add constraint FKlavy9axrt26sepreg5lqtuoap
foreign key (application_form_id)
references application_form;
alter table if exists comment
add constraint FKfg84w0i76tw9os13950272c6f
foreign key (form_element_id)
@@ -53,6 +72,11 @@ alter table if exists form_element_options
references form_element;
alter table if exists form_element
add constraint FKdniyq3l10lncw48tft15js5gb
add constraint FKdpr6k93m4hqllqjsvoa4or6mp
foreign key (form_element_section_id)
references form_element_section;
alter table if exists form_element_section
add constraint FKtn0lreovauwf2v29doo70o3qs
foreign key (application_form_id)
references application_form;

View File

@@ -83,11 +83,29 @@ export function useApplicationForm() {
}
}
async function submitApplicationForm(id: string): Promise<ApplicationFormDto> {
if (!id) {
return Promise.reject(new Error('ID missing'))
}
try {
return await applicationFormApi.submitApplicationForm(id)
} catch (e: unknown) {
if (e instanceof ResponseError) {
console.error(`Failed submitting application form with ID ${id}:`, e.response)
} else {
console.error(`Failed submitting application form with ID ${id}:`, e)
}
return Promise.reject(e)
}
}
return {
createApplicationForm,
getAllApplicationForms,
getApplicationFormById,
updateApplicationForm,
deleteApplicationFormById
deleteApplicationFormById,
submitApplicationForm
}
}

View File

@@ -45,11 +45,16 @@ export function useApplicationFormApi() {
return applicationFormApiClient.deleteApplicationForm({ id })
}
async function submitApplicationForm(id: string): Promise<ApplicationFormDto> {
return applicationFormApiClient.submitApplicationForm({ id })
}
return {
createApplicationForm,
getAllApplicationForms,
getApplicationFormById,
updateApplicationForm,
deleteApplicationFormById
deleteApplicationFormById,
submitApplicationForm
}
}

View File

@@ -15,7 +15,15 @@
<UDashboardToolbar>
<template #right>
<UButton icon="i-lucide-file-text" size="md" color="primary" variant="solid" target="_blank" :to="`/api/application-forms/${applicationForm.id}/pdf`">PDF Vorschau</UButton>
<UButton
icon="i-lucide-file-text"
size="md"
color="primary"
variant="solid"
target="_blank"
:to="`/api/application-forms/${applicationForm.id}/pdf`"
>PDF Vorschau</UButton
>
</template>
</UDashboardToolbar>
</template>
@@ -51,14 +59,15 @@
>
Next
</UButton>
<UButton
v-if="!stepper?.hasNext"
trailing-icon="i-lucide-send-horizontal"
:disabled="isReadOnly"
@click="onSubmit"
>
Submit
</UButton>
<div v-if="!stepper?.hasNext" class="flex flex-wrap items-center gap-1.5">
<UButton trailing-icon="i-lucide-save" :disabled="isReadOnly" variant="outline" @click="onSave">
Save
</UButton>
<UButton trailing-icon="i-lucide-send-horizontal" :disabled="isReadOnly" @click="onSubmit">
Submit
</UButton>
</div>
</div>
</UCard>
</div>
@@ -70,13 +79,14 @@
import type { ApplicationFormDto, FormElementSectionDto } from '~/.api-client'
import type { StepperItem } from '@nuxt/ui'
const { getApplicationFormById, updateApplicationForm } = useApplicationForm()
const { getApplicationFormById, updateApplicationForm, submitApplicationForm } = useApplicationForm()
const route = useRoute()
const { user } = useAuth()
const toast = useToast()
definePageMeta({
// Prevent whole page from re-rendering when navigating between sections to keep state
key: (route) => `${route.params.id}`,
key: (route) => `${route.params.id}`
})
onMounted(() => {
@@ -136,10 +146,18 @@ async function navigateStepper(direction: 'forward' | 'backward') {
await navigateTo(`/application-forms/${route.params.id}/${activeStepperItemIndex.value}`)
}
async function onSubmit() {
async function onSave() {
if (data?.value) {
await updateApplicationForm(data.value.id, data.value)
toast.add({ title: 'Success', description: 'Application form saved', color: 'success' })
}
}
async function onSubmit() {
if (data?.value) {
await submitApplicationForm(data.value.id)
await navigateTo('/')
toast.add({ title: 'Success', description: 'Application form submitted', color: 'success' })
}
}

View File

@@ -16,14 +16,11 @@
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-4xl mx-auto">
<!-- Permission Guard using Better Auth's native system -->
<div v-if="!canCreateApplicationForm" class="text-center py-12">
<UIcon name="i-lucide-shield-x" class="w-16 h-16 mx-auto text-red-400 mb-4" />
<h2 class="text-2xl font-semibold text-gray-700 mb-2">Keine Berechtigung</h2>
<p class="text-gray-500 mb-4">
Sie haben keine Berechtigung zum Erstellen von Anträgen.
</p>
<UAlert
<p class="text-gray-500 mb-4">Sie haben keine Berechtigung zum Erstellen von Anträgen.</p>
<UAlert
v-if="currentRoleInfo"
:title="`Ihre aktuelle Rolle: ${currentRoleInfo.name}`"
:description="currentRoleInfo.description"
@@ -32,59 +29,48 @@
class="max-w-md mx-auto"
/>
</div>
<div v-else>
Erstelle Formular für Organisation: {{ selectedOrganization?.name }}
<!-- Role Context Alert -->
<UAlert
v-if="currentRoleInfo"
:title="`Erstellen als: ${currentRoleInfo.name}`"
:description="`${currentRoleInfo.description} - Sie können Anträge erstellen und bearbeiten.`"
:color="currentRoleInfo.color"
variant="soft"
:icon="currentRoleInfo.icon"
class="mb-4"
/>
<UPageCard title="Ampelstatus" variant="naked" orientation="horizontal" class="mb-4">
{{ ampelStatusEmoji }}
{{ trafficLightStatusEmoji }}
</UPageCard>
<UPageCard variant="subtle">
<UForm class="space-y-4" :state="{}" @submit="onSubmit">
<UFormField label="Name">
<UInput v-if="applicationFormTemplate" v-model="applicationFormTemplate.name" />
</UFormField>
<UStepper ref="stepper" v-model="activeStepperItemIndex" :items="stepperItems" class="w-full" />
<h1 v-if="currentFormElementSection?.title" class="text-xl text-pretty font-bold text-highlighted">
{{ currentFormElementSection.title }}
</h1>
<FormEngine
v-if="currentFormElementSection?.formElements"
v-model="currentFormElementSection.formElements"
/>
<div class="flex gap-2 justify-between mt-4">
<UButton
leading-icon="i-lucide-arrow-left"
:disabled="!stepper?.hasPrev"
@click="navigateStepper('backward')"
>
Prev
</UButton>
<UPageCard variant="subtle">
<UForm class="space-y-4" :state="{}" @submit="onSubmit">
<UFormField label="Name">
<UInput v-if="applicationFormTemplate" v-model="applicationFormTemplate.name" />
</UFormField>
<UStepper ref="stepper" v-model="activeStepperItemIndex" :items="stepperItems" class="w-full" />
<h1 v-if="currentFormElementSection?.title" class="text-xl text-pretty font-bold text-highlighted">
{{ currentFormElementSection.title }}
</h1>
<FormEngine
v-if="currentFormElementSection?.formElements"
v-model="currentFormElementSection.formElements"
/>
<div class="flex gap-2 justify-between mt-4">
<UButton
leading-icon="i-lucide-arrow-left"
:disabled="!stepper?.hasPrev"
@click="navigateStepper('backward')"
>
Prev
</UButton>
<UButton
v-if="stepper?.hasNext"
trailing-icon="i-lucide-arrow-right"
:disabled="!stepper?.hasNext"
@click="navigateStepper('forward')"
>
Next
</UButton>
<UButton v-if="!stepper?.hasNext" @click="onSubmit"> Submit </UButton>
</div>
</UForm>
</UPageCard>
<UButton
v-if="stepper?.hasNext"
trailing-icon="i-lucide-arrow-right"
:disabled="!stepper?.hasNext"
@click="navigateStepper('forward')"
>
Next
</UButton>
<div v-if="!stepper?.hasNext" class="flex flex-wrap items-center gap-1.5">
<UButton trailing-icon="i-lucide-save" variant="outline" @click="onSave"> Save </UButton>
<UButton trailing-icon="i-lucide-send-horizontal" @click="onSubmit"> Submit </UButton>
</div>
</div>
</UForm>
</UPageCard>
</div>
</div>
</template>
@@ -98,10 +84,11 @@ import type { FormElementId } from '~/types/FormElement'
import type { StepperItem } from '@nuxt/ui'
const { getAllApplicationFormTemplates } = useApplicationFormTemplate()
const { createApplicationForm } = useApplicationForm()
const { createApplicationForm, submitApplicationForm } = useApplicationForm()
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
const { userDto, selectedOrganization } = useAuth()
const { canCreateApplicationForm, getCurrentRoleInfo } = usePermissions()
const toast = useToast()
// Get current role information for display
const currentRoleInfo = computed(() => getCurrentRoleInfo())
@@ -174,7 +161,7 @@ watch(
{ deep: true }
)
const ampelStatusEmoji = computed(() => {
const trafficLightStatusEmoji = computed(() => {
switch (validationStatus.value) {
case ComplianceStatus.Critical:
return '🔴'
@@ -187,16 +174,32 @@ const ampelStatusEmoji = computed(() => {
}
})
async function onSubmit() {
if (applicationFormTemplate.value) {
applicationFormTemplate.value.createdBy = userDto.value
applicationFormTemplate.value.lastModifiedBy = userDto.value
applicationFormTemplate.value.organizationId = selectedOrganization.value?.id ?? ''
await createApplicationForm(applicationFormTemplate.value)
await navigateTo('/')
} else {
console.error('Application form data is undefined')
async function onSave() {
const applicationForm = await prepareAndCreateApplicationForm()
if (applicationForm) {
toast.add({ title: 'Success', description: 'Application form saved', color: 'success' })
}
}
async function onSubmit() {
const applicationForm = await prepareAndCreateApplicationForm()
if (applicationForm) {
await submitApplicationForm(applicationForm.id)
await navigateTo('/')
toast.add({ title: 'Success', description: 'Application form submitted', color: 'success' })
}
}
async function prepareAndCreateApplicationForm() {
if (!applicationFormTemplate.value) {
console.error('Application form data is undefined')
return null
}
applicationFormTemplate.value.createdBy = userDto.value
applicationFormTemplate.value.lastModifiedBy = userDto.value
applicationFormTemplate.value.organizationId = selectedOrganization.value?.id ?? ''
return await createApplicationForm(applicationFormTemplate.value)
}
</script>

View File

@@ -49,6 +49,11 @@
<p class="text-(--ui-text-muted) text-sm">
Erstellt von {{ applicationFormElem.createdBy.name }} am {{ formatDate(applicationFormElem.createdAt) }}
</p>
<div class="mt-2">
<UChip size="sm">
{{ applicationFormElem.status }}
</UChip>
</div>
</div>
<div>
<UPageLinks :links="getLinksForApplicationForm(applicationFormElem)" />