Files
fm_be/internal/service/helicopter_file_service.go
2026-07-16 22:16:45 +07:00

562 lines
17 KiB
Go

package service
import (
"context"
"errors"
"path/filepath"
"strings"
"time"
filemanager "wucher/internal/domain/file_manager"
helicopterfile "wucher/internal/domain/helicopter_file"
helicopterfilerealtime "wucher/internal/realtime/helicopterfile"
"wucher/internal/shared/pkg/attachmentresolver"
"wucher/internal/shared/pkg/uuidv7"
)
type HelicopterFileService struct {
repo helicopterfile.Repository
fileManager filemanager.Service
events helicopterFileEventPublisher
}
func NewHelicopterFileService(repo helicopterfile.Repository) *HelicopterFileService {
return &HelicopterFileService{repo: repo}
}
type helicopterFileEventPublisher interface {
PublishToUsers(userUUIDs []string, event helicopterfilerealtime.Event) int
}
func (s *HelicopterFileService) WithFileManagerService(fileManagerSvc filemanager.Service) *HelicopterFileService {
s.fileManager = fileManagerSvc
return s
}
func (s *HelicopterFileService) WithHelicopterFileEvents(publisher helicopterFileEventPublisher) *HelicopterFileService {
s.events = publisher
return s
}
func (s *HelicopterFileService) Create(ctx context.Context, row *helicopterfile.HelicopterFile) error {
if row != nil && row.Position <= 0 {
next, err := s.nextPositionByHelicopterSection(ctx, row.HelicopterID, row.Section)
if err != nil {
return err
}
row.Position = next
}
return s.repo.Create(ctx, row)
}
func (s *HelicopterFileService) Update(ctx context.Context, row *helicopterfile.HelicopterFile) error {
return s.repo.Update(ctx, row)
}
func (s *HelicopterFileService) Delete(ctx context.Context, id []byte) error {
return s.repo.Delete(ctx, id)
}
func (s *HelicopterFileService) GetByID(ctx context.Context, id []byte) (*helicopterfile.HelicopterFile, error) {
return s.repo.GetByID(ctx, id)
}
func (s *HelicopterFileService) List(ctx context.Context, filter string, sort string, limit, offset int) ([]helicopterfile.HelicopterFile, int64, error) {
return s.repo.List(ctx, filter, normalizeHelicopterFileSort(sort), limit, offset)
}
func (s *HelicopterFileService) ListByHelicopter(ctx context.Context, helicopterID []byte) ([]helicopterfile.HelicopterFile, error) {
return s.repo.ListByHelicopter(ctx, helicopterID)
}
func (s *HelicopterFileService) ListByHelicopterAndSection(ctx context.Context, helicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) {
return s.repo.ListByHelicopterAndSection(ctx, helicopterID, section)
}
func (s *HelicopterFileService) HelicopterExists(ctx context.Context, helicopterID []byte) (bool, error) {
return s.repo.HelicopterExists(ctx, helicopterID)
}
func (s *HelicopterFileService) AttachmentExists(ctx context.Context, attachmentID []byte) (bool, error) {
return s.repo.AttachmentExists(ctx, attachmentID)
}
func (s *HelicopterFileService) RequestUploadIntentsBulk(ctx context.Context, inputs []helicopterfile.UploadIntentBulkInput, actorID []byte) []helicopterfile.UploadIntentBulkItemResult {
items := make([]helicopterfile.UploadIntentBulkItemResult, len(inputs))
if s.fileManager == nil {
for i := range items {
items[i] = helicopterfile.UploadIntentBulkItemResult{
Index: i,
Success: false,
Error: &helicopterfile.BulkItemError{
Status: 400,
Title: "Upload intent failed",
Detail: "file manager service is not configured",
},
}
}
return items
}
for i := range inputs {
item := inputs[i]
typ := strings.TrimSpace(item.Type)
if typ != "helicopter_file_upload" {
items[i] = uploadIntentBulkError(i, 422, "Validation error", "type must be helicopter_file_upload")
continue
}
name := strings.TrimSpace(item.Name)
if name == "" {
items[i] = uploadIntentBulkError(i, 422, "Validation error", "name is required")
continue
}
contentType := strings.TrimSpace(item.ContentType)
if contentType == "" {
items[i] = uploadIntentBulkError(i, 422, "Validation error", "content_type is required")
continue
}
if item.SizeBytes < 0 {
items[i] = uploadIntentBulkError(i, 422, "Validation error", "size_bytes must be greater than or equal to 0")
continue
}
grant, grantErr := s.fileManager.RequestFileUploadIntent(ctx, filemanager.RequestFileUploadIntentParams{
Name: name,
ContentType: contentType,
SizeBytes: item.SizeBytes,
ActorID: actorID,
})
if grantErr != nil {
items[i] = uploadIntentBulkError(i, 400, "Upload intent failed", grantErr.Error())
continue
}
items[i] = helicopterfile.UploadIntentBulkItemResult{
Index: i,
Success: true,
Grant: grant,
}
}
return items
}
func (s *HelicopterFileService) CreateFromUploadIntentsBulk(ctx context.Context, inputs []helicopterfile.CreateBulkInput, actorID []byte) []helicopterfile.CreateBulkItemResult {
items := make([]helicopterfile.CreateBulkItemResult, len(inputs))
if s.fileManager == nil {
for i := range items {
items[i] = createBulkError(i, 400, "Create failed", "file manager service is not configured")
}
return items
}
for i := range inputs {
item := inputs[i]
typ := strings.TrimSpace(item.Type)
if typ != "helicopter_file_create" && typ != "helicopter_file" {
items[i] = createBulkError(i, 422, "Validation error", "type must be helicopter_file_create or helicopter_file")
continue
}
section := strings.TrimSpace(item.Section)
if !isValidHelicopterFileSection(section) {
items[i] = createBulkError(i, 422, "Validation error", "section is invalid")
continue
}
helicopterID, err := uuidv7.ParseString(strings.TrimSpace(item.HelicopterID))
if err != nil {
items[i] = createBulkError(i, 422, "Validation error", "helicopter_id is invalid UUID")
continue
}
exists, err := s.repo.HelicopterExists(ctx, helicopterID)
if err != nil {
items[i] = createBulkError(i, 400, "Create failed", err.Error())
continue
}
if !exists {
items[i] = createBulkError(i, 422, "Validation error", "helicopter_id not found")
continue
}
uploadIntentID, parseErr := uuidv7.ParseString(strings.TrimSpace(item.UploadIntentUUID))
if parseErr != nil {
items[i] = createBulkError(i, 422, "Validation error", "upload_intent_uuid is invalid UUID")
continue
}
fileName := strings.TrimSpace(item.FileName)
if fileName == "" {
items[i] = createBulkError(i, 422, "Validation error", "file_name is required")
continue
}
var folderID []byte
folderProvided := false
if item.FolderUUID != nil {
folderProvided = true
folderUUID := strings.TrimSpace(*item.FolderUUID)
if folderUUID != "" {
parsedFolderID, folderErr := uuidv7.ParseString(folderUUID)
if folderErr != nil {
items[i] = createBulkError(i, 422, "Validation error", "folder_uuid is invalid UUID")
continue
}
folderID = parsedFolderID
}
}
createdFile, createErr := s.fileManager.CreateFileNodeFromUploadIntent(ctx, filemanager.CreateFileFromUploadIntentParams{
UploadIntentID: uploadIntentID,
FolderID: folderID,
FolderProvided: folderProvided,
Name: fileName,
ActorID: actorID,
IsTemplate: true,
})
if createErr != nil {
if errors.Is(createErr, filemanager.ErrDuplicateName) {
targetFolderID := folderID
if !folderProvided || len(targetFolderID) == 0 {
root, rootErr := s.fileManager.GetOrCreateSystemRootFolder(ctx)
if rootErr != nil || root == nil {
detail := "unable to resolve target folder"
if rootErr != nil {
detail = rootErr.Error()
}
items[i] = createBulkError(i, 400, "Create failed", detail)
continue
}
targetFolderID = root.ID
}
nameNormalized := strings.ToLower(filepath.Base(strings.TrimSpace(fileName)))
if nameNormalized == "" {
items[i] = createBulkError(i, 422, "Validation error", "file_name is invalid")
continue
}
existingFile, existingErr := s.fileManager.GetFileByFolderAndName(ctx, targetFolderID, nameNormalized)
if existingErr != nil || existingFile == nil {
detail := "duplicate file exists but cannot be resolved"
if existingErr != nil {
detail = existingErr.Error()
}
items[i] = createBulkError(i, 400, "Create failed", detail)
continue
}
createdFile = existingFile
} else {
items[i] = createBulkError(i, 400, "Create failed", createErr.Error())
continue
}
}
if createdFile == nil {
items[i] = createBulkError(i, 400, "Create failed", "unable to create file from upload intent")
continue
}
position := 0
if item.Position != nil {
position = *item.Position
}
isMandatory := false
if item.IsMandatory != nil {
isMandatory = *item.IsMandatory
}
row := &helicopterfile.HelicopterFile{
Section: section,
Position: position,
IsMandatory: isMandatory,
HelicopterID: helicopterID,
SourceFileID: append([]byte(nil), createdFile.ID...),
CreatedBy: actorID,
UpdatedBy: actorID,
}
if row.Position <= 0 {
next, nextErr := s.nextPositionByHelicopterSection(ctx, row.HelicopterID, row.Section)
if nextErr != nil {
items[i] = createBulkError(i, 400, "Create failed", nextErr.Error())
continue
}
row.Position = next
}
if err := s.repo.Create(ctx, row); err != nil {
items[i] = createBulkError(i, 400, "Create failed", err.Error())
continue
}
createdRow, getErr := s.repo.GetByID(ctx, row.ID)
if getErr == nil && createdRow != nil {
items[i] = helicopterfile.CreateBulkItemResult{
Index: i,
Success: true,
Row: createdRow,
}
continue
}
items[i] = helicopterfile.CreateBulkItemResult{
Index: i,
Success: true,
Row: row,
}
}
return items
}
func (s *HelicopterFileService) FinalizePendingAttachments(ctx context.Context, limit int) (int, error) {
if s == nil || s.repo == nil {
return 0, errors.New("helicopter file service is not configured")
}
if s.fileManager == nil {
return 0, nil
}
rows, err := s.repo.ListPendingAttachments(ctx, limit)
if err != nil {
return 0, err
}
return s.finalizePendingAttachmentRows(ctx, rows), nil
}
func (s *HelicopterFileService) FinalizePendingAttachmentsBySourceFileUUID(ctx context.Context, sourceFileUUID string, limit int) (int, error) {
if s == nil || s.repo == nil {
return 0, errors.New("helicopter file service is not configured")
}
if s.fileManager == nil {
return 0, nil
}
sourceFileUUID = strings.TrimSpace(sourceFileUUID)
if sourceFileUUID == "" {
return 0, nil
}
sourceFileID, err := uuidv7.ParseString(sourceFileUUID)
if err != nil {
return 0, err
}
rows, err := s.repo.ListPendingAttachmentsBySourceFileID(ctx, sourceFileID, limit)
if err != nil {
return 0, err
}
return s.finalizePendingAttachmentRows(ctx, rows), nil
}
func (s *HelicopterFileService) finalizePendingAttachmentRows(ctx context.Context, rows []helicopterfile.HelicopterFile) int {
finalized := 0
for i := range rows {
row := &rows[i]
if len(row.ID) != 16 || len(row.SourceFileID) != 16 || len(row.FileAttachmentID) == 16 {
continue
}
attachmentID, ok, err := s.finalizePendingAttachment(ctx, row)
if err != nil || !ok || len(attachmentID) != 16 {
continue
}
finalized++
}
return finalized
}
func (s *HelicopterFileService) finalizePendingAttachment(ctx context.Context, row *helicopterfile.HelicopterFile) ([]byte, bool, error) {
if s == nil || row == nil || s.fileManager == nil {
return nil, false, nil
}
if len(row.SourceFileID) != 16 || len(row.ID) != 16 || len(row.HelicopterID) != 16 {
return nil, false, nil
}
sourceFileUUID := uuidBytesToStringOrEmpty(row.SourceFileID)
if sourceFileUUID == "" {
return nil, false, nil
}
helicopterUUID := uuidBytesToStringOrEmpty(row.HelicopterID)
if helicopterUUID == "" {
return nil, false, nil
}
category := "helicopter_file"
attachmentID, err := attachmentresolver.Resolve(ctx, attachmentresolver.Params{
FileManager: s.fileManager,
RawFileID: &sourceFileUUID,
AttachmentField: "file_attachment_id",
FileField: "source_file_id",
RequireValue: true,
RefType: "helicopter",
RefID: helicopterUUID,
Category: &category,
IsPrimary: false,
ActorID: append([]byte(nil), row.UpdatedBy...),
})
if err != nil {
var resolveErr *attachmentresolver.ResolveError
if errors.As(err, &resolveErr) && resolveErr.Code == attachmentresolver.ErrorFileNotReadyForAttachment {
return nil, false, nil
}
return nil, false, err
}
if len(attachmentID) != 16 {
return nil, false, nil
}
row.FileAttachmentID = append([]byte(nil), attachmentID...)
row.SourceFileID = nil
row.UpdatedAt = time.Now().UTC()
if len(row.UpdatedBy) == 0 {
row.UpdatedBy = append([]byte(nil), row.CreatedBy...)
}
if err := s.repo.Update(ctx, row); err != nil {
return nil, false, err
}
s.publishHelicopterFileState(row, attachmentID)
if err := s.markAttachmentFileActive(ctx, attachmentID, row.UpdatedBy); err != nil {
return attachmentID, true, err
}
return attachmentID, true, nil
}
func (s *HelicopterFileService) publishHelicopterFileState(row *helicopterfile.HelicopterFile, fileID []byte) {
if s == nil || s.events == nil || row == nil || len(row.ID) != 16 || len(row.HelicopterID) != 16 {
return
}
actorID := row.UpdatedBy
if len(actorID) != 16 {
actorID = row.CreatedBy
}
if len(actorID) != 16 {
return
}
actorUUID := uuidBytesToStringOrEmpty(actorID)
helicopterUUID := uuidBytesToStringOrEmpty(row.HelicopterID)
helicopterFileUUID := uuidBytesToStringOrEmpty(row.ID)
attachmentUUID := uuidBytesToStringOrEmpty(fileID)
if attachmentUUID == "" {
attachmentUUID = uuidBytesToStringOrEmpty(row.FileAttachmentID)
}
if actorUUID == "" || helicopterUUID == "" || helicopterFileUUID == "" || attachmentUUID == "" {
return
}
updatedAt := row.UpdatedAt.UTC()
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
sourceFile := row.SourceFile
name := ""
mimeType := ""
if sourceFile != nil {
name = strings.TrimSpace(sourceFile.Name)
mimeType = strings.TrimSpace(sourceFile.MimeType)
}
s.events.PublishToUsers([]string{actorUUID}, helicopterfilerealtime.Event{
EventType: helicopterfilerealtime.DefaultEventType,
HelicopterID: helicopterUUID,
HelicopterFileID: helicopterFileUUID,
FileID: attachmentUUID,
Section: strings.TrimSpace(row.Section),
Name: name,
MimeType: mimeType,
AttachmentPending: len(row.FileAttachmentID) != 16 && len(row.SourceFileID) == 16,
Position: row.Position,
IsMandatory: row.IsMandatory,
UpdatedAt: updatedAt.Format(time.RFC3339),
})
}
func (s *HelicopterFileService) markAttachmentFileActive(ctx context.Context, attachmentID []byte, actorID []byte) error {
if s == nil || s.fileManager == nil || len(attachmentID) != 16 {
return nil
}
lifecycle, ok := s.fileManager.(filemanager.LifecycleManager)
if !ok {
return nil
}
attachment, err := s.fileManager.GetAttachmentByID(ctx, attachmentID)
if err != nil || attachment == nil || len(attachment.FileID) != 16 {
return err
}
return lifecycle.MarkFileActive(ctx, attachment.FileID, actorID)
}
func (s *HelicopterFileService) nextPositionByHelicopterSection(ctx context.Context, helicopterID []byte, section string) (int, error) {
rows, err := s.repo.ListByHelicopterAndSection(ctx, helicopterID, section)
if err != nil {
return 0, err
}
maxPos := 0
for i := range rows {
if rows[i].Position > maxPos {
maxPos = rows[i].Position
}
}
return maxPos + 1, nil
}
func uploadIntentBulkError(index int, status int, title, detail string) helicopterfile.UploadIntentBulkItemResult {
return helicopterfile.UploadIntentBulkItemResult{
Index: index,
Success: false,
Error: &helicopterfile.BulkItemError{
Status: status,
Title: title,
Detail: detail,
},
}
}
func createBulkError(index int, status int, title, detail string) helicopterfile.CreateBulkItemResult {
return helicopterfile.CreateBulkItemResult{
Index: index,
Success: false,
Error: &helicopterfile.BulkItemError{
Status: status,
Title: title,
Detail: detail,
},
}
}
func isValidHelicopterFileSection(section string) bool {
switch strings.TrimSpace(section) {
case helicopterfile.SectionBeforeFirstFlightInspection,
helicopterfile.SectionFlightPreparationAndWB,
helicopterfile.SectionAfterLastFlightInspection:
return true
default:
return false
}
}
func uuidBytesToStringOrEmpty(id []byte) string {
s, err := uuidv7.BytesToString(id)
if err != nil {
return ""
}
return s
}
func normalizeHelicopterFileSort(sort string) string {
if normalized := normalizeSortByRules(strings.TrimSpace(sort),
sortExpr("section ASC, position ASC, created_at ASC", "section DESC, position DESC, created_at DESC", "section"),
sortField("position"),
sortField("is_mandatory"),
sortField("created_at"),
sortField("updated_at"),
); normalized != "" {
return normalized
}
return "section ASC, position ASC, created_at ASC"
}