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(
applicationFormService.getApplicationFormsWithCommentCounts(organizationId).let { result ->
pagedApplicationFormMapper.toPagedApplicationFormDto(
applicationFormService.getApplicationForms(organizationId),
),
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 =
fun getComments(
applicationFormId: UUID,
formElementId: UUID?,
cursorCreatedAt: Instant?,
limit: Int,
): CursorCommentPage {
applicationFormRepository.findById(applicationFormId).orElse(null)
val pageable = PageRequest.of(0, 10)
return commentRepository.findAllByApplicationForm(applicationForm, pageable)
val pageSize = limit.coerceIn(1, 50)
val fetchSize = pageSize + 1
val comments =
commentRepository.findNextByApplicationFormId(
applicationFormId = applicationFormId,
formElementId = formElementId,
cursorCreatedAt = cursorCreatedAt,
pageable = PageRequest.of(0, fetchSize),
)
val hasMore = comments.size > pageSize
val pageItems = if (hasMore) comments.take(pageSize) else comments
val nextCursor = if (hasMore) pageItems.lastOrNull()?.createdAt else null
return CursorCommentPage(
comments = pageItems,
nextCursorCreatedAt = nextCursor,
hasMore = hasMore,
)
}
fun updateComment(commentDto: CommentDto): Comment {
// 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

@@ -2,8 +2,8 @@ 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,
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,
@@ -14,8 +14,8 @@ create table app_user
create table application_form
(
is_template boolean not null,
created_at timestamp(6) not null,
modified_at timestamp(6) 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,
@@ -28,7 +28,7 @@ create table application_form
create table application_form_version
(
version_number integer not null,
created_at timestamp(6) not null,
created_at timestamp(6) with time zone not null,
application_form_id uuid not null,
id uuid not null,
created_by_id varchar(255) not null,
@@ -41,13 +41,13 @@ create table application_form_version
create table comment
(
created_at timestamp(6) not null,
modified_at timestamp(6) 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 varchar(255) not null,
message TEXT not null,
primary key (id)
);
@@ -110,7 +110,7 @@ create table form_element_sub_section
create table notification
(
is_read boolean not null,
created_at timestamp(6) 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,
@@ -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,14 +32,36 @@
: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="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-0 group-hover:opacity-100'
: 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-within:opacity-100'
]"
>
<UDropdownMenu
@@ -63,6 +85,7 @@
</UDropdownMenu>
</div>
</div>
</div>
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" />
</template>
</template>
@@ -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>
<UButton color="neutral" variant="ghost" size="sm" icon="i-lucide-x" @click="$emit('close')" />
</div>
</template>
</UChatMessage>
</UChatMessages>
<div v-if="comments && comments.length > 0" class="relative">
<UProgress
v-if="isLoadingMore"
indeterminate
size="xs"
class="absolute top-0 inset-x-0 z-10"
:ui="{ base: 'bg-default' }"
/>
<UScrollArea ref="scrollAreaRef" class="max-h-96" :ui="{ viewport: 'space-y-4 pe-2' }">
<div v-for="comment in comments" :key="comment.id" class="space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<UAvatar icon="i-lucide-user" size="2xs" />
<div class="min-w-0">
<p class="text-sm text-highlighted truncate">
{{ comment.createdBy.name }}
</p>
<p class="text-xs text-muted">
{{ comment.createdAt ? formatDate(comment.createdAt) : '-' }}
</p>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<UDropdownMenu
v-if="isCommentByUser(comment)"
:items="[createCommentActions(comment)]"
:content="{ align: 'end' }"
>
<UButton icon="i-lucide-ellipsis" color="neutral" variant="ghost" size="xs" square />
</UDropdownMenu>
</div>
</div>
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
<!-- Edit comment form -->
<template v-if="editingCommentId === comment.id">
<UEditor
v-slot="{ editor }"
v-model="editingCommentEditorContent"
content-type="json"
:editable="true"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'min-h-[120px] p-3 bg-transparent'
}"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-default px-3 py-2 bg-default/50 overflow-x-auto"
/>
</UEditor>
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end p-3 border-t border-default">
<UButton color="neutral" variant="outline" @click="cancelEditComment">
{{ $t('common.cancel') }}
</UButton>
<UButton @click="saveEditedComment(comment)">
{{ $t('comments.saveChanges') }}
</UButton>
</div>
</template>
<UTextarea v-model="commentTextAreaValue" class="w-full" />
<UButton v-if="!isEditingComment" class="my-3 lg:my-4" @click="submitComment(formElementId)">
<!-- Display comment content when not editing -->
<UEditor
v-else
:key="`${comment.id}:${comment.modifiedAt?.toISOString?.() ?? ''}`"
:model-value="getCommentEditorModelValue(comment)"
content-type="json"
:editable="false"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'p-3 bg-transparent'
}"
/>
</div>
</div>
</UScrollArea>
</div>
<UEmpty v-else :title="$t('comments.empty')" icon="i-lucide-message-square" class="py-6" />
<template #footer>
<div class="space-y-3">
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
<UEditor
v-slot="{ editor }"
v-model="newCommentEditorContent"
content-type="json"
:editable="true"
:placeholder="$t('comments.placeholder')"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'min-h-[120px] p-3 bg-transparent'
}"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-default px-3 py-2 bg-transparent overflow-x-auto"
/>
</UEditor>
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end">
<UButton @click="submitComment(formElementId)">
{{ $t('comments.submit') }}
</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>
</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)
})
onClick: () => startEditComment(comment)
}
return chatMessageActions
]
}
function getCommentEditorModelValue(comment: CommentDto): JSONContent {
return toEditorJson(comment.message)
}
function toEditorJson(rawValue: string | undefined): JSONContent {
const raw = (rawValue ?? '').trim()
if (raw) {
try {
if (raw.startsWith('{')) {
return JSON.parse(raw) as JSONContent
}
} catch {
// fall through to plain text
}
return wrapPlainTextAsDoc(raw)
}
return wrapPlainTextAsDoc('')
}
function wrapPlainTextAsDoc(text: string): JSONContent {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: text
? [
{
type: 'text',
text
}
]
: []
}
]
}
}
const toolbarItems = [
[
{ kind: 'undo', icon: 'i-lucide-undo' },
{ kind: 'redo', icon: 'i-lucide-redo' }
],
[
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', label: 'H1' },
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', label: 'H2' },
{ kind: 'heading', level: 3, icon: 'i-lucide-heading-3', label: 'H3' }
],
[
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' },
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough' }
],
[
{ kind: 'bulletList', icon: 'i-lucide-list' },
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' }
],
[
{ kind: 'blockquote', icon: 'i-lucide-quote' },
{ kind: 'codeBlock', icon: 'i-lucide-code' }
],
[{ kind: 'link', icon: 'i-lucide-link' }]
]
</script>

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] = []
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)
}
acc[formElementId].push(comment)
return acc
}, {}) || {}
loadedForms.value.add(applicationFormId)
}
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]
// 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)
break
const commentCountsByFormElement = countsByApplicationFormId.value[applicationFormId]
// Decrement the comment count for the form element
if (commentCountsByFormElement && commentCountsByFormElement[formElementId] != null) {
commentCountsByFormElement[formElementId] = Math.max(
0,
(commentCountsByFormElement[formElementId] ?? 0) - 1
)
}
return
}
}
}
}
@@ -102,5 +159,67 @@ export const useCommentStore = defineStore('Comment', () => {
}
}
return { load, createComment, updateComment, deleteCommentById, comments }
function upsertComments(
applicationFormId: ApplicationFormId,
formElementId: FormElementId,
page: CursorPagedCommentDto,
options: { prepend: boolean }
) {
const applicationFormComments = commentsByApplicationFormId.value[applicationFormId] ?? {}
const formElementComments = applicationFormComments[formElementId] ?? (applicationFormComments[formElementId] = [])
const newComments = page.content.filter((newComment) => !formElementComments.some((c) => c.id === newComment.id))
if (options.prepend) {
// Prepend older comments at the beginning (they come in DESC order, so reverse to maintain chronological order)
formElementComments.unshift(...newComments.reverse())
} else {
// Initial load: comments come in DESC order (newest first), reverse for display (oldest at top, newest at bottom)
formElementComments.push(...newComments.reverse())
}
commentsByApplicationFormId.value[applicationFormId] = applicationFormComments
nextCursorByApplicationFormId.value[applicationFormId]![formElementId] = {
nextCursorCreatedAt: page.nextCursorCreatedAt ?? null,
hasMore: page.hasMore,
isLoading: false
}
}
function initializeCursorState(applicationFormId: ApplicationFormId, formElementId: FormElementId) {
if (!nextCursorByApplicationFormId.value[applicationFormId]) {
nextCursorByApplicationFormId.value[applicationFormId] = {}
}
if (!nextCursorByApplicationFormId.value[applicationFormId]![formElementId]) {
nextCursorByApplicationFormId.value[applicationFormId]![formElementId] = {
nextCursorCreatedAt: null,
hasMore: true,
isLoading: false
}
}
}
async function loadCounts(applicationFormId: ApplicationFormId) {
try {
const result = await commentApi.getGroupedCommentCountByApplicationFromId(applicationFormId)
countsByApplicationFormId.value[applicationFormId] = Object.fromEntries(
Object.entries(result.counts ?? {}).map(([formElementId, count]) => [formElementId, Number(count)])
)
} catch (e) {
logger.error('Failed loading comment counts:', e)
}
}
return {
loadCounts,
loadInitial,
loadMore,
createComment,
updateComment,
deleteCommentById,
commentsByApplicationFormId,
nextCursorByApplicationFormId,
countsByApplicationFormId
}
})