Add server-side persistence for AI Form Fill entities and workflow templates

This commit is contained in:
Reece
2026-04-27 14:21:24 +01:00
parent 38f5227ba1
commit a969c5035b
22 changed files with 1154 additions and 71 deletions
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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();
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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> = {