feat(#21): Replaced chat comment component with proper cursor-based commenting

This commit is contained in:
2025-12-25 18:03:31 +01:00
parent 7f7852a66a
commit e472a5715d
27 changed files with 968 additions and 244 deletions

View File

@@ -1,6 +1,19 @@
# Changelog # Changelog
### 2025-11-23 - Pipeline ## 2025-12-26 - Date/Time Handling & Comment Pagination
### Problem
- Comment infinite scroll pagination failed: older comments weren't loading
- Root cause: Backend used `LocalDateTime` (no timezone info), frontend's JavaScript `Date` interpreted it as local time, then converted to UTC when sending back - causing a 1-hour timezone shift
### Solution
- Migrated all date fields from `LocalDateTime` to `Instant` across all entities (Comment, ApplicationForm, Notification, User, ApplicationFormVersion)
- `Instant` represents a point in time in UTC and is:
- Supported by JPA's `@CreatedDate`/`@LastModifiedDate` annotations (unlike `OffsetDateTime`)
- Serialized by Jackson as ISO-8601 with `Z` suffix (e.g., `2025-12-26T08:13:48.608921Z`)
- Correctly parsed by JavaScript as UTC, ensuring round-trip consistency
## 2025-11-23 - Pipeline
- Gitea pipeline seems not to be stable enough yet. Jobs don't get assigned runners consistently. Especially the backend job. - Gitea pipeline seems not to be stable enough yet. Jobs don't get assigned runners consistently. Especially the backend job.
- Switch to local nektos act runner also led to issues - Switch to local nektos act runner also led to issues
- metadata-action not working properly: https://github.com/docker/metadata-action/issues/542 - metadata-action not working properly: https://github.com/docker/metadata-action/issues/542

View File

@@ -646,13 +646,67 @@ paths:
operationId: getCommentsByApplicationFormId operationId: getCommentsByApplicationFormId
tags: tags:
- comment - comment
parameters:
- in: query
name: formElementId
required: false
schema:
type: string
format: uuid
description: If provided, only comments for this form element are returned.
- in: query
name: cursorCreatedAt
required: false
schema:
type: string
format: date-time
description: Cursor for pagination (createdAt of the last received comment). When omitted, returns the first page.
- in: query
name: limit
required: false
schema:
type: integer
format: int32
default: 10
minimum: 1
maximum: 50
description: Number of comments to return.
responses: responses:
"200": "200":
description: Get comments for application form ID description: Get comments for application form ID
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PagedCommentDto" $ref: "#/components/schemas/CursorPagedCommentDto"
"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"
"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-forms/{applicationFormId}/comments/counts:
parameters:
- name: applicationFormId
in: path
required: true
schema:
type: string
format: uuid
get:
summary: Get comment counts grouped by form element for an application form
operationId: getGroupedCommentCountByApplicationFromId
tags:
- comment
responses:
"200":
description: Map of formElementId to comment count
content:
application/json:
schema:
$ref: "#/components/schemas/ApplicationFormCommentCountsDto"
"400": "400":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401": "401":
@@ -1132,6 +1186,12 @@ components:
allOf: allOf:
- $ref: "#/components/schemas/ApplicationFormStatus" - $ref: "#/components/schemas/ApplicationFormStatus"
nullable: true nullable: true
commentCount:
type: integer
format: int64
nullable: true
readOnly: true
description: Total number of comments associated with this application form.
PagedApplicationFormDto: PagedApplicationFormDto:
type: object type: object
@@ -1571,17 +1631,36 @@ components:
message: message:
type: string type: string
PagedCommentDto: CursorPagedCommentDto:
type: object type: object
allOf:
- $ref: "#/components/schemas/Page"
required: required:
- content - content
- hasMore
properties: properties:
content: content:
type: array type: array
items: items:
$ref: "#/components/schemas/CommentDto" $ref: "#/components/schemas/CommentDto"
nextCursorCreatedAt:
type: string
format: date-time
nullable: true
description: Cursor to fetch the next page (createdAt of the last item in this page). Null when no more pages.
hasMore:
type: boolean
description: Whether more comments exist after this page.
ApplicationFormCommentCountsDto:
type: object
required:
- counts
properties:
counts:
type: object
additionalProperties:
type: integer
format: int64
description: Keys are formElementId (UUID), values are comment counts.
####### RoleDto ####### ####### RoleDto #######
RoleDto: RoleDto:

View File

@@ -90,8 +90,8 @@ task generate_legalconsenthub_server(type: org.openapitools.generator.gradle.plu
enumPropertyNaming: 'original', enumPropertyNaming: 'original',
interfaceOnly : 'true', interfaceOnly : 'true',
useSpringBoot3 : 'true'] useSpringBoot3 : 'true']
typeMappings = [DateTime: "LocalDateTime"] typeMappings = [DateTime: "Instant"]
importMappings = [LocalDateTime: "java.time.LocalDateTime"] importMappings = [Instant: "java.time.Instant"]
} }
compileKotlin.dependsOn(tasks.generate_legalconsenthub_server) compileKotlin.dependsOn(tasks.generate_legalconsenthub_server)

View File

@@ -18,7 +18,7 @@ import jakarta.persistence.OneToMany
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime import java.time.Instant
import java.util.UUID import java.util.UUID
@Entity @Entity
@@ -47,8 +47,8 @@ class ApplicationForm(
var lastModifiedBy: User, var lastModifiedBy: User,
@CreatedDate @CreatedDate
@Column(nullable = false) @Column(nullable = false)
var createdAt: LocalDateTime? = null, var createdAt: Instant? = null,
@LastModifiedDate @LastModifiedDate
@Column(nullable = false) @Column(nullable = false)
var modifiedAt: LocalDateTime? = null, var modifiedAt: Instant? = null,
) )

View File

