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
### 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.
- Switch to local nektos act runner also led to issues
- metadata-action not working properly: https://github.com/docker/metadata-action/issues/542

View File

@@ -646,13 +646,67 @@ paths:
operationId: getCommentsByApplicationFormId
tags:
- 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:
"200":
description: Get comments for application form ID
content:
application/json:
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":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401":
@@ -1132,6 +1186,12 @@ components:
allOf:
- $ref: "#/components/schemas/ApplicationFormStatus"
nullable: true
commentCount:
type: integer
format: int64
nullable: true
readOnly: true
description: Total number of comments associated with this application form.
PagedApplicationFormDto:
type: object
@@ -1571,17 +1631,36 @@ components:
message:
type: string
PagedCommentDto:
CursorPagedCommentDto:
type: object
allOf:
- $ref: "#/components/schemas/Page"
required:
- content
- hasMore
properties:
content:
type: array
items:
$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:

View File

@@ -90,8 +90,8 @@ task generate_legalconsenthub_server(type: org.openapitools.generator.gradle.plu
enumPropertyNaming: 'original',
interfaceOnly : 'true',
useSpringBoot3 : 'true']
typeMappings = [DateTime: "LocalDateTime"]
importMappings = [LocalDateTime: "java.time.LocalDateTime"]
typeMappings = [DateTime: "Instant"]
importMappings = [Instant: "java.time.Instant"]
}
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.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import java.time.Instant
import java.util.UUID
@Entity
@@ -47,8 +47,8 @@ class ApplicationForm(
var lastModifiedBy: User,
@CreatedDate
@Column(nullable = false)
var createdAt: LocalDateTime? = null,
var createdAt: Instant? = null,
@LastModifiedDate
@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> =
ResponseEntity.ok(
pagedApplicationFormMapper.toPagedApplicationFormDto(
applicationFormService.getApplicationForms(organizationId),
),
applicationFormService.getApplicationFormsWithCommentCounts(organizationId).let { result ->
pagedApplicationFormMapper.toPagedApplicationFormDto(
result.page,
result.commentCountByApplicationFormId,
)
},
)
@PreAuthorize(

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package com.betriebsratkanzlei.legalconsenthub.application_form
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.ApplicationFormSubmittedEvent
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException
@@ -22,6 +23,11 @@ import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import java.util.UUID
data class ApplicationFormPageWithCommentCounts(
val page: Page<ApplicationForm>,
val commentCountByApplicationFormId: Map<UUID, Long>,
)
@Service
class ApplicationFormService(
private val applicationFormRepository: ApplicationFormRepository,
@@ -31,6 +37,7 @@ class ApplicationFormService(
private val versionService: ApplicationFormVersionService,
private val userService: UserService,
private val eventPublisher: ApplicationEventPublisher,
private val commentRepository: CommentRepository,
) {
fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
@@ -62,10 +69,26 @@ class ApplicationFormService(
}
fun getApplicationForms(organizationId: String?): Page<ApplicationForm> {
val pageable = PageRequest.of(0, 10)
val pageable = PageRequest.of(0, 100)
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(
id: UUID,
applicationFormDto: ApplicationFormDto,

View File

@@ -3,14 +3,25 @@ package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto
import org.springframework.data.domain.Page
import org.springframework.stereotype.Component
import java.util.UUID
@Component
class PagedApplicationFormMapper(
private val applicationFormMapper: ApplicationFormMapper,
) {
fun toPagedApplicationFormDto(pagedApplicationForm: Page<ApplicationForm>): PagedApplicationFormDto =
toPagedApplicationFormDto(pagedApplicationForm, emptyMap())
fun toPagedApplicationFormDto(
pagedApplicationForm: Page<ApplicationForm>,
commentCountByApplicationFormId: Map<UUID, Long>,
): 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,
propertySize = pagedApplicationForm.size,
numberOfElements = pagedApplicationForm.numberOfElements,

View File

@@ -16,7 +16,7 @@ import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import java.time.Instant
import java.util.UUID
@Entity
@@ -45,5 +45,5 @@ class ApplicationFormVersion(
var createdBy: User,
@CreatedDate
@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.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import java.time.Instant
import java.util.UUID
@Entity
@@ -22,17 +22,17 @@ class Comment(
@Id
@GeneratedValue
var id: UUID? = null,
@Column(nullable = false)
@Column(nullable = false, columnDefinition = "TEXT")
var message: String = "",
@ManyToOne
@JoinColumn(name = "created_by_id", nullable = false)
var createdBy: User,
@CreatedDate
@Column(nullable = false)
var createdAt: LocalDateTime? = null,
var createdAt: Instant? = null,
@LastModifiedDate
@Column(nullable = false)
var modifiedAt: LocalDateTime? = null,
var modifiedAt: Instant? = null,
@ManyToOne
@JoinColumn(name = "application_form_id", nullable = false)
var applicationForm: ApplicationForm? = null,

View File

@@ -1,19 +1,20 @@
package com.betriebsratkanzlei.legalconsenthub.comment
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.CreateCommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedCommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CursorPagedCommentDto
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController
import java.time.Instant
import java.util.UUID
@RestController
class CommentController(
val commentService: CommentService,
val commentMapper: CommentMapper,
val pagedCommentMapper: PagedCommentMapper,
) : CommentApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
@@ -32,12 +33,36 @@ class CommentController(
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getCommentsByApplicationFormId(applicationFormId: UUID): ResponseEntity<PagedCommentDto> =
ResponseEntity.ok(
pagedCommentMapper.toPagedCommentDto(
commentService.getComments(applicationFormId),
override fun getGroupedCommentCountByApplicationFromId(
applicationFormId: UUID,
): ResponseEntity<ApplicationFormCommentCountsDto> {
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(
"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.ok(
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.CreateCommentDto
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.Instant
import java.util.UUID
@Component
@@ -23,8 +23,8 @@ class CommentMapper(
CommentDto(
id = comment.id ?: throw IllegalStateException("Comment ID must not be null!"),
message = comment.message,
createdAt = comment.createdAt ?: LocalDateTime.now(),
modifiedAt = comment.modifiedAt ?: LocalDateTime.now(),
createdAt = comment.createdAt ?: Instant.now(),
modifiedAt = comment.modifiedAt ?: Instant.now(),
createdBy = userMapper.toUserDto(comment.createdBy),
applicationFormId =
comment.applicationForm?.id
@@ -56,6 +56,14 @@ class CommentMapper(
)
}
fun applyUpdate(
existing: Comment,
commentDto: CommentDto,
): Comment {
existing.message = commentDto.message
return existing
}
fun toComment(
applicationFormId: 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.Pageable
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 java.time.Instant
import java.util.UUID
interface ApplicationFormCommentCountProjection {
val applicationFormId: UUID
val commentCount: Long
}
interface FormElementCommentCountProjection {
val formElementId: UUID
val commentCount: Long
}
@Repository
interface CommentRepository : JpaRepository<Comment, UUID> {
fun findAllByApplicationForm(
applicationForm: ApplicationForm,
pageable: Pageable,
): 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_api.model.CommentDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import java.time.Instant
import java.util.UUID
data class CursorCommentPage(
val comments: List<Comment>,
val nextCursorCreatedAt: Instant?,
val hasMore: Boolean,
)
@Service
class CommentService(
private val commentRepository: CommentRepository,
@@ -36,25 +42,55 @@ class CommentService(
fun getCommentById(id: UUID): Comment = commentRepository.findById(id).orElseThrow { CommentNotFoundException(id) }
fun getComments(applicationFormId: UUID): Page<Comment> {
val applicationForm =
applicationFormRepository.findById(applicationFormId).orElse(null)
val pageable = PageRequest.of(0, 10)
return commentRepository.findAllByApplicationForm(applicationForm, pageable)
fun getComments(
applicationFormId: UUID,
formElementId: UUID?,
cursorCreatedAt: Instant?,
limit: Int,
): CursorCommentPage {
applicationFormRepository.findById(applicationFormId).orElse(null)
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 {
// TODO find statt mappen?
val comment = commentMapper.toComment(commentDto)
val updatedComment: Comment
fun getGroupedCommentCountByApplicationFromId(applicationFormId: UUID): Map<UUID, Long> {
applicationFormRepository.findById(applicationFormId).orElse(null)
try {
updatedComment = commentRepository.save(comment)
return commentRepository
.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) {
throw CommentNotUpdatedException(e, commentDto.id)
throw CommentNotUpdatedException(e, id)
}
return updatedComment
}
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 org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import java.time.Instant
import java.util.UUID
@Entity
@@ -42,5 +42,5 @@ class Notification(
var organizationId: String = "",
@CreatedDate
@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.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import java.time.Instant
@Entity
@EntityListeners(AuditingEntityListener::class)
@@ -29,8 +29,8 @@ class User(
var emailOnFormSubmitted: Boolean = true,
@CreatedDate
@Column(nullable = false)
var createdAt: LocalDateTime? = null,
var createdAt: Instant? = null,
@LastModifiedDate
@Column(nullable = false)
var modifiedAt: LocalDateTime? = null,
var modifiedAt: Instant? = null,
)

View File

@@ -1,53 +1,53 @@
create table app_user
(
email_on_form_created boolean not null,
email_on_form_submitted boolean not null,
created_at timestamp(6) not null,
modified_at timestamp(6) not null,
email_on_form_created boolean not null,
email_on_form_submitted boolean not null,
created_at timestamp(6) with time zone not null,
modified_at timestamp(6) with time zone not null,
email varchar(255),
keycloak_id varchar(255) not null,
name varchar(255) not null,
keycloak_id varchar(255) not null,
name varchar(255) not null,
organization_id varchar(255),
primary key (keycloak_id)
);
create table application_form
(
is_template boolean not null,
created_at timestamp(6) not null,
modified_at timestamp(6) not null,
id uuid not null,
created_by_id varchar(255) not null,
last_modified_by_id varchar(255) not null,
name varchar(255) not null,
is_template boolean not null,
created_at timestamp(6) with time zone not null,
modified_at timestamp(6) with time zone not null,
id uuid not null,
created_by_id varchar(255) not null,
last_modified_by_id varchar(255) not null,
name varchar(255) not null,
organization_id varchar(255),
status varchar(255) not null check (status in ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'SIGNED')),
status varchar(255) not null check (status in ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'SIGNED')),
primary key (id)
);
create table application_form_version
(
version_number integer not null,
created_at timestamp(6) not null,
application_form_id uuid not null,
id uuid not null,
created_by_id varchar(255) not null,
name varchar(255) not null,
organization_id varchar(255) not null,
snapshot_data TEXT not null,
status varchar(255) not null check (status in ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'SIGNED')),
version_number integer not null,
created_at timestamp(6) with time zone not null,
application_form_id uuid not null,
id uuid not null,
created_by_id varchar(255) not null,
name varchar(255) not null,
organization_id varchar(255) not null,
snapshot_data TEXT not null,
status varchar(255) not null check (status in ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'SIGNED')),
primary key (id)
);
create table comment
(
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,
message varchar(255) not null,
created_at timestamp(6) with time zone not null,
modified_at timestamp(6) with time zone 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,
message TEXT not null,
primary key (id)
);
@@ -109,16 +109,16 @@ create table form_element_sub_section
create table notification
(
is_read boolean not null,
created_at timestamp(6) not null,
id uuid not null,
click_target varchar(255) not null,
message TEXT not null,
organization_id varchar(255) not null,
is_read boolean not null,
created_at timestamp(6) with time zone not null,
id uuid not null,
click_target varchar(255) not null,
message TEXT not null,
organization_id varchar(255) not null,
recipient_id varchar(255),
target_roles TEXT,
title varchar(255) not null,
type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')),
title varchar(255) not null,
type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')),
primary key (id)
);
@@ -136,9 +136,7 @@ alter table if exists application_form_version
add constraint FKpfri4lhy9wqfsp8esabedkq6c
foreign key (application_form_id)
references application_form
on
delete
cascade;
on delete cascade;
alter table if exists application_form_version
add constraint FKl6fbcrvh439gbwgcvvfyxaggi

View File

@@ -32,35 +32,58 @@
:form-element-id="formElementItem.formElement.id"
:application-form-id="applicationFormId"
:comments="comments?.[formElementItem.formElement.id]"
:total-count="
commentCounts?.[formElementItem.formElement.id] ?? comments?.[formElementItem.formElement.id]?.length ?? 0
"
@close="activeFormElement = ''"
/>
</div>
<div
:class="[
'transition-opacity duration-200',
openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
]"
>
<UDropdownMenu
:items="
getDropdownItems(
formElementItem.formElement,
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
formElementItem.indexInSubsection
)
"
:content="{ align: 'end' }"
@update:open="
(isOpen: boolean) =>
handleDropdownToggle(
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
isOpen
)
"
<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
:class="[
'transition-opacity duration-200',
openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)
? 'opacity-100'
: 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-within:opacity-100'
]"
>
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" />
</UDropdownMenu>
<UDropdownMenu
:items="
getDropdownItems(
formElementItem.formElement,
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
formElementItem.indexInSubsection
)
"
:content="{ align: 'end' }"
@update:open="
(isOpen: boolean) =>
handleDropdownToggle(
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
isOpen
)
"
>
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" />
</UDropdownMenu>
</div>
</div>
</div>
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" />
@@ -89,14 +112,18 @@ const emit = defineEmits<{
}>()
const commentStore = useCommentStore()
const { load: loadComments } = commentStore
const { comments } = storeToRefs(commentStore)
const logger = useLogger().withTag('FormEngine')
const { loadInitial: loadCommentsInitial } = commentStore
const { commentsByApplicationFormId, countsByApplicationFormId } = storeToRefs(commentStore)
if (props.applicationFormId) {
logger.debug('Loading comments for application form:', props.applicationFormId)
await loadComments(props.applicationFormId)
}
const comments = computed(() => {
if (!props.applicationFormId) return {}
return commentsByApplicationFormId.value[props.applicationFormId] ?? {}
})
const commentCounts = computed(() => {
if (!props.applicationFormId) return {}
return countsByApplicationFormId.value[props.applicationFormId] ?? {}
})
const route = useRoute()
const activeFormElement = ref('')
@@ -189,12 +216,15 @@ function updateFormOptions(formOptions: FormOptionDto[], target: VisibleFormElem
emit('update:modelValue', updatedModelValue)
}
function toggleComments(formElementId: string) {
async function toggleComments(formElementId: string) {
if (activeFormElement.value === formElementId) {
activeFormElement.value = ''
return
}
activeFormElement.value = formElementId
if (props.applicationFormId) {
await loadCommentsInitial(props.applicationFormId, formElementId)
}
emit('click:comments', formElementId)
}

View File

@@ -1,67 +1,356 @@
<template>
<template v-if="comments && comments.length > 0">
<UChatMessages :auto-scroll="false" :should-scroll-to-bottom="false">
<UChatMessage
v-for="comment in comments"
:id="comment.id"
:key="comment.id"
:avatar="{ icon: 'i-lucide-bot' }"
:content="comment.message"
role="user"
:parts="[{ type: 'text', text: comment.message }]"
:side="isCommentByUser(comment) ? 'right' : 'left'"
variant="subtle"
:actions="createChatMessageActions(comment)"
>
<template>
<div class="flex flex-col">
<UAvatar icon="i-lucide-bot" />
<p class="text-sm">{{ comment.createdBy.name }}</p>
<div class="mt-4 lg:mt-6">
<UCard variant="subtle" :ui="{ body: 'p-4 sm:p-5', header: 'p-4 sm:p-5', footer: 'p-4 sm:p-5' }">
<template #header>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
{{ $t('comments.title') }}
</p>
<p class="text-xs text-muted">
{{ $t('comments.count', { count: commentCount }) }}
</p>
</div>
</template>
</UChatMessage>
</UChatMessages>
</template>
<UTextarea v-model="commentTextAreaValue" class="w-full" />
<UButton v-if="!isEditingComment" class="my-3 lg:my-4" @click="submitComment(formElementId)">
{{ $t('comments.submit') }}
</UButton>
<UButton v-if="isEditingComment" class="my-3 lg:my-4" @click="updateEditComment"> {{ $t('comments.edit') }} </UButton>
<UButton v-if="isEditingComment" class="my-3 lg:my-4" @click="cancelEditComment"> {{ $t('common.cancel') }} </UButton>
<UButton color="neutral" variant="ghost" size="sm" icon="i-lucide-x" @click="$emit('close')" />
</div>
</template>
<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>
<!-- 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') }}
</UButton>
</div>
</div>
</template>
</UCard>
</div>
</template>
<script setup lang="ts">
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<{
formElementId: string
applicationFormId: string
comments?: CommentDto[]
totalCount?: number
}>()
const commentActions = useCommentTextarea(props.applicationFormId)
const {
submitComment,
updateEditComment,
cancelEditComment,
editComment,
isEditingComment,
isCommentByUser,
commentTextAreaValue
} = commentActions
defineEmits<{
(e: 'close'): void
}>()
function createChatMessageActions(comment: CommentDto) {
const { t: $t } = useI18n()
const chatMessageActions = []
const commentStore = useCommentStore()
const { loadMore } = commentStore
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const toast = useToast()
const { t: $t } = useI18n()
if (isCommentByUser(comment)) {
chatMessageActions.push({
const scrollAreaRef = useTemplateRef('scrollAreaRef')
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'),
icon: 'i-lucide-pencil',
onClick: () => editComment(comment)
})
}
return chatMessageActions
onClick: () => startEditComment(comment)
}
]
}
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>

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 { wrappedFetchWrap } from '~/utils/wrappedFetch'
@@ -22,8 +29,24 @@ export function useCommentApi() {
return commentApiClient.createComment({ applicationFormId, formElementId, createCommentDto })
}
async function getCommentsByApplicationFormId(applicationFormId: string): Promise<PagedCommentDto> {
return commentApiClient.getCommentsByApplicationFormId({ applicationFormId })
async function getCommentsByApplicationFormId(
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> {
@@ -37,6 +60,7 @@ export function useCommentApi() {
return {
createComment,
getCommentsByApplicationFormId,
getGroupedCommentCountByApplicationFromId,
updateComment,
deleteCommentById
}

View File

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

View File

@@ -62,6 +62,18 @@
<p class="text-xs text-muted mt-1">#{{ index }}</p>
</div>
<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
v-if="applicationFormElem.status"
:label="applicationFormElem.status"

View File

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

View File

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

View File

@@ -1,47 +1,80 @@
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 { useLogger } from '../app/composables/useLogger'
type ApplicationFormId = string
type FormElementId = string
export const useCommentStore = defineStore('Comment', () => {
type FormElementId = string
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 comments = ref<Record<FormElementId, CommentDto[]>>({})
const loadedForms = ref(new Set<string>())
const loadedFormElementComments = ref(new Set<string>())
const logger = useLogger().withTag('commentStore')
async function load(applicationFormId: string) {
if (loadedForms.value.has(applicationFormId)) return
const { data, error } = await useAsyncData(`comments:${applicationFormId}`, () =>
commentApi.getCommentsByApplicationFormId(applicationFormId)
)
if (error.value) {
logger.error('Failed loading comments:', error.value)
async function loadInitial(applicationFormId: ApplicationFormId, formElementId: FormElementId) {
initializeCursorState(applicationFormId, formElementId)
const cacheKey = `${applicationFormId}:${formElementId}`
if (loadedFormElementComments.value.has(cacheKey)) return
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
}
comments.value =
data.value?.content.reduce((acc: Record<FormElementId, CommentDto[]>, comment: CommentDto) => {
const formElementId = comment.formElementId
if (!acc[formElementId]) {
acc[formElementId] = []
}
acc[formElementId].push(comment)
return acc
}, {}) || {}
loadedForms.value.add(applicationFormId)
loadedFormElementComments.value.add(cacheKey)
}
async function loadMore(applicationFormId: ApplicationFormId, formElementId: 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)
}
}
async function createComment(
applicationFormId: string,
applicationFormId: ApplicationFormId,
formElementId: string,
createCommentDto: CreateCommentDto
): Promise<CommentDto> {
try {
const newComment = await commentApi.createComment(applicationFormId, formElementId, createCommentDto)
if (!comments.value[formElementId]) {
comments.value[formElementId] = []
const commentsByFormElement = commentsByApplicationFormId.value[applicationFormId] ?? {}
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
} catch (e: unknown) {
if (e instanceof ResponseError) {
@@ -60,14 +93,18 @@ export const useCommentStore = defineStore('Comment', () => {
try {
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) {
const index = formElementComments.findIndex((comment) => comment.id === id)
if (index !== -1 && formElementComments[index]) {
formElementComments[index] = updatedComment
}
}
return updatedComment
} catch (e: unknown) {
if (e instanceof ResponseError) {
@@ -82,13 +119,33 @@ export const useCommentStore = defineStore('Comment', () => {
async function deleteCommentById(id: string): Promise<void> {
try {
await commentApi.deleteCommentById(id)
for (const formElementId in comments.value) {
const formElementComments = comments.value[formElementId]
if (formElementComments) {
const index = formElementComments.findIndex((comment) => comment.id === id)
if (index !== -1) {
formElementComments.splice(index, 1)
break
// 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) {
const index = formElementComments.findIndex((comment) => comment.id === id)
if (index !== -1) {
// Remove the comment from the array
formElementComments.splice(index, 1)
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
}
})