Add server-side persistence for AI Form Fill entities and workflow templates
This commit is contained in:
+78
@@ -0,0 +1,78 @@
|
||||
package stirling.software.proprietary.aiformfill.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.aiformfill.dto.FormFillEntityDTO;
|
||||
import stirling.software.proprietary.aiformfill.dto.FormFillEntityImportRequest;
|
||||
import stirling.software.proprietary.aiformfill.dto.FormFillEntityUpsertRequest;
|
||||
import stirling.software.proprietary.aiformfill.service.FormFillEntityService;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai-form-fill/entities")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(
|
||||
name = "AI Form Fill — Entities",
|
||||
description =
|
||||
"Per-user persistent entities (person, company, site, etc.) used by AI form"
|
||||
+ " fill")
|
||||
public class FormFillEntityController {
|
||||
|
||||
private final FormFillEntityService service;
|
||||
|
||||
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public List<FormFillEntityDTO> list() {
|
||||
User user = service.requireAuthenticatedUser();
|
||||
return service.list(user).stream().map(FormFillEntityDTO::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public FormFillEntityDTO get(@PathVariable String id) {
|
||||
User user = service.requireAuthenticatedUser();
|
||||
return FormFillEntityDTO.from(service.get(user, id));
|
||||
}
|
||||
|
||||
@PutMapping(
|
||||
value = "/{id}",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public FormFillEntityDTO upsert(
|
||||
@PathVariable String id, @Valid @RequestBody FormFillEntityUpsertRequest request) {
|
||||
User user = service.requireAuthenticatedUser();
|
||||
return FormFillEntityDTO.from(service.upsert(user, id, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable String id) {
|
||||
User user = service.requireAuthenticatedUser();
|
||||
service.delete(user, id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
value = "/import",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public List<FormFillEntityDTO> importBatch(
|
||||
@Valid @RequestBody FormFillEntityImportRequest request) {
|
||||
User user = service.requireAuthenticatedUser();
|
||||
return service.importBatch(user, request).stream().map(FormFillEntityDTO::from).toList();
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package stirling.software.proprietary.aiformfill.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.aiformfill.dto.FormFillWorkflowTemplateDTO;
|
||||
import stirling.software.proprietary.aiformfill.dto.FormFillWorkflowTemplateUpsertRequest;
|
||||
import stirling.software.proprietary.aiformfill.service.FormFillEntityService;
|
||||
import stirling.software.proprietary.aiformfill.service.FormFillWorkflowTemplateService;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai-form-fill/workflow-templates")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(
|
||||
name = "AI Form Fill — Workflow Templates",
|
||||
description = "Per-user saved role→entity mappings keyed by form signature")
|
||||
public class FormFillWorkflowTemplateController {
|
||||
|
||||
private final FormFillWorkflowTemplateService service;
|
||||
private final FormFillEntityService entityService;
|
||||
|
||||
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public List<FormFillWorkflowTemplateDTO> list() {
|
||||
User user = entityService.requireAuthenticatedUser();
|
||||
return service.list(user).stream().map(FormFillWorkflowTemplateDTO::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public FormFillWorkflowTemplateDTO get(@PathVariable String id) {
|
||||
User user = entityService.requireAuthenticatedUser();
|
||||
return FormFillWorkflowTemplateDTO.from(service.get(user, id));
|
||||
}
|
||||
|
||||
@GetMapping(params = "signature", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<FormFillWorkflowTemplateDTO> findBySignature(
|
||||
@RequestParam("signature") String signature) {
|
||||
User user = entityService.requireAuthenticatedUser();
|
||||
return service.findBySignature(user, signature)
|
||||
.map(FormFillWorkflowTemplateDTO::from)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PutMapping(
|
||||
value = "/{id}",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public FormFillWorkflowTemplateDTO upsert(
|
||||
@PathVariable String id,
|
||||
@Valid @RequestBody FormFillWorkflowTemplateUpsertRequest request) {
|
||||
User user = entityService.requireAuthenticatedUser();
|
||||
return FormFillWorkflowTemplateDTO.from(service.upsert(user, id, request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{id}/touch", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public FormFillWorkflowTemplateDTO touch(@PathVariable String id) {
|
||||
User user = entityService.requireAuthenticatedUser();
|
||||
return FormFillWorkflowTemplateDTO.from(service.touchLastUsed(user, id));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable String id) {
|
||||
User user = entityService.requireAuthenticatedUser();
|
||||
service.delete(user, id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.proprietary.aiformfill.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import stirling.software.proprietary.aiformfill.model.FormFillEntity;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class FormFillEntityDTO {
|
||||
private final String id;
|
||||
|
||||
@JsonProperty("type")
|
||||
private final String entityType;
|
||||
|
||||
private final String name;
|
||||
private final Map<String, String> fields;
|
||||
private final LocalDateTime createdAt;
|
||||
private final LocalDateTime updatedAt;
|
||||
|
||||
public static FormFillEntityDTO from(FormFillEntity entity) {
|
||||
return FormFillEntityDTO.builder()
|
||||
.id(entity.getId())
|
||||
.entityType(entity.getEntityType())
|
||||
.name(entity.getName())
|
||||
.fields(entity.getFields())
|
||||
.createdAt(entity.getCreatedAt())
|
||||
.updatedAt(entity.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package stirling.software.proprietary.aiformfill.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Bulk import payload — each item has a client-supplied UUID that gets upserted. */
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class FormFillEntityImportRequest {
|
||||
|
||||
@Valid private List<Item> entities;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public static class Item {
|
||||
@NotBlank private String id;
|
||||
@Valid private FormFillEntityUpsertRequest entity;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.aiformfill.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class FormFillEntityUpsertRequest {
|
||||
|
||||
@NotBlank
|
||||
@JsonProperty("type")
|
||||
private String entityType;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 255)
|
||||
private String name;
|
||||
|
||||
private Map<String, String> fields;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package stirling.software.proprietary.aiformfill.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import stirling.software.proprietary.aiformfill.model.FormFillWorkflowTemplate;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class FormFillWorkflowTemplateDTO {
|
||||
private final String id;
|
||||
private final String name;
|
||||
private final String formSignature;
|
||||
private final Map<String, String> roleEntityMap;
|
||||
private final Map<String, Map<String, String>> fileOverrides;
|
||||
private final LocalDateTime createdAt;
|
||||
private final LocalDateTime lastUsedAt;
|
||||
|
||||
public static FormFillWorkflowTemplateDTO from(FormFillWorkflowTemplate template) {
|
||||
return FormFillWorkflowTemplateDTO.builder()
|
||||
.id(template.getId())
|
||||
.name(template.getName())
|
||||
.formSignature(template.getFormSignature())
|
||||
.roleEntityMap(template.getRoleEntityMap())
|
||||
.fileOverrides(template.getFileOverrides())
|
||||
.createdAt(template.getCreatedAt())
|
||||
.lastUsedAt(template.getLastUsedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.aiformfill.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class FormFillWorkflowTemplateUpsertRequest {
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 255)
|
||||
private String name;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 128)
|
||||
private String formSignature;
|
||||
|
||||
private Map<String, String> roleEntityMap;
|
||||
|
||||
private Map<String, Map<String, String>> fileOverrides;
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package stirling.software.proprietary.aiformfill.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* Persistent entity used by the AI Form Fill feature (person, company, site, property,
|
||||
* certification, custom). Each row belongs to one user and holds a flexible field map that mirrors
|
||||
* the frontend's {@code Entity} type in {@code entityTypes.ts}.
|
||||
*
|
||||
* <p>Primary key is the client-generated UUID string rather than an auto-increment long so that
|
||||
* existing localStorage data and exported JSON blobs migrate with IDs intact and workflow
|
||||
* templates' role→entityId references remain stable.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "ai_form_fill_entities",
|
||||
indexes = {@Index(name = "idx_ai_form_fill_entities_owner", columnList = "owner_id")})
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class FormFillEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "id", length = 64, nullable = false, updatable = false)
|
||||
private String id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "owner_id", nullable = false)
|
||||
private User owner;
|
||||
|
||||
@Column(name = "entity_type", nullable = false, length = 32)
|
||||
private String entityType;
|
||||
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "fields", columnDefinition = "jsonb")
|
||||
private Map<String, String> fields = new HashMap<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package stirling.software.proprietary.aiformfill.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* Saved workflow template for AI Form Fill — remembers a form signature plus the role→entity
|
||||
* mapping (and any per-file overrides) so that returning users skip the role-assignment UI.
|
||||
*
|
||||
* <p>Primary key is the client-generated UUID string (see {@link FormFillEntity} for rationale).
|
||||
* The {@code roleEntityMap} values are {@link FormFillEntity#getId()} UUIDs, not DB references, so
|
||||
* no FK cascade is enforced; stale role→entity pointers are tolerated and surfaced to the UI.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "ai_form_fill_workflow_templates",
|
||||
indexes = {
|
||||
@Index(
|
||||
name = "idx_ai_form_fill_wf_owner_signature",
|
||||
columnList = "owner_id, form_signature")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class FormFillWorkflowTemplate implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "id", length = 64, nullable = false, updatable = false)
|
||||
private String id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "owner_id", nullable = false)
|
||||
private User owner;
|
||||
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "form_signature", nullable = false, length = 128)
|
||||
private String formSignature;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "role_entity_map", columnDefinition = "jsonb")
|
||||
private Map<String, String> roleEntityMap = new HashMap<>();
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "file_overrides", columnDefinition = "jsonb")
|
||||
private Map<String, Map<String, String>> fileOverrides = new HashMap<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "last_used_at")
|
||||
private LocalDateTime lastUsedAt;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.aiformfill.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.aiformfill.model.FormFillEntity;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Repository
|
||||
public interface FormFillEntityRepository extends JpaRepository<FormFillEntity, String> {
|
||||
|
||||
List<FormFillEntity> findAllByOwnerOrderByUpdatedAtDesc(User owner);
|
||||
|
||||
Optional<FormFillEntity> findByIdAndOwner(String id, User owner);
|
||||
|
||||
long countByOwner(User owner);
|
||||
|
||||
void deleteByIdAndOwner(String id, User owner);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.aiformfill.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.aiformfill.model.FormFillWorkflowTemplate;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Repository
|
||||
public interface FormFillWorkflowTemplateRepository
|
||||
extends JpaRepository<FormFillWorkflowTemplate, String> {
|
||||
|
||||
List<FormFillWorkflowTemplate> findAllByOwnerOrderByLastUsedAtDescCreatedAtDesc(User owner);
|
||||
|
||||
Optional<FormFillWorkflowTemplate> findByIdAndOwner(String id, User owner);
|
||||
|
||||
Optional<FormFillWorkflowTemplate> findByOwnerAndFormSignature(
|
||||
User owner, String formSignature);
|
||||
|
||||
void deleteByIdAndOwner(String id, User owner);
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package stirling.software.proprietary.aiformfill.service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.aiformfill.dto.FormFillEntityImportRequest;
|
||||
import stirling.software.proprietary.aiformfill.dto.FormFillEntityUpsertRequest;
|
||||
import stirling.software.proprietary.aiformfill.model.FormFillEntity;
|
||||
import stirling.software.proprietary.aiformfill.repository.FormFillEntityRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FormFillEntityService {
|
||||
|
||||
/** Hard cap to match the engine-side knowledge dict limit and guard against runaway growth. */
|
||||
private static final int MAX_ENTITIES_PER_USER = 1000;
|
||||
|
||||
private final FormFillEntityRepository repository;
|
||||
|
||||
public User requireAuthenticatedUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null
|
||||
|| !authentication.isAuthenticated()
|
||||
|| "anonymousUser".equals(authentication.getPrincipal())) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
Object principal = authentication.getPrincipal();
|
||||
if (principal instanceof User user) {
|
||||
return user;
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unsupported user principal");
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<FormFillEntity> list(User owner) {
|
||||
return repository.findAllByOwnerOrderByUpdatedAtDesc(owner);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public FormFillEntity get(User owner, String id) {
|
||||
return repository
|
||||
.findByIdAndOwner(id, owner)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "Entity not found"));
|
||||
}
|
||||
|
||||
public FormFillEntity upsert(User owner, String id, FormFillEntityUpsertRequest request) {
|
||||
if (id == null || id.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Entity id required");
|
||||
}
|
||||
|
||||
FormFillEntity entity =
|
||||
repository
|
||||
.findByIdAndOwner(id, owner)
|
||||
.orElseGet(
|
||||
() -> {
|
||||
enforceLimit(owner);
|
||||
FormFillEntity fresh = new FormFillEntity();
|
||||
fresh.setId(id);
|
||||
fresh.setOwner(owner);
|
||||
return fresh;
|
||||
});
|
||||
|
||||
entity.setEntityType(request.getEntityType());
|
||||
entity.setName(request.getName());
|
||||
entity.setFields(
|
||||
request.getFields() != null ? new HashMap<>(request.getFields()) : new HashMap<>());
|
||||
return repository.save(entity);
|
||||
}
|
||||
|
||||
public void delete(User owner, String id) {
|
||||
if (!repository.findByIdAndOwner(id, owner).isPresent()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Entity not found");
|
||||
}
|
||||
repository.deleteByIdAndOwner(id, owner);
|
||||
}
|
||||
|
||||
public List<FormFillEntity> importBatch(User owner, FormFillEntityImportRequest request) {
|
||||
if (request == null || request.getEntities() == null || request.getEntities().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return request.getEntities().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(item -> item.getId() != null && !item.getId().isBlank())
|
||||
.filter(item -> item.getEntity() != null)
|
||||
.map(item -> upsert(owner, item.getId(), item.getEntity()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void enforceLimit(User owner) {
|
||||
long count = repository.countByOwner(owner);
|
||||
if (count >= MAX_ENTITIES_PER_USER) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
"Entity limit of " + MAX_ENTITIES_PER_USER + " reached");
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package stirling.software.proprietary.aiformfill.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.aiformfill.dto.FormFillWorkflowTemplateUpsertRequest;
|
||||
import stirling.software.proprietary.aiformfill.model.FormFillWorkflowTemplate;
|
||||
import stirling.software.proprietary.aiformfill.repository.FormFillWorkflowTemplateRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FormFillWorkflowTemplateService {
|
||||
|
||||
private final FormFillWorkflowTemplateRepository repository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<FormFillWorkflowTemplate> list(User owner) {
|
||||
return repository.findAllByOwnerOrderByLastUsedAtDescCreatedAtDesc(owner);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public FormFillWorkflowTemplate get(User owner, String id) {
|
||||
return repository
|
||||
.findByIdAndOwner(id, owner)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "Workflow template not found"));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<FormFillWorkflowTemplate> findBySignature(User owner, String formSignature) {
|
||||
return repository.findByOwnerAndFormSignature(owner, formSignature);
|
||||
}
|
||||
|
||||
public FormFillWorkflowTemplate upsert(
|
||||
User owner, String id, FormFillWorkflowTemplateUpsertRequest request) {
|
||||
if (id == null || id.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Template id required");
|
||||
}
|
||||
|
||||
FormFillWorkflowTemplate template =
|
||||
repository
|
||||
.findByIdAndOwner(id, owner)
|
||||
.orElseGet(
|
||||
() -> {
|
||||
FormFillWorkflowTemplate fresh = new FormFillWorkflowTemplate();
|
||||
fresh.setId(id);
|
||||
fresh.setOwner(owner);
|
||||
return fresh;
|
||||
});
|
||||
|
||||
template.setName(request.getName());
|
||||
template.setFormSignature(request.getFormSignature());
|
||||
template.setRoleEntityMap(
|
||||
request.getRoleEntityMap() != null
|
||||
? new HashMap<>(request.getRoleEntityMap())
|
||||
: new HashMap<>());
|
||||
template.setFileOverrides(
|
||||
request.getFileOverrides() != null
|
||||
? new HashMap<>(request.getFileOverrides())
|
||||
: new HashMap<>());
|
||||
if (template.getLastUsedAt() == null) {
|
||||
template.setLastUsedAt(LocalDateTime.now());
|
||||
}
|
||||
return repository.save(template);
|
||||
}
|
||||
|
||||
public FormFillWorkflowTemplate touchLastUsed(User owner, String id) {
|
||||
FormFillWorkflowTemplate template = get(owner, id);
|
||||
template.setLastUsedAt(LocalDateTime.now());
|
||||
return repository.save(template);
|
||||
}
|
||||
|
||||
public void delete(User owner, String id) {
|
||||
if (!repository.findByIdAndOwner(id, owner).isPresent()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Workflow template not found");
|
||||
}
|
||||
repository.deleteByIdAndOwner(id, owner);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -30,13 +30,15 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.security.repository",
|
||||
"stirling.software.proprietary.repository",
|
||||
"stirling.software.proprietary.storage.repository",
|
||||
"stirling.software.proprietary.workflow.repository"
|
||||
"stirling.software.proprietary.workflow.repository",
|
||||
"stirling.software.proprietary.aiformfill.repository"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.proprietary.security.model",
|
||||
"stirling.software.proprietary.model",
|
||||
"stirling.software.proprietary.storage.model",
|
||||
"stirling.software.proprietary.workflow.model"
|
||||
"stirling.software.proprietary.workflow.model",
|
||||
"stirling.software.proprietary.aiformfill.model"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { ProfileManagementModal } from './ProfileManagementModal';
|
||||
import { RoleSection } from './RoleSection';
|
||||
import { FillPreview } from './FillPreview';
|
||||
import { usePassiveLearning } from './usePassiveLearning';
|
||||
import { checkExpiryDates, checkCrossFormConsistency } from './workflowTemplates';
|
||||
import { checkCrossFormConsistency } from './workflowTemplates';
|
||||
import type { CleanedLabel } from './types';
|
||||
import styles from './AiFormFill.module.css';
|
||||
|
||||
@@ -46,12 +46,6 @@ const AiFormFill = (_props: BaseToolProps) => {
|
||||
const [modalOpened, setModalOpened] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<string>('roles');
|
||||
|
||||
// Expiry warnings
|
||||
const expiryWarnings = useMemo(
|
||||
() => checkExpiryDates(knowledge.entityStore.entities),
|
||||
[knowledge.entityStore.entities]
|
||||
);
|
||||
|
||||
const allFiles = useMemo(() => {
|
||||
const files = selectors.getFiles();
|
||||
return files.filter((f) => isStirlingFile(f));
|
||||
@@ -136,21 +130,6 @@ const AiFormFill = (_props: BaseToolProps) => {
|
||||
? `${allFiles.length} file${allFiles.length > 1 ? 's' : ''} loaded. Analyse to detect form roles and fields.`
|
||||
: t('aiFormFill.noFile', 'Open PDF forms to get started.')}
|
||||
</Text>
|
||||
{/* Expiry warnings */}
|
||||
{expiryWarnings.length > 0 && (
|
||||
<Alert color={expiryWarnings.some((w) => w.isExpired) ? 'red' : 'yellow'} variant="light">
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={600}>
|
||||
{expiryWarnings.some((w) => w.isExpired) ? 'Expired credentials!' : 'Expiring soon'}
|
||||
</Text>
|
||||
{expiryWarnings.map((w, i) => (
|
||||
<Text key={i} size="xs">
|
||||
{w.entityName} — {w.fieldKey}: {w.isExpired ? `expired ${Math.abs(w.daysUntilExpiry)} days ago` : `expires in ${w.daysUntilExpiry} days`}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
@@ -399,6 +378,17 @@ const AiFormFill = (_props: BaseToolProps) => {
|
||||
|
||||
<div className={styles.content}>
|
||||
<Stack gap="sm">
|
||||
{batchFill.error && (
|
||||
<Alert
|
||||
icon={<WarningAmberIcon />}
|
||||
color="red"
|
||||
variant="light"
|
||||
withCloseButton
|
||||
onClose={batchFill.reset}
|
||||
>
|
||||
{batchFill.error}
|
||||
</Alert>
|
||||
)}
|
||||
{viewMode === 'roles' ? (
|
||||
// Role-grouped view
|
||||
roles.map((role) => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Entities tab — two-column layout: entity list grouped by type (left) + fields table (right).
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Table,
|
||||
Menu,
|
||||
Select,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
importEntitiesFromJson,
|
||||
importCsvToStore,
|
||||
} from './entityImportExport';
|
||||
import { checkExpiryDates } from './workflowTemplates';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
|
||||
@@ -77,11 +79,54 @@ export function EntitiesTab({ store }: { store: EntityStore }) {
|
||||
label: ENTITY_TYPE_LABELS[t],
|
||||
}));
|
||||
|
||||
const expiryWarnings = useMemo(
|
||||
() => checkExpiryDates(store.entities),
|
||||
[store.entities],
|
||||
);
|
||||
const expiringByEntityName = useMemo(() => {
|
||||
const byName: Record<string, number> = {};
|
||||
for (const w of expiryWarnings) byName[w.entityName] = (byName[w.entityName] ?? 0) + 1;
|
||||
return byName;
|
||||
}, [expiryWarnings]);
|
||||
const hasExpired = expiryWarnings.some((w) => w.isExpired);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '1rem', height: '100%', minHeight: 400 }}>
|
||||
{/* Left: entity list grouped by type */}
|
||||
<div style={{ width: '35%', borderRight: '1px solid var(--mantine-color-default-border)', paddingRight: '0.75rem', overflowY: 'auto' }}>
|
||||
<Stack gap="xs">
|
||||
{expiryWarnings.length > 0 && (
|
||||
<Alert color={hasExpired ? 'red' : 'yellow'} variant="light" p="xs">
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={600}>
|
||||
{hasExpired ? 'Expired credentials' : 'Expiring soon'}
|
||||
</Text>
|
||||
{expiryWarnings.map((w, i) => {
|
||||
const entity = store.entities.find((e) => e.name === w.entityName);
|
||||
const label = `${w.entityName} — ${w.fieldKey}: ${
|
||||
w.isExpired
|
||||
? `expired ${Math.abs(w.daysUntilExpiry)} days ago`
|
||||
: `expires in ${w.daysUntilExpiry} days`
|
||||
}`;
|
||||
return entity ? (
|
||||
<Text
|
||||
key={i}
|
||||
size="xs"
|
||||
style={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||
onClick={() => setSelectedId(entity.id)}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
) : (
|
||||
<Text key={i} size="xs">
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Create new entity */}
|
||||
<Group gap={4}>
|
||||
<Select
|
||||
|
||||
@@ -23,10 +23,14 @@ export function ProfileManagementModal({ opened, onClose, knowledge }: ProfileMa
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Entity Management"
|
||||
size="lg"
|
||||
size="90%"
|
||||
centered
|
||||
styles={{
|
||||
content: { height: '90vh', display: 'flex', flexDirection: 'column' },
|
||||
body: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' },
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Stack gap="md" style={{ flex: 1, minHeight: 0 }}>
|
||||
<SegmentedControl
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
@@ -37,11 +41,13 @@ export function ProfileManagementModal({ opened, onClose, knowledge }: ProfileMa
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{activeTab === 'entities' ? (
|
||||
<EntitiesTab store={knowledge.entityStore} />
|
||||
) : (
|
||||
<ImportTab documentImport={documentImport} knowledge={knowledge} />
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
{activeTab === 'entities' ? (
|
||||
<EntitiesTab store={knowledge.entityStore} />
|
||||
) : (
|
||||
<ImportTab documentImport={documentImport} knowledge={knowledge} />
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* REST client for the server-side AI Form Fill entity store.
|
||||
* The server owns authoritative state; localStorage is the anonymous/offline fallback.
|
||||
*/
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { Entity, EntityType } from './entityTypes';
|
||||
|
||||
interface ServerEntityDTO {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
fields: Record<string, string> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function fromServer(dto: ServerEntityDTO): Entity {
|
||||
return {
|
||||
id: dto.id,
|
||||
type: (dto.type as EntityType) ?? 'custom',
|
||||
name: dto.name,
|
||||
fields: dto.fields ?? {},
|
||||
createdAt: Date.parse(dto.createdAt),
|
||||
updatedAt: Date.parse(dto.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchEntities(): Promise<Entity[]> {
|
||||
const res = await apiClient.get<ServerEntityDTO[]>(
|
||||
'/api/v1/ai-form-fill/entities',
|
||||
{ suppressErrorToast: true } as any,
|
||||
);
|
||||
return (res.data ?? []).map(fromServer);
|
||||
}
|
||||
|
||||
export async function upsertEntity(entity: Entity): Promise<Entity> {
|
||||
const res = await apiClient.put<ServerEntityDTO>(
|
||||
`/api/v1/ai-form-fill/entities/${encodeURIComponent(entity.id)}`,
|
||||
{
|
||||
type: entity.type,
|
||||
name: entity.name,
|
||||
fields: entity.fields,
|
||||
},
|
||||
);
|
||||
return fromServer(res.data);
|
||||
}
|
||||
|
||||
export async function deleteEntityRemote(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/ai-form-fill/entities/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function importEntities(entities: Entity[]): Promise<Entity[]> {
|
||||
if (entities.length === 0) return [];
|
||||
const res = await apiClient.post<ServerEntityDTO[]>(
|
||||
'/api/v1/ai-form-fill/entities/import',
|
||||
{
|
||||
entities: entities.map((e) => ({
|
||||
id: e.id,
|
||||
entity: { type: e.type, name: e.name, fields: e.fields },
|
||||
})),
|
||||
},
|
||||
);
|
||||
return (res.data ?? []).map(fromServer);
|
||||
}
|
||||
@@ -116,6 +116,16 @@ export function useBatchFormFillFlow(
|
||||
return true;
|
||||
});
|
||||
|
||||
if (dedupedRequests.length === 0) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
phase: 'error',
|
||||
error:
|
||||
'Nothing to fill yet — assign an entity to at least one role before filling.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build batch request — one entry per file, with merged knowledge
|
||||
const rawKnowledge = dedupedRequests[0]?.mergeResult.knowledge || {};
|
||||
const batchRequest = {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
/**
|
||||
* Entity store — localStorage-backed typed entity management.
|
||||
* Replaces the flat profile store. Migrates v1 profiles to v2 entities on first load.
|
||||
* Entity store — typed entity management.
|
||||
*
|
||||
* Authoritative state lives server-side (see FormFillEntityController) when the user is
|
||||
* logged in. localStorage remains the anonymous/offline fallback and first-paint cache so
|
||||
* the UI stays synchronous. Mutations update local state instantly and fire to the server
|
||||
* in the background; server failures don't block the UI.
|
||||
*/
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import type { Entity, EntityType, EntityStoreData } from './entityTypes';
|
||||
import {
|
||||
fetchEntities,
|
||||
upsertEntity as upsertEntityRemote,
|
||||
deleteEntityRemote,
|
||||
importEntities as importEntitiesRemote,
|
||||
} from './entityApiClient';
|
||||
|
||||
const STORAGE_KEY = 'stirling-pdf-ai-profiles';
|
||||
|
||||
@@ -113,8 +124,49 @@ export interface EntityStore {
|
||||
}
|
||||
|
||||
export function useEntityStore(): EntityStore {
|
||||
const { user } = useAuth();
|
||||
const isAuthenticated = !!user;
|
||||
const [data, setData] = useState<EntityStoreData>(loadFromStorage);
|
||||
|
||||
// Stable ref so async callbacks can read the latest state without re-binding.
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
|
||||
// Hydrate from server on login. Server wins over local when it has rows; if the server
|
||||
// is empty and local has entries, push local up as a one-shot migration.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const remote = await fetchEntities();
|
||||
if (cancelled) return;
|
||||
if (remote.length > 0) {
|
||||
const entities: Record<string, Entity> = {};
|
||||
for (const e of remote) entities[e.id] = e;
|
||||
const localDefault = dataRef.current.defaultEntityId;
|
||||
const defaultEntityId =
|
||||
localDefault && entities[localDefault]
|
||||
? localDefault
|
||||
: remote[0]?.id ?? null;
|
||||
const next: EntityStoreData = { version: 2, entities, defaultEntityId };
|
||||
setData(next);
|
||||
saveToStorage(next);
|
||||
} else {
|
||||
const localEntities = Object.values(dataRef.current.entities);
|
||||
if (localEntities.length > 0) {
|
||||
importEntitiesRemote(localEntities).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* offline or server unavailable — keep local state */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const update = useCallback((fn: (prev: EntityStoreData) => EntityStoreData) => {
|
||||
setData((prev) => {
|
||||
const next = fn(prev);
|
||||
@@ -123,6 +175,22 @@ export function useEntityStore(): EntityStore {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const syncEntity = useCallback(
|
||||
(entity: Entity) => {
|
||||
if (!isAuthenticated) return;
|
||||
upsertEntityRemote(entity).catch(() => {});
|
||||
},
|
||||
[isAuthenticated],
|
||||
);
|
||||
|
||||
const syncDelete = useCallback(
|
||||
(id: string) => {
|
||||
if (!isAuthenticated) return;
|
||||
deleteEntityRemote(id).catch(() => {});
|
||||
},
|
||||
[isAuthenticated],
|
||||
);
|
||||
|
||||
const entities = useMemo(() => Object.values(data.entities), [data.entities]);
|
||||
|
||||
const getEntity = useCallback(
|
||||
@@ -153,22 +221,23 @@ export function useEntityStore(): EntityStore {
|
||||
...prev,
|
||||
entities: { ...prev.entities, [entity.id]: entity },
|
||||
}));
|
||||
syncEntity(entity);
|
||||
return entity;
|
||||
}, [update]);
|
||||
}, [update, syncEntity]);
|
||||
|
||||
const updateEntity = useCallback((id: string, updates: Partial<Pick<Entity, 'name' | 'type'>>) => {
|
||||
let updated: Entity | undefined;
|
||||
update((prev) => {
|
||||
const entity = prev.entities[id];
|
||||
if (!entity) return prev;
|
||||
updated = { ...entity, ...updates, updatedAt: Date.now() };
|
||||
return {
|
||||
...prev,
|
||||
entities: {
|
||||
...prev.entities,
|
||||
[id]: { ...entity, ...updates, updatedAt: Date.now() },
|
||||
},
|
||||
entities: { ...prev.entities, [id]: updated },
|
||||
};
|
||||
});
|
||||
}, [update]);
|
||||
if (updated) syncEntity(updated);
|
||||
}, [update, syncEntity]);
|
||||
|
||||
const deleteEntity = useCallback((id: string) => {
|
||||
update((prev) => {
|
||||
@@ -182,7 +251,8 @@ export function useEntityStore(): EntityStore {
|
||||
: prev.defaultEntityId,
|
||||
};
|
||||
});
|
||||
}, [update]);
|
||||
syncDelete(id);
|
||||
}, [update, syncDelete]);
|
||||
|
||||
const duplicateEntity = useCallback((id: string, newName: string): Entity => {
|
||||
const source = data.entities[id];
|
||||
@@ -198,78 +268,79 @@ export function useEntityStore(): EntityStore {
|
||||
...prev,
|
||||
entities: { ...prev.entities, [entity.id]: entity },
|
||||
}));
|
||||
syncEntity(entity);
|
||||
return entity;
|
||||
}, [data.entities, update]);
|
||||
}, [data.entities, update, syncEntity]);
|
||||
|
||||
const setDefaultEntity = useCallback((id: string) => {
|
||||
update((prev) => ({ ...prev, defaultEntityId: id }));
|
||||
}, [update]);
|
||||
|
||||
const setField = useCallback((entityId: string, key: string, value: string) => {
|
||||
let updated: Entity | undefined;
|
||||
update((prev) => {
|
||||
const entity = prev.entities[entityId];
|
||||
if (!entity) return prev;
|
||||
updated = {
|
||||
...entity,
|
||||
fields: { ...entity.fields, [key]: value },
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
entities: {
|
||||
...prev.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
fields: { ...entity.fields, [key]: value },
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
entities: { ...prev.entities, [entityId]: updated },
|
||||
};
|
||||
});
|
||||
}, [update]);
|
||||
if (updated) syncEntity(updated);
|
||||
}, [update, syncEntity]);
|
||||
|
||||
const setManyFields = useCallback((entityId: string, fields: Record<string, string>) => {
|
||||
let updated: Entity | undefined;
|
||||
update((prev) => {
|
||||
const entity = prev.entities[entityId];
|
||||
if (!entity) return prev;
|
||||
updated = {
|
||||
...entity,
|
||||
fields: { ...entity.fields, ...fields },
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
entities: {
|
||||
...prev.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
fields: { ...entity.fields, ...fields },
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
entities: { ...prev.entities, [entityId]: updated },
|
||||
};
|
||||
});
|
||||
}, [update]);
|
||||
if (updated) syncEntity(updated);
|
||||
}, [update, syncEntity]);
|
||||
|
||||
const removeField = useCallback((entityId: string, key: string) => {
|
||||
let updated: Entity | undefined;
|
||||
update((prev) => {
|
||||
const entity = prev.entities[entityId];
|
||||
if (!entity) return prev;
|
||||
const fields = { ...entity.fields };
|
||||
delete fields[key];
|
||||
updated = { ...entity, fields, updatedAt: Date.now() };
|
||||
return {
|
||||
...prev,
|
||||
entities: {
|
||||
...prev.entities,
|
||||
[entityId]: { ...entity, fields, updatedAt: Date.now() },
|
||||
},
|
||||
entities: { ...prev.entities, [entityId]: updated },
|
||||
};
|
||||
});
|
||||
}, [update]);
|
||||
if (updated) syncEntity(updated);
|
||||
}, [update, syncEntity]);
|
||||
|
||||
const clearFields = useCallback((entityId: string) => {
|
||||
let updated: Entity | undefined;
|
||||
update((prev) => {
|
||||
const entity = prev.entities[entityId];
|
||||
if (!entity) return prev;
|
||||
updated = { ...entity, fields: {}, updatedAt: Date.now() };
|
||||
return {
|
||||
...prev,
|
||||
entities: {
|
||||
...prev.entities,
|
||||
[entityId]: { ...entity, fields: {}, updatedAt: Date.now() },
|
||||
},
|
||||
entities: { ...prev.entities, [entityId]: updated },
|
||||
};
|
||||
});
|
||||
}, [update]);
|
||||
if (updated) syncEntity(updated);
|
||||
}, [update, syncEntity]);
|
||||
|
||||
const selectData = useMemo(() => {
|
||||
const groups: Array<{ group: string; items: Array<{ value: string; label: string }> }> = [];
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* REST client for the server-side AI Form Fill workflow template store.
|
||||
*/
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { WorkflowTemplate } from './workflowTemplates';
|
||||
|
||||
interface ServerTemplateDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
formSignature: string;
|
||||
roleEntityMap: Record<string, string> | null;
|
||||
fileOverrides: Record<string, Record<string, string>> | null;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
function fromServer(dto: ServerTemplateDTO): WorkflowTemplate {
|
||||
return {
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
formSignature: dto.formSignature,
|
||||
roleEntityMap: dto.roleEntityMap ?? {},
|
||||
fileOverrides: dto.fileOverrides ?? {},
|
||||
createdAt: Date.parse(dto.createdAt),
|
||||
lastUsedAt: dto.lastUsedAt ? Date.parse(dto.lastUsedAt) : Date.parse(dto.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchWorkflowTemplates(): Promise<WorkflowTemplate[]> {
|
||||
const res = await apiClient.get<ServerTemplateDTO[]>(
|
||||
'/api/v1/ai-form-fill/workflow-templates',
|
||||
{ suppressErrorToast: true } as any,
|
||||
);
|
||||
return (res.data ?? []).map(fromServer);
|
||||
}
|
||||
|
||||
export async function upsertWorkflowTemplate(
|
||||
template: WorkflowTemplate,
|
||||
): Promise<WorkflowTemplate> {
|
||||
const res = await apiClient.put<ServerTemplateDTO>(
|
||||
`/api/v1/ai-form-fill/workflow-templates/${encodeURIComponent(template.id)}`,
|
||||
{
|
||||
name: template.name,
|
||||
formSignature: template.formSignature,
|
||||
roleEntityMap: template.roleEntityMap,
|
||||
fileOverrides: template.fileOverrides,
|
||||
},
|
||||
);
|
||||
return fromServer(res.data);
|
||||
}
|
||||
|
||||
export async function touchWorkflowTemplateRemote(id: string): Promise<WorkflowTemplate> {
|
||||
const res = await apiClient.post<ServerTemplateDTO>(
|
||||
`/api/v1/ai-form-fill/workflow-templates/${encodeURIComponent(id)}/touch`,
|
||||
);
|
||||
return fromServer(res.data);
|
||||
}
|
||||
|
||||
export async function deleteWorkflowTemplateRemote(id: string): Promise<void> {
|
||||
await apiClient.delete(
|
||||
`/api/v1/ai-form-fill/workflow-templates/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
/**
|
||||
* Saved workflow templates — remember form + role + entity mappings for repeat use.
|
||||
* Also handles dynamic values ({{today}}, date formatting) and quick fill.
|
||||
*
|
||||
* Server-side storage is used via {@link useWorkflowStore} when the user is logged in;
|
||||
* {@link createWorkflowStore} remains the local-only factory for anonymous contexts.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import {
|
||||
fetchWorkflowTemplates,
|
||||
upsertWorkflowTemplate,
|
||||
touchWorkflowTemplateRemote,
|
||||
deleteWorkflowTemplateRemote,
|
||||
} from './workflowTemplateApiClient';
|
||||
|
||||
const TEMPLATES_STORAGE_KEY = 'stirling-pdf-ai-workflows';
|
||||
|
||||
@@ -97,6 +108,107 @@ export function createWorkflowStore(): WorkflowStore {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook variant of {@link createWorkflowStore} that syncs with the server-side store when
|
||||
* the user is authenticated. Follows the same pattern as {@code useEntityStore}: local-first for
|
||||
* synchronous UI, server mutations fired in the background, server state wins on hydrate.
|
||||
*/
|
||||
export function useWorkflowStore(): WorkflowStore {
|
||||
const { user } = useAuth();
|
||||
const isAuthenticated = !!user;
|
||||
const [templates, setTemplates] = useState<WorkflowTemplate[]>(() => loadTemplates());
|
||||
const templatesRef = useRef(templates);
|
||||
templatesRef.current = templates;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const remote = await fetchWorkflowTemplates();
|
||||
if (cancelled) return;
|
||||
if (remote.length > 0) {
|
||||
setTemplates(remote);
|
||||
saveTemplates(remote);
|
||||
} else if (templatesRef.current.length > 0) {
|
||||
for (const t of templatesRef.current) {
|
||||
upsertWorkflowTemplate(t).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* offline / unauthorised — keep local state */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const persist = useCallback((next: WorkflowTemplate[]) => {
|
||||
setTemplates(next);
|
||||
saveTemplates(next);
|
||||
}, []);
|
||||
|
||||
const findBySignature = useCallback(
|
||||
(signature: string) => templates.find((t) => t.formSignature === signature),
|
||||
[templates],
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
(template: Omit<WorkflowTemplate, 'id' | 'createdAt' | 'lastUsedAt'>): WorkflowTemplate => {
|
||||
const full: WorkflowTemplate = {
|
||||
...template,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: Date.now(),
|
||||
};
|
||||
persist([...templatesRef.current, full]);
|
||||
if (isAuthenticated) upsertWorkflowTemplate(full).catch(() => {});
|
||||
return full;
|
||||
},
|
||||
[persist, isAuthenticated],
|
||||
);
|
||||
|
||||
const updateLastUsed = useCallback(
|
||||
(id: string) => {
|
||||
persist(
|
||||
templatesRef.current.map((t) =>
|
||||
t.id === id ? { ...t, lastUsedAt: Date.now() } : t,
|
||||
),
|
||||
);
|
||||
if (isAuthenticated) touchWorkflowTemplateRemote(id).catch(() => {});
|
||||
},
|
||||
[persist, isAuthenticated],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(id: string) => {
|
||||
persist(templatesRef.current.filter((t) => t.id !== id));
|
||||
if (isAuthenticated) deleteWorkflowTemplateRemote(id).catch(() => {});
|
||||
},
|
||||
[persist, isAuthenticated],
|
||||
);
|
||||
|
||||
const removeAll = useCallback(() => {
|
||||
const ids = templatesRef.current.map((t) => t.id);
|
||||
persist([]);
|
||||
if (isAuthenticated) {
|
||||
for (const id of ids) deleteWorkflowTemplateRemote(id).catch(() => {});
|
||||
}
|
||||
}, [persist, isAuthenticated]);
|
||||
|
||||
return {
|
||||
get templates() {
|
||||
return templates;
|
||||
},
|
||||
findBySignature,
|
||||
save,
|
||||
updateLastUsed,
|
||||
remove,
|
||||
removeAll,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Dynamic Values ---
|
||||
|
||||
const DYNAMIC_PATTERNS: Record<string, () => string> = {
|
||||
|
||||
Reference in New Issue
Block a user