init push
This commit is contained in:
152
internal/domain/file_manager/file_processing_outbox.go
Normal file
152
internal/domain/file_manager/file_processing_outbox.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
FileProcessingOutboxStatusPending = "pending"
|
||||
FileProcessingOutboxStatusProcessing = "processing"
|
||||
FileProcessingOutboxStatusPublished = "published"
|
||||
FileProcessingOutboxStatusDead = "dead"
|
||||
FileProcessingOutboxKind = "file_manager.processing"
|
||||
)
|
||||
|
||||
type FileProcessingOutboundMessage struct {
|
||||
ID string
|
||||
Body []byte
|
||||
Attributes map[string]string
|
||||
MessageGroupID string
|
||||
DeduplicationID string
|
||||
}
|
||||
|
||||
type FileProcessingOutboxMessage struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
MessageID string `gorm:"type:varchar(64);uniqueIndex;not null;column:message_id"`
|
||||
Kind string `gorm:"type:varchar(64);index;not null;column:kind"`
|
||||
JobType string `gorm:"type:varchar(64);index;not null;column:job_type"`
|
||||
Body []byte `gorm:"type:longblob;not null;column:body"`
|
||||
Attributes []byte `gorm:"type:longblob;column:attributes"`
|
||||
MessageGroupID string `gorm:"type:varchar(128);column:message_group_id"`
|
||||
DeduplicationID string `gorm:"type:varchar(128);column:deduplication_id"`
|
||||
Status string `gorm:"type:varchar(32);index;not null;column:status"`
|
||||
Attempts int `gorm:"not null;default:0;column:attempts"`
|
||||
AvailableAt time.Time `gorm:"index;not null;column:available_at"`
|
||||
LockedAt *time.Time `gorm:"index;column:locked_at"`
|
||||
LockedBy string `gorm:"type:varchar(64);column:locked_by"`
|
||||
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||
LastError string `gorm:"type:text;column:last_error"`
|
||||
CorrelationID string `gorm:"type:varchar(64);index;column:correlation_id"`
|
||||
TraceID string `gorm:"type:varchar(64);index;column:trace_id"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (FileProcessingOutboxMessage) TableName() string {
|
||||
return "file_processing_outbox_messages"
|
||||
}
|
||||
|
||||
func (m *FileProcessingOutboxMessage) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(m.ID) == 0 {
|
||||
m.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if strings.TrimSpace(m.Status) == "" {
|
||||
m.Status = FileProcessingOutboxStatusPending
|
||||
}
|
||||
if m.AvailableAt.IsZero() {
|
||||
m.AvailableAt = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewFileProcessingOutboxMessage(now time.Time, message *FileProcessingOutboundMessage) (*FileProcessingOutboxMessage, error) {
|
||||
if message == nil {
|
||||
return nil, errors.New("outbound message is required")
|
||||
}
|
||||
if strings.TrimSpace(message.ID) == "" {
|
||||
return nil, errors.New("outbound message id is required")
|
||||
}
|
||||
if len(message.Body) == 0 {
|
||||
return nil, errors.New("outbound message body is required")
|
||||
}
|
||||
attributes, err := json.Marshal(message.Attributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
kind := strings.TrimSpace(message.Attributes["message_kind"])
|
||||
if kind == "" {
|
||||
kind = FileProcessingOutboxKind
|
||||
}
|
||||
jobType := strings.TrimSpace(message.Attributes["job_type"])
|
||||
if jobType == "" {
|
||||
jobType = "file_processing"
|
||||
}
|
||||
|
||||
return &FileProcessingOutboxMessage{
|
||||
MessageID: strings.TrimSpace(message.ID),
|
||||
Kind: kind,
|
||||
JobType: jobType,
|
||||
Body: append([]byte(nil), message.Body...),
|
||||
Attributes: append([]byte(nil), attributes...),
|
||||
MessageGroupID: strings.TrimSpace(message.MessageGroupID),
|
||||
DeduplicationID: strings.TrimSpace(message.DeduplicationID),
|
||||
Status: FileProcessingOutboxStatusPending,
|
||||
AvailableAt: now.UTC(),
|
||||
CorrelationID: strings.TrimSpace(message.Attributes["correlation_id"]),
|
||||
TraceID: strings.TrimSpace(message.Attributes["trace_id"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *FileProcessingOutboxMessage) OutboundMessage() (*FileProcessingOutboundMessage, error) {
|
||||
if m == nil {
|
||||
return nil, errors.New("file processing outbox message is required")
|
||||
}
|
||||
attributes := map[string]string{}
|
||||
if len(m.Attributes) > 0 {
|
||||
if err := json.Unmarshal(m.Attributes, &attributes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(m.MessageID) == "" {
|
||||
return nil, errors.New("message id is required")
|
||||
}
|
||||
if len(m.Body) == 0 {
|
||||
return nil, errors.New("message body is required")
|
||||
}
|
||||
return &FileProcessingOutboundMessage{
|
||||
ID: m.MessageID,
|
||||
Body: append([]byte(nil), m.Body...),
|
||||
Attributes: attributes,
|
||||
MessageGroupID: strings.TrimSpace(m.MessageGroupID),
|
||||
DeduplicationID: strings.TrimSpace(m.DeduplicationID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type FileProcessingOutboxWriter interface {
|
||||
CreateFileProcessingOutboxMessage(ctx context.Context, message *FileProcessingOutboxMessage) error
|
||||
}
|
||||
|
||||
type FileProcessingOutboxRepository interface {
|
||||
FileProcessingOutboxWriter
|
||||
ClaimPendingFileProcessingOutboxMessages(ctx context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]FileProcessingOutboxMessage, error)
|
||||
MarkFileProcessingOutboxMessagePublished(ctx context.Context, id []byte, publishedAt time.Time) error
|
||||
MarkFileProcessingOutboxMessageRetry(ctx context.Context, id []byte, availableAt time.Time, lastErr string) error
|
||||
MarkFileProcessingOutboxMessageDead(ctx context.Context, id []byte, failedAt time.Time, lastErr string) error
|
||||
CountPendingFileProcessingOutboxMessages(ctx context.Context, now time.Time) (int64, error)
|
||||
}
|
||||
|
||||
type FileTransactionalRepository interface {
|
||||
WithTransaction(ctx context.Context, fn func(repo FileRepository, outbox FileProcessingOutboxWriter) error) error
|
||||
}
|
||||
47
internal/domain/file_manager/model_attachment.go
Normal file
47
internal/domain/file_manager/model_attachment.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type Attachment struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FileID []byte `gorm:"type:binary(16);not null;column:file_id;index:idx_attachments_file_id;uniqueIndex:uq_attachments_ref_file,priority:3"`
|
||||
RefType string `gorm:"type:varchar(64);not null;column:ref_type;index:idx_attachments_ref_sort,priority:1;index:idx_attachments_ref_category,priority:1;uniqueIndex:uq_attachments_ref_file,priority:1"`
|
||||
RefID string `gorm:"type:varchar(191);not null;column:ref_id;index:idx_attachments_ref_sort,priority:2;index:idx_attachments_ref_category,priority:2;uniqueIndex:uq_attachments_ref_file,priority:2"`
|
||||
Category *string `gorm:"type:varchar(64);column:category;index:idx_attachments_ref_category,priority:3"`
|
||||
SortOrder int `gorm:"type:int;not null;default:0;column:sort_order;index:idx_attachments_ref_sort,priority:3"`
|
||||
IsPrimary bool `gorm:"type:boolean;not null;default:false;column:is_primary"`
|
||||
AttachedBy []byte `gorm:"type:binary(16);column:attached_by"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;index:idx_attachments_ref_sort,priority:4"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
File *File `gorm:"foreignKey:FileID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (Attachment) TableName() string { return "attachments" }
|
||||
|
||||
func (a *Attachment) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(a.ID) == 0 {
|
||||
a.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if trimmed := strings.TrimSpace(a.RefType); trimmed != "" {
|
||||
a.RefType = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(a.RefID); trimmed != "" {
|
||||
a.RefID = trimmed
|
||||
}
|
||||
if a.Category != nil {
|
||||
value := strings.TrimSpace(*a.Category)
|
||||
if value == "" {
|
||||
a.Category = nil
|
||||
} else {
|
||||
a.Category = &value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
149
internal/domain/file_manager/model_file.go
Normal file
149
internal/domain/file_manager/model_file.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
FileStatusUploaded = "uploaded"
|
||||
FileStatusProcessing = "processing"
|
||||
FileStatusValidated = "validated"
|
||||
FileStatusFailed = "failed"
|
||||
FileStatusReady = "ready"
|
||||
FileStatusTrashed = "trashed"
|
||||
|
||||
FileLifecycleStatusTemp = "temp"
|
||||
FileLifecycleStatusDraft = "draft"
|
||||
FileLifecycleStatusActive = "active"
|
||||
FileLifecycleStatusFinal = "final"
|
||||
FileLifecycleStatusOrphanPending = "orphan_pending"
|
||||
FileLifecycleStatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FolderID []byte `gorm:"type:binary(16);not null;column:folder_id;index:idx_file_files_folder_active,priority:1;index:uq_file_files_folder_name_slot,priority:1"`
|
||||
Name string `gorm:"type:varchar(255);not null;column:name"`
|
||||
NameNormalized string `gorm:"type:varchar(255);not null;column:name_normalized;index:uq_file_files_folder_name_slot,priority:2"`
|
||||
Extension string `gorm:"type:varchar(32);column:extension"`
|
||||
SizeBytes int64 `gorm:"type:bigint unsigned;not null;default:0;column:size_bytes"`
|
||||
MimeType string `gorm:"type:varchar(255);not null;column:mime_type"`
|
||||
Bucket string `gorm:"type:varchar(128);not null;column:bucket"`
|
||||
ObjectKey string `gorm:"type:varchar(512);not null;column:object_key;uniqueIndex:uq_file_files_object_key"`
|
||||
ObjectVersion string `gorm:"type:varchar(128);column:object_version"`
|
||||
ETag string `gorm:"type:varchar(128);column:etag"`
|
||||
ThumbnailMimeType *string `gorm:"type:varchar(255);column:thumbnail_mime_type"`
|
||||
ThumbnailSizeBytes int64 `gorm:"type:bigint unsigned;column:thumbnail_size_bytes"`
|
||||
ThumbnailBucket *string `gorm:"type:varchar(128);column:thumbnail_bucket"`
|
||||
ThumbnailObjectKey *string `gorm:"type:varchar(512);column:thumbnail_object_key;uniqueIndex:uq_file_files_thumbnail_object_key"`
|
||||
ThumbnailObjectVersion *string `gorm:"type:varchar(128);column:thumbnail_object_version"`
|
||||
ThumbnailETag *string `gorm:"type:varchar(128);column:thumbnail_etag"`
|
||||
PreviewMimeType *string `gorm:"type:varchar(255);column:preview_mime_type"`
|
||||
PreviewSizeBytes int64 `gorm:"type:bigint unsigned;column:preview_size_bytes"`
|
||||
PreviewBucket *string `gorm:"type:varchar(128);column:preview_bucket"`
|
||||
PreviewObjectKey *string `gorm:"type:varchar(512);column:preview_object_key;uniqueIndex:uq_file_files_preview_object_key"`
|
||||
PreviewObjectVersion *string `gorm:"type:varchar(128);column:preview_object_version"`
|
||||
PreviewETag *string `gorm:"type:varchar(128);column:preview_etag"`
|
||||
ChecksumSHA256 string `gorm:"type:char(64);column:checksum_sha256"`
|
||||
IsTemplate bool `gorm:"column:is_template;not null;default:false"`
|
||||
TemplateUUID []byte `gorm:"type:binary(16);column:template_uuid;index:idx_file_files_template_takeover,priority:1"`
|
||||
TakeoverID []byte `gorm:"type:binary(16);column:takeover_id;index:idx_file_files_template_takeover,priority:2"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:'uploaded';column:status;index:idx_file_files_status_updated,priority:1"`
|
||||
ProcessingStartedAt *time.Time `gorm:"column:processing_started_at"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at"`
|
||||
ValidatedAt *time.Time `gorm:"column:validated_at"`
|
||||
FailedAt *time.Time `gorm:"column:failed_at"`
|
||||
FailureReason string `gorm:"type:text;column:failure_reason"`
|
||||
UploadError string `gorm:"type:text;column:upload_error"`
|
||||
NameSlot string `gorm:"type:char(36);not null;default:'live';column:name_slot;index:uq_file_files_folder_name_slot,priority:3"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;index:idx_file_files_status_updated,priority:2"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;index:idx_file_files_folder_active,priority:2"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
TrashedAt *time.Time `gorm:"column:trashed_at"`
|
||||
PurgeAt *time.Time `gorm:"column:purge_at;index:idx_file_files_purge_at"`
|
||||
LifecycleStatus string `gorm:"type:varchar(32);not null;default:'temp';column:lifecycle_status;index:idx_file_files_lifecycle_status_orphaned,priority:1"`
|
||||
AttachedAt *time.Time `gorm:"column:attached_at"`
|
||||
OrphanedAt *time.Time `gorm:"column:orphaned_at;index:idx_file_files_lifecycle_status_orphaned,priority:2"`
|
||||
LifecycleDeletedAt *time.Time `gorm:"column:lifecycle_deleted_at"`
|
||||
WOPILockID string `gorm:"type:varchar(128);column:wopi_lock_id"`
|
||||
WOPILockJTI string `gorm:"type:varchar(128);column:wopi_lock_jti"`
|
||||
WOPILockedAt *time.Time `gorm:"column:wopi_locked_at"`
|
||||
WOPILockExpiresAt *time.Time `gorm:"column:wopi_lock_expires_at"`
|
||||
|
||||
Folder *Folder `gorm:"foreignKey:FolderID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
||||
}
|
||||
|
||||
func (File) TableName() string { return "file_files" }
|
||||
|
||||
func (f *File) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeFileStatus(status string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(status))
|
||||
switch s {
|
||||
case "pending":
|
||||
return FileStatusUploaded
|
||||
case "uploading":
|
||||
return FileStatusProcessing
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func IsKnownFileStatus(status string) bool {
|
||||
switch NormalizeFileStatus(status) {
|
||||
case FileStatusUploaded, FileStatusProcessing, FileStatusValidated, FileStatusFailed, FileStatusReady, FileStatusTrashed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func CanTransitionFileStatus(fromStatus, toStatus string) bool {
|
||||
from := NormalizeFileStatus(fromStatus)
|
||||
to := NormalizeFileStatus(toStatus)
|
||||
|
||||
switch from {
|
||||
case FileStatusUploaded:
|
||||
return to == FileStatusProcessing || to == FileStatusFailed
|
||||
case FileStatusProcessing:
|
||||
return to == FileStatusValidated || to == FileStatusFailed
|
||||
case FileStatusValidated:
|
||||
return to == FileStatusReady || to == FileStatusFailed
|
||||
case FileStatusReady:
|
||||
return to == FileStatusTrashed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeFileLifecycleStatus(status string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(status))
|
||||
switch s {
|
||||
case "":
|
||||
return FileLifecycleStatusTemp
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func IsKnownFileLifecycleStatus(status string) bool {
|
||||
switch NormalizeFileLifecycleStatus(status) {
|
||||
case FileLifecycleStatusTemp, FileLifecycleStatusDraft, FileLifecycleStatusActive, FileLifecycleStatusFinal, FileLifecycleStatusOrphanPending, FileLifecycleStatusDeleted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
123
internal/domain/file_manager/model_file_test.go
Normal file
123
internal/domain/file_manager/model_file_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFileTableName(t *testing.T) {
|
||||
var m File
|
||||
if got := m.TableName(); got != "file_files" {
|
||||
t.Fatalf("table name mismatch: got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileBeforeCreate(t *testing.T) {
|
||||
t.Run("assigns uuid when missing", func(t *testing.T) {
|
||||
m := &File{}
|
||||
if err := m.BeforeCreate(&gorm.DB{}); err != nil {
|
||||
t.Fatalf("before create error: %v", err)
|
||||
}
|
||||
if len(m.ID) != 16 {
|
||||
t.Fatalf("expected 16-byte id, got %d", len(m.ID))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keeps existing id", func(t *testing.T) {
|
||||
id := make([]byte, 16)
|
||||
for i := range id {
|
||||
id[i] = byte(i + 1)
|
||||
}
|
||||
m := &File{ID: append([]byte(nil), id...)}
|
||||
if err := m.BeforeCreate(&gorm.DB{}); err != nil {
|
||||
t.Fatalf("before create error: %v", err)
|
||||
}
|
||||
for i := range id {
|
||||
if m.ID[i] != id[i] {
|
||||
t.Fatalf("id changed at %d: got %d want %d", i, m.ID[i], id[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeFileStatus(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"uploaded": FileStatusUploaded,
|
||||
"processing": FileStatusProcessing,
|
||||
"validated": FileStatusValidated,
|
||||
"failed": FileStatusFailed,
|
||||
"ready": FileStatusReady,
|
||||
"trashed": FileStatusTrashed,
|
||||
" pending ": FileStatusUploaded,
|
||||
"UPLOADING": FileStatusProcessing,
|
||||
"": "",
|
||||
}
|
||||
|
||||
for in, want := range cases {
|
||||
if got := NormalizeFileStatus(in); got != want {
|
||||
t.Fatalf("NormalizeFileStatus(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanTransitionFileStatus(t *testing.T) {
|
||||
valid := [][2]string{
|
||||
{FileStatusUploaded, FileStatusProcessing},
|
||||
{FileStatusUploaded, FileStatusFailed},
|
||||
{FileStatusProcessing, FileStatusValidated},
|
||||
{FileStatusProcessing, FileStatusFailed},
|
||||
{FileStatusValidated, FileStatusReady},
|
||||
{FileStatusValidated, FileStatusFailed},
|
||||
{FileStatusReady, FileStatusTrashed},
|
||||
}
|
||||
for i := range valid {
|
||||
if !CanTransitionFileStatus(valid[i][0], valid[i][1]) {
|
||||
t.Fatalf("expected valid transition %s -> %s", valid[i][0], valid[i][1])
|
||||
}
|
||||
}
|
||||
|
||||
invalid := [][2]string{
|
||||
{FileStatusUploaded, FileStatusReady},
|
||||
{FileStatusFailed, FileStatusReady},
|
||||
{FileStatusTrashed, FileStatusProcessing},
|
||||
{FileStatusReady, FileStatusValidated},
|
||||
}
|
||||
for i := range invalid {
|
||||
if CanTransitionFileStatus(invalid[i][0], invalid[i][1]) {
|
||||
t.Fatalf("expected invalid transition %s -> %s", invalid[i][0], invalid[i][1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFileLifecycleStatus(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": FileLifecycleStatusTemp,
|
||||
" temp ": FileLifecycleStatusTemp,
|
||||
"ACTIVE": FileLifecycleStatusActive,
|
||||
"orphan_pending": FileLifecycleStatusOrphanPending,
|
||||
"deleted": FileLifecycleStatusDeleted,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormalizeFileLifecycleStatus(in); got != want {
|
||||
t.Fatalf("NormalizeFileLifecycleStatus(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKnownFileLifecycleStatus(t *testing.T) {
|
||||
known := []string{
|
||||
FileLifecycleStatusTemp,
|
||||
FileLifecycleStatusActive,
|
||||
FileLifecycleStatusOrphanPending,
|
||||
FileLifecycleStatusDeleted,
|
||||
}
|
||||
for i := range known {
|
||||
if !IsKnownFileLifecycleStatus(known[i]) {
|
||||
t.Fatalf("expected known lifecycle status %q", known[i])
|
||||
}
|
||||
}
|
||||
if IsKnownFileLifecycleStatus("unknown") {
|
||||
t.Fatalf("expected unknown lifecycle status to be false")
|
||||
}
|
||||
}
|
||||
37
internal/domain/file_manager/model_folder.go
Normal file
37
internal/domain/file_manager/model_folder.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type Folder struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
ParentID []byte `gorm:"type:binary(16);column:parent_id;index:idx_file_folders_parent_active,priority:1;index:uq_file_folders_parent_name_slot,priority:1"`
|
||||
Name string `gorm:"type:varchar(128);not null;column:name"`
|
||||
NameNormalized string `gorm:"type:varchar(128);not null;column:name_normalized;index:uq_file_folders_parent_name_slot,priority:2"`
|
||||
Depth int `gorm:"type:smallint unsigned;not null;default:0;column:depth"`
|
||||
PathCache string `gorm:"type:varchar(2048);column:path_cache"`
|
||||
NameSlot string `gorm:"type:char(36);not null;default:'live';column:name_slot;index:uq_file_folders_parent_name_slot,priority:3"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at;index:idx_file_folders_parent_active,priority:2;index:idx_file_folders_deleted_at"`
|
||||
DeletedBy []byte `gorm:"type:binary(16);column:deleted_by"`
|
||||
PurgeAt *time.Time `gorm:"column:purge_at;index:idx_file_folders_purge_at"`
|
||||
|
||||
Parent *Folder `gorm:"foreignKey:ParentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
||||
}
|
||||
|
||||
func (Folder) TableName() string { return "file_folders" }
|
||||
|
||||
func (f *Folder) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(f.ID) == 0 {
|
||||
f.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
42
internal/domain/file_manager/model_folder_test.go
Normal file
42
internal/domain/file_manager/model_folder_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFolderTableName(t *testing.T) {
|
||||
var m Folder
|
||||
if got := m.TableName(); got != "file_folders" {
|
||||
t.Fatalf("table name mismatch: got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderBeforeCreate(t *testing.T) {
|
||||
t.Run("assigns uuid when missing", func(t *testing.T) {
|
||||
m := &Folder{}
|
||||
if err := m.BeforeCreate(&gorm.DB{}); err != nil {
|
||||
t.Fatalf("before create error: %v", err)
|
||||
}
|
||||
if len(m.ID) != 16 {
|
||||
t.Fatalf("expected 16-byte id, got %d", len(m.ID))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keeps existing id", func(t *testing.T) {
|
||||
id := make([]byte, 16)
|
||||
for i := range id {
|
||||
id[i] = byte(i + 1)
|
||||
}
|
||||
m := &Folder{ID: append([]byte(nil), id...)}
|
||||
if err := m.BeforeCreate(&gorm.DB{}); err != nil {
|
||||
t.Fatalf("before create error: %v", err)
|
||||
}
|
||||
for i := range id {
|
||||
if m.ID[i] != id[i] {
|
||||
t.Fatalf("id changed at %d: got %d want %d", i, m.ID[i], id[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
77
internal/domain/file_manager/realtime_event.go
Normal file
77
internal/domain/file_manager/realtime_event.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type FileStatusRealtimeEventInput struct {
|
||||
FileID []byte
|
||||
UserID []byte
|
||||
Status string
|
||||
Name string
|
||||
FailureReason string
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
type FileStatusRealtimeEventWriter interface {
|
||||
CreateFileStatusRealtimeEvent(ctx context.Context, input FileStatusRealtimeEventInput) error
|
||||
}
|
||||
|
||||
type FileStatusRealtimeEvent struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FileID []byte `gorm:"type:binary(16);index;not null;column:file_id"`
|
||||
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
|
||||
Status string `gorm:"type:varchar(32);index;not null;column:status"`
|
||||
Name string `gorm:"type:varchar(255);not null;column:name"`
|
||||
FailureReason string `gorm:"type:text;column:failure_reason"`
|
||||
OccurredAt time.Time `gorm:"index;not null;column:occurred_at"`
|
||||
DeliveredAt *time.Time `gorm:"index;column:delivered_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (FileStatusRealtimeEvent) TableName() string {
|
||||
return "file_status_realtime_events"
|
||||
}
|
||||
|
||||
func (e *FileStatusRealtimeEvent) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(e.ID) == 0 {
|
||||
e.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if e.OccurredAt.IsZero() {
|
||||
e.OccurredAt = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewFileStatusRealtimeEvent(input FileStatusRealtimeEventInput) (*FileStatusRealtimeEvent, error) {
|
||||
if len(input.FileID) != 16 {
|
||||
return nil, errors.New("file id must be uuid bytes")
|
||||
}
|
||||
if len(input.UserID) != 16 {
|
||||
return nil, errors.New("user id must be uuid bytes")
|
||||
}
|
||||
status := NormalizeFileStatus(input.Status)
|
||||
if !IsKnownFileStatus(status) {
|
||||
return nil, errors.New("invalid status")
|
||||
}
|
||||
occurredAt := input.OccurredAt.UTC()
|
||||
if input.OccurredAt.IsZero() {
|
||||
occurredAt = time.Now().UTC()
|
||||
}
|
||||
return &FileStatusRealtimeEvent{
|
||||
FileID: append([]byte(nil), input.FileID...),
|
||||
UserID: append([]byte(nil), input.UserID...),
|
||||
Status: status,
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
FailureReason: strings.TrimSpace(input.FailureReason),
|
||||
OccurredAt: occurredAt,
|
||||
}, nil
|
||||
}
|
||||
131
internal/domain/file_manager/repository.go
Normal file
131
internal/domain/file_manager/repository.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FolderRepository interface {
|
||||
Create(ctx context.Context, row *Folder) error
|
||||
Update(ctx context.Context, row *Folder) error
|
||||
MoveSubtree(ctx context.Context, id []byte, targetParentID []byte, targetName string, targetNameNormalized string, updatedBy []byte) (*Folder, error)
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error
|
||||
Restore(ctx context.Context, id []byte, restoredBy []byte) error
|
||||
Purge(ctx context.Context, id []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Folder, error)
|
||||
GetByIDAny(ctx context.Context, id []byte) (*Folder, error)
|
||||
GetByParentAndName(ctx context.Context, parentID []byte, nameNormalized string) (*Folder, error)
|
||||
ListByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
SearchByName(ctx context.Context, name string, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
ListTrash(ctx context.Context, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
ListTrashByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
}
|
||||
|
||||
type FileFinalizeParams struct {
|
||||
ID []byte
|
||||
ExpectedFrom string
|
||||
FolderID []byte
|
||||
Name string
|
||||
NameNormalized string
|
||||
Extension string
|
||||
UpdatedBy []byte
|
||||
}
|
||||
|
||||
type FileStatusTransitionParams struct {
|
||||
ID []byte
|
||||
ExpectedFrom string
|
||||
To string
|
||||
UpdatedBy []byte
|
||||
ProcessingStartedAt *time.Time
|
||||
ProcessedAt *time.Time
|
||||
ValidatedAt *time.Time
|
||||
FailedAt *time.Time
|
||||
FailureReason *string
|
||||
}
|
||||
|
||||
type FileRepository interface {
|
||||
Create(ctx context.Context, row *File) error
|
||||
Update(ctx context.Context, row *File) error
|
||||
TransitionStatus(ctx context.Context, params FileStatusTransitionParams) error
|
||||
Finalize(ctx context.Context, params FileFinalizeParams) error
|
||||
Delete(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error
|
||||
Restore(ctx context.Context, id []byte, restoredBy []byte) error
|
||||
Purge(ctx context.Context, id []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*File, error)
|
||||
GetByIDAny(ctx context.Context, id []byte) (*File, error)
|
||||
GetByObjectKey(ctx context.Context, objectKey string) (*File, error)
|
||||
GetByFolderAndName(ctx context.Context, folderID []byte, nameNormalized string) (*File, error)
|
||||
GetByTemplateAndTakeover(ctx context.Context, templateUUID, takeoverID []byte) (*File, error)
|
||||
ListEditedTemplatesByTakeoverID(ctx context.Context, takeoverID []byte) ([]File, error)
|
||||
ListByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]File, int64, error)
|
||||
SearchByName(ctx context.Context, name string, sort string, limit, offset int) ([]File, int64, error)
|
||||
ListTrashByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]File, int64, error)
|
||||
}
|
||||
|
||||
type FileLifecycleTransitionParams struct {
|
||||
ID []byte
|
||||
Status string
|
||||
UpdatedBy []byte
|
||||
AttachedAt *time.Time
|
||||
OrphanedAt *time.Time
|
||||
LifecycleDelete *time.Time
|
||||
ClearOrphanedAt bool
|
||||
}
|
||||
|
||||
type FileLifecycleListFilter struct {
|
||||
Status string
|
||||
OrphanedBeforeEq *time.Time
|
||||
CreatedBeforeEq *time.Time
|
||||
ExcludeInTrash bool
|
||||
ExcludeLifecycleDeleted bool
|
||||
Limit int
|
||||
}
|
||||
|
||||
type FileLifecycleRepository interface {
|
||||
UpdateLifecycle(ctx context.Context, params FileLifecycleTransitionParams) error
|
||||
IsFileReferenced(ctx context.Context, fileID []byte) (bool, error)
|
||||
ListFilesByLifecycle(ctx context.Context, filter FileLifecycleListFilter) ([]File, error)
|
||||
}
|
||||
|
||||
type FileUploadIntentCompleteParams struct {
|
||||
ID []byte
|
||||
UpdatedBy []byte
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
type FileUploadIntentExpireParams struct {
|
||||
ID []byte
|
||||
UpdatedBy []byte
|
||||
ExpiredAt time.Time
|
||||
}
|
||||
|
||||
type FileUploadIntentSizeUpdateParams struct {
|
||||
ID []byte
|
||||
SizeBytes int64
|
||||
UpdatedBy []byte
|
||||
}
|
||||
|
||||
type FileUploadIntentRepository interface {
|
||||
CreateUploadIntent(ctx context.Context, row *FileUploadIntent) error
|
||||
GetUploadIntentByID(ctx context.Context, id []byte) (*FileUploadIntent, error)
|
||||
GetUploadIntentByIDForUpdate(ctx context.Context, id []byte) (*FileUploadIntent, error)
|
||||
MarkUploadIntentCompleted(ctx context.Context, params FileUploadIntentCompleteParams) error
|
||||
MarkUploadIntentExpired(ctx context.Context, params FileUploadIntentExpireParams) error
|
||||
}
|
||||
|
||||
type AttachmentListFilter struct {
|
||||
RefType string
|
||||
RefID string
|
||||
Category *string
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type AttachmentRepository interface {
|
||||
Create(ctx context.Context, row *Attachment) error
|
||||
Delete(ctx context.Context, id []byte) error
|
||||
GetByID(ctx context.Context, id []byte) (*Attachment, error)
|
||||
GetByRefAndFile(ctx context.Context, refType, refID string, fileID []byte) (*Attachment, error)
|
||||
ListByReference(ctx context.Context, filter AttachmentListFilter) ([]Attachment, int64, error)
|
||||
}
|
||||
242
internal/domain/file_manager/service.go
Normal file
242
internal/domain/file_manager/service.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SystemRootFolderName = "__system_root_files__"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDuplicateName = errors.New("duplicate name in target folder")
|
||||
ErrInvalidMove = errors.New("invalid folder move")
|
||||
ErrFolderNotFound = errors.New("folder not found")
|
||||
ErrFileNotFound = errors.New("file not found")
|
||||
ErrFileAlreadyFinalized = errors.New("file already finalized")
|
||||
ErrFileNotEligibleForFinalize = errors.New("file is not eligible for finalize")
|
||||
ErrInvalidFileStatusTransition = errors.New("invalid file status transition")
|
||||
ErrFileStatusConflict = errors.New("file status conflict")
|
||||
ErrStorageUnavailable = errors.New("storage unavailable")
|
||||
ErrFolderNotEmpty = errors.New("folder is not empty")
|
||||
ErrFolderNotInTrash = errors.New("folder is not in trash")
|
||||
ErrFileNotInTrash = errors.New("file is not in trash")
|
||||
ErrInvalidName = errors.New("invalid name")
|
||||
ErrReservedName = errors.New("reserved name")
|
||||
ErrFileTooLarge = errors.New("file too large")
|
||||
ErrFileMimeTypeNotAllowed = errors.New("file mime type is not allowed")
|
||||
ErrFileExtensionNotAllowed = errors.New("file extension is not allowed")
|
||||
ErrEmptyFileNotAllowed = errors.New("empty file is not allowed")
|
||||
ErrFileNameTooLong = errors.New("file name exceeds maximum length")
|
||||
ErrFileProcessingQueueDisabled = errors.New("file processing queue is not configured")
|
||||
ErrUploadIntentRepositoryDisabled = errors.New("file upload intent repository is not configured")
|
||||
ErrUploadIntentNotFound = errors.New("upload intent not found")
|
||||
ErrUploadIntentExpired = errors.New("upload intent is expired")
|
||||
ErrUploadIntentAlreadyComplete = errors.New("upload intent already completed")
|
||||
ErrUploadIntentStatusConflict = errors.New("upload intent status conflict")
|
||||
ErrUploadObjectNotFound = errors.New("uploaded object not found in storage")
|
||||
ErrUploadObjectMismatch = errors.New("uploaded object does not match upload intent")
|
||||
ErrAttachmentRepositoryDisabled = errors.New("attachment repository is not configured")
|
||||
ErrAttachmentNotFound = errors.New("attachment not found")
|
||||
ErrAttachmentAlreadyExists = errors.New("attachment already exists for this reference")
|
||||
ErrAttachmentRefRequired = errors.New("attachment ref_type and ref_id are required")
|
||||
ErrFileNotReadyForAttachment = errors.New("file is not ready for attachment")
|
||||
)
|
||||
|
||||
type FileUploadPolicy struct {
|
||||
MaxFileSizeBytes int64
|
||||
AllowedMimeTypes []string
|
||||
AllowedExtensions []string
|
||||
AllowEmptyFile bool
|
||||
MaxFilenameLength int
|
||||
}
|
||||
|
||||
type FileProcessingJob struct {
|
||||
JobID string
|
||||
FileUUID string
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
UploadedBy string
|
||||
MimeType string
|
||||
SizeBytes int64
|
||||
TraceID string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type FileProcessingQueue interface {
|
||||
Enqueue(ctx context.Context, job FileProcessingJob) error
|
||||
}
|
||||
|
||||
type CreateFolderParams struct {
|
||||
Name string
|
||||
ParentID []byte
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type MoveFolderParams struct {
|
||||
Name *string
|
||||
ParentID []byte
|
||||
ParentProvided bool
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type UploadFileParams struct {
|
||||
Name string
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
Body io.Reader
|
||||
ActorID []byte
|
||||
IsTemplate bool
|
||||
}
|
||||
|
||||
type RequestFileUploadIntentParams struct {
|
||||
Name string
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type FileUploadIntentGrant struct {
|
||||
UploadIntentID []byte
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
Name string
|
||||
MimeType string
|
||||
SizeBytes int64
|
||||
Method string
|
||||
UploadURL string
|
||||
Headers map[string]string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type CompleteFileUploadIntentParams struct {
|
||||
UploadIntentID []byte
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type CancelFileUploadIntentParams struct {
|
||||
UploadIntentID []byte
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type CreateFileFromUploadParams struct {
|
||||
UploadFileID []byte
|
||||
FolderID []byte
|
||||
FolderProvided bool
|
||||
Name *string
|
||||
ActorID []byte
|
||||
IsTemplate bool
|
||||
}
|
||||
|
||||
type CreateFileFromUploadIntentParams struct {
|
||||
UploadIntentID []byte
|
||||
FolderID []byte
|
||||
FolderProvided bool
|
||||
Name string
|
||||
ActorID []byte
|
||||
IsTemplate bool
|
||||
}
|
||||
|
||||
type BulkFileNodeResult struct {
|
||||
Index int
|
||||
File *File
|
||||
Err error
|
||||
}
|
||||
|
||||
type MoveFileParams struct {
|
||||
Name *string
|
||||
FolderID []byte
|
||||
FolderProvided bool
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type CreateAttachmentParams struct {
|
||||
FileID []byte
|
||||
RefType string
|
||||
RefID string
|
||||
Category *string
|
||||
SortOrder int
|
||||
IsPrimary bool
|
||||
ActorID []byte
|
||||
}
|
||||
|
||||
type ListAttachmentsParams struct {
|
||||
RefType string
|
||||
RefID string
|
||||
Category *string
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type FileCleanupStats struct {
|
||||
Scanned int
|
||||
Deleted int
|
||||
Skipped int
|
||||
Failed int
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// LifecycleManager is an optional extension implemented by concrete services
|
||||
// to expose file lifecycle transitions without widening the base Service
|
||||
// interface used by existing handlers/tests.
|
||||
type LifecycleManager interface {
|
||||
MarkFileActive(ctx context.Context, fileID []byte, actorID []byte) error
|
||||
ReconcileFileLifecycle(ctx context.Context, fileID []byte, actorID []byte) error
|
||||
CleanupOrphanPendingFiles(ctx context.Context, now time.Time, gracePeriod time.Duration, limit int, dryRun bool) (FileCleanupStats, error)
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
SetObjectStorage(storage ObjectStorage)
|
||||
SetUploadPolicy(policy FileUploadPolicy)
|
||||
SetFileProcessingQueue(fileQueue FileProcessingQueue)
|
||||
SetFileUploadIntentRepository(repo FileUploadIntentRepository)
|
||||
RequestFileUploadIntent(ctx context.Context, params RequestFileUploadIntentParams) (*FileUploadIntentGrant, error)
|
||||
CompleteFileUploadIntent(ctx context.Context, params CompleteFileUploadIntentParams) (*File, error)
|
||||
CancelFileUploadIntent(ctx context.Context, params CancelFileUploadIntentParams) error
|
||||
CreateFileNodeFromUploadIntent(ctx context.Context, params CreateFileFromUploadIntentParams) (*File, error)
|
||||
GetOrCreateSystemRootFolder(ctx context.Context) (*Folder, error)
|
||||
CreateFolderNode(ctx context.Context, params CreateFolderParams) (*Folder, error)
|
||||
MoveFolderNode(ctx context.Context, id []byte, params MoveFolderParams) (*Folder, error)
|
||||
UploadFileNode(ctx context.Context, params UploadFileParams) (*File, error)
|
||||
CreateFileNodeFromUpload(ctx context.Context, params CreateFileFromUploadParams) (*File, error)
|
||||
UploadFileNodesBulk(ctx context.Context, params []UploadFileParams) []BulkFileNodeResult
|
||||
CreateFileNodesFromUploadBulk(ctx context.Context, params []CreateFileFromUploadParams) []BulkFileNodeResult
|
||||
MoveFileNode(ctx context.Context, id []byte, params MoveFileParams) (*File, error)
|
||||
PurgeFolderNode(ctx context.Context, id []byte) error
|
||||
PurgeFileNode(ctx context.Context, id []byte) error
|
||||
ListNodesByParent(ctx context.Context, parentID []byte) ([]Folder, []File, error)
|
||||
SearchNodes(ctx context.Context, query string) ([]Folder, []File, error)
|
||||
ListTrashNodes(ctx context.Context) ([]Folder, []File, error)
|
||||
|
||||
CreateFolder(ctx context.Context, row *Folder) error
|
||||
UpdateFolder(ctx context.Context, row *Folder) error
|
||||
DeleteFolder(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error
|
||||
RestoreFolder(ctx context.Context, id []byte, targetParentID []byte, targetProvided bool, restoredBy []byte) error
|
||||
PurgeFolder(ctx context.Context, id []byte) error
|
||||
GetFolderByID(ctx context.Context, id []byte) (*Folder, error)
|
||||
GetFolderByIDAny(ctx context.Context, id []byte) (*Folder, error)
|
||||
GetFolderByParentAndName(ctx context.Context, parentID []byte, nameNormalized string) (*Folder, error)
|
||||
ListFoldersByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
ListTrashFoldersByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]Folder, int64, error)
|
||||
|
||||
CreateFile(ctx context.Context, row *File) error
|
||||
UpdateFile(ctx context.Context, row *File) error
|
||||
DeleteFile(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error
|
||||
RestoreFile(ctx context.Context, id []byte, targetFolderID []byte, targetProvided bool, restoredBy []byte) error
|
||||
PurgeFile(ctx context.Context, id []byte) error
|
||||
GetFileByID(ctx context.Context, id []byte) (*File, error)
|
||||
GetFileByIDAny(ctx context.Context, id []byte) (*File, error)
|
||||
GetFileByObjectKey(ctx context.Context, objectKey string) (*File, error)
|
||||
GetFileByFolderAndName(ctx context.Context, folderID []byte, nameNormalized string) (*File, error)
|
||||
ListFilesByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]File, int64, error)
|
||||
ListTrashFilesByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]File, int64, error)
|
||||
CreateAttachment(ctx context.Context, params CreateAttachmentParams) (*Attachment, error)
|
||||
GetAttachmentByID(ctx context.Context, id []byte) (*Attachment, error)
|
||||
ListAttachmentsByReference(ctx context.Context, params ListAttachmentsParams) ([]Attachment, int64, error)
|
||||
DeleteAttachment(ctx context.Context, id []byte) error
|
||||
ListEditedWBFilesByTakeoverID(ctx context.Context, takeoverID []byte) ([]File, error)
|
||||
}
|
||||
56
internal/domain/file_manager/storage.go
Normal file
56
internal/domain/file_manager/storage.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PutObjectInput struct {
|
||||
ObjectKey string
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
Body io.Reader
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type PresignPutObjectInput struct {
|
||||
ObjectKey string
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
Metadata map[string]string
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
type PresignedPutObject struct {
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
Method string
|
||||
URL string
|
||||
Headers map[string]string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type ObjectMetadata struct {
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
ETag string
|
||||
VersionID string
|
||||
SizeBytes int64
|
||||
LastModified *time.Time
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type ObjectStorage interface {
|
||||
EnsureBucket(ctx context.Context) error
|
||||
PutObject(ctx context.Context, input PutObjectInput) (ObjectMetadata, error)
|
||||
DeleteObject(ctx context.Context, objectKey string) error
|
||||
HeadObject(ctx context.Context, objectKey string) (ObjectMetadata, error)
|
||||
PresignPutObject(ctx context.Context, input PresignPutObjectInput) (PresignedPutObject, error)
|
||||
PresignGetObject(ctx context.Context, objectKey string, ttl time.Duration) (string, error)
|
||||
}
|
||||
|
||||
type ObjectTaggingStorage interface {
|
||||
PutObjectTagging(ctx context.Context, objectKey string, tags map[string]string) error
|
||||
DeleteObjectTaggingKeys(ctx context.Context, objectKey string, keys []string) error
|
||||
}
|
||||
52
internal/domain/file_manager/upload_intent.go
Normal file
52
internal/domain/file_manager/upload_intent.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
FileUploadIntentStatusPending = "pending"
|
||||
FileUploadIntentStatusCompleted = "completed"
|
||||
FileUploadIntentStatusExpired = "expired"
|
||||
)
|
||||
|
||||
type FileUploadIntent struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
Name string `gorm:"type:varchar(255);not null;column:name"`
|
||||
NameNormalized string `gorm:"type:varchar(255);not null;column:name_normalized"`
|
||||
Extension string `gorm:"type:varchar(32);column:extension"`
|
||||
SizeBytes int64 `gorm:"type:bigint unsigned;not null;default:0;column:size_bytes"`
|
||||
MimeType string `gorm:"type:varchar(255);not null;column:mime_type"`
|
||||
Bucket string `gorm:"type:varchar(128);not null;column:bucket"`
|
||||
ObjectKey string `gorm:"type:varchar(512);not null;uniqueIndex:uq_file_upload_intents_object_key"`
|
||||
Status string `gorm:"type:varchar(32);index;not null;default:'pending';column:status"`
|
||||
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (FileUploadIntent) TableName() string {
|
||||
return "file_upload_intents"
|
||||
}
|
||||
|
||||
func (m *FileUploadIntent) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(m.ID) == 0 {
|
||||
m.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if strings.TrimSpace(m.Status) == "" {
|
||||
m.Status = FileUploadIntentStatusPending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeFileUploadIntentStatus(status string) string {
|
||||
return strings.ToLower(strings.TrimSpace(status))
|
||||
}
|
||||
Reference in New Issue
Block a user