feat(fullstack): Add organization scoping for application form

This commit is contained in:
2025-05-03 18:06:18 +02:00
parent 2771c71253
commit f748e14c81
12 changed files with 112 additions and 23 deletions

View File

@@ -20,6 +20,12 @@ paths:
operationId: getAllApplicationForms
tags:
- application-form
parameters:
- in: query
name: organizationId
schema:
type: string
description: Filter application forms by organization ID
responses:
"200":
description: Paged list of application forms
@@ -580,6 +586,7 @@ components:
- name
- formElements
- isTemplate
- organizationId
- createdBy
- lastModifiedBy
- createdAt
@@ -596,6 +603,8 @@ components:
$ref: "#/components/schemas/FormElementDto"
isTemplate:
type: boolean
organizationId:
type: string
createdBy:
$ref: "#/components/schemas/UserDto"
lastModifiedBy:
@@ -612,8 +621,6 @@ components:
- name
- formElements
- isTemplate
- createdBy
- lastModifiedBy
type: object
properties:
name:
@@ -625,6 +632,8 @@ components:
isTemplate:
type: boolean
default: false
organizationId:
type: string
PagedApplicationFormDto:
type: object

View File

@@ -34,6 +34,8 @@ class ApplicationForm(
@Column(nullable = false)
var isTemplate: Boolean,
var organizationId: String = "",
@Embedded
@AttributeOverrides(
AttributeOverride(name = "id", column = Column(name = "created_by_id", nullable = false)),

View File

@@ -24,10 +24,10 @@ class ApplicationFormController(
)
}
override fun getAllApplicationForms(): ResponseEntity<PagedApplicationFormDto> {
override fun getAllApplicationForms(organizationId: String?): ResponseEntity<PagedApplicationFormDto> {
return ResponseEntity.ok(
pagedApplicationFormMapper.toPagedApplicationFormDto(
applicationFormService.getApplicationForms()
applicationFormService.getApplicationForms(organizationId)
)
)
}

View File

@@ -17,6 +17,7 @@ class ApplicationFormMapper(private val formElementMapper: FormElementMapper, pr
name = applicationForm.name,
formElements = applicationForm.formElements.map { formElementMapper.toFormElementDto(it) },
isTemplate = applicationForm.isTemplate,
organizationId = applicationForm.organizationId,
createdBy = userMapper.toUserDto(applicationForm.createdBy),
lastModifiedBy = userMapper.toUserDto(applicationForm.lastModifiedBy),
createdAt = applicationForm.createdAt ?: LocalDateTime.now(),
@@ -30,6 +31,7 @@ class ApplicationFormMapper(private val formElementMapper: FormElementMapper, pr
name = applicationForm.name,
formElements = applicationForm.formElements.map { formElementMapper.toFormElement(it) }.toMutableList(),
isTemplate = applicationForm.isTemplate,
organizationId = applicationForm.organizationId,
createdBy = userMapper.toUser(applicationForm.createdBy),
lastModifiedBy = userMapper.toUser(applicationForm.lastModifiedBy),
createdAt = applicationForm.createdAt,
@@ -38,6 +40,7 @@ class ApplicationFormMapper(private val formElementMapper: FormElementMapper, pr
}
fun toApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm {
// TODO: Move this in upper layer
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val createdBy = User(principal.name ?: "UNKNOWN USER", principal.id ?: "")
val lastModifiedBy = User(principal.name ?: "UNKNOWN USER", principal.id ?: "")
@@ -45,6 +48,7 @@ class ApplicationFormMapper(private val formElementMapper: FormElementMapper, pr
val applicationForm = ApplicationForm(
name = createApplicationFormDto.name,
isTemplate = createApplicationFormDto.isTemplate,
organizationId = createApplicationFormDto.organizationId ?: "",
createdBy = createdBy,
lastModifiedBy = lastModifiedBy,
)

View File

@@ -3,11 +3,14 @@ package com.betriebsratkanzlei.legalconsenthub.application_form
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface ApplicationFormRepository : JpaRepository<ApplicationForm, UUID> {
fun findAllByIsTemplateTrue(page: Pageable): Page<ApplicationForm>
fun findAllByIsTemplateFalse(page: Pageable): Page<ApplicationForm>
@Query("SELECT c FROM ApplicationForm c WHERE (c.isTemplate IS false) AND (:organizationId is null or c.organizationId = :organizationId)")
fun findAllByIsTemplateFalseAndOrganizationId(organizationId: String?, page: Pageable): Page<ApplicationForm>
}

View File

@@ -33,12 +33,13 @@ class ApplicationFormService(
return applicationFormRepository.findById(id).orElseThrow { ApplicationFormNotFoundException(id) }
}
fun getApplicationForms(): Page<ApplicationForm> {
fun getApplicationForms(organizationId: String?): Page<ApplicationForm> {
val pageable = PageRequest.of(0, 10)
return applicationFormRepository.findAllByIsTemplateFalse(pageable)
return applicationFormRepository.findAllByIsTemplateFalseAndOrganizationId(organizationId, pageable)
}
fun updateApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
// TODO find statt mappen?
val applicationForm = applicationFormMapper.toApplicationForm(applicationFormDto)
val updatedApplicationForm: ApplicationForm

View File

@@ -9,6 +9,7 @@ create table application_form
last_modified_by_id varchar(255) not null,
last_modified_by_name varchar(255) not null,
name varchar(255) not null,
organization_id varchar(255),
primary key (id)
);

View File

@@ -27,9 +27,9 @@ export function useApplicationForm() {
}
}
async function getAllApplicationForms(): Promise<PagedApplicationFormDto> {
async function getAllApplicationForms(organizationId: string): Promise<PagedApplicationFormDto> {
try {
return await applicationFormApi.getAllApplicationForms()
return await applicationFormApi.getAllApplicationForms(organizationId)
} catch (e: unknown) {
if (e instanceof ResponseError) {
console.error('Failed retrieving application forms:', e.response)

View File

@@ -21,8 +21,8 @@ export function useApplicationFormApi() {
return applicationFormApiClient.createApplicationForm({ createApplicationFormDto })
}
async function getAllApplicationForms(): Promise<PagedApplicationFormDto> {
return applicationFormApiClient.getAllApplicationForms()
async function getAllApplicationForms(organizationId: string): Promise<PagedApplicationFormDto> {
return applicationFormApiClient.getAllApplicationForms({ organizationId })
}
async function getApplicationFormById(id: string): Promise<ApplicationFormDto> {

View File

@@ -11,6 +11,22 @@ interface RuntimeAuthConfig {
redirectGuestTo: RouteLocationRaw | string
}
const session = ref<InferSessionFromClient<ClientOptions> | null>(null)
const user = ref<InferUserFromClient<ClientOptions> | null>(null)
const sessionFetching = import.meta.server ? ref(false) : ref(false)
const jwt = ref<string | null>(null)
const organizations = ref<
{ id: string; name: string; createdAt: Date; slug: string; metadata?: any; logo?: string | null }[]
>([])
const selectedOrganization = ref<{
id: string
name: string
createdAt: Date
slug: string
metadata?: any
logo?: string | null
} | null>(null)
export function useAuth() {
const url = useRequestURL()
const headers = import.meta.server ? useRequestHeaders() : undefined
@@ -27,10 +43,6 @@ export function useAuth() {
redirectUserTo: '/',
redirectGuestTo: '/login'
})
const session = useState<InferSessionFromClient<ClientOptions> | null>('auth:session', () => null)
const user = useState<InferUserFromClient<ClientOptions> | null>('auth:user', () => null)
const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false)
const jwt = useState<string | null>('auth:jwt', () => null)
async function fetchSession() {
if (sessionFetching.value) {
@@ -43,13 +55,33 @@ export function useAuth() {
headers
}
})
jwt.value = (await client.token()).data?.token ?? null
session.value = data?.session || null
user.value = data?.user || null
sessionFetching.value = false
// Fetch JWT - workaround for not working extraction of JWT out of session (https://github.com/better-auth/better-auth/issues/1835)
jwt.value = (await client.token()).data?.token ?? null
// Fetch organization
organizations.value = (await client.organization.list()).data ?? []
if (!selectedOrganization.value && organizations.value.length > 0) {
selectedOrganization.value = organizations.value[0]
}
return data
}
watch(
() => selectedOrganization.value,
async (newValue) => {
if (newValue) {
await client.organization.setActive({
organizationId: newValue?.id
})
}
}
)
if (import.meta.client) {
client.$store.listen('$sessionSignal', async (signal) => {
if (!signal) return
@@ -79,6 +111,8 @@ export function useAuth() {
signUp: client.signUp,
signOut,
organization: client.organization,
organizations,
selectedOrganization,
options,
fetchSession,
client,

View File

@@ -16,6 +16,7 @@
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-4xl mx-auto">
Erstelle Forumular für Organisation: {{ selectedOrganization?.name }}
<UPageCard title="Ampelstatus" variant="naked" orientation="horizontal" class="mb-4">
{{ ampelStatusEmoji }}
</UPageCard>
@@ -35,14 +36,14 @@
</template>
<script setup lang="ts">
import { ComplianceStatus, type PagedApplicationFormDto } from '~/.api-client'
import { ComplianceStatus, type PagedApplicationFormDto, type UserDto } from '~/.api-client'
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
import type { FormElementId } from '~/types/FormElement'
const { getAllApplicationFormTemplates } = useApplicationFormTemplate()
const { createApplicationForm } = useApplicationForm()
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
const { user } = await useAuth()
const { user, selectedOrganization } = useAuth()
const { data } = await useAsyncData<PagedApplicationFormDto>(async () => {
return await getAllApplicationFormTemplates()
@@ -92,8 +93,13 @@ const ampelStatusEmoji = computed(() => {
async function onSubmit() {
if (applicationFormTemplate.value) {
applicationFormTemplate.value.createdBy = user.value?.name ?? 'Unknown'
applicationFormTemplate.value.lastModifiedBy = user.value?.name ?? 'Unknown'
const userDto: UserDto = {
id: user.value?.id ?? '',
name: user.value?.name ?? 'Unknown'
}
applicationFormTemplate.value.createdBy = userDto
applicationFormTemplate.value.lastModifiedBy = userDto
applicationFormTemplate.value.organizationId = selectedOrganization.value?.id ?? ''
await createApplicationForm(applicationFormTemplate.value)
await navigateTo('/')

View File

@@ -7,6 +7,18 @@
</template>
<template #right>
Aktuelle Organisation
<USelect
v-model="selectedOrganizationId"
:items="organizations"
value-key="id"
label-key="name"
size="lg"
:ui="{
trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200'
}"
class="w-48"
/>
<UDropdownMenu :items="items">
<UButton icon="i-lucide-plus" size="md" class="rounded-full" />
</UDropdownMenu>
@@ -58,10 +70,17 @@ import type { ApplicationFormDto, PagedApplicationFormDto } from '~/.api-client'
const { getAllApplicationForms, deleteApplicationFormById } = useApplicationForm()
const route = useRoute()
const { organizations, selectedOrganization } = useAuth()
const { data } = await useAsyncData<PagedApplicationFormDto>(async () => {
return await getAllApplicationForms()
})
const { data } = await useAsyncData<PagedApplicationFormDto>(
async () => {
if (!selectedOrganization.value) {
throw new Error('No organization selected')
}
return await getAllApplicationForms(selectedOrganization.value.id)
},
{ watch: [selectedOrganization] }
)
const isDeleteModalOpen = computed<boolean>({
get: () => 'delete' in route.query,
@@ -75,6 +94,16 @@ const applicationFormNameToDelete = computed(() => {
return data?.value?.content.find((appForm) => appForm.id === route.query.id)
})
const selectedOrganizationId = computed({
get() {
return selectedOrganization.value?.id
},
set(item) {
// TODO: USelect triggers multiple times after single selection
selectedOrganization.value = organizations.value.find((i) => i.id === item) ?? null
}
})
const items = [
[
{