diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/controller/FormFillEntityController.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/controller/FormFillEntityController.java new file mode 100644 index 0000000000..33b73a16f9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/controller/FormFillEntityController.java @@ -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 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 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 importBatch( + @Valid @RequestBody FormFillEntityImportRequest request) { + User user = service.requireAuthenticatedUser(); + return service.importBatch(user, request).stream().map(FormFillEntityDTO::from).toList(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/controller/FormFillWorkflowTemplateController.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/controller/FormFillWorkflowTemplateController.java new file mode 100644 index 0000000000..78c9b5a075 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/controller/FormFillWorkflowTemplateController.java @@ -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 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 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 delete(@PathVariable String id) { + User user = entityService.requireAuthenticatedUser(); + service.delete(user, id); + return ResponseEntity.noContent().build(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillEntityDTO.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillEntityDTO.java new file mode 100644 index 0000000000..076fe4280c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillEntityDTO.java @@ -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 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(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillEntityImportRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillEntityImportRequest.java new file mode 100644 index 0000000000..09b511a1cf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillEntityImportRequest.java @@ -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 entities; + + @Getter + @Setter + @NoArgsConstructor + public static class Item { + @NotBlank private String id; + @Valid private FormFillEntityUpsertRequest entity; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillEntityUpsertRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillEntityUpsertRequest.java new file mode 100644 index 0000000000..bb1a8121eb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillEntityUpsertRequest.java @@ -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 fields; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillWorkflowTemplateDTO.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillWorkflowTemplateDTO.java new file mode 100644 index 0000000000..d85296e6f7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillWorkflowTemplateDTO.java @@ -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 roleEntityMap; + private final Map> 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(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillWorkflowTemplateUpsertRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillWorkflowTemplateUpsertRequest.java new file mode 100644 index 0000000000..6825125f81 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/dto/FormFillWorkflowTemplateUpsertRequest.java @@ -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 roleEntityMap; + + private Map> fileOverrides; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/model/FormFillEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/model/FormFillEntity.java new file mode 100644 index 0000000000..8e3dc88b5a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/model/FormFillEntity.java @@ -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}. + * + *

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 fields = new HashMap<>(); + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/model/FormFillWorkflowTemplate.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/model/FormFillWorkflowTemplate.java new file mode 100644 index 0000000000..4fac5f2185 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/model/FormFillWorkflowTemplate.java @@ -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. + * + *

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 roleEntityMap = new HashMap<>(); + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "file_overrides", columnDefinition = "jsonb") + private Map> fileOverrides = new HashMap<>(); + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/repository/FormFillEntityRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/repository/FormFillEntityRepository.java new file mode 100644 index 0000000000..4e860987ca --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/repository/FormFillEntityRepository.java @@ -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 { + + List findAllByOwnerOrderByUpdatedAtDesc(User owner); + + Optional findByIdAndOwner(String id, User owner); + + long countByOwner(User owner); + + void deleteByIdAndOwner(String id, User owner); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/repository/FormFillWorkflowTemplateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/repository/FormFillWorkflowTemplateRepository.java new file mode 100644 index 0000000000..457581ff80 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/repository/FormFillWorkflowTemplateRepository.java @@ -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 { + + List findAllByOwnerOrderByLastUsedAtDescCreatedAtDesc(User owner); + + Optional findByIdAndOwner(String id, User owner); + + Optional findByOwnerAndFormSignature( + User owner, String formSignature); + + void deleteByIdAndOwner(String id, User owner); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/service/FormFillEntityService.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/service/FormFillEntityService.java new file mode 100644 index 0000000000..8466e895f5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/service/FormFillEntityService.java @@ -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 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 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"); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/service/FormFillWorkflowTemplateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/service/FormFillWorkflowTemplateService.java new file mode 100644 index 0000000000..e09c820fbf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/aiformfill/service/FormFillWorkflowTemplateService.java @@ -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 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 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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index eddc0faf4a..e1c560cf5d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -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 { diff --git a/frontend/src/core/tools/aiFormFill/AiFormFill.tsx b/frontend/src/core/tools/aiFormFill/AiFormFill.tsx index f2212b0428..0659f3842c 100644 --- a/frontend/src/core/tools/aiFormFill/AiFormFill.tsx +++ b/frontend/src/core/tools/aiFormFill/AiFormFill.tsx @@ -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('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.')} - {/* Expiry warnings */} - {expiryWarnings.length > 0 && ( - w.isExpired) ? 'red' : 'yellow'} variant="light"> - - - {expiryWarnings.some((w) => w.isExpired) ? 'Expired credentials!' : 'Expiring soon'} - - {expiryWarnings.map((w, i) => ( - - {w.entityName} — {w.fieldKey}: {w.isExpired ? `expired ${Math.abs(w.daysUntilExpiry)} days ago` : `expires in ${w.daysUntilExpiry} days`} - - ))} - - - )}

@@ -399,6 +378,17 @@ const AiFormFill = (_props: BaseToolProps) => {
+ {batchFill.error && ( + } + color="red" + variant="light" + withCloseButton + onClose={batchFill.reset} + > + {batchFill.error} + + )} {viewMode === 'roles' ? ( // Role-grouped view roles.map((role) => ( diff --git a/frontend/src/core/tools/aiFormFill/EntitiesTab.tsx b/frontend/src/core/tools/aiFormFill/EntitiesTab.tsx index 4e3fe8837f..4df99a433e 100644 --- a/frontend/src/core/tools/aiFormFill/EntitiesTab.tsx +++ b/frontend/src/core/tools/aiFormFill/EntitiesTab.tsx @@ -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 = {}; + for (const w of expiryWarnings) byName[w.entityName] = (byName[w.entityName] ?? 0) + 1; + return byName; + }, [expiryWarnings]); + const hasExpired = expiryWarnings.some((w) => w.isExpired); + return (
{/* Left: entity list grouped by type */}
+ {expiryWarnings.length > 0 && ( + + + + {hasExpired ? 'Expired credentials' : 'Expiring soon'} + + {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 ? ( + setSelectedId(entity.id)} + > + {label} + + ) : ( + + {label} + + ); + })} + + + )} + {/* Create new entity */}