add server-backed watch folder persistence
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
+100
@@ -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<>();
|
||||
}
|
||||
+103
@@ -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;
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
+294
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user