feat(#21): Replaced chat comment component with proper cursor-based commenting
This commit is contained in:
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,6 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
### 2025-11-23 - Pipeline
|
## 2025-12-26 - Date/Time Handling & Comment Pagination
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
- Comment infinite scroll pagination failed: older comments weren't loading
|
||||||
|
- Root cause: Backend used `LocalDateTime` (no timezone info), frontend's JavaScript `Date` interpreted it as local time, then converted to UTC when sending back - causing a 1-hour timezone shift
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
- Migrated all date fields from `LocalDateTime` to `Instant` across all entities (Comment, ApplicationForm, Notification, User, ApplicationFormVersion)
|
||||||
|
- `Instant` represents a point in time in UTC and is:
|
||||||
|
- Supported by JPA's `@CreatedDate`/`@LastModifiedDate` annotations (unlike `OffsetDateTime`)
|
||||||
|
- Serialized by Jackson as ISO-8601 with `Z` suffix (e.g., `2025-12-26T08:13:48.608921Z`)
|
||||||
|
- Correctly parsed by JavaScript as UTC, ensuring round-trip consistency
|
||||||
|
|
||||||
|
## 2025-11-23 - Pipeline
|
||||||
- Gitea pipeline seems not to be stable enough yet. Jobs don't get assigned runners consistently. Especially the backend job.
|
- Gitea pipeline seems not to be stable enough yet. Jobs don't get assigned runners consistently. Especially the backend job.
|
||||||
- Switch to local nektos act runner also led to issues
|
- Switch to local nektos act runner also led to issues
|
||||||
- metadata-action not working properly: https://github.com/docker/metadata-action/issues/542
|
- metadata-action not working properly: https://github.com/docker/metadata-action/issues/542
|
||||||
|
|||||||
@@ -646,13 +646,67 @@ paths:
|
|||||||
operationId: getCommentsByApplicationFormId
|
operationId: getCommentsByApplicationFormId
|
||||||
tags:
|
tags:
|
||||||
- comment
|
- comment
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: formElementId
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: If provided, only comments for this form element are returned.
|
||||||
|
- in: query
|
||||||
|
name: cursorCreatedAt
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Cursor for pagination (createdAt of the last received comment). When omitted, returns the first page.
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
default: 10
|
||||||
|
minimum: 1
|
||||||
|
maximum: 50
|
||||||
|
description: Number of comments to return.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Get comments for application form ID
|
description: Get comments for application form ID
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/PagedCommentDto"
|
$ref: "#/components/schemas/CursorPagedCommentDto"
|
||||||
|
"400":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
||||||
|
"500":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||||
|
"503":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
|
||||||
|
|
||||||
|
/application-forms/{applicationFormId}/comments/counts:
|
||||||
|
parameters:
|
||||||
|
- name: applicationFormId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
get:
|
||||||
|
summary: Get comment counts grouped by form element for an application form
|
||||||
|
operationId: getGroupedCommentCountByApplicationFromId
|
||||||
|
tags:
|
||||||
|
- comment
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Map of formElementId to comment count
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ApplicationFormCommentCountsDto"
|
||||||
"400":
|
"400":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
||||||
"401":
|
"401":
|
||||||
@@ -1132,6 +1186,12 @@ components:
|
|||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/components/schemas/ApplicationFormStatus"
|
- $ref: "#/components/schemas/ApplicationFormStatus"
|
||||||
nullable: true
|
nullable: true
|
||||||
|
commentCount:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
nullable: true
|
||||||
|
readOnly: true
|
||||||
|
description: Total number of comments associated with this application form.
|
||||||
|
|
||||||
PagedApplicationFormDto:
|
PagedApplicationFormDto:
|
||||||
type: object
|
type: object
|
||||||
@@ -1571,17 +1631,36 @@ components:
|
|||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
PagedCommentDto:
|
CursorPagedCommentDto:
|
||||||
type: object
|
type: object
|
||||||
allOf:
|
|
||||||
- $ref: "#/components/schemas/Page"
|
|
||||||
required:
|
required:
|
||||||
- content
|
- content
|
||||||
|
- hasMore
|
||||||
properties:
|
properties:
|
||||||
content:
|
content:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/CommentDto"
|
$ref: "#/components/schemas/CommentDto"
|
||||||
|
nextCursorCreatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: Cursor to fetch the next page (createdAt of the last item in this page). Null when no more pages.
|
||||||
|
hasMore:
|
||||||
|
type: boolean
|
||||||
|
description: Whether more comments exist after this page.
|
||||||
|
|
||||||
|
ApplicationFormCommentCountsDto:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- counts
|
||||||
|
properties:
|
||||||
|
counts:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: Keys are formElementId (UUID), values are comment counts.
|
||||||
|
|
||||||
####### RoleDto #######
|
####### RoleDto #######
|
||||||
RoleDto:
|
RoleDto:
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ task generate_legalconsenthub_server(type: org.openapitools.generator.gradle.plu
|
|||||||
enumPropertyNaming: 'original',
|
enumPropertyNaming: 'original',
|
||||||
interfaceOnly : 'true',
|
interfaceOnly : 'true',
|
||||||
useSpringBoot3 : 'true']
|
useSpringBoot3 : 'true']
|
||||||
typeMappings = [DateTime: "LocalDateTime"]
|
typeMappings = [DateTime: "Instant"]
|
||||||
importMappings = [LocalDateTime: "java.time.LocalDateTime"]
|
importMappings = [Instant: "java.time.Instant"]
|
||||||
}
|
}
|
||||||
|
|
||||||
compileKotlin.dependsOn(tasks.generate_legalconsenthub_server)
|
compileKotlin.dependsOn(tasks.generate_legalconsenthub_server)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import jakarta.persistence.OneToMany
|
|||||||
import org.springframework.data.annotation.CreatedDate
|
import org.springframework.data.annotation.CreatedDate
|
||||||
import org.springframework.data.annotation.LastModifiedDate
|
import org.springframework.data.annotation.LastModifiedDate
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import java.time.LocalDateTime
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -47,8 +47,8 @@ class ApplicationForm(
|
|||||||
var lastModifiedBy: User,
|
var lastModifiedBy: User,
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var createdAt: LocalDateTime? = null,
|
var createdAt: Instant? = null,
|
||||||
@LastModifiedDate
|
@LastModifiedDate
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var modifiedAt: LocalDateTime? = null,
|
var modifiedAt: Instant? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ class ApplicationFormController(
|
|||||||
)
|
)
|
||||||
override fun getAllApplicationForms(organizationId: String?): ResponseEntity<PagedApplicationFormDto> =
|
override fun getAllApplicationForms(organizationId: String?): ResponseEntity<PagedApplicationFormDto> =
|
||||||
ResponseEntity.ok(
|
ResponseEntity.ok(
|
||||||
pagedApplicationFormMapper.toPagedApplicationFormDto(
|
applicationFormService.getApplicationFormsWithCommentCounts(organizationId).let { result ->
|
||||||
applicationFormService.getApplicationForms(organizationId),
|
pagedApplicationFormMapper.toPagedApplicationFormDto(
|
||||||
),
|
result.page,
|
||||||
|
result.commentCountByApplicationFormId,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@PreAuthorize(
|
@PreAuthorize(
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSubSectionSna
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.thymeleaf.TemplateEngine
|
import org.thymeleaf.TemplateEngine
|
||||||
import org.thymeleaf.context.Context
|
import org.thymeleaf.context.Context
|
||||||
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
|
||||||
@@ -39,7 +40,7 @@ class ApplicationFormFormatService(
|
|||||||
|
|
||||||
fun generatePdf(
|
fun generatePdf(
|
||||||
snapshot: ApplicationFormSnapshotDto,
|
snapshot: ApplicationFormSnapshotDto,
|
||||||
createdAt: LocalDateTime?,
|
createdAt: Instant?,
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
val latexContent = generateLatex(snapshot, createdAt)
|
val latexContent = generateLatex(snapshot, createdAt)
|
||||||
return pdfRenderer.render(latexContent)
|
return pdfRenderer.render(latexContent)
|
||||||
@@ -58,7 +59,7 @@ class ApplicationFormFormatService(
|
|||||||
|
|
||||||
fun generateLatex(
|
fun generateLatex(
|
||||||
snapshot: ApplicationFormSnapshotDto,
|
snapshot: ApplicationFormSnapshotDto,
|
||||||
createdAt: LocalDateTime?,
|
createdAt: Instant?,
|
||||||
): String {
|
): String {
|
||||||
val filteredSnapshot = filterVisibleElements(snapshot)
|
val filteredSnapshot = filterVisibleElements(snapshot)
|
||||||
val exportModel = buildLatexExportModel(filteredSnapshot, createdAt)
|
val exportModel = buildLatexExportModel(filteredSnapshot, createdAt)
|
||||||
@@ -88,7 +89,7 @@ class ApplicationFormFormatService(
|
|||||||
organizationId = LatexEscaper.escape(applicationForm.organizationId),
|
organizationId = LatexEscaper.escape(applicationForm.organizationId),
|
||||||
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${applicationForm.organizationId}"),
|
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${applicationForm.organizationId}"),
|
||||||
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${applicationForm.organizationId}"),
|
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${applicationForm.organizationId}"),
|
||||||
createdAt = applicationForm.createdAt?.format(dateFormatter) ?: "",
|
createdAt = applicationForm.createdAt?.atZone(ZoneId.of("Europe/Berlin"))?.format(dateFormatter) ?: "",
|
||||||
sections =
|
sections =
|
||||||
applicationForm.formElementSections.map { section ->
|
applicationForm.formElementSections.map { section ->
|
||||||
LatexSection(
|
LatexSection(
|
||||||
@@ -116,7 +117,7 @@ class ApplicationFormFormatService(
|
|||||||
|
|
||||||
private fun buildLatexExportModel(
|
private fun buildLatexExportModel(
|
||||||
snapshot: ApplicationFormSnapshotDto,
|
snapshot: ApplicationFormSnapshotDto,
|
||||||
createdAt: LocalDateTime?,
|
createdAt: Instant?,
|
||||||
): LatexExportModel {
|
): LatexExportModel {
|
||||||
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ class ApplicationFormFormatService(
|
|||||||
organizationId = LatexEscaper.escape(snapshot.organizationId),
|
organizationId = LatexEscaper.escape(snapshot.organizationId),
|
||||||
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${snapshot.organizationId}"),
|
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${snapshot.organizationId}"),
|
||||||
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${snapshot.organizationId}"),
|
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${snapshot.organizationId}"),
|
||||||
createdAt = createdAt?.format(dateFormatter) ?: "",
|
createdAt = createdAt?.atZone(ZoneId.of("Europe/Berlin"))?.format(dateFormatter) ?: "",
|
||||||
sections =
|
sections =
|
||||||
snapshot.sections.map { section ->
|
snapshot.sections.map { section ->
|
||||||
LatexSection(
|
LatexSection(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
|
|||||||
import com.betriebsratkanzlei.legalconsenthub.user.UserService
|
import com.betriebsratkanzlei.legalconsenthub.user.UserService
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import java.time.LocalDateTime
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -16,6 +16,12 @@ class ApplicationFormMapper(
|
|||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
) {
|
) {
|
||||||
fun toApplicationFormDto(applicationForm: ApplicationForm): ApplicationFormDto =
|
fun toApplicationFormDto(applicationForm: ApplicationForm): ApplicationFormDto =
|
||||||
|
toApplicationFormDto(applicationForm, null)
|
||||||
|
|
||||||
|
fun toApplicationFormDto(
|
||||||
|
applicationForm: ApplicationForm,
|
||||||
|
commentCount: Long?,
|
||||||
|
): ApplicationFormDto =
|
||||||
ApplicationFormDto(
|
ApplicationFormDto(
|
||||||
id = applicationForm.id,
|
id = applicationForm.id,
|
||||||
name = applicationForm.name,
|
name = applicationForm.name,
|
||||||
@@ -30,9 +36,10 @@ class ApplicationFormMapper(
|
|||||||
organizationId = applicationForm.organizationId,
|
organizationId = applicationForm.organizationId,
|
||||||
createdBy = userMapper.toUserDto(applicationForm.createdBy),
|
createdBy = userMapper.toUserDto(applicationForm.createdBy),
|
||||||
lastModifiedBy = userMapper.toUserDto(applicationForm.lastModifiedBy),
|
lastModifiedBy = userMapper.toUserDto(applicationForm.lastModifiedBy),
|
||||||
createdAt = applicationForm.createdAt ?: LocalDateTime.now(),
|
createdAt = applicationForm.createdAt ?: Instant.now(),
|
||||||
modifiedAt = applicationForm.modifiedAt ?: LocalDateTime.now(),
|
modifiedAt = applicationForm.modifiedAt ?: Instant.now(),
|
||||||
status = applicationForm.status,
|
status = applicationForm.status,
|
||||||
|
commentCount = commentCount,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toNewApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
|
fun toNewApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.betriebsratkanzlei.legalconsenthub.application_form
|
package com.betriebsratkanzlei.legalconsenthub.application_form
|
||||||
|
|
||||||
import com.betriebsratkanzlei.legalconsenthub.application_form_version.ApplicationFormVersionService
|
import com.betriebsratkanzlei.legalconsenthub.application_form_version.ApplicationFormVersionService
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.comment.CommentRepository
|
||||||
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormCreatedEvent
|
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormCreatedEvent
|
||||||
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormSubmittedEvent
|
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormSubmittedEvent
|
||||||
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException
|
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException
|
||||||
@@ -22,6 +23,11 @@ import org.springframework.data.domain.PageRequest
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class ApplicationFormPageWithCommentCounts(
|
||||||
|
val page: Page<ApplicationForm>,
|
||||||
|
val commentCountByApplicationFormId: Map<UUID, Long>,
|
||||||
|
)
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ApplicationFormService(
|
class ApplicationFormService(
|
||||||
private val applicationFormRepository: ApplicationFormRepository,
|
private val applicationFormRepository: ApplicationFormRepository,
|
||||||
@@ -31,6 +37,7 @@ class ApplicationFormService(
|
|||||||
private val versionService: ApplicationFormVersionService,
|
private val versionService: ApplicationFormVersionService,
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
private val eventPublisher: ApplicationEventPublisher,
|
private val eventPublisher: ApplicationEventPublisher,
|
||||||
|
private val commentRepository: CommentRepository,
|
||||||
) {
|
) {
|
||||||
fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
|
fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
|
||||||
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
|
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
|
||||||
@@ -62,10 +69,26 @@ class ApplicationFormService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getApplicationForms(organizationId: String?): Page<ApplicationForm> {
|
fun getApplicationForms(organizationId: String?): Page<ApplicationForm> {
|
||||||
val pageable = PageRequest.of(0, 10)
|
val pageable = PageRequest.of(0, 100)
|
||||||
return applicationFormRepository.findAllByIsTemplateFalseAndOrganizationId(organizationId, pageable)
|
return applicationFormRepository.findAllByIsTemplateFalseAndOrganizationId(organizationId, pageable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getApplicationFormsWithCommentCounts(organizationId: String?): ApplicationFormPageWithCommentCounts {
|
||||||
|
val page = getApplicationForms(organizationId)
|
||||||
|
val applicationFormIds = page.content.mapNotNull { it.id }
|
||||||
|
|
||||||
|
if (applicationFormIds.isEmpty()) {
|
||||||
|
return ApplicationFormPageWithCommentCounts(page, emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
val counts =
|
||||||
|
commentRepository.countByApplicationFormIds(applicationFormIds).associate { projection ->
|
||||||
|
projection.applicationFormId to projection.commentCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplicationFormPageWithCommentCounts(page, counts)
|
||||||
|
}
|
||||||
|
|
||||||
fun updateApplicationForm(
|
fun updateApplicationForm(
|
||||||
id: UUID,
|
id: UUID,
|
||||||
applicationFormDto: ApplicationFormDto,
|
applicationFormDto: ApplicationFormDto,
|
||||||
|
|||||||
@@ -3,14 +3,25 @@ package com.betriebsratkanzlei.legalconsenthub.application_form
|
|||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto
|
||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class PagedApplicationFormMapper(
|
class PagedApplicationFormMapper(
|
||||||
private val applicationFormMapper: ApplicationFormMapper,
|
private val applicationFormMapper: ApplicationFormMapper,
|
||||||
) {
|
) {
|
||||||
fun toPagedApplicationFormDto(pagedApplicationForm: Page<ApplicationForm>): PagedApplicationFormDto =
|
fun toPagedApplicationFormDto(pagedApplicationForm: Page<ApplicationForm>): PagedApplicationFormDto =
|
||||||
|
toPagedApplicationFormDto(pagedApplicationForm, emptyMap())
|
||||||
|
|
||||||
|
fun toPagedApplicationFormDto(
|
||||||
|
pagedApplicationForm: Page<ApplicationForm>,
|
||||||
|
commentCountByApplicationFormId: Map<UUID, Long>,
|
||||||
|
): PagedApplicationFormDto =
|
||||||
PagedApplicationFormDto(
|
PagedApplicationFormDto(
|
||||||
content = pagedApplicationForm.content.map { applicationFormMapper.toApplicationFormDto(it) },
|
content =
|
||||||
|
pagedApplicationForm.content.map { applicationForm ->
|
||||||
|
val count = applicationForm.id?.let { commentCountByApplicationFormId[it] } ?: 0L
|
||||||
|
applicationFormMapper.toApplicationFormDto(applicationForm, count)
|
||||||
|
},
|
||||||
number = pagedApplicationForm.number,
|
number = pagedApplicationForm.number,
|
||||||
propertySize = pagedApplicationForm.size,
|
propertySize = pagedApplicationForm.size,
|
||||||
numberOfElements = pagedApplicationForm.numberOfElements,
|
numberOfElements = pagedApplicationForm.numberOfElements,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import org.hibernate.annotations.OnDelete
|
|||||||
import org.hibernate.annotations.OnDeleteAction
|
import org.hibernate.annotations.OnDeleteAction
|
||||||
import org.springframework.data.annotation.CreatedDate
|
import org.springframework.data.annotation.CreatedDate
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import java.time.LocalDateTime
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -45,5 +45,5 @@ class ApplicationFormVersion(
|
|||||||
var createdBy: User,
|
var createdBy: User,
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var createdAt: LocalDateTime? = null,
|
var createdAt: Instant? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import jakarta.persistence.ManyToOne
|
|||||||
import org.springframework.data.annotation.CreatedDate
|
import org.springframework.data.annotation.CreatedDate
|
||||||
import org.springframework.data.annotation.LastModifiedDate
|
import org.springframework.data.annotation.LastModifiedDate
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import java.time.LocalDateTime
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -22,17 +22,17 @@ class Comment(
|
|||||||
@Id
|
@Id
|
||||||
@GeneratedValue
|
@GeneratedValue
|
||||||
var id: UUID? = null,
|
var id: UUID? = null,
|
||||||
@Column(nullable = false)
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
var message: String = "",
|
var message: String = "",
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "created_by_id", nullable = false)
|
@JoinColumn(name = "created_by_id", nullable = false)
|
||||||
var createdBy: User,
|
var createdBy: User,
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var createdAt: LocalDateTime? = null,
|
var createdAt: Instant? = null,
|
||||||
@LastModifiedDate
|
@LastModifiedDate
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var modifiedAt: LocalDateTime? = null,
|
var modifiedAt: Instant? = null,
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "application_form_id", nullable = false)
|
@JoinColumn(name = "application_form_id", nullable = false)
|
||||||
var applicationForm: ApplicationForm? = null,
|
var applicationForm: ApplicationForm? = null,
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
package com.betriebsratkanzlei.legalconsenthub.comment
|
package com.betriebsratkanzlei.legalconsenthub.comment
|
||||||
|
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.api.CommentApi
|
import com.betriebsratkanzlei.legalconsenthub_api.api.CommentApi
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormCommentCountsDto
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedCommentDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.CursorPagedCommentDto
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
class CommentController(
|
class CommentController(
|
||||||
val commentService: CommentService,
|
val commentService: CommentService,
|
||||||
val commentMapper: CommentMapper,
|
val commentMapper: CommentMapper,
|
||||||
val pagedCommentMapper: PagedCommentMapper,
|
|
||||||
) : CommentApi {
|
) : CommentApi {
|
||||||
@PreAuthorize(
|
@PreAuthorize(
|
||||||
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
||||||
@@ -32,12 +33,36 @@ class CommentController(
|
|||||||
@PreAuthorize(
|
@PreAuthorize(
|
||||||
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
||||||
)
|
)
|
||||||
override fun getCommentsByApplicationFormId(applicationFormId: UUID): ResponseEntity<PagedCommentDto> =
|
override fun getGroupedCommentCountByApplicationFromId(
|
||||||
ResponseEntity.ok(
|
applicationFormId: UUID,
|
||||||
pagedCommentMapper.toPagedCommentDto(
|
): ResponseEntity<ApplicationFormCommentCountsDto> {
|
||||||
commentService.getComments(applicationFormId),
|
val counts = commentService.getGroupedCommentCountByApplicationFromId(applicationFormId)
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
ApplicationFormCommentCountsDto(
|
||||||
|
counts = counts.mapKeys { it.key.toString() },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize(
|
||||||
|
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
||||||
|
)
|
||||||
|
override fun getCommentsByApplicationFormId(
|
||||||
|
applicationFormId: UUID,
|
||||||
|
formElementId: UUID?,
|
||||||
|
cursorCreatedAt: Instant?,
|
||||||
|
limit: Int,
|
||||||
|
): ResponseEntity<CursorPagedCommentDto> {
|
||||||
|
val page = commentService.getComments(applicationFormId, formElementId, cursorCreatedAt, limit)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
CursorPagedCommentDto(
|
||||||
|
content = page.comments.map { commentMapper.toCommentDto(it) },
|
||||||
|
nextCursorCreatedAt = page.nextCursorCreatedAt,
|
||||||
|
hasMore = page.hasMore,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@PreAuthorize(
|
@PreAuthorize(
|
||||||
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
||||||
@@ -48,7 +73,7 @@ class CommentController(
|
|||||||
): ResponseEntity<CommentDto> =
|
): ResponseEntity<CommentDto> =
|
||||||
ResponseEntity.ok(
|
ResponseEntity.ok(
|
||||||
commentMapper.toCommentDto(
|
commentMapper.toCommentDto(
|
||||||
commentService.updateComment(commentDto),
|
commentService.updateComment(id, commentDto),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import com.betriebsratkanzlei.legalconsenthub.user.UserService
|
|||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import java.time.LocalDateTime
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -23,8 +23,8 @@ class CommentMapper(
|
|||||||
CommentDto(
|
CommentDto(
|
||||||
id = comment.id ?: throw IllegalStateException("Comment ID must not be null!"),
|
id = comment.id ?: throw IllegalStateException("Comment ID must not be null!"),
|
||||||
message = comment.message,
|
message = comment.message,
|
||||||
createdAt = comment.createdAt ?: LocalDateTime.now(),
|
createdAt = comment.createdAt ?: Instant.now(),
|
||||||
modifiedAt = comment.modifiedAt ?: LocalDateTime.now(),
|
modifiedAt = comment.modifiedAt ?: Instant.now(),
|
||||||
createdBy = userMapper.toUserDto(comment.createdBy),
|
createdBy = userMapper.toUserDto(comment.createdBy),
|
||||||
applicationFormId =
|
applicationFormId =
|
||||||
comment.applicationForm?.id
|
comment.applicationForm?.id
|
||||||
@@ -56,6 +56,14 @@ class CommentMapper(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun applyUpdate(
|
||||||
|
existing: Comment,
|
||||||
|
commentDto: CommentDto,
|
||||||
|
): Comment {
|
||||||
|
existing.message = commentDto.message
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
fun toComment(
|
fun toComment(
|
||||||
applicationFormId: UUID,
|
applicationFormId: UUID,
|
||||||
formElementId: UUID,
|
formElementId: UUID,
|
||||||
|
|||||||
@@ -4,13 +4,68 @@ import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
|
|||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface ApplicationFormCommentCountProjection {
|
||||||
|
val applicationFormId: UUID
|
||||||
|
val commentCount: Long
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormElementCommentCountProjection {
|
||||||
|
val formElementId: UUID
|
||||||
|
val commentCount: Long
|
||||||
|
}
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface CommentRepository : JpaRepository<Comment, UUID> {
|
interface CommentRepository : JpaRepository<Comment, UUID> {
|
||||||
fun findAllByApplicationForm(
|
fun findAllByApplicationForm(
|
||||||
applicationForm: ApplicationForm,
|
applicationForm: ApplicationForm,
|
||||||
pageable: Pageable,
|
pageable: Pageable,
|
||||||
): Page<Comment>
|
): Page<Comment>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select c.applicationForm.id as applicationFormId, count(c.id) as commentCount
|
||||||
|
from Comment c
|
||||||
|
where c.applicationForm.id in :applicationFormIds
|
||||||
|
group by c.applicationForm.id
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
fun countByApplicationFormIds(
|
||||||
|
@Param("applicationFormIds") applicationFormIds: Collection<UUID>,
|
||||||
|
): List<ApplicationFormCommentCountProjection>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select c.*
|
||||||
|
from comment c
|
||||||
|
where c.application_form_id = :applicationFormId
|
||||||
|
and (cast(:formElementId as uuid) is null or c.form_element_id = :formElementId)
|
||||||
|
and (cast(:cursorCreatedAt as timestamp) is null or c.created_at < :cursorCreatedAt)
|
||||||
|
order by c.created_at desc, c.id desc
|
||||||
|
""",
|
||||||
|
nativeQuery = true,
|
||||||
|
)
|
||||||
|
fun findNextByApplicationFormId(
|
||||||
|
@Param("applicationFormId") applicationFormId: UUID,
|
||||||
|
@Param("formElementId") formElementId: UUID?,
|
||||||
|
@Param("cursorCreatedAt") cursorCreatedAt: Instant?,
|
||||||
|
pageable: Pageable,
|
||||||
|
): List<Comment>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select c.formElement.id as formElementId, count(c.id) as commentCount
|
||||||
|
from Comment c
|
||||||
|
where c.applicationForm.id = :applicationFormId
|
||||||
|
group by c.formElement.id
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
fun countByApplicationFormIdGroupByFormElementId(
|
||||||
|
@Param("applicationFormId") applicationFormId: UUID,
|
||||||
|
): List<FormElementCommentCountProjection>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import com.betriebsratkanzlei.legalconsenthub.error.CommentNotFoundException
|
|||||||
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotUpdatedException
|
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotUpdatedException
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
|
||||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
|
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
|
||||||
import org.springframework.data.domain.Page
|
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class CursorCommentPage(
|
||||||
|
val comments: List<Comment>,
|
||||||
|
val nextCursorCreatedAt: Instant?,
|
||||||
|
val hasMore: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class CommentService(
|
class CommentService(
|
||||||
private val commentRepository: CommentRepository,
|
private val commentRepository: CommentRepository,
|
||||||
@@ -36,25 +42,55 @@ class CommentService(
|
|||||||
|
|
||||||
fun getCommentById(id: UUID): Comment = commentRepository.findById(id).orElseThrow { CommentNotFoundException(id) }
|
fun getCommentById(id: UUID): Comment = commentRepository.findById(id).orElseThrow { CommentNotFoundException(id) }
|
||||||
|
|
||||||
fun getComments(applicationFormId: UUID): Page<Comment> {
|
fun getComments(
|
||||||
val applicationForm =
|
applicationFormId: UUID,
|
||||||
applicationFormRepository.findById(applicationFormId).orElse(null)
|
formElementId: UUID?,
|
||||||
val pageable = PageRequest.of(0, 10)
|
cursorCreatedAt: Instant?,
|
||||||
return commentRepository.findAllByApplicationForm(applicationForm, pageable)
|
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 {
|
fun getGroupedCommentCountByApplicationFromId(applicationFormId: UUID): Map<UUID, Long> {
|
||||||
// TODO find statt mappen?
|
applicationFormRepository.findById(applicationFormId).orElse(null)
|
||||||
val comment = commentMapper.toComment(commentDto)
|
|
||||||
val updatedComment: Comment
|
|
||||||
|
|
||||||
try {
|
return commentRepository
|
||||||
updatedComment = commentRepository.save(comment)
|
.countByApplicationFormIdGroupByFormElementId(applicationFormId)
|
||||||
|
.associate { it.formElementId to it.commentCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateComment(
|
||||||
|
id: UUID,
|
||||||
|
commentDto: CommentDto,
|
||||||
|
): Comment {
|
||||||
|
val existing = getCommentById(id)
|
||||||
|
val updated = commentMapper.applyUpdate(existing, commentDto)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
commentRepository.save(updated)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw CommentNotUpdatedException(e, commentDto.id)
|
throw CommentNotUpdatedException(e, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedComment
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteCommentByID(id: UUID) {
|
fun deleteCommentByID(id: UUID) {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ import jakarta.persistence.JoinColumn
|
|||||||
import jakarta.persistence.ManyToOne
|
import jakarta.persistence.ManyToOne
|
||||||
import org.springframework.data.annotation.CreatedDate
|
import org.springframework.data.annotation.CreatedDate
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import java.time.LocalDateTime
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -42,5 +42,5 @@ class Notification(
|
|||||||
var organizationId: String = "",
|
var organizationId: String = "",
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var createdAt: LocalDateTime? = null,
|
var createdAt: Instant? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import jakarta.persistence.Table
|
|||||||
import org.springframework.data.annotation.CreatedDate
|
import org.springframework.data.annotation.CreatedDate
|
||||||
import org.springframework.data.annotation.LastModifiedDate
|
import org.springframework.data.annotation.LastModifiedDate
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import java.time.LocalDateTime
|
import java.time.Instant
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@EntityListeners(AuditingEntityListener::class)
|
@EntityListeners(AuditingEntityListener::class)
|
||||||
@@ -29,8 +29,8 @@ class User(
|
|||||||
var emailOnFormSubmitted: Boolean = true,
|
var emailOnFormSubmitted: Boolean = true,
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var createdAt: LocalDateTime? = null,
|
var createdAt: Instant? = null,
|
||||||
@LastModifiedDate
|
@LastModifiedDate
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var modifiedAt: LocalDateTime? = null,
|
var modifiedAt: Instant? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
create table app_user
|
create table app_user
|
||||||
(
|
(
|
||||||
email_on_form_created boolean not null,
|
email_on_form_created boolean not null,
|
||||||
email_on_form_submitted boolean not null,
|
email_on_form_submitted boolean not null,
|
||||||
created_at timestamp(6) not null,
|
created_at timestamp(6) with time zone not null,
|
||||||
modified_at timestamp(6) not null,
|
modified_at timestamp(6) with time zone not null,
|
||||||
email varchar(255),
|
email varchar(255),
|
||||||
keycloak_id varchar(255) not null,
|
keycloak_id varchar(255) not null,
|
||||||
name varchar(255) not null,
|
name varchar(255) not null,
|
||||||
organization_id varchar(255),
|
organization_id varchar(255),
|
||||||
primary key (keycloak_id)
|
primary key (keycloak_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
create table application_form
|
create table application_form
|
||||||
(
|
(
|
||||||
is_template boolean not null,
|
is_template boolean not null,
|
||||||
created_at timestamp(6) not null,
|
created_at timestamp(6) with time zone not null,
|
||||||
modified_at timestamp(6) not null,
|
modified_at timestamp(6) with time zone not null,
|
||||||
id uuid not null,
|
id uuid not null,
|
||||||
created_by_id varchar(255) not null,
|
created_by_id varchar(255) not null,
|
||||||
last_modified_by_id varchar(255) not null,
|
last_modified_by_id varchar(255) not null,
|
||||||
name varchar(255) not null,
|
name varchar(255) not null,
|
||||||
organization_id varchar(255),
|
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)
|
primary key (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
create table application_form_version
|
create table application_form_version
|
||||||
(
|
(
|
||||||
version_number integer not null,
|
version_number integer not null,
|
||||||
created_at timestamp(6) not null,
|
created_at timestamp(6) with time zone not null,
|
||||||
application_form_id uuid not null,
|
application_form_id uuid not null,
|
||||||
id uuid not null,
|
id uuid not null,
|
||||||
created_by_id varchar(255) not null,
|
created_by_id varchar(255) not null,
|
||||||
name varchar(255) not null,
|
name varchar(255) not null,
|
||||||
organization_id varchar(255) not null,
|
organization_id varchar(255) not null,
|
||||||
snapshot_data TEXT not null,
|
snapshot_data TEXT not null,
|
||||||
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)
|
primary key (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
create table comment
|
create table comment
|
||||||
(
|
(
|
||||||
created_at timestamp(6) not null,
|
created_at timestamp(6) with time zone not null,
|
||||||
modified_at timestamp(6) not null,
|
modified_at timestamp(6) with time zone not null,
|
||||||
application_form_id uuid not null,
|
application_form_id uuid not null,
|
||||||
form_element_id uuid not null,
|
form_element_id uuid not null,
|
||||||
id uuid not null,
|
id uuid not null,
|
||||||
created_by_id varchar(255) not null,
|
created_by_id varchar(255) not null,
|
||||||
message varchar(255) not null,
|
message TEXT not null,
|
||||||
primary key (id)
|
primary key (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -109,16 +109,16 @@ create table form_element_sub_section
|
|||||||
|
|
||||||
create table notification
|
create table notification
|
||||||
(
|
(
|
||||||
is_read boolean not null,
|
is_read boolean not null,
|
||||||
created_at timestamp(6) not null,
|
created_at timestamp(6) with time zone not null,
|
||||||
id uuid not null,
|
id uuid not null,
|
||||||
click_target varchar(255) not null,
|
click_target varchar(255) not null,
|
||||||
message TEXT not null,
|
message TEXT not null,
|
||||||
organization_id varchar(255) not null,
|
organization_id varchar(255) not null,
|
||||||
recipient_id varchar(255),
|
recipient_id varchar(255),
|
||||||
target_roles TEXT,
|
target_roles TEXT,
|
||||||
title varchar(255) not null,
|
title varchar(255) not null,
|
||||||
type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')),
|
type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')),
|
||||||
primary key (id)
|
primary key (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -136,9 +136,7 @@ alter table if exists application_form_version
|
|||||||
add constraint FKpfri4lhy9wqfsp8esabedkq6c
|
add constraint FKpfri4lhy9wqfsp8esabedkq6c
|
||||||
foreign key (application_form_id)
|
foreign key (application_form_id)
|
||||||
references application_form
|
references application_form
|
||||||
on
|
on delete cascade;
|
||||||
delete
|
|
||||||
cascade;
|
|
||||||
|
|
||||||
alter table if exists application_form_version
|
alter table if exists application_form_version
|
||||||
add constraint FKl6fbcrvh439gbwgcvvfyxaggi
|
add constraint FKl6fbcrvh439gbwgcvvfyxaggi
|
||||||
|
|||||||
@@ -32,35 +32,58 @@
|
|||||||
:form-element-id="formElementItem.formElement.id"
|
:form-element-id="formElementItem.formElement.id"
|
||||||
:application-form-id="applicationFormId"
|
:application-form-id="applicationFormId"
|
||||||
:comments="comments?.[formElementItem.formElement.id]"
|
:comments="comments?.[formElementItem.formElement.id]"
|
||||||
|
:total-count="
|
||||||
|
commentCounts?.[formElementItem.formElement.id] ?? comments?.[formElementItem.formElement.id]?.length ?? 0
|
||||||
|
"
|
||||||
|
@close="activeFormElement = ''"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex items-start gap-1">
|
||||||
:class="[
|
<div class="min-w-9">
|
||||||
'transition-opacity duration-200',
|
<UButton
|
||||||
openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)
|
v-if="
|
||||||
? 'opacity-100'
|
applicationFormId &&
|
||||||
: 'opacity-0 group-hover:opacity-100'
|
formElementItem.formElement.id &&
|
||||||
]"
|
(commentCounts?.[formElementItem.formElement.id] ?? 0) > 0
|
||||||
>
|
"
|
||||||
<UDropdownMenu
|
color="neutral"
|
||||||
:items="
|
variant="soft"
|
||||||
getDropdownItems(
|
size="xs"
|
||||||
formElementItem.formElement,
|
icon="i-lucide-message-square"
|
||||||
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
|
class="w-full justify-center"
|
||||||
formElementItem.indexInSubsection
|
@click="toggleComments(formElementItem.formElement.id)"
|
||||||
)
|
>
|
||||||
"
|
{{ commentCounts?.[formElementItem.formElement.id] ?? 0 }}
|
||||||
:content="{ align: 'end' }"
|
</UButton>
|
||||||
@update:open="
|
</div>
|
||||||
(isOpen: boolean) =>
|
<div
|
||||||
handleDropdownToggle(
|
:class="[
|
||||||
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
|
'transition-opacity duration-200',
|
||||||
isOpen
|
openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)
|
||||||
)
|
? 'opacity-100'
|
||||||
"
|
: 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-within:opacity-100'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" />
|
<UDropdownMenu
|
||||||
</UDropdownMenu>
|
:items="
|
||||||
|
getDropdownItems(
|
||||||
|
formElementItem.formElement,
|
||||||
|
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
|
||||||
|
formElementItem.indexInSubsection
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:content="{ align: 'end' }"
|
||||||
|
@update:open="
|
||||||
|
(isOpen: boolean) =>
|
||||||
|
handleDropdownToggle(
|
||||||
|
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
|
||||||
|
isOpen
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" />
|
||||||
|
</UDropdownMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" />
|
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" />
|
||||||
@@ -89,14 +112,18 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const commentStore = useCommentStore()
|
const commentStore = useCommentStore()
|
||||||
const { load: loadComments } = commentStore
|
const { loadInitial: loadCommentsInitial } = commentStore
|
||||||
const { comments } = storeToRefs(commentStore)
|
const { commentsByApplicationFormId, countsByApplicationFormId } = storeToRefs(commentStore)
|
||||||
const logger = useLogger().withTag('FormEngine')
|
|
||||||
|
|
||||||
if (props.applicationFormId) {
|
const comments = computed(() => {
|
||||||
logger.debug('Loading comments for application form:', props.applicationFormId)
|
if (!props.applicationFormId) return {}
|
||||||
await loadComments(props.applicationFormId)
|
return commentsByApplicationFormId.value[props.applicationFormId] ?? {}
|
||||||
}
|
})
|
||||||
|
|
||||||
|
const commentCounts = computed(() => {
|
||||||
|
if (!props.applicationFormId) return {}
|
||||||
|
return countsByApplicationFormId.value[props.applicationFormId] ?? {}
|
||||||
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const activeFormElement = ref('')
|
const activeFormElement = ref('')
|
||||||
@@ -189,12 +216,15 @@ function updateFormOptions(formOptions: FormOptionDto[], target: VisibleFormElem
|
|||||||
emit('update:modelValue', updatedModelValue)
|
emit('update:modelValue', updatedModelValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleComments(formElementId: string) {
|
async function toggleComments(formElementId: string) {
|
||||||
if (activeFormElement.value === formElementId) {
|
if (activeFormElement.value === formElementId) {
|
||||||
activeFormElement.value = ''
|
activeFormElement.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
activeFormElement.value = formElementId
|
activeFormElement.value = formElementId
|
||||||
|
if (props.applicationFormId) {
|
||||||
|
await loadCommentsInitial(props.applicationFormId, formElementId)
|
||||||
|
}
|
||||||
emit('click:comments', formElementId)
|
emit('click:comments', formElementId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +1,356 @@
|
|||||||
<template>
|
<template>
|
||||||
<template v-if="comments && comments.length > 0">
|
<div class="mt-4 lg:mt-6">
|
||||||
<UChatMessages :auto-scroll="false" :should-scroll-to-bottom="false">
|
<UCard variant="subtle" :ui="{ body: 'p-4 sm:p-5', header: 'p-4 sm:p-5', footer: 'p-4 sm:p-5' }">
|
||||||
<UChatMessage
|
<template #header>
|
||||||
v-for="comment in comments"
|
<div class="flex items-center justify-between gap-3">
|
||||||
:id="comment.id"
|
<div class="min-w-0">
|
||||||
:key="comment.id"
|
<p class="text-sm font-medium text-highlighted">
|
||||||
:avatar="{ icon: 'i-lucide-bot' }"
|
{{ $t('comments.title') }}
|
||||||
:content="comment.message"
|
</p>
|
||||||
role="user"
|
<p class="text-xs text-muted">
|
||||||
:parts="[{ type: 'text', text: comment.message }]"
|
{{ $t('comments.count', { count: commentCount }) }}
|
||||||
:side="isCommentByUser(comment) ? 'right' : 'left'"
|
</p>
|
||||||
variant="subtle"
|
|
||||||
:actions="createChatMessageActions(comment)"
|
|
||||||
>
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<UAvatar icon="i-lucide-bot" />
|
|
||||||
<p class="text-sm">{{ comment.createdBy.name }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<UButton color="neutral" variant="ghost" size="sm" icon="i-lucide-x" @click="$emit('close')" />
|
||||||
</UChatMessage>
|
</div>
|
||||||
</UChatMessages>
|
</template>
|
||||||
</template>
|
|
||||||
<UTextarea v-model="commentTextAreaValue" class="w-full" />
|
<div v-if="comments && comments.length > 0" class="relative">
|
||||||
<UButton v-if="!isEditingComment" class="my-3 lg:my-4" @click="submitComment(formElementId)">
|
<UProgress
|
||||||
{{ $t('comments.submit') }}
|
v-if="isLoadingMore"
|
||||||
</UButton>
|
indeterminate
|
||||||
<UButton v-if="isEditingComment" class="my-3 lg:my-4" @click="updateEditComment"> {{ $t('comments.edit') }} </UButton>
|
size="xs"
|
||||||
<UButton v-if="isEditingComment" class="my-3 lg:my-4" @click="cancelEditComment"> {{ $t('common.cancel') }} </UButton>
|
class="absolute top-0 inset-x-0 z-10"
|
||||||
|
:ui="{ base: 'bg-default' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UScrollArea ref="scrollAreaRef" class="max-h-96" :ui="{ viewport: 'space-y-4 pe-2' }">
|
||||||
|
<div v-for="comment in comments" :key="comment.id" class="space-y-2">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<UAvatar icon="i-lucide-user" size="2xs" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm text-highlighted truncate">
|
||||||
|
{{ comment.createdBy.name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
{{ comment.createdAt ? formatDate(comment.createdAt) : '-' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<UDropdownMenu
|
||||||
|
v-if="isCommentByUser(comment)"
|
||||||
|
:items="[createCommentActions(comment)]"
|
||||||
|
:content="{ align: 'end' }"
|
||||||
|
>
|
||||||
|
<UButton icon="i-lucide-ellipsis" color="neutral" variant="ghost" size="xs" square />
|
||||||
|
</UDropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
|
||||||
|
<!-- Edit comment form -->
|
||||||
|
<template v-if="editingCommentId === comment.id">
|
||||||
|
<UEditor
|
||||||
|
v-slot="{ editor }"
|
||||||
|
v-model="editingCommentEditorContent"
|
||||||
|
content-type="json"
|
||||||
|
:editable="true"
|
||||||
|
:ui="{
|
||||||
|
root: 'bg-transparent',
|
||||||
|
content: 'bg-transparent',
|
||||||
|
base: 'min-h-[120px] p-3 bg-transparent'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<UEditorToolbar
|
||||||
|
:editor="editor"
|
||||||
|
:items="toolbarItems"
|
||||||
|
class="border-b border-default px-3 py-2 bg-default/50 overflow-x-auto"
|
||||||
|
/>
|
||||||
|
</UEditor>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end p-3 border-t border-default">
|
||||||
|
<UButton color="neutral" variant="outline" @click="cancelEditComment">
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton @click="saveEditedComment(comment)">
|
||||||
|
{{ $t('comments.saveChanges') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Display comment content when not editing -->
|
||||||
|
<UEditor
|
||||||
|
v-else
|
||||||
|
:key="`${comment.id}:${comment.modifiedAt?.toISOString?.() ?? ''}`"
|
||||||
|
:model-value="getCommentEditorModelValue(comment)"
|
||||||
|
content-type="json"
|
||||||
|
:editable="false"
|
||||||
|
:ui="{
|
||||||
|
root: 'bg-transparent',
|
||||||
|
content: 'bg-transparent',
|
||||||
|
base: 'p-3 bg-transparent'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UEmpty v-else :title="$t('comments.empty')" icon="i-lucide-message-square" class="py-6" />
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
|
||||||
|
<UEditor
|
||||||
|
v-slot="{ editor }"
|
||||||
|
v-model="newCommentEditorContent"
|
||||||
|
content-type="json"
|
||||||
|
:editable="true"
|
||||||
|
:placeholder="$t('comments.placeholder')"
|
||||||
|
:ui="{
|
||||||
|
root: 'bg-transparent',
|
||||||
|
content: 'bg-transparent',
|
||||||
|
base: 'min-h-[120px] p-3 bg-transparent'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<UEditorToolbar
|
||||||
|
:editor="editor"
|
||||||
|
:items="toolbarItems"
|
||||||
|
class="border-b border-default px-3 py-2 bg-transparent overflow-x-auto"
|
||||||
|
/>
|
||||||
|
</UEditor>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end">
|
||||||
|
<UButton @click="submitComment(formElementId)">
|
||||||
|
{{ $t('comments.submit') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CommentDto } from '~~/.api-client'
|
import type { CommentDto } from '~~/.api-client'
|
||||||
import { useCommentTextarea } from '~/composables/comment/useCommentTextarea'
|
import type { JSONContent } from '@tiptap/vue-3'
|
||||||
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||||
|
import { useInfiniteScroll } from '@vueuse/core'
|
||||||
|
import { useCommentStore } from '~~/stores/useCommentStore'
|
||||||
|
import { useUserStore } from '~~/stores/useUserStore'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
formElementId: string
|
formElementId: string
|
||||||
applicationFormId: string
|
applicationFormId: string
|
||||||
comments?: CommentDto[]
|
comments?: CommentDto[]
|
||||||
|
totalCount?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const commentActions = useCommentTextarea(props.applicationFormId)
|
defineEmits<{
|
||||||
const {
|
(e: 'close'): void
|
||||||
submitComment,
|
}>()
|
||||||
updateEditComment,
|
|
||||||
cancelEditComment,
|
|
||||||
editComment,
|
|
||||||
isEditingComment,
|
|
||||||
isCommentByUser,
|
|
||||||
commentTextAreaValue
|
|
||||||
} = commentActions
|
|
||||||
|
|
||||||
function createChatMessageActions(comment: CommentDto) {
|
const commentStore = useCommentStore()
|
||||||
const { t: $t } = useI18n()
|
const { loadMore } = commentStore
|
||||||
const chatMessageActions = []
|
const userStore = useUserStore()
|
||||||
|
const { user } = storeToRefs(userStore)
|
||||||
|
const toast = useToast()
|
||||||
|
const { t: $t } = useI18n()
|
||||||
|
|
||||||
if (isCommentByUser(comment)) {
|
const scrollAreaRef = useTemplateRef('scrollAreaRef')
|
||||||
chatMessageActions.push({
|
const scrollContainerEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const commentCount = computed(() => props.totalCount ?? props.comments?.length ?? 0)
|
||||||
|
|
||||||
|
const commentCursorSate = computed(
|
||||||
|
() => commentStore.nextCursorByApplicationFormId[props.applicationFormId]?.[props.formElementId]
|
||||||
|
)
|
||||||
|
const canLoadMore = computed(() => commentCursorSate.value?.hasMore === true)
|
||||||
|
const isLoadingMore = computed(() => commentCursorSate.value?.isLoading === true)
|
||||||
|
const newCommentValue = ref<string>('')
|
||||||
|
|
||||||
|
const newCommentEditorContent = computed<JSONContent>({
|
||||||
|
get: () => toEditorJson(newCommentValue.value),
|
||||||
|
set: (newValue) => {
|
||||||
|
newCommentValue.value = JSON.stringify(newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const editingCommentId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const editingCommentValue = ref<string>('')
|
||||||
|
const editingCommentEditorContent = computed<JSONContent>({
|
||||||
|
get: () => toEditorJson(editingCommentValue.value),
|
||||||
|
set: (newValue) => {
|
||||||
|
editingCommentValue.value = JSON.stringify(newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => scrollAreaRef.value,
|
||||||
|
async (scrollAreaComponent) => {
|
||||||
|
if (!scrollAreaComponent) {
|
||||||
|
scrollContainerEl.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const rootEl = scrollAreaComponent.$el as HTMLElement | undefined
|
||||||
|
if (!rootEl) return
|
||||||
|
|
||||||
|
scrollContainerEl.value = rootEl
|
||||||
|
|
||||||
|
// Wait another tick for content to be measured, then scroll to bottom
|
||||||
|
await nextTick()
|
||||||
|
// Use requestAnimationFrame to ensure layout is complete
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
rootEl.scrollTop = rootEl.scrollHeight
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
useInfiniteScroll(
|
||||||
|
scrollContainerEl,
|
||||||
|
async () => {
|
||||||
|
const scrollEl = scrollContainerEl.value
|
||||||
|
if (!scrollEl) return
|
||||||
|
|
||||||
|
const previousScrollHeight = scrollEl.scrollHeight
|
||||||
|
await loadMore(props.applicationFormId, props.formElementId)
|
||||||
|
// Maintain scroll position after prepending older comments
|
||||||
|
await nextTick()
|
||||||
|
const newScrollHeight = scrollEl.scrollHeight
|
||||||
|
scrollEl.scrollTop = newScrollHeight - previousScrollHeight + scrollEl.scrollTop
|
||||||
|
},
|
||||||
|
{
|
||||||
|
direction: 'top',
|
||||||
|
distance: 100,
|
||||||
|
canLoadMore: () => canLoadMore.value && !isLoadingMore.value
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async function submitComment(formElementId: string) {
|
||||||
|
if (!newCommentValue.value.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await commentStore.createComment(props.applicationFormId, formElementId, { message: newCommentValue.value })
|
||||||
|
newCommentValue.value = ''
|
||||||
|
toast.add({ title: $t('comments.created'), color: 'success' })
|
||||||
|
} catch {
|
||||||
|
toast.add({ title: $t('comments.createError'), color: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditComment(comment: CommentDto) {
|
||||||
|
editingCommentId.value = comment.id
|
||||||
|
editingCommentValue.value = comment.message || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditComment() {
|
||||||
|
editingCommentId.value = null
|
||||||
|
editingCommentValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditedComment(comment: CommentDto) {
|
||||||
|
try {
|
||||||
|
const updatedComment: CommentDto = {
|
||||||
|
...comment,
|
||||||
|
message: editingCommentValue.value,
|
||||||
|
modifiedAt: new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
await commentStore.updateComment(comment.id, updatedComment)
|
||||||
|
cancelEditComment()
|
||||||
|
toast.add({ title: $t('comments.updated'), color: 'success' })
|
||||||
|
} catch {
|
||||||
|
toast.add({ title: $t('comments.updateError'), color: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommentByUser(comment: CommentDto) {
|
||||||
|
return comment.createdBy.keycloakId === user.value?.keycloakId
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCommentActions(comment: CommentDto): DropdownMenuItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
label: $t('comments.editAction'),
|
label: $t('comments.editAction'),
|
||||||
icon: 'i-lucide-pencil',
|
icon: 'i-lucide-pencil',
|
||||||
onClick: () => editComment(comment)
|
onClick: () => startEditComment(comment)
|
||||||
})
|
}
|
||||||
}
|
]
|
||||||
return chatMessageActions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCommentEditorModelValue(comment: CommentDto): JSONContent {
|
||||||
|
return toEditorJson(comment.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditorJson(rawValue: string | undefined): JSONContent {
|
||||||
|
const raw = (rawValue ?? '').trim()
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
if (raw.startsWith('{')) {
|
||||||
|
return JSON.parse(raw) as JSONContent
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to plain text
|
||||||
|
}
|
||||||
|
return wrapPlainTextAsDoc(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapPlainTextAsDoc('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapPlainTextAsDoc(text: string): JSONContent {
|
||||||
|
return {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: text
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbarItems = [
|
||||||
|
[
|
||||||
|
{ kind: 'undo', icon: 'i-lucide-undo' },
|
||||||
|
{ kind: 'redo', icon: 'i-lucide-redo' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', label: 'H1' },
|
||||||
|
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', label: 'H2' },
|
||||||
|
{ kind: 'heading', level: 3, icon: 'i-lucide-heading-3', label: 'H3' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
|
||||||
|
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
|
||||||
|
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' },
|
||||||
|
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'bulletList', icon: 'i-lucide-list' },
|
||||||
|
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'blockquote', icon: 'i-lucide-quote' },
|
||||||
|
{ kind: 'codeBlock', icon: 'i-lucide-code' }
|
||||||
|
],
|
||||||
|
[{ kind: 'link', icon: 'i-lucide-link' }]
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { CommentApi, Configuration, type CommentDto, type CreateCommentDto, type PagedCommentDto } from '~~/.api-client'
|
import {
|
||||||
|
CommentApi,
|
||||||
|
Configuration,
|
||||||
|
type ApplicationFormCommentCountsDto,
|
||||||
|
type CommentDto,
|
||||||
|
type CreateCommentDto,
|
||||||
|
type CursorPagedCommentDto
|
||||||
|
} from '~~/.api-client'
|
||||||
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
|
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
|
||||||
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
|
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
|
||||||
|
|
||||||
@@ -22,8 +29,24 @@ export function useCommentApi() {
|
|||||||
return commentApiClient.createComment({ applicationFormId, formElementId, createCommentDto })
|
return commentApiClient.createComment({ applicationFormId, formElementId, createCommentDto })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCommentsByApplicationFormId(applicationFormId: string): Promise<PagedCommentDto> {
|
async function getCommentsByApplicationFormId(
|
||||||
return commentApiClient.getCommentsByApplicationFormId({ applicationFormId })
|
applicationFormId: string,
|
||||||
|
formElementId?: string,
|
||||||
|
cursorCreatedAt?: Date,
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<CursorPagedCommentDto> {
|
||||||
|
return commentApiClient.getCommentsByApplicationFormId({
|
||||||
|
applicationFormId,
|
||||||
|
formElementId,
|
||||||
|
cursorCreatedAt,
|
||||||
|
limit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroupedCommentCountByApplicationFromId(
|
||||||
|
applicationFormId: string
|
||||||
|
): Promise<ApplicationFormCommentCountsDto> {
|
||||||
|
return commentApiClient.getGroupedCommentCountByApplicationFromId({ applicationFormId })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateComment(id: string, commentDto: CommentDto): Promise<CommentDto> {
|
async function updateComment(id: string, commentDto: CommentDto): Promise<CommentDto> {
|
||||||
@@ -37,6 +60,7 @@ export function useCommentApi() {
|
|||||||
return {
|
return {
|
||||||
createComment,
|
createComment,
|
||||||
getCommentsByApplicationFormId,
|
getCommentsByApplicationFormId,
|
||||||
|
getGroupedCommentCountByApplicationFromId,
|
||||||
updateComment,
|
updateComment,
|
||||||
deleteCommentById
|
deleteCommentById
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,10 +57,12 @@ import {
|
|||||||
import type { FormElementId } from '~~/types/formElement'
|
import type { FormElementId } from '~~/types/formElement'
|
||||||
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
|
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
|
||||||
import { useUserStore } from '~~/stores/useUserStore'
|
import { useUserStore } from '~~/stores/useUserStore'
|
||||||
|
import { useCommentStore } from '~~/stores/useCommentStore'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t: $t } = useI18n()
|
const { t: $t } = useI18n()
|
||||||
|
const commentStore = useCommentStore()
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
// Prevent whole page from re-rendering when navigating between sections to keep state
|
// Prevent whole page from re-rendering when navigating between sections to keep state
|
||||||
@@ -74,6 +76,10 @@ const {
|
|||||||
updateApplicationForm
|
updateApplicationForm
|
||||||
} = await useApplicationFormNavigation(applicationFormId!)
|
} = await useApplicationFormNavigation(applicationFormId!)
|
||||||
|
|
||||||
|
if (applicationFormId) {
|
||||||
|
await commentStore.loadCounts(applicationFormId)
|
||||||
|
}
|
||||||
|
|
||||||
const { updateApplicationForm: updateForm, submitApplicationForm } = useApplicationForm()
|
const { updateApplicationForm: updateForm, submitApplicationForm } = useApplicationForm()
|
||||||
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
|
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
|
||||||
const { evaluateVisibility } = useFormElementVisibility()
|
const { evaluateVisibility } = useFormElementVisibility()
|
||||||
|
|||||||
@@ -62,6 +62,18 @@
|
|||||||
<p class="text-xs text-muted mt-1">#{{ index }}</p>
|
<p class="text-xs text-muted mt-1">#{{ index }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<UTooltip
|
||||||
|
v-if="(applicationFormElem.commentCount ?? 0) > 0"
|
||||||
|
:text="$t('comments.count', { count: applicationFormElem.commentCount ?? 0 })"
|
||||||
|
>
|
||||||
|
<UBadge
|
||||||
|
:label="applicationFormElem.commentCount ?? 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-message-square"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="applicationFormElem.status"
|
v-if="applicationFormElem.status"
|
||||||
:label="applicationFormElem.status"
|
:label="applicationFormElem.status"
|
||||||
|
|||||||
@@ -91,9 +91,13 @@
|
|||||||
"comments": {
|
"comments": {
|
||||||
"title": "Kommentare",
|
"title": "Kommentare",
|
||||||
"empty": "Keine Kommentare vorhanden",
|
"empty": "Keine Kommentare vorhanden",
|
||||||
|
"count": "{count} Kommentare",
|
||||||
|
"placeholder": "Kommentar hinzufügen...",
|
||||||
|
"loadMore": "Mehr laden",
|
||||||
"submit": "Absenden",
|
"submit": "Absenden",
|
||||||
"edit": "Kommentar bearbeiten",
|
"edit": "Kommentar bearbeiten",
|
||||||
"editAction": "Bearbeiten",
|
"editAction": "Bearbeiten",
|
||||||
|
"saveChanges": "Änderungen speichern",
|
||||||
"created": "Kommentar erfolgreich erstellt",
|
"created": "Kommentar erfolgreich erstellt",
|
||||||
"createError": "Fehler beim Erstellen des Kommentars",
|
"createError": "Fehler beim Erstellen des Kommentars",
|
||||||
"updated": "Kommentar erfolgreich aktualisiert",
|
"updated": "Kommentar erfolgreich aktualisiert",
|
||||||
|
|||||||
@@ -91,9 +91,13 @@
|
|||||||
"comments": {
|
"comments": {
|
||||||
"title": "Comments",
|
"title": "Comments",
|
||||||
"empty": "No comments available",
|
"empty": "No comments available",
|
||||||
|
"count": "{count} comments",
|
||||||
|
"placeholder": "Add a comment...",
|
||||||
|
"loadMore": "Load more",
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"edit": "Edit Comment",
|
"edit": "Edit Comment",
|
||||||
"editAction": "Edit",
|
"editAction": "Edit",
|
||||||
|
"saveChanges": "Save changes",
|
||||||
"created": "Comment created successfully",
|
"created": "Comment created successfully",
|
||||||
"createError": "Error creating comment",
|
"createError": "Error creating comment",
|
||||||
"updated": "Comment updated successfully",
|
"updated": "Comment updated successfully",
|
||||||
|
|||||||
@@ -1,47 +1,80 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { type CreateCommentDto, type CommentDto, ResponseError } from '~~/.api-client'
|
import { type CreateCommentDto, type CommentDto, ResponseError, type CursorPagedCommentDto } from '~~/.api-client'
|
||||||
import { useCommentApi } from '~/composables/comment/useCommentApi'
|
import { useCommentApi } from '~/composables/comment/useCommentApi'
|
||||||
import { useLogger } from '../app/composables/useLogger'
|
import { useLogger } from '../app/composables/useLogger'
|
||||||
|
|
||||||
|
type ApplicationFormId = string
|
||||||
|
type FormElementId = string
|
||||||
|
|
||||||
export const useCommentStore = defineStore('Comment', () => {
|
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 commentApi = useCommentApi()
|
||||||
const comments = ref<Record<FormElementId, CommentDto[]>>({})
|
const loadedFormElementComments = ref(new Set<string>())
|
||||||
const loadedForms = ref(new Set<string>())
|
|
||||||
const logger = useLogger().withTag('commentStore')
|
const logger = useLogger().withTag('commentStore')
|
||||||
|
|
||||||
async function load(applicationFormId: string) {
|
async function loadInitial(applicationFormId: ApplicationFormId, formElementId: FormElementId) {
|
||||||
if (loadedForms.value.has(applicationFormId)) return
|
initializeCursorState(applicationFormId, formElementId)
|
||||||
const { data, error } = await useAsyncData(`comments:${applicationFormId}`, () =>
|
|
||||||
commentApi.getCommentsByApplicationFormId(applicationFormId)
|
const cacheKey = `${applicationFormId}:${formElementId}`
|
||||||
)
|
if (loadedFormElementComments.value.has(cacheKey)) return
|
||||||
if (error.value) {
|
|
||||||
logger.error('Failed loading comments:', error.value)
|
nextCursorByApplicationFormId.value[applicationFormId]![formElementId]!.isLoading = true
|
||||||
|
try {
|
||||||
|
const page = await commentApi.getCommentsByApplicationFormId(applicationFormId, formElementId, undefined, 10)
|
||||||
|
upsertComments(applicationFormId, formElementId, page, { prepend: false })
|
||||||
|
} catch (e) {
|
||||||
|
nextCursorByApplicationFormId.value[applicationFormId]![formElementId]!.isLoading = false
|
||||||
|
logger.error('Failed loading initial comments:', e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
comments.value =
|
loadedFormElementComments.value.add(cacheKey)
|
||||||
data.value?.content.reduce((acc: Record<FormElementId, CommentDto[]>, comment: CommentDto) => {
|
}
|
||||||
const formElementId = comment.formElementId
|
|
||||||
if (!acc[formElementId]) {
|
async function loadMore(applicationFormId: ApplicationFormId, formElementId: FormElementId) {
|
||||||
acc[formElementId] = []
|
initializeCursorState(applicationFormId, formElementId)
|
||||||
}
|
const state = nextCursorByApplicationFormId.value[applicationFormId]![formElementId]!
|
||||||
acc[formElementId].push(comment)
|
if (state.isLoading || !state.hasMore) return
|
||||||
return acc
|
|
||||||
}, {}) || {}
|
state.isLoading = true
|
||||||
loadedForms.value.add(applicationFormId)
|
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(
|
async function createComment(
|
||||||
applicationFormId: string,
|
applicationFormId: ApplicationFormId,
|
||||||
formElementId: string,
|
formElementId: string,
|
||||||
createCommentDto: CreateCommentDto
|
createCommentDto: CreateCommentDto
|
||||||
): Promise<CommentDto> {
|
): Promise<CommentDto> {
|
||||||
try {
|
try {
|
||||||
const newComment = await commentApi.createComment(applicationFormId, formElementId, createCommentDto)
|
const newComment = await commentApi.createComment(applicationFormId, formElementId, createCommentDto)
|
||||||
if (!comments.value[formElementId]) {
|
const commentsByFormElement = commentsByApplicationFormId.value[applicationFormId] ?? {}
|
||||||
comments.value[formElementId] = []
|
if (!commentsByFormElement[formElementId]) {
|
||||||
|
commentsByFormElement[formElementId] = []
|
||||||
}
|
}
|
||||||
comments.value[formElementId].push(newComment)
|
commentsByFormElement[formElementId].push(newComment)
|
||||||
|
commentsByApplicationFormId.value[applicationFormId] = commentsByFormElement
|
||||||
|
|
||||||
|
const currentCounts = countsByApplicationFormId.value[applicationFormId] ?? {}
|
||||||
|
currentCounts[formElementId] = (currentCounts[formElementId] ?? 0) + 1
|
||||||
|
countsByApplicationFormId.value[applicationFormId] = currentCounts
|
||||||
|
|
||||||
return newComment
|
return newComment
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof ResponseError) {
|
if (e instanceof ResponseError) {
|
||||||
@@ -60,14 +93,18 @@ export const useCommentStore = defineStore('Comment', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedComment = await commentApi.updateComment(id, commentDto)
|
const updatedComment = await commentApi.updateComment(id, commentDto)
|
||||||
const formElementId = updatedComment.formElementId
|
|
||||||
const formElementComments = comments.value?.[formElementId]
|
const commentsByFormElement = commentsByApplicationFormId.value[updatedComment.applicationFormId]
|
||||||
|
const formElementComments = commentsByFormElement?.[updatedComment.formElementId]
|
||||||
|
|
||||||
|
// Update the comment in the store
|
||||||
if (formElementComments) {
|
if (formElementComments) {
|
||||||
const index = formElementComments.findIndex((comment) => comment.id === id)
|
const index = formElementComments.findIndex((comment) => comment.id === id)
|
||||||
if (index !== -1 && formElementComments[index]) {
|
if (index !== -1 && formElementComments[index]) {
|
||||||
formElementComments[index] = updatedComment
|
formElementComments[index] = updatedComment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedComment
|
return updatedComment
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof ResponseError) {
|
if (e instanceof ResponseError) {
|
||||||
@@ -82,13 +119,33 @@ export const useCommentStore = defineStore('Comment', () => {
|
|||||||
async function deleteCommentById(id: string): Promise<void> {
|
async function deleteCommentById(id: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await commentApi.deleteCommentById(id)
|
await commentApi.deleteCommentById(id)
|
||||||
for (const formElementId in comments.value) {
|
|
||||||
const formElementComments = comments.value[formElementId]
|
// Remove the comment from the store
|
||||||
if (formElementComments) {
|
for (const applicationFormId in commentsByApplicationFormId.value) {
|
||||||
const index = formElementComments.findIndex((comment) => comment.id === id)
|
const commentsByFormElement = commentsByApplicationFormId.value[applicationFormId]
|
||||||
if (index !== -1) {
|
|
||||||
formElementComments.splice(index, 1)
|
if (!commentsByFormElement) continue
|
||||||
break
|
|
||||||
|
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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user