init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View 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
}

View 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
}

View 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
}
}

View 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")
}
}

View 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
}

View 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])
}
}
})
}

View 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
}

View 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)
}

View 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)
}

View 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
}

View 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))
}