add server-backed watch folder persistence

This commit is contained in:
Reece
2026-04-22 17:58:17 +01:00
parent db6710eb9c
commit 11d8d513cd
28 changed files with 1834 additions and 25 deletions
@@ -9,6 +9,7 @@ import java.util.List;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.core.env.Environment;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
@@ -1003,6 +1004,52 @@ public class GlobalExceptionHandler {
* @param request the HTTP servlet request
* @return ProblemDetail with HTTP 400 BAD_REQUEST
*/
/**
* Handle {@link DataIntegrityViolationException} — typically a unique-constraint violation or a
* PK collision surfacing from a Spring Data repository write. Mapped to {@code 409 Conflict} so
* clients can tell the difference between "bad input" (400) and "that key is already taken by
* someone else" (409).
*
* @param ex the DataIntegrityViolationException
* @param request the HTTP servlet request
* @return ProblemDetail with 409 Conflict status
*/
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ProblemDetail> handleDataIntegrityViolation(
DataIntegrityViolationException ex, HttpServletRequest request) {
log.warn("Data integrity violation at {}: {}", request.getRequestURI(), ex.getMessage());
String title =
getLocalizedMessage(
"error.conflict.title", ErrorTitles.CONFLICT_DEFAULT);
// Do NOT leak the underlying SQL / constraint message — the root cause often includes DB
// identifiers that we'd rather not echo back to the caller. A fixed, neutral detail is
// enough for the client to understand the outcome.
ProblemDetail problemDetail =
createBaseProblemDetail(
HttpStatus.CONFLICT,
"The request conflicts with existing data. The resource already exists or"
+ " violates a uniqueness constraint.",
request);
problemDetail.setType(URI.create(ErrorTypes.CONFLICT));
problemDetail.setTitle(title);
problemDetail.setProperty("title", title);
addStandardHints(
problemDetail,
"error.conflict.hints",
List.of(
"Check whether a resource with the same identifier already exists.",
"Retry with a different identifier, or update the existing resource"
+ " instead.",
"If this was a concurrent write, re-fetch the latest state and retry."));
problemDetail.setProperty("actionRequired", "Resolve the conflict and retry.");
return ResponseEntity.status(HttpStatus.CONFLICT)
.contentType(PROBLEM_JSON)
.body(problemDetail);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ProblemDetail> handleIllegalArgument(
IllegalArgumentException ex, HttpServletRequest request) {
@@ -1378,6 +1425,7 @@ public class GlobalExceptionHandler {
static final String INVALID_ARGUMENT = "/errors/invalid-argument";
static final String IO_ERROR = "/errors/io-error";
static final String UNEXPECTED = "/errors/unexpected";
static final String CONFLICT = "/errors/conflict";
}
/** Constants for default error titles. */
@@ -1405,5 +1453,6 @@ public class GlobalExceptionHandler {
static final String INVALID_ARGUMENT_DEFAULT = "Invalid Argument";
static final String IO_ERROR_DEFAULT = "File Processing Error";
static final String UNEXPECTED_DEFAULT = "Internal Server Error";
static final String CONFLICT_DEFAULT = "Conflict";
}
}
@@ -0,0 +1,100 @@
package stirling.software.proprietary.controller.api;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.WatchFolder;
import stirling.software.proprietary.model.WatchFolderFile;
import stirling.software.proprietary.model.WatchFolderRun;
import stirling.software.proprietary.service.WatchFolderService;
@RestController
@RequestMapping("/api/v1/watch-folders")
@PreAuthorize("isAuthenticated()")
@RequiredArgsConstructor
public class WatchFolderController {
/** Upper bound for /runs/batch payloads, to stop a single request from inserting millions. */
private static final int RUNS_BATCH_MAX = 500;
private final WatchFolderService service;
// ── Folder CRUD ────────────────────────────────────────────────────────
@GetMapping
public List<WatchFolder> list() {
return service.listFolders();
}
@GetMapping("/{id}")
public ResponseEntity<WatchFolder> get(@PathVariable String id) {
return service.getFolder(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<WatchFolder> create(@Valid @RequestBody WatchFolder folder) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.createFolder(folder));
}
@PutMapping("/{id}")
public ResponseEntity<WatchFolder> update(
@PathVariable String id, @Valid @RequestBody WatchFolder folder) {
return ResponseEntity.ok(service.updateFolder(id, folder));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteFolder(id);
return ResponseEntity.noContent().build();
}
// ── Folder files ───────────────────────────────────────────────────────
@GetMapping("/{folderId}/files")
public List<WatchFolderFile> listFiles(@PathVariable String folderId) {
return service.listFiles(folderId);
}
@PutMapping("/{folderId}/files")
public WatchFolderFile upsertFile(
@PathVariable String folderId, @Valid @RequestBody WatchFolderFile file) {
return service.upsertFile(folderId, file);
}
@DeleteMapping("/{folderId}/files")
public ResponseEntity<Void> deleteFiles(@PathVariable String folderId) {
service.deleteFiles(folderId);
return ResponseEntity.noContent().build();
}
// ── Folder runs ────────────────────────────────────────────────────────
@GetMapping("/{folderId}/runs")
public List<WatchFolderRun> listRuns(@PathVariable String folderId) {
return service.listRuns(folderId);
}
@PostMapping("/{folderId}/runs")
public ResponseEntity<WatchFolderRun> addRun(
@PathVariable String folderId, @Valid @RequestBody WatchFolderRun run) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.addRun(folderId, run));
}
@PostMapping("/{folderId}/runs/batch")
public ResponseEntity<List<WatchFolderRun>> addRuns(
@PathVariable String folderId,
@Valid @RequestBody @Size(max = RUNS_BATCH_MAX) List<WatchFolderRun> runs) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.addRuns(folderId, runs));
}
}
@@ -0,0 +1,140 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;
import stirling.software.proprietary.model.watchfolder.FolderScope;
import stirling.software.proprietary.model.watchfolder.InputSource;
import stirling.software.proprietary.model.watchfolder.OutputMode;
import stirling.software.proprietary.model.watchfolder.OutputNamePosition;
import stirling.software.proprietary.model.watchfolder.ProcessingMode;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(
name = "watch_folders",
indexes = {
@Index(name = "idx_wf_owner", columnList = "owner_id"),
@Index(name = "idx_wf_scope", columnList = "scope"),
})
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class WatchFolder implements Serializable {
private static final long serialVersionUID = 1L;
/**
* Client-supplied identifier (matches the IndexedDB folder id used by the React frontend).
* Using a String PK — rather than an IDENTITY {@code Long} like other entities — keeps the
* same opaque id across the local IDB cache and the server, so the frontend can round-trip a
* folder between offline/local mode and server mode without remapping. Callers are expected
* to supply a UUID (or equivalently collision-resistant string).
*/
@Id
@EqualsAndHashCode.Include
@NotBlank
@Size(max = 64)
@Column(name = "id", length = 64)
private String id;
@NotBlank
@Size(max = 255)
@Column(name = "name", nullable = false)
private String name;
@Size(max = 1024)
@Column(name = "description", length = 1024)
private String description;
/** JSON-serialised automation operations array — the full pipeline definition. */
@Size(max = 65_536)
@Column(name = "automation_config", columnDefinition = "TEXT")
private String automationConfig;
@Size(max = 64)
@Column(name = "icon", length = 64)
private String icon;
@Size(max = 16)
@Column(name = "accent_color", length = 16)
private String accentColor;
/** Visibility scope. Never null — defaulted to {@link FolderScope#PERSONAL}. */
@Column(name = "scope", nullable = false, length = 16)
private FolderScope scope = FolderScope.PERSONAL;
/** Owner of this folder. Null for ORGANISATION-scoped folders. */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id")
@JsonIgnore
private User owner;
@Column(name = "order_index")
private Integer orderIndex;
@Column(name = "is_default", nullable = false)
private Boolean isDefault = false;
@Column(name = "is_paused", nullable = false)
private Boolean isPaused = false;
@Column(name = "input_source", nullable = false, length = 32)
private InputSource inputSource = InputSource.IDB;
@Column(name = "processing_mode", nullable = false, length = 16)
private ProcessingMode processingMode = ProcessingMode.LOCAL;
@Column(name = "output_mode", nullable = false, length = 16)
private OutputMode outputMode = OutputMode.NEW_FILE;
@Size(max = 255)
@Column(name = "output_name", length = 255)
private String outputName;
@Column(name = "output_name_position", nullable = false, length = 16)
private OutputNamePosition outputNamePosition = OutputNamePosition.PREFIX;
@Column(name = "output_ttl_hours")
private Integer outputTtlHours;
@Column(name = "delete_output_on_download", nullable = false)
private Boolean deleteOutputOnDownload = false;
@Column(name = "max_retries", nullable = false)
private Integer maxRetries = 3;
@Column(name = "retry_delay_minutes", nullable = false)
private Integer retryDelayMinutes = 5;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "folder", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
private List<WatchFolderFile> files = new ArrayList<>();
@OneToMany(mappedBy = "folder", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
private List<WatchFolderRun> runs = new ArrayList<>();
}
@@ -0,0 +1,103 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;
import stirling.software.proprietary.model.watchfolder.FileStatus;
@Entity
@Table(
name = "watch_folder_files",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_wff_folder_file",
columnNames = {"folder_id", "file_id"})
},
indexes = {
@Index(name = "idx_wff_folder", columnList = "folder_id"),
@Index(name = "idx_wff_file_id", columnList = "file_id"),
@Index(name = "idx_wff_status", columnList = "status"),
})
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class WatchFolderFile implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@EqualsAndHashCode.Include
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "folder_id", nullable = false)
@JsonIgnore
private WatchFolder folder;
/** Client-side file identifier (matches the IDB file id). */
@NotBlank
@Size(max = 128)
@Column(name = "file_id", nullable = false, length = 128)
private String fileId;
@Column(name = "status", nullable = false, length = 16)
private FileStatus status = FileStatus.PENDING;
@Size(max = 1024)
@Column(name = "name", length = 1024)
private String name;
@Size(max = 4096)
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "failed_attempts", nullable = false)
private Integer failedAttempts = 0;
/**
* True when this row represents a file that the folder itself created / ingested (e.g. dropped
* on the folder from disk) and therefore owns — on folder deletion these files can be cleaned
* up. False when the file comes from the shared sidebar store and must be left alone.
*/
@Column(name = "owned_by_folder", nullable = false)
private Boolean ownedByFolder = false;
/**
* True while the file has been uploaded to the server-side watch folder and is awaiting
* processing by the pipeline directory processor (only meaningful when the owning folder's
* input source is {@code server-folder}).
*/
@Column(name = "pending_on_server", nullable = false)
private Boolean pendingOnServer = false;
/** JSON array of output file ids. */
@Size(max = 65_536)
@Column(name = "display_file_ids", columnDefinition = "TEXT")
private String displayFileIds;
/** JSON array of server-side output filenames. */
@Size(max = 65_536)
@Column(name = "server_output_filenames", columnDefinition = "TEXT")
private String serverOutputFilenames;
@CreationTimestamp
@Column(name = "added_at", updatable = false)
private LocalDateTime addedAt;
@Column(name = "processed_at")
private LocalDateTime processedAt;
}
@@ -0,0 +1,61 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;
import stirling.software.proprietary.model.watchfolder.RunStatus;
@Entity
@Table(
name = "watch_folder_runs",
indexes = {
@Index(name = "idx_wfr_folder", columnList = "folder_id"),
})
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class WatchFolderRun implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@EqualsAndHashCode.Include
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "folder_id", nullable = false)
@JsonIgnore
private WatchFolder folder;
@NotBlank
@Size(max = 128)
@Column(name = "input_file_id", nullable = false, length = 128)
private String inputFileId;
@Size(max = 128)
@Column(name = "display_file_id", length = 128)
private String displayFileId;
/** JSON array of all output file ids. */
@Size(max = 65_536)
@Column(name = "display_file_ids", columnDefinition = "TEXT")
private String displayFileIds;
@Column(name = "status", nullable = false, length = 16)
private RunStatus status = RunStatus.PROCESSING;
@Column(name = "processed_at")
private LocalDateTime processedAt;
}
@@ -0,0 +1,51 @@
package stirling.software.proprietary.model.watchfolder;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* Lifecycle status of a single file tracked inside a watch folder. Wire/DB values are lowercase and
* match the frontend {@code FolderFileMetadata.status} union.
*/
public enum FileStatus {
PENDING("pending"),
PROCESSING("processing"),
PROCESSED("processed"),
ERROR("error");
private final String wire;
FileStatus(String wire) {
this.wire = wire;
}
@JsonValue
public String wireValue() {
return wire;
}
@JsonCreator
public static FileStatus fromWire(String value) {
if (value == null) return null;
for (FileStatus s : values()) {
if (s.wire.equalsIgnoreCase(value)) return s;
}
throw new IllegalArgumentException("Unknown FileStatus: " + value);
}
@Converter(autoApply = true)
public static class DbConverter implements AttributeConverter<FileStatus, String> {
@Override
public String convertToDatabaseColumn(FileStatus attribute) {
return attribute == null ? null : attribute.wire;
}
@Override
public FileStatus convertToEntityAttribute(String dbData) {
return fromWire(dbData);
}
}
}
@@ -0,0 +1,48 @@
package stirling.software.proprietary.model.watchfolder;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* Visibility scope of a {@code WatchFolder}.
*
* <ul>
* <li>{@link #PERSONAL} — only the owner can see / modify the folder.</li>
* <li>{@link #ORGANISATION} — visible to every authenticated user; only admins may create or
* modify.</li>
* </ul>
*/
public enum FolderScope {
PERSONAL,
ORGANISATION;
@JsonValue
public String wireValue() {
return name();
}
@JsonCreator
public static FolderScope fromWire(String value) {
if (value == null) return null;
for (FolderScope s : values()) {
if (s.name().equalsIgnoreCase(value)) return s;
}
throw new IllegalArgumentException("Unknown FolderScope: " + value);
}
@Converter(autoApply = true)
public static class DbConverter implements AttributeConverter<FolderScope, String> {
@Override
public String convertToDatabaseColumn(FolderScope attribute) {
return attribute == null ? null : attribute.name();
}
@Override
public FolderScope convertToEntityAttribute(String dbData) {
return fromWire(dbData);
}
}
}
@@ -0,0 +1,55 @@
package stirling.software.proprietary.model.watchfolder;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* Where input files for a watch folder come from.
*
* <ul>
* <li>{@link #IDB} — files dropped / picked in the browser, stored in IndexedDB.</li>
* <li>{@link #LOCAL_FOLDER} — a real folder on the user's machine (desktop build).</li>
* <li>{@link #SERVER_FOLDER} — a directory watched on the server.</li>
* </ul>
*/
public enum InputSource {
IDB("idb"),
LOCAL_FOLDER("local-folder"),
SERVER_FOLDER("server-folder");
private final String wire;
InputSource(String wire) {
this.wire = wire;
}
@JsonValue
public String wireValue() {
return wire;
}
@JsonCreator
public static InputSource fromWire(String value) {
if (value == null) return null;
for (InputSource s : values()) {
if (s.wire.equalsIgnoreCase(value)) return s;
}
throw new IllegalArgumentException("Unknown InputSource: " + value);
}
@Converter(autoApply = true)
public static class DbConverter implements AttributeConverter<InputSource, String> {
@Override
public String convertToDatabaseColumn(InputSource attribute) {
return attribute == null ? null : attribute.wire;
}
@Override
public InputSource convertToEntityAttribute(String dbData) {
return fromWire(dbData);
}
}
}
@@ -0,0 +1,54 @@
package stirling.software.proprietary.model.watchfolder;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* How automation output files are produced.
*
* <ul>
* <li>{@link #NEW_FILE} — always produce a new, separately-named file.</li>
* <li>{@link #NEW_VERSION} — produce a new version of the input file (replacing / versioning
* semantics handled client-side).</li>
* </ul>
*/
public enum OutputMode {
NEW_FILE("new_file"),
NEW_VERSION("new_version");
private final String wire;
OutputMode(String wire) {
this.wire = wire;
}
@JsonValue
public String wireValue() {
return wire;
}
@JsonCreator
public static OutputMode fromWire(String value) {
if (value == null) return null;
for (OutputMode s : values()) {
if (s.wire.equalsIgnoreCase(value)) return s;
}
throw new IllegalArgumentException("Unknown OutputMode: " + value);
}
@Converter(autoApply = true)
public static class DbConverter implements AttributeConverter<OutputMode, String> {
@Override
public String convertToDatabaseColumn(OutputMode attribute) {
return attribute == null ? null : attribute.wire;
}
@Override
public OutputMode convertToEntityAttribute(String dbData) {
return fromWire(dbData);
}
}
}
@@ -0,0 +1,50 @@
package stirling.software.proprietary.model.watchfolder;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* Where the configured {@code outputName} string is placed relative to the input filename when
* building an output file name. {@link #AUTO_NUMBER} appends a monotonically increasing counter.
*/
public enum OutputNamePosition {
PREFIX("prefix"),
SUFFIX("suffix"),
AUTO_NUMBER("auto-number");
private final String wire;
OutputNamePosition(String wire) {
this.wire = wire;
}
@JsonValue
public String wireValue() {
return wire;
}
@JsonCreator
public static OutputNamePosition fromWire(String value) {
if (value == null) return null;
for (OutputNamePosition s : values()) {
if (s.wire.equalsIgnoreCase(value)) return s;
}
throw new IllegalArgumentException("Unknown OutputNamePosition: " + value);
}
@Converter(autoApply = true)
public static class DbConverter implements AttributeConverter<OutputNamePosition, String> {
@Override
public String convertToDatabaseColumn(OutputNamePosition attribute) {
return attribute == null ? null : attribute.wire;
}
@Override
public OutputNamePosition convertToEntityAttribute(String dbData) {
return fromWire(dbData);
}
}
}
@@ -0,0 +1,54 @@
package stirling.software.proprietary.model.watchfolder;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* Where the automation pipeline runs.
*
* <ul>
* <li>{@link #LOCAL} — runs entirely in the user's browser.</li>
* <li>{@link #SERVER} — runs on the server (forced when {@link InputSource#SERVER_FOLDER} is in
* use).</li>
* </ul>
*/
public enum ProcessingMode {
LOCAL("local"),
SERVER("server");
private final String wire;
ProcessingMode(String wire) {
this.wire = wire;
}
@JsonValue
public String wireValue() {
return wire;
}
@JsonCreator
public static ProcessingMode fromWire(String value) {
if (value == null) return null;
for (ProcessingMode s : values()) {
if (s.wire.equalsIgnoreCase(value)) return s;
}
throw new IllegalArgumentException("Unknown ProcessingMode: " + value);
}
@Converter(autoApply = true)
public static class DbConverter implements AttributeConverter<ProcessingMode, String> {
@Override
public String convertToDatabaseColumn(ProcessingMode attribute) {
return attribute == null ? null : attribute.wire;
}
@Override
public ProcessingMode convertToEntityAttribute(String dbData) {
return fromWire(dbData);
}
}
}
@@ -0,0 +1,49 @@
package stirling.software.proprietary.model.watchfolder;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* Status of a single run entry — a completed (or in-flight) execution of the folder's automation
* over one input file. Wire/DB values match the frontend {@code SmartFolderRunEntry.status} union.
*/
public enum RunStatus {
PROCESSING("processing"),
PROCESSED("processed");
private final String wire;
RunStatus(String wire) {
this.wire = wire;
}
@JsonValue
public String wireValue() {
return wire;
}
@JsonCreator
public static RunStatus fromWire(String value) {
if (value == null) return null;
for (RunStatus s : values()) {
if (s.wire.equalsIgnoreCase(value)) return s;
}
throw new IllegalArgumentException("Unknown RunStatus: " + value);
}
@Converter(autoApply = true)
public static class DbConverter implements AttributeConverter<RunStatus, String> {
@Override
public String convertToDatabaseColumn(RunStatus attribute) {
return attribute == null ? null : attribute.wire;
}
@Override
public RunStatus convertToEntityAttribute(String dbData) {
return fromWire(dbData);
}
}
}
@@ -0,0 +1,26 @@
package stirling.software.proprietary.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import stirling.software.proprietary.model.WatchFolderFile;
@Repository
public interface WatchFolderFileRepository extends JpaRepository<WatchFolderFile, Long> {
List<WatchFolderFile> findByFolderIdOrderByAddedAtDesc(String folderId);
Optional<WatchFolderFile> findByFolderIdAndFileId(String folderId, String fileId);
@Modifying
@Transactional
@Query("DELETE FROM WatchFolderFile f WHERE f.folder.id = :folderId")
int deleteAllByFolderId(@Param("folderId") String folderId);
}
@@ -0,0 +1,32 @@
package stirling.software.proprietary.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.model.WatchFolder;
import stirling.software.proprietary.model.watchfolder.FolderScope;
@Repository
public interface WatchFolderRepository extends JpaRepository<WatchFolder, String> {
List<WatchFolder> findByOwnerIdOrderByOrderIndexAscCreatedAtAsc(Long ownerId);
List<WatchFolder> findByScopeOrderByOrderIndexAscCreatedAtAsc(FolderScope scope);
/**
* Return all folders visible to a user: those they own plus any folder with the given scope
* (normally {@link FolderScope#ORGANISATION}). A secondary sort on {@code createdAt} keeps
* output stable across databases when {@code orderIndex} is null for multiple rows.
*/
@Query(
"SELECT f FROM WatchFolder f "
+ "WHERE (f.owner IS NOT NULL AND f.owner.id = :ownerId) "
+ "OR f.scope = :scope "
+ "ORDER BY f.orderIndex ASC, f.createdAt ASC")
List<WatchFolder> findVisibleToUser(
@Param("ownerId") Long ownerId, @Param("scope") FolderScope scope);
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import stirling.software.proprietary.model.WatchFolderRun;
@Repository
public interface WatchFolderRunRepository extends JpaRepository<WatchFolderRun, Long> {
List<WatchFolderRun> findByFolderIdOrderByProcessedAtDesc(String folderId);
@Modifying
@Transactional
@Query("DELETE FROM WatchFolderRun r WHERE r.folder.id = :folderId")
int deleteAllByFolderId(@Param("folderId") String folderId);
}
@@ -0,0 +1,294 @@
package stirling.software.proprietary.service;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.WatchFolder;
import stirling.software.proprietary.model.WatchFolderFile;
import stirling.software.proprietary.model.WatchFolderRun;
import stirling.software.proprietary.model.watchfolder.FolderScope;
import stirling.software.proprietary.repository.WatchFolderFileRepository;
import stirling.software.proprietary.repository.WatchFolderRepository;
import stirling.software.proprietary.repository.WatchFolderRunRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@Service
@RequiredArgsConstructor
public class WatchFolderService {
private final WatchFolderRepository folderRepo;
private final WatchFolderFileRepository fileRepo;
private final WatchFolderRunRepository runRepo;
private final UserRepository userRepo;
private final UserService userService;
// ── Folder CRUD ────────────────────────────────────────────────────────
/** Get all folders the current user can see (their own + organisation). */
@Transactional(readOnly = true)
public List<WatchFolder> listFolders() {
User user = currentUser();
if (user == null) return List.of();
return folderRepo.findVisibleToUser(user.getId(), FolderScope.ORGANISATION);
}
@Transactional(readOnly = true)
public Optional<WatchFolder> getFolder(String id) {
return folderRepo.findById(id).filter(this::canRead);
}
@Transactional
public WatchFolder createFolder(WatchFolder folder) {
// Reject id collisions explicitly rather than letting JpaRepository.save() silently merge
// into someone else's folder row. This closes a takeover vector where a caller POSTs a
// payload whose id matches an existing admin-owned ORGANISATION folder.
if (folder.getId() != null && folderRepo.existsById(folder.getId())) {
throw new DataIntegrityViolationException(
"Watch folder with id '" + folder.getId() + "' already exists");
}
if (FolderScope.ORGANISATION.equals(folder.getScope())) {
requireAdmin();
folder.setOwner(null);
} else {
// Force any missing / non-ORGANISATION scope to PERSONAL owned by the caller. This also
// prevents a non-admin client from "promoting" by omitting scope and relying on the
// default — createFolder is the server's chance to establish ownership.
folder.setScope(FolderScope.PERSONAL);
folder.setOwner(requireCurrentUser());
}
return folderRepo.save(folder);
}
@Transactional
public WatchFolder updateFolder(String id, WatchFolder updates) {
WatchFolder existing =
folderRepo
.findById(id)
.orElseThrow(
() -> new IllegalArgumentException("Folder not found: " + id));
requireWriteAccess(existing);
existing.setName(updates.getName());
existing.setDescription(updates.getDescription());
existing.setAutomationConfig(updates.getAutomationConfig());
existing.setIcon(updates.getIcon());
existing.setAccentColor(updates.getAccentColor());
existing.setOrderIndex(updates.getOrderIndex());
existing.setIsPaused(updates.getIsPaused());
existing.setInputSource(updates.getInputSource());
existing.setProcessingMode(updates.getProcessingMode());
existing.setOutputMode(updates.getOutputMode());
existing.setOutputName(updates.getOutputName());
existing.setOutputNamePosition(updates.getOutputNamePosition());
existing.setOutputTtlHours(updates.getOutputTtlHours());
existing.setDeleteOutputOnDownload(updates.getDeleteOutputOnDownload());
existing.setMaxRetries(updates.getMaxRetries());
existing.setRetryDelayMinutes(updates.getRetryDelayMinutes());
// Scope change: only admin can promote to organisation
if (FolderScope.ORGANISATION.equals(updates.getScope())
&& !FolderScope.ORGANISATION.equals(existing.getScope())) {
requireAdmin();
existing.setScope(FolderScope.ORGANISATION);
existing.setOwner(null);
}
return folderRepo.save(existing);
}
@Transactional
public void deleteFolder(String id) {
WatchFolder folder =
folderRepo
.findById(id)
.orElseThrow(
() -> new IllegalArgumentException("Folder not found: " + id));
requireWriteAccess(folder);
// Don't rely on CascadeType.ALL to remove children one-row-at-a-time — for a folder with
// tens of thousands of files and runs that loads every child into memory. Use the bulk
// @Modifying queries instead, then remove the parent row.
fileRepo.deleteAllByFolderId(id);
runRepo.deleteAllByFolderId(id);
folderRepo.deleteById(id);
}
// ── Folder files ───────────────────────────────────────────────────────
@Transactional(readOnly = true)
public List<WatchFolderFile> listFiles(String folderId) {
requireReadAccess(folderId);
return fileRepo.findByFolderIdOrderByAddedAtDesc(folderId);
}
/**
* Create or update a file row keyed by {@code (folderId, fileId)}. Any client-supplied {@code
* id} on the payload is discarded — otherwise an attacker could pass the primary key of a row
* in a folder they don't own and have Hibernate re-parent it into their folder on save.
*
* <p>Idempotent under concurrent callers: if two requests for the same pair race past the
* lookup, the DB's unique constraint fires on one, which is then retried through the merge
* branch.
*/
@Transactional
public WatchFolderFile upsertFile(String folderId, WatchFolderFile file) {
requireWriteAccess(folderId);
WatchFolder folder = folderRepo.getReferenceById(folderId);
// IMPORTANT: drop the client-supplied primary key. We identify existing rows by the
// (folderId, fileId) unique pair, never by the numeric id from the JSON body.
file.setId(null);
file.setFolder(folder);
if (file.getFileId() != null) {
Optional<WatchFolderFile> existing =
fileRepo.findByFolderIdAndFileId(folderId, file.getFileId());
if (existing.isPresent()) {
return mergeFile(existing.get(), file);
}
}
try {
return fileRepo.save(file);
} catch (DataIntegrityViolationException race) {
// Another writer inserted the same (folderId, fileId) between our lookup and save.
// Re-read and merge. If the row has vanished again (e.g. concurrent delete), let the
// exception propagate as 409 Conflict.
log.debug(
"upsertFile race for folder={} fileId={}; retrying via merge",
folderId,
file.getFileId());
return fileRepo.findByFolderIdAndFileId(folderId, file.getFileId())
.map(existing -> mergeFile(existing, file))
.orElseThrow(() -> race);
}
}
private WatchFolderFile mergeFile(WatchFolderFile existing, WatchFolderFile incoming) {
existing.setStatus(incoming.getStatus());
existing.setName(incoming.getName());
existing.setErrorMessage(incoming.getErrorMessage());
existing.setFailedAttempts(incoming.getFailedAttempts());
existing.setOwnedByFolder(incoming.getOwnedByFolder());
existing.setPendingOnServer(incoming.getPendingOnServer());
existing.setDisplayFileIds(incoming.getDisplayFileIds());
existing.setServerOutputFilenames(incoming.getServerOutputFilenames());
existing.setProcessedAt(incoming.getProcessedAt());
return fileRepo.save(existing);
}
@Transactional
public void deleteFiles(String folderId) {
requireWriteAccess(folderId);
fileRepo.deleteAllByFolderId(folderId);
}
// ── Folder runs ────────────────────────────────────────────────────────
@Transactional(readOnly = true)
public List<WatchFolderRun> listRuns(String folderId) {
requireReadAccess(folderId);
return runRepo.findByFolderIdOrderByProcessedAtDesc(folderId);
}
@Transactional
public WatchFolderRun addRun(String folderId, WatchFolderRun run) {
requireWriteAccess(folderId);
WatchFolder folder = folderRepo.getReferenceById(folderId);
// Discard any client-supplied primary key so we always INSERT — otherwise a caller could
// UPDATE an existing run row in a different folder by guessing its id.
run.setId(null);
run.setFolder(folder);
return runRepo.save(run);
}
@Transactional
public List<WatchFolderRun> addRuns(String folderId, List<WatchFolderRun> runs) {
requireWriteAccess(folderId);
WatchFolder folder = folderRepo.getReferenceById(folderId);
runs.stream()
.filter(Objects::nonNull)
.forEach(
r -> {
r.setId(null);
r.setFolder(folder);
});
return runRepo.saveAll(runs);
}
// ── Auth helpers ───────────────────────────────────────────────────────
private User currentUser() {
String username = userService.getCurrentUsername();
if (username == null) return null;
return userRepo.findByUsernameIgnoreCase(username).orElse(null);
}
private User requireCurrentUser() {
User user = currentUser();
if (user == null) throw new AccessDeniedException("Authentication required");
return user;
}
private void requireAdmin() {
if (!userService.isCurrentUserAdmin()) {
throw new AccessDeniedException("Admin access required");
}
}
private boolean canRead(WatchFolder folder) {
if (FolderScope.ORGANISATION.equals(folder.getScope())) return true;
User user = currentUser();
return user != null
&& folder.getOwner() != null
&& user.getId().equals(folder.getOwner().getId());
}
private void requireReadAccess(String folderId) {
WatchFolder folder =
folderRepo
.findById(folderId)
.orElseThrow(
() ->
new IllegalArgumentException(
"Folder not found: " + folderId));
if (!canRead(folder)) {
throw new AccessDeniedException("Access denied to folder: " + folderId);
}
}
private void requireWriteAccess(WatchFolder folder) {
if (FolderScope.ORGANISATION.equals(folder.getScope())) {
requireAdmin();
} else {
User user = requireCurrentUser();
if (folder.getOwner() == null || !user.getId().equals(folder.getOwner().getId())) {
throw new AccessDeniedException("Access denied to folder: " + folder.getId());
}
}
}
private void requireWriteAccess(String folderId) {
WatchFolder folder =
folderRepo
.findById(folderId)
.orElseThrow(
() ->
new IllegalArgumentException(
"Folder not found: " + folderId));
requireWriteAccess(folder);
}
}
@@ -25,6 +25,8 @@ import AppConfigLoader from '@app/components/shared/AppConfigLoader';
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
import { FolderFileContextProvider } from "@app/contexts/FolderFileContext";
import { WatchFolderStorageProvider } from "@app/contexts/WatchFolderStorageContext";
import { idbBackend } from "@app/services/watchFolderIdbBackend";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@@ -124,9 +126,11 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
<FolderFileContextProvider>
{children}
</FolderFileContextProvider>
<WatchFolderStorageProvider backend={idbBackend}>
<FolderFileContextProvider>
{children}
</FolderFileContextProvider>
</WatchFolderStorageProvider>
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
@@ -0,0 +1,63 @@
/**
* Context for abstracting Watch Folder storage.
*
* Core provides a default IDB-only implementation.
* Proprietary can override with a server-backed implementation
* that uses IDB as a local cache.
*/
import React, { createContext, useContext } from 'react';
import { SmartFolder, FolderRecord } from '@app/types/smartFolders';
import { SmartFolderRunEntry } from '@app/types/smartFolders';
// ── Storage interface ──────────────────────────────────────────────────────
export interface WatchFolderStorageBackend {
// Folder CRUD
getAllFolders(): Promise<SmartFolder[]>;
getFolder(id: string): Promise<SmartFolder | null>;
createFolder(data: Omit<SmartFolder, 'id' | 'createdAt' | 'updatedAt'>): Promise<SmartFolder>;
createFolderWithId(folder: SmartFolder): Promise<SmartFolder>;
updateFolder(folder: SmartFolder): Promise<SmartFolder>;
deleteFolder(id: string): Promise<void>;
// File metadata
getFolderData(folderId: string): Promise<FolderRecord | null>;
updateFileMetadata(folderId: string, fileId: string, meta: Partial<import('@app/types/smartFolders').FolderFileMetadata>): Promise<void>;
addFileToFolder(folderId: string, fileId: string, meta?: Partial<import('@app/types/smartFolders').FolderFileMetadata>): Promise<void>;
clearFolder(folderId: string): Promise<void>;
// Run state
getFolderRunState(folderId: string): Promise<SmartFolderRunEntry[]>;
addFolderRunEntries(folderId: string, entries: SmartFolderRunEntry[]): Promise<void>;
clearFolderRunState(folderId: string): Promise<void>;
/** Subscribe to storage change events. Returns unsubscribe function. */
onChange(callback: () => void): () => void;
}
// ── Context ────────────────────────────────────────────────────────────────
const WatchFolderStorageContext = createContext<WatchFolderStorageBackend | null>(null);
export function WatchFolderStorageProvider({
backend,
children,
}: {
backend: WatchFolderStorageBackend;
children: React.ReactNode;
}) {
return (
<WatchFolderStorageContext.Provider value={backend}>
{children}
</WatchFolderStorageContext.Provider>
);
}
/**
* Returns the storage backend from context, or null if none is provided
* (meaning hooks should fall back to direct IDB imports).
*/
export function useWatchFolderStorage(): WatchFolderStorageBackend | null {
return useContext(WatchFolderStorageContext);
}
+10 -2
View File
@@ -7,23 +7,31 @@
import { useState, useEffect } from 'react';
import { SmartFolder } from '@app/types/smartFolders';
import { smartFolderStorage, SMART_FOLDER_STORAGE_CHANGE_EVENT } from '@app/services/smartFolderStorage';
import { useWatchFolderStorage } from '@app/contexts/WatchFolderStorageContext';
export function useAllSmartFolders(): SmartFolder[] {
const backend = useWatchFolderStorage();
const [folders, setFolders] = useState<SmartFolder[]>([]);
useEffect(() => {
const load = async () => {
try {
setFolders(await smartFolderStorage.getAllFolders());
const all = backend
? await backend.getAllFolders()
: await smartFolderStorage.getAllFolders();
setFolders(all);
} catch (err) {
console.error('Failed to load smart folders:', err);
}
};
load();
if (backend) {
return backend.onChange(load);
}
window.addEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, load);
return () => window.removeEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, load);
}, []);
}, [backend]);
return folders;
}
+24 -8
View File
@@ -5,6 +5,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { FolderFileMetadata, FolderRecord } from '@app/types/smartFolders';
import { folderStorage } from '@app/services/folderStorage';
import { useWatchFolderStorage } from '@app/contexts/WatchFolderStorageContext';
interface UseFolderDataReturn {
folderRecord: FolderRecord | null;
@@ -22,17 +23,20 @@ interface UseFolderDataReturn {
}
export function useFolderData(folderId: string): UseFolderDataReturn {
const backend = useWatchFolderStorage();
const [folderRecord, setFolderRecord] = useState<FolderRecord | null>(null);
const refresh = useCallback(async () => {
if (!folderId) return;
try {
const record = await folderStorage.getFolderData(folderId);
const record = backend
? await backend.getFolderData(folderId)
: await folderStorage.getFolderData(folderId);
setFolderRecord(record);
} catch (error) {
console.error('Failed to load folder data:', error);
}
}, [folderId]);
}, [folderId, backend]);
useEffect(() => {
refresh();
@@ -55,9 +59,13 @@ export function useFolderData(folderId: string): UseFolderDataReturn {
const addFile = useCallback(
async (fileId: string, metadata?: Partial<FolderFileMetadata>) => {
await folderStorage.addFileToFolder(folderId, fileId, metadata);
if (backend) {
await backend.addFileToFolder(folderId, fileId, metadata);
} else {
await folderStorage.addFileToFolder(folderId, fileId, metadata);
}
},
[folderId]
[folderId, backend]
);
const removeFile = useCallback(
@@ -69,14 +77,22 @@ export function useFolderData(folderId: string): UseFolderDataReturn {
const updateFileMetadata = useCallback(
async (fileId: string, updates: Partial<FolderFileMetadata>) => {
await folderStorage.updateFileMetadata(folderId, fileId, updates);
if (backend) {
await backend.updateFileMetadata(folderId, fileId, updates);
} else {
await folderStorage.updateFileMetadata(folderId, fileId, updates);
}
},
[folderId]
[folderId, backend]
);
const clearFolder = useCallback(async () => {
await folderStorage.clearFolder(folderId);
}, [folderId]);
if (backend) {
await backend.clearFolder(folderId);
} else {
await folderStorage.clearFolder(folderId);
}
}, [folderId, backend]);
const getFileMetadata = useCallback(
(fileId: string): FolderFileMetadata | null => {
@@ -6,6 +6,7 @@
import { useState, useEffect, useRef } from 'react';
import { SmartFolder, SmartFolderRunEntry } from '@app/types/smartFolders';
import { folderRunStateStorage } from '@app/services/folderRunStateStorage';
import { useWatchFolderStorage } from '@app/contexts/WatchFolderStorageContext';
export type FolderRunStatus = 'idle' | 'processing' | 'done';
@@ -19,6 +20,7 @@ function deriveStatus(runs: SmartFolderRunEntry[]): FolderRunStatus {
}
export function useFolderRunStatuses(folders: SmartFolder[]): Record<string, FolderRunStatus> {
const backend = useWatchFolderStorage();
const [statuses, setStatuses] = useState<Record<string, FolderRunStatus>>({});
const doneTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const foldersRef = useRef(folders);
@@ -31,7 +33,9 @@ export function useFolderRunStatuses(folders: SmartFolder[]): Record<string, Fol
const results = await Promise.all(
folders.map(async (folder) => {
try {
const runs = await folderRunStateStorage.getFolderRunState(folder.id);
const runs = backend
? await backend.getFolderRunState(folder.id)
: await folderRunStateStorage.getFolderRunState(folder.id);
return [folder.id, deriveStatus(runs)] as const;
} catch {
return [folder.id, 'idle' as FolderRunStatus] as const;
+39 -11
View File
@@ -12,6 +12,7 @@ import { deleteServerFolder } from '@app/services/serverFolderApiService';
import { folderRetryScheduleStorage } from '@app/services/folderRetryScheduleStorage';
import { folderSeenFilesStorage } from '@app/services/folderSeenFilesStorage';
import { folderDirectoryHandleStorage } from '@app/services/folderDirectoryHandleStorage';
import { useWatchFolderStorage } from '@app/contexts/WatchFolderStorageContext';
import { FileId } from '@app/types/fileContext';
interface UseSmartFoldersReturn {
@@ -24,52 +25,67 @@ interface UseSmartFoldersReturn {
}
export function useSmartFolders(): UseSmartFoldersReturn {
const backend = useWatchFolderStorage();
const [folders, setFolders] = useState<SmartFolder[]>([]);
const [loading, setLoading] = useState(true);
const refreshFolders = useCallback(async () => {
try {
const all = await smartFolderStorage.getAllFolders();
const store = backend ?? { getAllFolders: () => smartFolderStorage.getAllFolders() };
const all = await store.getAllFolders();
setFolders(all);
} catch (error) {
console.error('Failed to load smart folders:', error);
} finally {
setLoading(false);
}
}, []);
}, [backend]);
useEffect(() => {
refreshFolders();
}, [refreshFolders]);
useEffect(() => {
if (backend) {
return backend.onChange(() => refreshFolders());
}
const handler = () => { refreshFolders(); };
window.addEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, handler);
return () => window.removeEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, handler);
}, [refreshFolders]);
}, [backend, refreshFolders]);
const createFolder = useCallback(
async (data: Omit<SmartFolder, 'id' | 'createdAt' | 'updatedAt'>): Promise<SmartFolder> => {
if (backend) return backend.createFolder(data);
return smartFolderStorage.createFolder(data);
},
[]
[backend]
);
const updateFolder = useCallback(
async (folder: SmartFolder): Promise<SmartFolder> => {
if (backend) return backend.updateFolder(folder);
return smartFolderStorage.updateFolder(folder);
},
[]
[backend]
);
const deleteFolder = useCallback(async (id: string): Promise<void> => {
const store = backend ?? {
getFolder: (fid: string) => smartFolderStorage.getFolder(fid),
deleteFolder: (fid: string) => smartFolderStorage.deleteFolder(fid),
getFolderData: (fid: string) => folderStorage.getFolderData(fid),
clearFolder: (fid: string) => folderStorage.clearFolder(fid),
clearFolderRunState: (fid: string) => folderRunStateStorage.clearFolderRunState(fid),
};
// Clean up server watch folder first (best-effort — don't block if server is down)
const folderMeta = await smartFolderStorage.getFolder(id);
const folderMeta = await store.getFolder(id);
if (folderMeta && isServerFolderInput(folderMeta)) {
await deleteServerFolder(id).catch(() => {});
}
const record = await folderStorage.getFolderData(id);
const record = await (backend ? backend.getFolderData(id) : folderStorage.getFolderData(id));
if (record) {
// Only delete input files the folder created from disk — never touch sidebar-sourced files.
const ownedInputIds = Object.entries(record.files)
@@ -81,16 +97,28 @@ export function useSmartFolders(): UseSmartFoldersReturn {
const toDelete = [...new Set([...ownedInputIds, ...outputIds])];
await Promise.all(toDelete.map(fid => fileStorage.deleteStirlingFile(fid as FileId).catch(() => {})));
}
await folderStorage.clearFolder(id);
await folderRunStateStorage.clearFolderRunState(id);
if (backend) {
await backend.clearFolder(id);
await backend.clearFolderRunState(id);
} else {
await folderStorage.clearFolder(id);
await folderRunStateStorage.clearFolderRunState(id);
}
// These are always IDB-only (browser API objects / ephemeral state)
await folderRetryScheduleStorage.clearFolder(id).catch(() => {});
await folderSeenFilesStorage.clearFolder(id).catch(() => {});
await folderDirectoryHandleStorage.remove(id).catch(() => {});
await folderDirectoryHandleStorage.removeInput(id).catch(() => {});
await smartFolderStorage.deleteFolder(id);
if (backend) {
await backend.deleteFolder(id);
} else {
await smartFolderStorage.deleteFolder(id);
}
// Notify the sidebar file list that files have been removed.
window.dispatchEvent(new CustomEvent('stirling:files-changed'));
}, []);
}, [backend]);
return { folders, loading, createFolder, updateFolder, deleteFolder, refreshFolders };
}
@@ -138,6 +138,21 @@ class FolderStorage {
});
}
/** Overwrite the entire folder record (used by sync from server). */
async setFolderData(folderId: string, record: FolderRecord): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.recordsStore], 'readwrite');
const store = transaction.objectStore(this.recordsStore);
const request = store.put(record);
request.onsuccess = () => {
this.dispatchChange(folderId);
resolve();
};
request.onerror = () => reject(new Error('Failed to set folder data'));
});
}
async clearFolder(folderId: string): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
@@ -0,0 +1,39 @@
/**
* IDB-only implementation of WatchFolderStorageBackend.
*
* Wraps the existing IDB singletons into the storage interface
* so core hooks can use them through the context.
*/
import type { WatchFolderStorageBackend } from '@app/contexts/WatchFolderStorageContext';
import { smartFolderStorage, SMART_FOLDER_STORAGE_CHANGE_EVENT } from '@app/services/smartFolderStorage';
import { folderStorage } from '@app/services/folderStorage';
import { folderRunStateStorage } from '@app/services/folderRunStateStorage';
import type { SmartFolder, FolderRecord, FolderFileMetadata, SmartFolderRunEntry } from '@app/types/smartFolders';
export const idbBackend: WatchFolderStorageBackend = {
// Folder CRUD
getAllFolders: () => smartFolderStorage.getAllFolders(),
getFolder: (id) => smartFolderStorage.getFolder(id),
createFolder: (data) => smartFolderStorage.createFolder(data),
createFolderWithId: (folder) => smartFolderStorage.createFolderWithId(folder),
updateFolder: (folder) => smartFolderStorage.updateFolder(folder),
deleteFolder: (id) => smartFolderStorage.deleteFolder(id),
// File metadata
getFolderData: (folderId) => folderStorage.getFolderData(folderId),
updateFileMetadata: (folderId, fileId, meta) => folderStorage.updateFileMetadata(folderId, fileId, meta),
addFileToFolder: (folderId, fileId, meta) => folderStorage.addFileToFolder(folderId, fileId, meta),
clearFolder: (folderId) => folderStorage.clearFolder(folderId),
// Run state
getFolderRunState: (folderId) => folderRunStateStorage.getFolderRunState(folderId),
addFolderRunEntries: (folderId, entries) => folderRunStateStorage.appendRunEntries(folderId, entries),
clearFolderRunState: (folderId) => folderRunStateStorage.clearFolderRunState(folderId),
// Events
onChange(callback) {
window.addEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, callback);
return () => window.removeEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, callback);
},
};
+3
View File
@@ -13,6 +13,7 @@ import InviteAccept from "@app/routes/InviteAccept";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
import SmartFoldersRegistration from "@app/components/smartFolders/SmartFoldersRegistration";
import { WatchFolderServerProvider } from "@proprietary/components/WatchFolderServerProvider";
// Import global styles
import "@app/styles/tailwind.css";
import "@app/styles/cookieconsent.css";
@@ -52,6 +53,7 @@ export default function App() {
path="*"
element={
<AppProviders>
<WatchFolderServerProvider>
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
@@ -66,6 +68,7 @@ export default function App() {
<Onboarding />
<SmartFoldersRegistration />
</AppLayout>
</WatchFolderServerProvider>
</AppProviders>
}
/>
@@ -0,0 +1,26 @@
/**
* Proprietary wrapper that overrides the core IDB-only WatchFolderStorageProvider
* with the server-backed implementation when premium is enabled.
*/
import React from 'react';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { WatchFolderStorageProvider } from '@app/contexts/WatchFolderStorageContext';
import { serverBackend } from '@proprietary/services/watchFolderServerBackend';
export function WatchFolderServerProvider({ children }: { children: React.ReactNode }) {
const { config } = useAppConfig();
const isPremium = config?.premiumEnabled === true;
if (!isPremium) {
// Core's IDB provider is already in place — just pass through
return <>{children}</>;
}
// Override with server-backed storage
return (
<WatchFolderStorageProvider backend={serverBackend}>
{children}
</WatchFolderStorageProvider>
);
}
@@ -0,0 +1,120 @@
/**
* REST client for the server-side Watch Folder persistence API.
*
* These endpoints store folder configs, file metadata, and run history
* in the server database — the source of truth for proprietary deployments.
*/
import apiClient from '@app/services/apiClient';
// ── Types matching the JPA entities ────────────────────────────────────────
export interface WatchFolderDTO {
id: string;
name: string;
description?: string;
automationConfig?: string; // JSON-stringified operations array
icon?: string;
accentColor?: string;
scope: 'PERSONAL' | 'ORGANISATION';
orderIndex?: number;
isDefault?: boolean;
isPaused?: boolean;
inputSource?: string;
processingMode?: string;
outputMode?: string;
outputName?: string;
outputNamePosition?: string;
outputTtlHours?: number | null;
deleteOutputOnDownload?: boolean;
maxRetries?: number;
retryDelayMinutes?: number;
createdAt?: string;
updatedAt?: string;
}
export interface WatchFolderFileDTO {
id?: number;
fileId: string;
status: string;
name?: string;
errorMessage?: string;
failedAttempts?: number;
ownedByFolder?: boolean;
pendingOnServer?: boolean;
displayFileIds?: string; // JSON array
serverOutputFilenames?: string; // JSON array
addedAt?: string;
processedAt?: string;
}
export interface WatchFolderRunDTO {
id?: number;
inputFileId: string;
displayFileId?: string;
displayFileIds?: string; // JSON array
status: string;
processedAt?: string;
}
// ── API calls ──────────────────────────────────────────────────────────────
const BASE = '/api/v1/watch-folders';
export const watchFolderApi = {
// Folders
async list(): Promise<WatchFolderDTO[]> {
const res = await apiClient.get<WatchFolderDTO[]>(BASE);
return res.data;
},
async get(id: string): Promise<WatchFolderDTO> {
const res = await apiClient.get<WatchFolderDTO>(`${BASE}/${id}`);
return res.data;
},
async create(folder: WatchFolderDTO): Promise<WatchFolderDTO> {
const res = await apiClient.post<WatchFolderDTO>(BASE, folder);
return res.data;
},
async update(id: string, folder: Partial<WatchFolderDTO>): Promise<WatchFolderDTO> {
const res = await apiClient.put<WatchFolderDTO>(`${BASE}/${id}`, folder);
return res.data;
},
async remove(id: string): Promise<void> {
await apiClient.delete(`${BASE}/${id}`);
},
// Files
async listFiles(folderId: string): Promise<WatchFolderFileDTO[]> {
const res = await apiClient.get<WatchFolderFileDTO[]>(`${BASE}/${folderId}/files`);
return res.data;
},
async upsertFile(folderId: string, file: WatchFolderFileDTO): Promise<WatchFolderFileDTO> {
const res = await apiClient.put<WatchFolderFileDTO>(`${BASE}/${folderId}/files`, file);
return res.data;
},
async deleteFiles(folderId: string): Promise<void> {
await apiClient.delete(`${BASE}/${folderId}/files`);
},
// Runs
async listRuns(folderId: string): Promise<WatchFolderRunDTO[]> {
const res = await apiClient.get<WatchFolderRunDTO[]>(`${BASE}/${folderId}/runs`);
return res.data;
},
async addRun(folderId: string, run: WatchFolderRunDTO): Promise<WatchFolderRunDTO> {
const res = await apiClient.post<WatchFolderRunDTO>(`${BASE}/${folderId}/runs`, run);
return res.data;
},
async addRuns(folderId: string, runs: WatchFolderRunDTO[]): Promise<WatchFolderRunDTO[]> {
const res = await apiClient.post<WatchFolderRunDTO[]>(`${BASE}/${folderId}/runs/batch`, runs);
return res.data;
},
};
@@ -0,0 +1,294 @@
/**
* Server-backed implementation of WatchFolderStorageBackend.
*
* Writes go to the server API first (source of truth), then mirror to IDB for fast reads.
* Reads come from IDB (populated on init and after writes).
* Falls back to IDB-only if the server is unreachable.
*/
import type { WatchFolderStorageBackend } from '@app/contexts/WatchFolderStorageContext';
import { smartFolderStorage, SMART_FOLDER_STORAGE_CHANGE_EVENT } from '@app/services/smartFolderStorage';
import { folderStorage } from '@app/services/folderStorage';
import { folderRunStateStorage } from '@app/services/folderRunStateStorage';
import type { SmartFolder, FolderRecord, FolderFileMetadata, SmartFolderRunEntry } from '@app/types/smartFolders';
import { watchFolderApi, WatchFolderDTO, WatchFolderFileDTO, WatchFolderRunDTO } from './watchFolderApiService';
// ── DTO ↔ Domain conversions ───────────────────────────────────────────────
function toSmartFolder(dto: WatchFolderDTO): SmartFolder {
return {
id: dto.id,
name: dto.name,
description: dto.description ?? '',
automationId: '', // automation config is inlined — hooks resolve this
icon: dto.icon ?? 'FolderIcon',
accentColor: dto.accentColor ?? '#3b82f6',
createdAt: dto.createdAt ?? new Date().toISOString(),
updatedAt: dto.updatedAt ?? new Date().toISOString(),
order: dto.orderIndex,
isDefault: dto.isDefault,
isPaused: dto.isPaused,
inputSource: (dto.inputSource as SmartFolder['inputSource']) ?? 'idb',
processingMode: (dto.processingMode as SmartFolder['processingMode']) ?? 'local',
outputMode: (dto.outputMode as SmartFolder['outputMode']) ?? 'new_file',
outputName: dto.outputName,
outputNamePosition: (dto.outputNamePosition as SmartFolder['outputNamePosition']) ?? 'prefix',
outputTtlHours: dto.outputTtlHours,
deleteOutputOnDownload: dto.deleteOutputOnDownload,
maxRetries: dto.maxRetries,
retryDelayMinutes: dto.retryDelayMinutes,
};
}
function toDTO(folder: SmartFolder & { scope?: string }): WatchFolderDTO {
return {
id: folder.id,
name: folder.name,
description: folder.description,
icon: folder.icon,
accentColor: folder.accentColor,
scope: (folder as any).scope ?? 'PERSONAL',
orderIndex: folder.order,
isDefault: folder.isDefault,
isPaused: folder.isPaused,
inputSource: folder.inputSource,
processingMode: folder.processingMode,
outputMode: folder.outputMode,
outputName: folder.outputName,
outputNamePosition: folder.outputNamePosition,
outputTtlHours: folder.outputTtlHours,
deleteOutputOnDownload: folder.deleteOutputOnDownload,
maxRetries: folder.maxRetries,
retryDelayMinutes: folder.retryDelayMinutes,
};
}
function dispatchChange() {
window.dispatchEvent(new Event(SMART_FOLDER_STORAGE_CHANGE_EVENT));
}
// ── Sync helper: pull server state into IDB ────────────────────────────────
async function syncFoldersToIdb(): Promise<SmartFolder[]> {
const dtos = await watchFolderApi.list();
const folders = dtos.map(toSmartFolder);
// Overwrite IDB with server state
const existing = await smartFolderStorage.getAllFolders();
const serverIds = new Set(folders.map(f => f.id));
// Remove folders from IDB that no longer exist on server
for (const f of existing) {
if (!serverIds.has(f.id)) {
await smartFolderStorage.deleteFolder(f.id).catch(() => {});
}
}
// Upsert server folders into IDB
for (const f of folders) {
await smartFolderStorage.createFolderWithId(f).catch(() => {});
}
dispatchChange();
return folders;
}
// ── Backend implementation ─────────────────────────────────────────────────
export const serverBackend: WatchFolderStorageBackend = {
async getAllFolders() {
try {
return await syncFoldersToIdb();
} catch {
// Server unreachable — fall back to IDB
return smartFolderStorage.getAllFolders();
}
},
async getFolder(id) {
try {
const dto = await watchFolderApi.get(id);
const folder = toSmartFolder(dto);
await smartFolderStorage.createFolderWithId(folder).catch(() => {});
return folder;
} catch {
return smartFolderStorage.getFolder(id);
}
},
async createFolder(data) {
const timestamp = new Date().toISOString();
const folder: SmartFolder = {
id: crypto.randomUUID(),
...data,
createdAt: timestamp,
updatedAt: timestamp,
};
try {
const created = await watchFolderApi.create(toDTO(folder));
const result = toSmartFolder(created);
await smartFolderStorage.createFolderWithId(result).catch(() => {});
dispatchChange();
return result;
} catch {
// Offline — create in IDB only
return smartFolderStorage.createFolder(data);
}
},
async createFolderWithId(folder) {
try {
const created = await watchFolderApi.create(toDTO(folder));
const result = toSmartFolder(created);
await smartFolderStorage.createFolderWithId(result).catch(() => {});
dispatchChange();
return result;
} catch {
return smartFolderStorage.createFolderWithId(folder);
}
},
async updateFolder(folder) {
try {
const updated = await watchFolderApi.update(folder.id, toDTO(folder));
const result = toSmartFolder(updated);
await smartFolderStorage.createFolderWithId(result).catch(() => {});
dispatchChange();
return result;
} catch {
return smartFolderStorage.updateFolder(folder);
}
},
async deleteFolder(id) {
try {
await watchFolderApi.remove(id);
} catch {
// best-effort server delete
}
await smartFolderStorage.deleteFolder(id);
},
// File metadata — server-backed with IDB cache
async getFolderData(folderId) {
try {
const files = await watchFolderApi.listFiles(folderId);
const record: FolderRecord = {
folderId,
files: {},
lastUpdated: Date.now(),
};
for (const f of files) {
record.files[f.fileId] = {
addedAt: f.addedAt ? new Date(f.addedAt) : new Date(),
status: f.status as FolderFileMetadata['status'],
name: f.name,
errorMessage: f.errorMessage,
failedAttempts: f.failedAttempts,
ownedByFolder: f.ownedByFolder,
pendingOnServerFolder: f.pendingOnServer,
displayFileIds: f.displayFileIds ? JSON.parse(f.displayFileIds) : undefined,
serverOutputFilenames: f.serverOutputFilenames ? JSON.parse(f.serverOutputFilenames) : undefined,
processedAt: f.processedAt ? new Date(f.processedAt) : undefined,
};
}
// Mirror to IDB
await folderStorage.setFolderData(folderId, record).catch(() => {});
return record;
} catch {
return folderStorage.getFolderData(folderId);
}
},
async updateFileMetadata(folderId, fileId, meta) {
// Update IDB immediately for fast UI
await folderStorage.updateFileMetadata(folderId, fileId, meta);
// Sync to server
try {
const existing = await folderStorage.getFolderData(folderId);
const fileMeta = existing?.files[fileId];
if (fileMeta) {
await watchFolderApi.upsertFile(folderId, {
fileId,
status: fileMeta.status,
name: fileMeta.name,
errorMessage: fileMeta.errorMessage,
failedAttempts: fileMeta.failedAttempts,
ownedByFolder: fileMeta.ownedByFolder,
pendingOnServer: fileMeta.pendingOnServerFolder,
displayFileIds: fileMeta.displayFileIds ? JSON.stringify(fileMeta.displayFileIds) : undefined,
serverOutputFilenames: fileMeta.serverOutputFilenames ? JSON.stringify(fileMeta.serverOutputFilenames) : undefined,
addedAt: fileMeta.addedAt?.toISOString(),
processedAt: fileMeta.processedAt?.toISOString(),
});
}
} catch {
// Server sync failed — IDB is still up to date
}
},
async addFileToFolder(folderId, fileId, meta) {
await folderStorage.addFileToFolder(folderId, fileId, meta);
try {
await watchFolderApi.upsertFile(folderId, {
fileId,
status: meta?.status ?? 'pending',
name: meta?.name,
ownedByFolder: meta?.ownedByFolder,
addedAt: meta?.addedAt?.toISOString() ?? new Date().toISOString(),
});
} catch {
// offline — IDB has the data
}
},
async clearFolder(folderId) {
try {
await watchFolderApi.deleteFiles(folderId);
} catch { /* best-effort */ }
await folderStorage.clearFolder(folderId);
},
// Run state
async getFolderRunState(folderId) {
try {
const runs = await watchFolderApi.listRuns(folderId);
const entries: SmartFolderRunEntry[] = runs.map(r => ({
inputFileId: r.inputFileId,
displayFileId: r.displayFileId ?? '',
displayFileIds: r.displayFileIds ? JSON.parse(r.displayFileIds) : undefined,
status: r.status as SmartFolderRunEntry['status'],
processedAt: r.processedAt ? new Date(r.processedAt) : undefined,
}));
return entries;
} catch {
return folderRunStateStorage.getFolderRunState(folderId);
}
},
async addFolderRunEntries(folderId, entries) {
// IDB first
await folderRunStateStorage.appendRunEntries(folderId, entries);
// Server sync
try {
await watchFolderApi.addRuns(
folderId,
entries.map(e => ({
inputFileId: e.inputFileId,
displayFileId: e.displayFileId,
displayFileIds: e.displayFileIds ? JSON.stringify(e.displayFileIds) : undefined,
status: e.status,
processedAt: e.processedAt?.toISOString(),
}))
);
} catch { /* offline */ }
},
async clearFolderRunState(folderId) {
try {
// No dedicated endpoint yet — runs are deleted with the folder
} catch { /* best-effort */ }
await folderRunStateStorage.clearFolderRunState(folderId);
},
onChange(callback) {
window.addEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, callback);
return () => window.removeEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, callback);
},
};