@@ -33,9 +33,12 @@ class ApplicationFormController(
) )
override fun getAllApplicationForms(organizationId: String?): ResponseEntity<PagedApplicationFormDto> = override fun getAllApplicationForms(organizationId: String?): ResponseEntity<PagedApplicationFormDto> =
ResponseEntity.ok( ResponseEntity.ok(
applicationFormService.getApplicationFormsWithCommentCounts(organizationId).let { result ->
pagedApplicationFormMapper.toPagedApplicationFormDto( pagedApplicationFormMapper.toPagedApplicationFormDto(
applicationFormService.getApplicationForms(organizationId), result.page,
), result.commentCountByApplicationFormId,
)
},
) )
@PreAuthorize( @PreAuthorize(

View File

@@ -19,8 +19,9 @@ import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSubSectionSna
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.thymeleaf.TemplateEngine import org.thymeleaf.TemplateEngine
import org.thymeleaf.context.Context import org.thymeleaf.context.Context
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.UUID import java.util.UUID
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
@@ -39,7 +40,7 @@ class ApplicationFormFormatService(
fun generatePdf( fun generatePdf(
snapshot: ApplicationFormSnapshotDto, snapshot: ApplicationFormSnapshotDto,
createdAt: LocalDateTime?, createdAt: Instant?,
): ByteArray { ): ByteArray {
val latexContent = generateLatex(snapshot, createdAt) val latexContent = generateLatex(snapshot, createdAt)
return pdfRenderer.render(latexContent) return pdfRenderer.render(latexContent)
@@ -58,7 +59,7 @@ class ApplicationFormFormatService(
fun generateLatex( fun generateLatex(
snapshot: ApplicationFormSnapshotDto, snapshot: ApplicationFormSnapshotDto,
createdAt: LocalDateTime?, createdAt: Instant?,
): String { ): String {
val filteredSnapshot = filterVisibleElements(snapshot) val filteredSnapshot = filterVisibleElements(snapshot)
val exportModel = buildLatexExportModel(filteredSnapshot, createdAt) val exportModel = buildLatexExportModel(filteredSnapshot, createdAt)
@@ -88,7 +89,7 @@ class ApplicationFormFormatService(
organizationId = LatexEscaper.escape(applicationForm.organizationId), organizationId = LatexEscaper.escape(applicationForm.organizationId),
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${applicationForm.organizationId}"), employer = LatexEscaper.escape("Arbeitgeber der Organisation ${applicationForm.organizationId}"),
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${applicationForm.organizationId}"), worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${applicationForm.organizationId}"),
createdAt = applicationForm.createdAt?.format(dateFormatter) ?: "", createdAt = applicationForm.createdAt?.atZone(ZoneId.of("Europe/Berlin"))?.format(dateFormatter) ?: "",
sections = sections =
applicationForm.formElementSections.map { section -> applicationForm.formElementSections.map { section ->
LatexSection( LatexSection(
@@ -116,7 +117,7 @@ class ApplicationFormFormatService(
private fun buildLatexExportModel( private fun buildLatexExportModel(
snapshot: ApplicationFormSnapshotDto, snapshot: ApplicationFormSnapshotDto,
createdAt: LocalDateTime?, createdAt: Instant?,
): LatexExportModel { ): LatexExportModel {
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
@@ -126,7 +127,7 @@ class ApplicationFormFormatService(
organizationId = LatexEscaper.escape(snapshot.organizationId), organizationId = LatexEscaper.escape(snapshot.organizationId),
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${snapshot.organizationId}"), employer = LatexEscaper.escape("Arbeitgeber der Organisation ${snapshot.organizationId}"),
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${snapshot.organizationId}"), worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${snapshot.organizationId}"),
createdAt = createdAt?.format(dateFormatter) ?: "", createdAt = createdAt?.atZone(ZoneId.of("Europe/Berlin"))?.format(dateFormatter) ?: "",
sections = sections =
snapshot.sections.map { section -> snapshot.sections.map { section ->
LatexSection( LatexSection(

View File

@@ -6,7 +6,7 @@ import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
import com.betriebsratkanzlei.legalconsenthub.user.UserService import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.time.LocalDateTime import java.time.Instant
import java.util.UUID import java.util.UUID
@Component @Component
@@ -16,6 +16,12 @@ class ApplicationFormMapper(
private val userService: UserService, private val userService: UserService,
) { ) {
fun toApplicationFormDto(applicationForm: ApplicationForm): ApplicationFormDto = fun toApplicationFormDto(applicationForm: ApplicationForm): ApplicationFormDto =
toApplicationFormDto(applicationForm, null)
fun toApplicationFormDto(
applicationForm: ApplicationForm,
commentCount: Long?,
): ApplicationFormDto =
ApplicationFormDto( ApplicationFormDto(
id = applicationForm.id, id = applicationForm.id,
name = applicationForm.name, name = applicationForm.name,
@@ -30,9 +36,10 @@ class ApplicationFormMapper(
organizationId = applicationForm.organizationId, organizationId = applicationForm.organizationId,
createdBy = userMapper.toUserDto(applicationForm.createdBy), createdBy = userMapper.toUserDto(applicationForm.createdBy),
lastModifiedBy = userMapper.toUserDto(applicationForm.lastModifiedBy), lastModifiedBy = userMapper.toUserDto(applicationForm.lastModifiedBy),
createdAt = applicationForm.createdAt ?: LocalDateTime.now(), createdAt = applicationForm.createdAt ?: Instant.now(),
modifiedAt = applicationForm.modifiedAt ?: LocalDateTime.now(), modifiedAt = applicationForm.modifiedAt ?: Instant.now(),
status = applicationForm.status, status = applicationForm.status,
commentCount = commentCount,
) )
fun toNewApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm { fun toNewApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {

View File

@@ -1,6 +1,7 @@
package com.betriebsratkanzlei.legalconsenthub.application_form package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub.application_form_version.ApplicationFormVersionService import com.betriebsratkanzlei.legalconsenthub.application_form_version.ApplicationFormVersionService
import com.betriebsratkanzlei.legalconsenthub.comment.CommentRepository
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormCreatedEvent import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormCreatedEvent
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormSubmittedEvent import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormSubmittedEvent
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException
@@ -22,6 +23,11 @@ import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.UUID import java.util.UUID
data class ApplicationFormPageWithCommentCounts(
val page: Page<ApplicationForm>,
val commentCountByApplicationFormId: Map<UUID, Long>,
)
@Service @Service
class ApplicationFormService( class ApplicationFormService(
private val applicationFormRepository: ApplicationFormRepository, private val applicationFormRepository: ApplicationFormRepository,
@@ -31,6 +37,7 @@ class ApplicationFormService(
private val versionService: ApplicationFormVersionService, private val versionService: ApplicationFormVersionService,
private val userService: UserService, private val userService: UserService,
private val eventPublisher: ApplicationEventPublisher, private val eventPublisher: ApplicationEventPublisher,
private val commentRepository: CommentRepository,
) { ) {
fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm { fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto) val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
@@ -62,10 +69,26 @@ class ApplicationFormService(
} }
fun getApplicationForms(organizationId: String?): Page<ApplicationForm> { fun getApplicationForms(organizationId: String?): Page<ApplicationForm> {
val pageable = PageRequest.of(0, 10) val pageable = PageRequest.of(0, 100)
return applicationFormRepository.findAllByIsTemplateFalseAndOrganizationId(organizationId, pageable) return applicationFormRepository.findAllByIsTemplateFalseAndOrganizationId(organizationId, pageable)
} }
fun getApplicationFormsWithCommentCounts(organizationId: String?): ApplicationFormPageWithCommentCounts {
val page = getApplicationForms(organizationId)
val applicationFormIds = page.content.mapNotNull { it.id }
if (applicationFormIds.isEmpty()) {
return ApplicationFormPageWithCommentCounts(page, emptyMap())
}
val counts =
commentRepository.countByApplicationFormIds(applicationFormIds).associate { projection ->
projection.applicationFormId to projection.commentCount
}
return ApplicationFormPageWithCommentCounts(page, counts)
}
fun updateApplicationForm( fun updateApplicationForm(
id: UUID, id: UUID,
applicationFormDto: ApplicationFormDto, applicationFormDto: ApplicationFormDto,

View File

@@ -3,14 +3,25 @@ package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.util.UUID
@Component @Component
class PagedApplicationFormMapper( class PagedApplicationFormMapper(
private val applicationFormMapper: ApplicationFormMapper, private val applicationFormMapper: ApplicationFormMapper,
) { ) {
fun toPagedApplicationFormDto(pagedApplicationForm: Page<ApplicationForm>): PagedApplicationFormDto = fun toPagedApplicationFormDto(pagedApplicationForm: Page<ApplicationForm>): PagedApplicationFormDto =
toPagedApplicationFormDto(pagedApplicationForm, emptyMap())
fun toPagedApplicationFormDto(
pagedApplicationForm: Page<ApplicationForm>,
commentCountByApplicationFormId: Map<UUID, Long>,
): PagedApplicationFormDto =
PagedApplicationFormDto( PagedApplicationFormDto(
content = pagedApplicationForm.content.map { applicationFormMapper.toApplicationFormDto(it) }, content =
pagedApplicationForm.content.map { applicationForm ->
val count = applicationForm.id?.let { commentCountByApplicationFormId[it] } ?: 0L
applicationFormMapper.toApplicationFormDto(applicationForm, count)
},
number = pagedApplicationForm.number, number = pagedApplicationForm.number,
propertySize = pagedApplicationForm.size, propertySize = pagedApplicationForm.size,
numberOfElements = pagedApplicationForm.numberOfElements, numberOfElements = pagedApplicationForm.numberOfElements,

View File

@@ -16,7 +16,7 @@ import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction import org.hibernate.annotations.OnDeleteAction
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime import java.time.Instant
import java.util.UUID import java.util.UUID
@Entity @Entity
@@ -45,5 +45,5 @@ class ApplicationFormVersion(
var createdBy: User, var createdBy: User,
@CreatedDate @CreatedDate
@Column(nullable = false) @Column(nullable = false)
var createdAt: LocalDateTime? = null, var createdAt: Instant? = null,
) )

View File

@@ -13,7 +13,7 @@ import jakarta.persistence.ManyToOne
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime import java.time.Instant
import java.util.UUID import java.util.UUID
@Entity @Entity
@@ -22,17 +22,17 @@ class Comment(
@Id @Id
@GeneratedValue @GeneratedValue
var id: UUID? = null, var id: UUID? = null,
@Column(nullable = false) @Column(nullable = false, columnDefinition = "TEXT")
var message: String = "", var message: String = "",
@ManyToOne @ManyToOne
@JoinColumn(name = "created_by_id", nullable = false) @JoinColumn(name = "created_by_id", nullable = false)
var createdBy: User, var createdBy: User,
@CreatedDate @CreatedDate
@Column(nullable = false) @Column(nullable = false)
var createdAt: LocalDateTime? = null, var createdAt: Instant? = null,
@LastModifiedDate @LastModifiedDate
@Column(nullable = false) @Column(nullable = false)
var modifiedAt: LocalDateTime? = null, var modifiedAt: Instant? = null,
@ManyToOne @ManyToOne
@JoinColumn(name = "application_form_id", nullable = false) @JoinColumn(name = "application_form_id", nullable = false)
var applicationForm: ApplicationForm? = null, var applicationForm: ApplicationForm? = null,

View File

@@ -1,19 +1,20 @@
package com.betriebsratkanzlei.legalconsenthub.comment package com.betriebsratkanzlei.legalconsenthub.comment
import com.betriebsratkanzlei.legalconsenthub_api.api.CommentApi import com.betriebsratkanzlei.legalconsenthub_api.api.CommentApi
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormCommentCountsDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedCommentDto import com.betriebsratkanzlei.legalconsenthub_api.model.CursorPagedCommentDto
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.time.Instant
import java.util.UUID import java.util.UUID
@RestController @RestController
class CommentController( class CommentController(
val commentService: CommentService, val commentService: CommentService,
val commentMapper: CommentMapper, val commentMapper: CommentMapper,
val pagedCommentMapper: PagedCommentMapper,
) : CommentApi { ) : CommentApi {
@PreAuthorize( @PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')", "hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
@@ -32,12 +33,36 @@ class CommentController(
@PreAuthorize( @PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')", "hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
) )
override fun getCommentsByApplicationFormId(applicationFormId: UUID): ResponseEntity<PagedCommentDto> = override fun getGroupedCommentCountByApplicationFromId(
ResponseEntity.ok( applicationFormId: UUID,
pagedCommentMapper.toPagedCommentDto( ): ResponseEntity<ApplicationFormCommentCountsDto> {
commentService.getComments(applicationFormId), val counts = commentService.getGroupedCommentCountByApplicationFromId(applicationFormId)
return ResponseEntity.ok(
ApplicationFormCommentCountsDto(
counts = counts.mapKeys { it.key.toString() },
), ),
) )
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getCommentsByApplicationFormId(
applicationFormId: UUID,
formElementId: UUID?,
cursorCreatedAt: Instant?,
limit: Int,
): ResponseEntity<CursorPagedCommentDto> {
val page = commentService.getComments(applicationFormId, formElementId, cursorCreatedAt, limit)
return ResponseEntity.ok(
CursorPagedCommentDto(
content = page.comments.map { commentMapper.toCommentDto(it) },
nextCursorCreatedAt = page.nextCursorCreatedAt,
hasMore = page.hasMore,
),
)
}
@PreAuthorize( @PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')", "hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
@@ -48,7 +73,7 @@ class CommentController(
): ResponseEntity<CommentDto> = ): ResponseEntity<CommentDto> =
ResponseEntity.ok( ResponseEntity.ok(
commentMapper.toCommentDto( commentMapper.toCommentDto(
commentService.updateComment(commentDto), commentService.updateComment(id, commentDto),
), ),
) )

View File

@@ -9,7 +9,7 @@ import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.time.LocalDateTime import java.time.Instant
import java.util.UUID import java.util.UUID
@Component @Component
@@ -23,8 +23,8 @@ class CommentMapper(
CommentDto( CommentDto(
id = comment.id ?: throw IllegalStateException("Comment ID must not be null!"), id = comment.id ?: throw IllegalStateException("Comment ID must not be null!"),
message = comment.message, message = comment.message,
createdAt = comment.createdAt ?: LocalDateTime.now(), createdAt = comment.createdAt ?: Instant.now(),
modifiedAt = comment.modifiedAt ?: LocalDateTime.now(), modifiedAt = comment.modifiedAt ?: Instant.now(),
createdBy = userMapper.toUserDto(comment.createdBy), createdBy = userMapper.toUserDto(comment.createdBy),
applicationFormId = applicationFormId =
comment.applicationForm?.id comment.applicationForm?.id
@@ -56,6 +56,14 @@ class CommentMapper(
) )
} }
fun applyUpdate(
existing: Comment,
commentDto: CommentDto,
): Comment {
existing.message = commentDto.message
return existing
}
fun toComment( fun toComment(
applicationFormId: UUID, applicationFormId: UUID,
formElementId: UUID, formElementId: UUID,

View File

@@ -4,13 +4,68 @@ import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.Instant
import java.util.UUID import java.util.UUID
interface ApplicationFormCommentCountProjection {
val applicationFormId: UUID
val commentCount: Long
}
interface FormElementCommentCountProjection {
val formElementId: UUID
val commentCount: Long
}
@Repository @Repository
interface CommentRepository : JpaRepository<Comment, UUID> { interface CommentRepository : JpaRepository<Comment, UUID> {
fun findAllByApplicationForm( fun findAllByApplicationForm(
applicationForm: ApplicationForm, applicationForm: ApplicationForm,
pageable: Pageable, pageable: Pageable,
): Page<Comment> ): Page<Comment>
@Query(
"""
select c.applicationForm.id as applicationFormId, count(c.id) as commentCount
from Comment c
where c.applicationForm.id in :applicationFormIds
group by c.applicationForm.id
""",
)
fun countByApplicationFormIds(
@Param("applicationFormIds") applicationFormIds: Collection<UUID>,
): List<ApplicationFormCommentCountProjection>
@Query(
"""
select c.*
from comment c
where c.application_form_id = :applicationFormId
and (cast(:formElementId as uuid) is null or c.form_element_id = :formElementId)
and (cast(:cursorCreatedAt as timestamp) is null or c.created_at < :cursorCreatedAt)
order by c.created_at desc, c.id desc
""",
nativeQuery = true,
)
fun findNextByApplicationFormId(
@Param("applicationFormId") applicationFormId: UUID,
@Param("formElementId") formElementId: UUID?,
@Param("cursorCreatedAt") cursorCreatedAt: Instant?,
pageable: Pageable,
): List<Comment>
@Query(
"""
select c.formElement.id as formElementId, count(c.id) as commentCount
from Comment c
where c.applicationForm.id = :applicationFormId
group by c.formElement.id
""",
)
fun countByApplicationFormIdGroupByFormElementId(
@Param("applicationFormId") applicationFormId: UUID,
): List<FormElementCommentCountProjection>
} }

View File

@@ -7,11 +7,17 @@ import com.betriebsratkanzlei.legalconsenthub.error.CommentNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotUpdatedException import com.betriebsratkanzlei.legalconsenthub.error.CommentNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Instant
import java.util.UUID import java.util.UUID
data class CursorCommentPage(
val comments: List<Comment>,
val nextCursorCreatedAt: Instant?,
val hasMore: Boolean,
)
@Service @Service
class CommentService( class CommentService(
private val commentRepository: CommentRepository, private val commentRepository: CommentRepository,
@@ -36,25 +42,55 @@ class CommentService(
fun getCommentById(id: UUID): Comment = commentRepository.findById(id).orElseThrow { CommentNotFoundException(id) } fun getCommentById(id: UUID): Comment = commentRepository.findById(id).orElseThrow { CommentNotFoundException(id) }
fun getComments(applicationFormId: UUID): Page<Comment> { fun getComments(
val applicationForm = applicationFormId: UUID,
formElementId: UUID?,
cursorCreatedAt: Instant?,
limit: Int,
): CursorCommentPage {
applicationFormRepository.findById(applicationFormId).orElse(null) applicationFormRepository.findById(applicationFormId).orElse(null)
val pageable = PageRequest.of(0, 10)
return commentRepository.findAllByApplicationForm(applicationForm, pageable) val pageSize = limit.coerceIn(1, 50)
val fetchSize = pageSize + 1
val comments =
commentRepository.findNextByApplicationFormId(
applicationFormId = applicationFormId,
formElementId = formElementId,
cursorCreatedAt = cursorCreatedAt,
pageable = PageRequest.of(0, fetchSize),
)
val hasMore = comments.size > pageSize
val pageItems = if (hasMore) comments.take(pageSize) else comments
val nextCursor = if (hasMore) pageItems.lastOrNull()?.createdAt else null
return CursorCommentPage(
comments = pageItems,
nextCursorCreatedAt = nextCursor,
hasMore = hasMore,
)
} }
fun updateComment(commentDto: CommentDto): Comment { fun getGroupedCommentCountByApplicationFromId(applicationFormId: UUID): Map<UUID, Long> {
// TODO find statt mappen? applicationFormRepository.findById(applicationFormId).orElse(null)
val comment = commentMapper.toComment(commentDto)
val updatedComment: Comment
try { return commentRepository
updatedComment = commentRepository.save(comment) .countByApplicationFormIdGroupByFormElementId(applicationFormId)
.associate { it.formElementId to it.commentCount }
}
fun updateComment(
id: UUID,
commentDto: CommentDto,
): Comment {
val existing = getCommentById(id)
val updated = commentMapper.applyUpdate(existing, commentDto)
return try {
commentRepository.save(updated)
} catch (e: Exception) { } catch (e: Exception) {
throw CommentNotUpdatedException(e, commentDto.id) throw CommentNotUpdatedException(e, id)
} }
return updatedComment
} }
fun deleteCommentByID(id: UUID) { fun deleteCommentByID(id: UUID) {

View File

@@ -1,23 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.comment
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedCommentDto
import org.springframework.data.domain.Page
import org.springframework.stereotype.Component
@Component
class PagedCommentMapper(
private val commentMapper: CommentMapper,
) {
fun toPagedCommentDto(pagedComment: Page<Comment>): PagedCommentDto =
PagedCommentDto(
content = pagedComment.content.map { commentMapper.toCommentDto(it) },
number = pagedComment.number,
propertySize = pagedComment.size,
numberOfElements = pagedComment.numberOfElements,
last = pagedComment.isLast,
totalPages = pagedComment.totalPages,
totalElements = pagedComment.totalElements,
empty = pagedComment.isEmpty,
first = pagedComment.isFirst,
)
}

View File

@@ -13,7 +13,7 @@ import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne import jakarta.persistence.ManyToOne
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime import java.time.Instant
import java.util.UUID import java.util.UUID
@Entity @Entity
@@ -42,5 +42,5 @@ class Notification(
var organizationId: String = "", var organizationId: String = "",
@CreatedDate @CreatedDate
@Column(nullable = false) @Column(nullable = false)
var createdAt: LocalDateTime? = null, var createdAt: Instant? = null,
) )

View File

@@ -8,7 +8,7 @@ import jakarta.persistence.Table
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime import java.time.Instant
@Entity @Entity
@EntityListeners(AuditingEntityListener::class) @EntityListeners(AuditingEntityListener::class)
@@ -29,8 +29,8 @@ class User(
var emailOnFormSubmitted: Boolean = true, var emailOnFormSubmitted: Boolean = true,
@CreatedDate @CreatedDate
@Column(nullable = false) @Column(nullable = false)
var createdAt: LocalDateTime? = null, var createdAt: Instant? = null,
@LastModifiedDate @LastModifiedDate
@Column(nullable = false) @Column(nullable = false)
var modifiedAt: LocalDateTime? = null, var modifiedAt: Instant? = null,
) )

View File

@@ -2,8 +2,8 @@ create table app_user
( (
email_on_form_created boolean not null, email_on_form_created boolean not null,
email_on_form_submitted boolean not null, email_on_form_submitted boolean not null,
created_at timestamp(6) not null, created_at timestamp(6) with time zone not null,
modified_at timestamp(6) not null, modified_at timestamp(6) with time zone not null,
email varchar(255), email varchar(255),
keycloak_id varchar(255) not null, keycloak_id varchar(255) not null,
name varchar(255) not null, name varchar(255) not null,
@@ -14,8 +14,8 @@ create table app_user
create table application_form create table application_form
( (
is_template boolean not null, is_template boolean not null,
created_at timestamp(6) not null, created_at timestamp(6) with time zone not null,
modified_at timestamp(6) not null, modified_at timestamp(6) with time zone not null,
id uuid not null, id uuid not null,
created_by_id varchar(255) not null, created_by_id varchar(255) not null,
last_modified_by_id varchar(255) not null, last_modified_by_id varchar(255) not null,
@@ -28,7 +28,7 @@ create table application_form
create table application_form_version create table application_form_version
( (
version_number integer not null, version_number integer not null,
created_at timestamp(6) not null, created_at timestamp(6) with time zone not null,
application_form_id uuid not null, application_form_id uuid not null,
id uuid not null, id uuid not null,
created_by_id varchar(255) not null, created_by_id varchar(255) not null,
@@ -41,13 +41,13 @@ create table application_form_version
create table comment create table comment
( (
created_at timestamp(6) not null, created_at timestamp(6) with time zone not null,
modified_at timestamp(6) not null, modified_at timestamp(6) with time zone not null,
application_form_id uuid not null, application_form_id uuid not null,
form_element_id uuid not null, form_element_id uuid not null,
id uuid not null, id uuid not null,
created_by_id varchar(255) not null, created_by_id varchar(255) not null,
message varchar(255) not null, message TEXT not null,
primary key (id) primary key (id)
); );
@@ -110,7 +110,7 @@ create table form_element_sub_section
create table notification create table notification
( (
is_read boolean not null, is_read boolean not null,
created_at timestamp(6) not null, created_at timestamp(6) with time zone not null,
id uuid not null, id uuid not null,
click_target varchar(255) not null, click_target varchar(255) not null,
message TEXT not null, message TEXT not null,
@@ -136,9 +136,7 @@ alter table if exists application_form_version
add constraint FKpfri4lhy9wqfsp8esabedkq6c add constraint FKpfri4lhy9wqfsp8esabedkq6c
foreign key (application_form_id) foreign key (application_form_id)
references application_form references application_form
on on delete cascade;
delete
cascade;
alter table if exists application_form_version alter table if exists application_form_version
add constraint FKl6fbcrvh439gbwgcvvfyxaggi add constraint FKl6fbcrvh439gbwgcvvfyxaggi

View File

@@ -32,14 +32,36 @@
:form-element-id="formElementItem.formElement.id" :form-element-id="formElementItem.formElement.id"
:application-form-id="applicationFormId" :application-form-id="applicationFormId"
:comments="comments?.[formElementItem.formElement.id]" :comments="comments?.[formElementItem.formElement.id]"
:total-count="
commentCounts?.[formElementItem.formElement.id] ?? comments?.[formElementItem.formElement.id]?.length ?? 0
"
@close="activeFormElement = ''"
/> />
</div> </div>
<div class="flex items-start gap-1">
<div class="min-w-9">
<UButton
v-if="
applicationFormId &&
formElementItem.formElement.id &&
(commentCounts?.[formElementItem.formElement.id] ?? 0) > 0
"
color="neutral"
variant="soft"
size="xs"
icon="i-lucide-message-square"
class="w-full justify-center"
@click="toggleComments(formElementItem.formElement.id)"
>
{{ commentCounts?.[formElementItem.formElement.id] ?? 0 }}
</UButton>
</div>
<div <div
:class="[ :class="[
'transition-opacity duration-200', 'transition-opacity duration-200',
openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection) openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)
? 'opacity-100' ? 'opacity-100'
: 'opacity-0 group-hover:opacity-100' : 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-within:opacity-100'
]" ]"
> >
<UDropdownMenu <UDropdownMenu
@@ -63,6 +85,7 @@
</UDropdownMenu> </UDropdownMenu>
</div> </div>
</div> </div>
</div>
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" /> <USeparator v-if="visibleIndex < visibleFormElements.length - 1" />
</template> </template>
</template> </template>
@@ -89,14 +112,18 @@ const emit = defineEmits<{
}>() }>()
const commentStore = useCommentStore() const commentStore = useCommentStore()
const { load: loadComments } = commentStore const { loadInitial: loadCommentsInitial } = commentStore
const { comments } = storeToRefs(commentStore) const { commentsByApplicationFormId, countsByApplicationFormId } = storeToRefs(commentStore)
const logger = useLogger().withTag('FormEngine')
if (props.applicationFormId) { const comments = computed(() => {
logger.debug('Loading comments for application form:', props.applicationFormId) if (!props.applicationFormId) return {}
await loadComments(props.applicationFormId) return commentsByApplicationFormId.value[props.applicationFormId] ?? {}
} })
const commentCounts = computed(() => {
if (!props.applicationFormId) return {}
return countsByApplicationFormId.value[props.applicationFormId] ?? {}
})
const route = useRoute() const route = useRoute()
const activeFormElement = ref('') const activeFormElement = ref('')
@@ -189,12 +216,15 @@ function updateFormOptions(formOptions: FormOptionDto[], target: VisibleFormElem
emit('update:modelValue', updatedModelValue) emit('update:modelValue', updatedModelValue)
} }
function toggleComments(formElementId: string) { async function toggleComments(formElementId: string) {
if (activeFormElement.value === formElementId) { if (activeFormElement.value === formElementId) {
activeFormElement.value = '' activeFormElement.value = ''
return return
} }
activeFormElement.value = formElementId activeFormElement.value = formElementId
if (props.applicationFormId) {
await loadCommentsInitial(props.applicationFormId, formElementId)
}
emit('click:comments', formElementId) emit('click:comments', formElementId)
} }

View File

@@ -1,67 +1,356 @@
<template> <template>
<template v-if="comments && comments.length > 0"> <div class="mt-4 lg:mt-6">
<UChatMessages :auto-scroll="false" :should-scroll-to-bottom="false"> <UCard variant="subtle" :ui="{ body: 'p-4 sm:p-5', header: 'p-4 sm:p-5', footer: 'p-4 sm:p-5' }">
<UChatMessage <template #header>
v-for="comment in comments" <div class="flex items-center justify-between gap-3">
:id="comment.id" <div class="min-w-0">
:key="comment.id" <p class="text-sm font-medium text-highlighted">
:avatar="{ icon: 'i-lucide-bot' }" {{ $t('comments.title') }}
:content="comment.message" </p>
role="user" <p class="text-xs text-muted">
:parts="[{ type: 'text', text: comment.message }]" {{ $t('comments.count', { count: commentCount }) }}
:side="isCommentByUser(comment) ? 'right' : 'left'" </p>
variant="subtle" </div>
:actions="createChatMessageActions(comment)" <UButton color="neutral" variant="ghost" size="sm" icon="i-lucide-x" @click="$emit('close')" />
>
<template>
<div class="flex flex-col">
<UAvatar icon="i-lucide-bot" />
<p class="text-sm">{{ comment.createdBy.name }}</p>
</div> </div>
</template> </template>
</UChatMessage>
</UChatMessages> <div v-if="comments && comments.length > 0" class="relative">
<UProgress
v-if="isLoadingMore"
indeterminate
size="xs"
class="absolute top-0 inset-x-0 z-10"
:ui="{ base: 'bg-default' }"
/>
<UScrollArea ref="scrollAreaRef" class="max-h-96" :ui="{ viewport: 'space-y-4 pe-2' }">
<div v-for="comment in comments" :key="comment.id" class="space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<UAvatar icon="i-lucide-user" size="2xs" />
<div class="min-w-0">
<p class="text-sm text-highlighted truncate">
{{ comment.createdBy.name }}
</p>
<p class="text-xs text-muted">
{{ comment.createdAt ? formatDate(comment.createdAt) : '-' }}
</p>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<UDropdownMenu
v-if="isCommentByUser(comment)"
:items="[createCommentActions(comment)]"
:content="{ align: 'end' }"
>
<UButton icon="i-lucide-ellipsis" color="neutral" variant="ghost" size="xs" square />
</UDropdownMenu>
</div>
</div>
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
<!-- Edit comment form -->
<template v-if="editingCommentId === comment.id">
<UEditor
v-slot="{ editor }"
v-model="editingCommentEditorContent"
content-type="json"
:editable="true"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'min-h-[120px] p-3 bg-transparent'
}"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-default px-3 py-2 bg-default/50 overflow-x-auto"
/>
</UEditor>
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end p-3 border-t border-default">
<UButton color="neutral" variant="outline" @click="cancelEditComment">
{{ $t('common.cancel') }}
</UButton>
<UButton @click="saveEditedComment(comment)">
{{ $t('comments.saveChanges') }}
</UButton>
</div>
</template> </template>
<UTextarea v-model="commentTextAreaValue" class="w-full" />
<UButton v-if="!isEditingComment" class="my-3 lg:my-4" @click="submitComment(formElementId)"> <!-- Display comment content when not editing -->
<UEditor
v-else
:key="`${comment.id}:${comment.modifiedAt?.toISOString?.() ?? ''}`"
:model-value="getCommentEditorModelValue(comment)"
content-type="json"
:editable="false"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'p-3 bg-transparent'
}"
/>
</div>
</div>
</UScrollArea>
</div>
<UEmpty v-else :title="$t('comments.empty')" icon="i-lucide-message-square" class="py-6" />
<template #footer>
<div class="space-y-3">
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
<UEditor
v-slot="{ editor }"
v-model="newCommentEditorContent"
content-type="json"
:editable="true"
:placeholder="$t('comments.placeholder')"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'min-h-[120px] p-3 bg-transparent'
}"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-default px-3 py-2 bg-transparent overflow-x-auto"
/>
</UEditor>
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end">
<UButton @click="submitComment(formElementId)">
{{ $t('comments.submit') }} {{ $t('comments.submit') }}
</UButton> </UButton>
<UButton v-if="isEditingComment" class="my-3 lg:my-4" @click="updateEditComment"> {{ $t('comments.edit') }} </UButton> </div>
<UButton v-if="isEditingComment" class="my-3 lg:my-4" @click="cancelEditComment"> {{ $t('common.cancel') }} </UButton> </div>
</template>
</UCard>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { CommentDto } from '~~/.api-client' import type { CommentDto } from '~~/.api-client'
import { useCommentTextarea } from '~/composables/comment/useCommentTextarea' import type { JSONContent } from '@tiptap/vue-3'
import type { DropdownMenuItem } from '@nuxt/ui'
import { useInfiniteScroll } from '@vueuse/core'
import { useCommentStore } from '~~/stores/useCommentStore'
import { useUserStore } from '~~/stores/useUserStore'
const props = defineProps<{ const props = defineProps<{
formElementId: string formElementId: string
applicationFormId: string applicationFormId: string
comments?: CommentDto[] comments?: CommentDto[]
totalCount?: number
}>() }>()
const commentActions = useCommentTextarea(props.applicationFormId) defineEmits<{
const { (e: 'close'): void
submitComment, }>()
updateEditComment,
cancelEditComment,
editComment,
isEditingComment,
isCommentByUser,
commentTextAreaValue
} = commentActions
function createChatMessageActions(comment: CommentDto) { const commentStore = useCommentStore()
const { loadMore } = commentStore
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const toast = useToast()
const { t: $t } = useI18n() const { t: $t } = useI18n()
const chatMessageActions = []
if (isCommentByUser(comment)) { const scrollAreaRef = useTemplateRef('scrollAreaRef')
chatMessageActions.push({ const scrollContainerEl = ref<HTMLElement | null>(null)
const commentCount = computed(() => props.totalCount ?? props.comments?.length ?? 0)
const commentCursorSate = computed(
() => commentStore.nextCursorByApplicationFormId[props.applicationFormId]?.[props.formElementId]
)
const canLoadMore = computed(() => commentCursorSate.value?.hasMore === true)
const isLoadingMore = computed(() => commentCursorSate.value?.isLoading === true)
const newCommentValue = ref<string>('')
const newCommentEditorContent = computed<JSONContent>({
get: () => toEditorJson(newCommentValue.value),
set: (newValue) => {
newCommentValue.value = JSON.stringify(newValue)
}
})
const editingCommentId = ref<string | null>(null)
const editingCommentValue = ref<string>('')
const editingCommentEditorContent = computed<JSONContent>({
get: () => toEditorJson(editingCommentValue.value),
set: (newValue) => {
editingCommentValue.value = JSON.stringify(newValue)
}
})
watch(
() => scrollAreaRef.value,
async (scrollAreaComponent) => {
if (!scrollAreaComponent) {
scrollContainerEl.value = null
return
}
await nextTick()
const rootEl = scrollAreaComponent.$el as HTMLElement | undefined
if (!rootEl) return
scrollContainerEl.value = rootEl
// Wait another tick for content to be measured, then scroll to bottom
await nextTick()
// Use requestAnimationFrame to ensure layout is complete
requestAnimationFrame(() => {
rootEl.scrollTop = rootEl.scrollHeight
})
},
{ immediate: true }
)
useInfiniteScroll(
scrollContainerEl,
async () => {
const scrollEl = scrollContainerEl.value
if (!scrollEl) return
const previousScrollHeight = scrollEl.scrollHeight
await loadMore(props.applicationFormId, props.formElementId)
// Maintain scroll position after prepending older comments
await nextTick()
const newScrollHeight = scrollEl.scrollHeight
scrollEl.scrollTop = newScrollHeight - previousScrollHeight + scrollEl.scrollTop
},
{
direction: 'top',
distance: 100,
canLoadMore: () => canLoadMore.value && !isLoadingMore.value
}
)
async function submitComment(formElementId: string) {
if (!newCommentValue.value.trim()) {
return
}
try {
await commentStore.createComment(props.applicationFormId, formElementId, { message: newCommentValue.value })
newCommentValue.value = ''
toast.add({ title: $t('comments.created'), color: 'success' })
} catch {
toast.add({ title: $t('comments.createError'), color: 'error' })
}
}
function startEditComment(comment: CommentDto) {
editingCommentId.value = comment.id
editingCommentValue.value = comment.message || ''
}
function cancelEditComment() {
editingCommentId.value = null
editingCommentValue.value = ''
}
async function saveEditedComment(comment: CommentDto) {
try {
const updatedComment: CommentDto = {
...comment,
message: editingCommentValue.value,
modifiedAt: new Date()
}
await commentStore.updateComment(comment.id, updatedComment)
cancelEditComment()
toast.add({ title: $t('comments.updated'), color: 'success' })
} catch {
toast.add({ title: $t('comments.updateError'), color: 'error' })
}
}
function isCommentByUser(comment: CommentDto) {
return comment.createdBy.keycloakId === user.value?.keycloakId
}
function createCommentActions(comment: CommentDto): DropdownMenuItem[] {
return [
{
label: $t('comments.editAction'), label: $t('comments.editAction'),
icon: 'i-lucide-pencil', icon: 'i-lucide-pencil',
onClick: () => editComment(comment) onClick: () => startEditComment(comment)
})
} }
return chatMessageActions ]
} }
function getCommentEditorModelValue(comment: CommentDto): JSONContent {
return toEditorJson(comment.message)
}
function toEditorJson(rawValue: string | undefined): JSONContent {
const raw = (rawValue ?? '').trim()
if (raw) {
try {
if (raw.startsWith('{')) {
return JSON.parse(raw) as JSONContent
}
} catch {
// fall through to plain text
}
return wrapPlainTextAsDoc(raw)
}
return wrapPlainTextAsDoc('')
}
function wrapPlainTextAsDoc(text: string): JSONContent {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: text
? [
{
type: 'text',
text
}
]
: []
}
]
}
}
const toolbarItems = [
[
{ kind: 'undo', icon: 'i-lucide-undo' },
{ kind: 'redo', icon: 'i-lucide-redo' }
],
[
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', label: 'H1' },
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', label: 'H2' },
{ kind: 'heading', level: 3, icon: 'i-lucide-heading-3', label: 'H3' }
],
[
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' },
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough' }
],
[
{ kind: 'bulletList', icon: 'i-lucide-list' },
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' }
],
[
{ kind: 'blockquote', icon: 'i-lucide-quote' },
{ kind: 'codeBlock', icon: 'i-lucide-code' }
],
[{ kind: 'link', icon: 'i-lucide-link' }]
]
</script> </script>

View File

@@ -1,4 +1,11 @@
import { CommentApi, Configuration, type CommentDto, type CreateCommentDto, type PagedCommentDto } from '~~/.api-client' import {
CommentApi,
Configuration,
type ApplicationFormCommentCountsDto,
type CommentDto,
type CreateCommentDto,
type CursorPagedCommentDto
} from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo' import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch' import { wrappedFetchWrap } from '~/utils/wrappedFetch'
@@ -22,8 +29,24 @@ export function useCommentApi() {
return commentApiClient.createComment({ applicationFormId, formElementId, createCommentDto }) return commentApiClient.createComment({ applicationFormId, formElementId, createCommentDto })
} }
async function getCommentsByApplicationFormId(applicationFormId: string): Promise<PagedCommentDto> { async function getCommentsByApplicationFormId(
return commentApiClient.getCommentsByApplicationFormId({ applicationFormId }) applicationFormId: string,
formElementId?: string,
cursorCreatedAt?: Date,
limit: number = 10
): Promise<CursorPagedCommentDto> {
return commentApiClient.getCommentsByApplicationFormId({
applicationFormId,
formElementId,
cursorCreatedAt,
limit
})
}
async function getGroupedCommentCountByApplicationFromId(
applicationFormId: string
): Promise<ApplicationFormCommentCountsDto> {
return commentApiClient.getGroupedCommentCountByApplicationFromId({ applicationFormId })
} }
async function updateComment(id: string, commentDto: CommentDto): Promise<CommentDto> { async function updateComment(id: string, commentDto: CommentDto): Promise<CommentDto> {
@@ -37,6 +60,7 @@ export function useCommentApi() {
return { return {
createComment, createComment,
getCommentsByApplicationFormId, getCommentsByApplicationFormId,
getGroupedCommentCountByApplicationFromId,
updateComment, updateComment,
deleteCommentById deleteCommentById
} }

View File

@@ -57,10 +57,12 @@ import {
import type { FormElementId } from '~~/types/formElement' import type { FormElementId } from '~~/types/formElement'
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator' import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
import { useUserStore } from '~~/stores/useUserStore' import { useUserStore } from '~~/stores/useUserStore'
import { useCommentStore } from '~~/stores/useCommentStore'
const route = useRoute() const route = useRoute()
const toast = useToast() const toast = useToast()
const { t: $t } = useI18n() const { t: $t } = useI18n()
const commentStore = useCommentStore()
definePageMeta({ definePageMeta({
// Prevent whole page from re-rendering when navigating between sections to keep state // Prevent whole page from re-rendering when navigating between sections to keep state
@@ -74,6 +76,10 @@ const {
updateApplicationForm updateApplicationForm
} = await useApplicationFormNavigation(applicationFormId!) } = await useApplicationFormNavigation(applicationFormId!)
if (applicationFormId) {
await commentStore.loadCounts(applicationFormId)
}
const { updateApplicationForm: updateForm, submitApplicationForm } = useApplicationForm() const { updateApplicationForm: updateForm, submitApplicationForm } = useApplicationForm()
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator() const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
const { evaluateVisibility } = useFormElementVisibility() const { evaluateVisibility } = useFormElementVisibility()

View File

@@ -62,6 +62,18 @@
<p class="text-xs text-muted mt-1">#{{ index }}</p> <p class="text-xs text-muted mt-1">#{{ index }}</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UTooltip
v-if="(applicationFormElem.commentCount ?? 0) > 0"
:text="$t('comments.count', { count: applicationFormElem.commentCount ?? 0 })"
>
<UBadge
:label="applicationFormElem.commentCount ?? 0"
color="neutral"
variant="subtle"
icon="i-lucide-message-square"
size="sm"
/>
</UTooltip>
<UBadge <UBadge
v-if="applicationFormElem.status" v-if="applicationFormElem.status"
:label="applicationFormElem.status" :label="applicationFormElem.status"

View File

@@ -91,9 +91,13 @@
"comments": { "comments": {
"title": "Kommentare", "title": "Kommentare",
"empty": "Keine Kommentare vorhanden", "empty": "Keine Kommentare vorhanden",
"count": "{count} Kommentare",
"placeholder": "Kommentar hinzufügen...",
"loadMore": "Mehr laden",
"submit": "Absenden", "submit": "Absenden",
"edit": "Kommentar bearbeiten", "edit": "Kommentar bearbeiten",
"editAction": "Bearbeiten", "editAction": "Bearbeiten",
"saveChanges": "Änderungen speichern",
"created": "Kommentar erfolgreich erstellt", "created": "Kommentar erfolgreich erstellt",
"createError": "Fehler beim Erstellen des Kommentars", "createError": "Fehler beim Erstellen des Kommentars",
"updated": "Kommentar erfolgreich aktualisiert", "updated": "Kommentar erfolgreich aktualisiert",

View File

@@ -91,9 +91,13 @@
"comments": { "comments": {
"title": "Comments", "title": "Comments",
"empty": "No comments available", "empty": "No comments available",
"count": "{count} comments",
"placeholder": "Add a comment...",
"loadMore": "Load more",
"submit": "Submit", "submit": "Submit",
"edit": "Edit Comment", "edit": "Edit Comment",
"editAction": "Edit", "editAction": "Edit",
"saveChanges": "Save changes",
"created": "Comment created successfully", "created": "Comment created successfully",
"createError": "Error creating comment", "createError": "Error creating comment",
"updated": "Comment updated successfully", "updated": "Comment updated successfully",

View File

@@ -1,47 +1,80 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { type CreateCommentDto, type CommentDto, ResponseError } from '~~/.api-client' import { type CreateCommentDto, type CommentDto, ResponseError, type CursorPagedCommentDto } from '~~/.api-client'
import { useCommentApi } from '~/composables/comment/useCommentApi' import { useCommentApi } from '~/composables/comment/useCommentApi'
import { useLogger } from '../app/composables/useLogger' import { useLogger } from '../app/composables/useLogger'
export const useCommentStore = defineStore('Comment', () => { type ApplicationFormId = string
type FormElementId = string type FormElementId = string
export const useCommentStore = defineStore('Comment', () => {
const nextCursorByApplicationFormId = ref<
Record<
ApplicationFormId,
Record<FormElementId, { nextCursorCreatedAt: Date | null; hasMore: boolean; isLoading: boolean }>
>
>({})
const commentsByApplicationFormId = ref<Record<ApplicationFormId, Record<FormElementId, CommentDto[]>>>({})
const countsByApplicationFormId = ref<Record<ApplicationFormId, Record<FormElementId, number>>>({})
const commentApi = useCommentApi() const commentApi = useCommentApi()
const comments = ref<Record<FormElementId, CommentDto[]>>({}) const loadedFormElementComments = ref(new Set<string>())
const loadedForms = ref(new Set<string>())
const logger = useLogger().withTag('commentStore') const logger = useLogger().withTag('commentStore')
async function load(applicationFormId: string) { async function loadInitial(applicationFormId: ApplicationFormId, formElementId: FormElementId) {
if (loadedForms.value.has(applicationFormId)) return initializeCursorState(applicationFormId, formElementId)
const { data, error } = await useAsyncData(`comments:${applicationFormId}`, () =>
commentApi.getCommentsByApplicationFormId(applicationFormId) const cacheKey = `${applicationFormId}:${formElementId}`
) if (loadedFormElementComments.value.has(cacheKey)) return
if (error.value) {
logger.error('Failed loading comments:', error.value) nextCursorByApplicationFormId.value[applicationFormId]![formElementId]!.isLoading = true
try {
const page = await commentApi.getCommentsByApplicationFormId(applicationFormId, formElementId, undefined, 10)
upsertComments(applicationFormId, formElementId, page, { prepend: false })
} catch (e) {
nextCursorByApplicationFormId.value[applicationFormId]![formElementId]!.isLoading = false
logger.error('Failed loading initial comments:', e)
return return
} }
comments.value = loadedFormElementComments.value.add(cacheKey)
data.value?.content.reduce((acc: Record<FormElementId, CommentDto[]>, comment: CommentDto) => { }
const formElementId = comment.formElementId
if (!acc[formElementId]) { async function loadMore(applicationFormId: ApplicationFormId, formElementId: FormElementId) {
acc[formElementId] = [] initializeCursorState(applicationFormId, formElementId)
const state = nextCursorByApplicationFormId.value[applicationFormId]![formElementId]!
if (state.isLoading || !state.hasMore) return
state.isLoading = true
const cursor = state.nextCursorCreatedAt ?? undefined
try {
const page = await commentApi.getCommentsByApplicationFormId(applicationFormId, formElementId, cursor, 10)
// Prepend older comments when loading more (scroll up)
upsertComments(applicationFormId, formElementId, page, { prepend: true })
} catch (e) {
state.isLoading = false
logger.error('Failed loading more comments:', e)
return Promise.reject(e)
} }
acc[formElementId].push(comment)
return acc
}, {}) || {}
loadedForms.value.add(applicationFormId)
} }
async function createComment( async function createComment(
applicationFormId: string, applicationFormId: ApplicationFormId,
formElementId: string, formElementId: string,
createCommentDto: CreateCommentDto createCommentDto: CreateCommentDto
): Promise<CommentDto> { ): Promise<CommentDto> {
try { try {
const newComment = await commentApi.createComment(applicationFormId, formElementId, createCommentDto) const newComment = await commentApi.createComment(applicationFormId, formElementId, createCommentDto)
if (!comments.value[formElementId]) { const commentsByFormElement = commentsByApplicationFormId.value[applicationFormId] ?? {}
comments.value[formElementId] = [] if (!commentsByFormElement[formElementId]) {
commentsByFormElement[formElementId] = []
} }
comments.value[formElementId].push(newComment) commentsByFormElement[formElementId].push(newComment)
commentsByApplicationFormId.value[applicationFormId] = commentsByFormElement
const currentCounts = countsByApplicationFormId.value[applicationFormId] ?? {}
currentCounts[formElementId] = (currentCounts[formElementId] ?? 0) + 1
countsByApplicationFormId.value[applicationFormId] = currentCounts
return newComment return newComment
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof ResponseError) { if (e instanceof ResponseError) {
@@ -60,14 +93,18 @@ export const useCommentStore = defineStore('Comment', () => {
try { try {
const updatedComment = await commentApi.updateComment(id, commentDto) const updatedComment = await commentApi.updateComment(id, commentDto)
const formElementId = updatedComment.formElementId
const formElementComments = comments.value?.[formElementId] const commentsByFormElement = commentsByApplicationFormId.value[updatedComment.applicationFormId]
const formElementComments = commentsByFormElement?.[updatedComment.formElementId]
// Update the comment in the store
if (formElementComments) { if (formElementComments) {
const index = formElementComments.findIndex((comment) => comment.id === id) const index = formElementComments.findIndex((comment) => comment.id === id)
if (index !== -1 && formElementComments[index]) { if (index !== -1 && formElementComments[index]) {
formElementComments[index] = updatedComment formElementComments[index] = updatedComment
} }
} }
return updatedComment return updatedComment
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof ResponseError) { if (e instanceof ResponseError) {
@@ -82,13 +119,33 @@ export const useCommentStore = defineStore('Comment', () => {
async function deleteCommentById(id: string): Promise<void> { async function deleteCommentById(id: string): Promise<void> {
try { try {
await commentApi.deleteCommentById(id) await commentApi.deleteCommentById(id)
for (const formElementId in comments.value) {
const formElementComments = comments.value[formElementId] // Remove the comment from the store
for (const applicationFormId in commentsByApplicationFormId.value) {
const commentsByFormElement = commentsByApplicationFormId.value[applicationFormId]
if (!commentsByFormElement) continue
for (const formElementId in commentsByFormElement) {
const formElementComments = commentsByFormElement[formElementId]
if (formElementComments) { if (formElementComments) {
const index = formElementComments.findIndex((comment) => comment.id === id) const index = formElementComments.findIndex((comment) => comment.id === id)
if (index !== -1) { if (index !== -1) {
// Remove the comment from the array
formElementComments.splice(index, 1) formElementComments.splice(index, 1)
break const commentCountsByFormElement = countsByApplicationFormId.value[applicationFormId]
// Decrement the comment count for the form element
if (commentCountsByFormElement && commentCountsByFormElement[formElementId] != null) {
commentCountsByFormElement[formElementId] = Math.max(
0,
(commentCountsByFormElement[formElementId] ?? 0) - 1
)
}
return
}
} }
} }
} }
@@ -102,5 +159,67 @@ export const useCommentStore = defineStore('Comment', () => {
} }
} }
return { load, createComment, updateComment, deleteCommentById, comments } function upsertComments(
applicationFormId: ApplicationFormId,
formElementId: FormElementId,
page: CursorPagedCommentDto,
options: { prepend: boolean }
) {
const applicationFormComments = commentsByApplicationFormId.value[applicationFormId] ?? {}
const formElementComments = applicationFormComments[formElementId] ?? (applicationFormComments[formElementId] = [])
const newComments = page.content.filter((newComment) => !formElementComments.some((c) => c.id === newComment.id))
if (options.prepend) {
// Prepend older comments at the beginning (they come in DESC order, so reverse to maintain chronological order)
formElementComments.unshift(...newComments.reverse())
} else {
// Initial load: comments come in DESC order (newest first), reverse for display (oldest at top, newest at bottom)
formElementComments.push(...newComments.reverse())
}
commentsByApplicationFormId.value[applicationFormId] = applicationFormComments
nextCursorByApplicationFormId.value[applicationFormId]![formElementId] = {
nextCursorCreatedAt: page.nextCursorCreatedAt ?? null,
hasMore: page.hasMore,
isLoading: false
}
}
function initializeCursorState(applicationFormId: ApplicationFormId, formElementId: FormElementId) {
if (!nextCursorByApplicationFormId.value[applicationFormId]) {
nextCursorByApplicationFormId.value[applicationFormId] = {}
}
if (!nextCursorByApplicationFormId.value[applicationFormId]![formElementId]) {
nextCursorByApplicationFormId.value[applicationFormId]![formElementId] = {
nextCursorCreatedAt: null,
hasMore: true,
isLoading: false
}
}
}
async function loadCounts(applicationFormId: ApplicationFormId) {
try {
const result = await commentApi.getGroupedCommentCountByApplicationFromId(applicationFormId)
countsByApplicationFormId.value[applicationFormId] = Object.fromEntries(
Object.entries(result.counts ?? {}).map(([formElementId, count]) => [formElementId, Number(count)])
)
} catch (e) {
logger.error('Failed loading comment counts:', e)
}
}
return {
loadCounts,
loadInitial,
loadMore,
createComment,
updateComment,
deleteCommentById,
commentsByApplicationFormId,
nextCursorByApplicationFormId,
countsByApplicationFormId
}
}) })