diff --git a/CHANGELOG.md b/CHANGELOG.md index 877dbe0..ccc03e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/legalconsenthub.yml b/api/legalconsenthub.yml index 4390c45..34129c8 100644 --- a/api/legalconsenthub.yml +++ b/api/legalconsenthub.yml @@ -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: diff --git a/legalconsenthub-backend/build.gradle b/legalconsenthub-backend/build.gradle index 4d879f8..d4ec4a9 100644 --- a/legalconsenthub-backend/build.gradle +++ b/legalconsenthub-backend/build.gradle @@ -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) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationForm.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationForm.kt index 1c1be94..525c556 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationForm.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationForm.kt @@ -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, ) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormController.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormController.kt index 4bc9c31..4f52387 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormController.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormController.kt @@ -33,9 +33,12 @@ class ApplicationFormController( ) override fun getAllApplicationForms(organizationId: String?): ResponseEntity = ResponseEntity.ok( - pagedApplicationFormMapper.toPagedApplicationFormDto( - applicationFormService.getApplicationForms(organizationId), - ), + applicationFormService.getApplicationFormsWithCommentCounts(organizationId).let { result -> + pagedApplicationFormMapper.toPagedApplicationFormDto( + result.page, + result.commentCountByApplicationFormId, + ) + }, ) @PreAuthorize( diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt index 02bdb71..fcd5224 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt @@ -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( diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormMapper.kt index 9febeff..fc10bc2 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormMapper.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormMapper.kt @@ -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 { diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt index 0409779..cc3d55a 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormService.kt @@ -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, + val commentCountByApplicationFormId: Map, +) + @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 { - 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, diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/PagedApplicationFormMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/PagedApplicationFormMapper.kt index 9aecb0f..9d4877c 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/PagedApplicationFormMapper.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/PagedApplicationFormMapper.kt @@ -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): PagedApplicationFormDto = + toPagedApplicationFormDto(pagedApplicationForm, emptyMap()) + + fun toPagedApplicationFormDto( + pagedApplicationForm: Page, + commentCountByApplicationFormId: Map, + ): 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, diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersion.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersion.kt index c9ee286..9218d40 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersion.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form_version/ApplicationFormVersion.kt @@ -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, ) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/Comment.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/Comment.kt index 1296688..8b262dc 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/Comment.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/Comment.kt @@ -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, diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentController.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentController.kt index 6d01f0f..28b0805 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentController.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentController.kt @@ -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 = - ResponseEntity.ok( - pagedCommentMapper.toPagedCommentDto( - commentService.getComments(applicationFormId), + override fun getGroupedCommentCountByApplicationFromId( + applicationFormId: UUID, + ): ResponseEntity { + 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 { + 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 = ResponseEntity.ok( commentMapper.toCommentDto( - commentService.updateComment(commentDto), + commentService.updateComment(id, commentDto), ), ) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentMapper.kt index 27f4aaf..5e3ca9c 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentMapper.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentMapper.kt @@ -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, diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentRepository.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentRepository.kt index 2fff9d5..b9eabf0 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentRepository.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentRepository.kt @@ -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 { fun findAllByApplicationForm( applicationForm: ApplicationForm, pageable: Pageable, ): Page + + @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, + ): List + + @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 + + @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 } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentService.kt index 0f896d9..e612c0a 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/CommentService.kt @@ -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, + 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 { - 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 { + 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) { diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/PagedCommentMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/PagedCommentMapper.kt deleted file mode 100644 index 5a1698c..0000000 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/comment/PagedCommentMapper.kt +++ /dev/null @@ -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): 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, - ) -} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt index ae4de71..77d6731 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/Notification.kt @@ -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, ) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt index 6dc461a..daba5c7 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/user/User.kt @@ -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, ) diff --git a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql index c706ebb..ccd78af 100644 --- a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql +++ b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql @@ -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 diff --git a/legalconsenthub/app/components/FormEngine.vue b/legalconsenthub/app/components/FormEngine.vue index afaee12..b5630bf 100644 --- a/legalconsenthub/app/components/FormEngine.vue +++ b/legalconsenthub/app/components/FormEngine.vue @@ -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 = ''" /> -
- +
+ + {{ commentCounts?.[formElementItem.formElement.id] ?? 0 }} + +
+
- - + + + +
@@ -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) } diff --git a/legalconsenthub/app/components/TheComment.vue b/legalconsenthub/app/components/TheComment.vue index 44dbb1f..6c0d0d3 100644 --- a/legalconsenthub/app/components/TheComment.vue +++ b/legalconsenthub/app/components/TheComment.vue @@ -1,67 +1,356 @@ diff --git a/legalconsenthub/app/composables/comment/useCommentApi.ts b/legalconsenthub/app/composables/comment/useCommentApi.ts index 444a090..fc1489b 100644 --- a/legalconsenthub/app/composables/comment/useCommentApi.ts +++ b/legalconsenthub/app/composables/comment/useCommentApi.ts @@ -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 { - return commentApiClient.getCommentsByApplicationFormId({ applicationFormId }) + async function getCommentsByApplicationFormId( + applicationFormId: string, + formElementId?: string, + cursorCreatedAt?: Date, + limit: number = 10 + ): Promise { + return commentApiClient.getCommentsByApplicationFormId({ + applicationFormId, + formElementId, + cursorCreatedAt, + limit + }) + } + + async function getGroupedCommentCountByApplicationFromId( + applicationFormId: string + ): Promise { + return commentApiClient.getGroupedCommentCountByApplicationFromId({ applicationFormId }) } async function updateComment(id: string, commentDto: CommentDto): Promise { @@ -37,6 +60,7 @@ export function useCommentApi() { return { createComment, getCommentsByApplicationFormId, + getGroupedCommentCountByApplicationFromId, updateComment, deleteCommentById } diff --git a/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue b/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue index a67a777..c51e034 100644 --- a/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue +++ b/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue @@ -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() diff --git a/legalconsenthub/app/pages/index.vue b/legalconsenthub/app/pages/index.vue index a6f3ba9..e947426 100644 --- a/legalconsenthub/app/pages/index.vue +++ b/legalconsenthub/app/pages/index.vue @@ -62,6 +62,18 @@

#{{ index }}

+ + + { - type FormElementId = string + const nextCursorByApplicationFormId = ref< + Record< + ApplicationFormId, + Record + > + >({}) + const commentsByApplicationFormId = ref>>({}) + const countsByApplicationFormId = ref>>({}) + const commentApi = useCommentApi() - const comments = ref>({}) - const loadedForms = ref(new Set()) + const loadedFormElementComments = ref(new Set()) 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, 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 { 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 { 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 + } })