diff --git a/internal/domain/main-entities/patient/dto.go b/internal/domain/main-entities/patient/dto.go index 02e9b0e7..b8630a0f 100644 --- a/internal/domain/main-entities/patient/dto.go +++ b/internal/domain/main-entities/patient/dto.go @@ -1,13 +1,16 @@ package patient import ( + "mime/multipart" + "time" + ecore "simrs-vx/internal/domain/base-entities/core" ep "simrs-vx/internal/domain/main-entities/person" epa "simrs-vx/internal/domain/main-entities/person-address" epc "simrs-vx/internal/domain/main-entities/person-contact" epr "simrs-vx/internal/domain/main-entities/person-relative" erc "simrs-vx/internal/domain/references/common" - "time" + ere "simrs-vx/internal/domain/references/encounter" ) type CreateDto struct { @@ -56,6 +59,17 @@ type SearchDto struct { Search string `json:"search"` } +type UploadDto struct { + Id uint `json:"-"` + Code ere.UploadCode `json:"-"` + File multipart.File `json:"-"` + FileHeader *multipart.FileHeader `json:"-"` + Filename string `json:"-"` + Size int64 `json:"-"` + MimeType string `json:"-"` + MedRecNumber string `json:"-"` +} + type MetaDto struct { PageNumber int `json:"page_number"` PageSize int `json:"page_size"` diff --git a/internal/domain/references/encounter/encounter.go b/internal/domain/references/encounter/encounter.go index 8ed4dd81..14e6a269 100644 --- a/internal/domain/references/encounter/encounter.go +++ b/internal/domain/references/encounter/encounter.go @@ -85,3 +85,12 @@ func (ec EncounterClassCode) Code() string { return "UNKNOWN" } } + +func IsValidUploadCode(code UploadCode) bool { + switch UploadCode(code) { + case UCPRN, UCPDL, UCPP, UCPFC, UCMIR: + return true + default: + return false + } +} diff --git a/internal/infra/minio/minio.go b/internal/infra/minio/minio.go index 36e9c546..e403b964 100644 --- a/internal/infra/minio/minio.go +++ b/internal/infra/minio/minio.go @@ -2,9 +2,6 @@ package minio import ( "errors" - "io" - "net/url" - "time" a "github.com/karincake/apem" lo "github.com/karincake/apem/loggero" @@ -24,28 +21,6 @@ type MinioCfg struct { BucketName []string `yaml:"bucketName"` } -type UploadReaderInput struct { - File io.Reader - Name string - Size int64 - ContentType string - BucketName string -} - -type UploadPathInput struct { - BucketName string - ContentType string - Name string - Path string -} - -type PresignedGetInput struct { - Bucket string - Object string - Expiry time.Duration - ReqParams url.Values -} - type ResponsePostPolicy struct { Url string `json:"url"` FormData map[string]string `json:"form-data"` diff --git a/internal/interface/main-handler/main-handler.go b/internal/interface/main-handler/main-handler.go index f62f7036..7b02de54 100644 --- a/internal/interface/main-handler/main-handler.go +++ b/internal/interface/main-handler/main-handler.go @@ -235,6 +235,7 @@ func SetRoutes() http.Handler { "PATCH /{id}": patient.O.Update, "DELETE /{id}": patient.O.Delete, "GET /by-identifier": patient.O.Search, + "POST /{id}/upload": patient.O.Upload, }) /******************** sources ********************/ diff --git a/internal/interface/main-handler/patient/handler.go b/internal/interface/main-handler/patient/handler.go index c392ee4c..1bcdacee 100644 --- a/internal/interface/main-handler/patient/handler.go +++ b/internal/interface/main-handler/patient/handler.go @@ -10,6 +10,8 @@ import ( e "simrs-vx/internal/domain/main-entities/patient" u "simrs-vx/internal/use-case/main-use-case/patient" + + ere "simrs-vx/internal/domain/references/encounter" ) type myBase struct{} @@ -76,3 +78,37 @@ func (obj myBase) Search(w http.ResponseWriter, r *http.Request) { res, err := u.Search(dto) rw.DataResponse(w, res, err) } + +func (obj myBase) Upload(w http.ResponseWriter, r *http.Request) { + id := rw.ValidateInt(w, "id", r.PathValue("id")) + if id <= 0 { + return + } + + err := r.ParseMultipartForm(10 << 20) // 10 MB + if err != nil { + rw.DataResponse(w, nil, err) + return + } + + code := r.FormValue("code") + + file, header, err := r.FormFile("content") + if err != nil { + rw.DataResponse(w, nil, err) + return + } + defer file.Close() + + dto := e.UploadDto{} + dto.Id = uint(id) + dto.Code = ere.UploadCode(code) + dto.File = file + dto.FileHeader = header + dto.Filename = header.Filename + dto.Size = header.Size + dto.MimeType = header.Header.Get("Content-Type") + + res, err := u.Upload(dto) + rw.DataResponse(w, res, err) +} diff --git a/internal/use-case/main-use-case/patient/case.go b/internal/use-case/main-use-case/patient/case.go index b17c756a..6fca5ab1 100644 --- a/internal/use-case/main-use-case/patient/case.go +++ b/internal/use-case/main-use-case/patient/case.go @@ -1,6 +1,7 @@ package patient import ( + "errors" "strconv" e "simrs-vx/internal/domain/main-entities/patient" @@ -10,6 +11,8 @@ import ( upc "simrs-vx/internal/use-case/main-use-case/person-contact" upr "simrs-vx/internal/use-case/main-use-case/person-relative" + ere "simrs-vx/internal/domain/references/encounter" + pl "simrs-vx/pkg/logger" pu "simrs-vx/pkg/use-case-helper" @@ -359,3 +362,73 @@ func Search(input e.SearchDto) (*d.Data, error) { Data: data.ToResponse(), }, nil } + +func Upload(input e.UploadDto) (*d.Data, error) { + rdDto := e.ReadDetailDto{Id: uint16(input.Id)} + var data *e.Patient + var err error + + event := pl.Event{ + Feature: "Upload", + Source: source, + } + + // Start log + pl.SetLogInfo(&event, input, "started", "upload") + + err = dg.I.Transaction(func(tx *gorm.DB) error { + if !ere.IsValidUploadCode(input.Code) { + return errors.New("invalid upload code") + } + + pl.SetLogInfo(&event, rdDto, "started", "DBReadDetail") + if data, err = ReadDetailData(rdDto, &event, tx); err != nil { + return err + } + + if data.Person == nil { + return errors.New("person not found") + } + + person := data.Person + + input.MedRecNumber = *data.Number + pubUrl, err := uploadAndGenerateFileUrl(input, &event) + if err != nil { + event.Action = "" + } + switch input.Code { + case ere.UCPRN: + person.ResidentIdentityFileUrl = &pubUrl + case ere.UCPDL: + person.DrivingLicenseFileUrl = &pubUrl + case ere.UCPP: + person.PassportFileUrl = &pubUrl + case ere.UCPFC: + person.FamilyIdentityFileUrl = &pubUrl + default: + return errors.New("invalid upload code") + } + + if err := tx.Save(&person).Error; err != nil { + return err + } + + pl.SetLogInfo(&event, nil, "complete") + + return nil + }) + + if err != nil { + return nil, err + } + + return &d.Data{ + Meta: d.IS{ + "source": source, + "structure": "single-data", + "status": "uploaded", + }, + Data: data.ToResponse(), + }, nil +} diff --git a/internal/use-case/main-use-case/patient/helper.go b/internal/use-case/main-use-case/patient/helper.go index 09f94ef6..be1a4355 100644 --- a/internal/use-case/main-use-case/patient/helper.go +++ b/internal/use-case/main-use-case/patient/helper.go @@ -6,8 +6,16 @@ package patient import ( "fmt" - e "simrs-vx/internal/domain/main-entities/patient" + "path/filepath" "strconv" + "strings" + "time" + + e "simrs-vx/internal/domain/main-entities/patient" + + pl "simrs-vx/pkg/logger" + pmh "simrs-vx/pkg/minio-helper" + puh "simrs-vx/pkg/upload-helper" dg "github.com/karincake/apem/db-gorm-pg" ) @@ -81,3 +89,33 @@ func GenerateNextMedicalRecordNumber() (string, error) { return fmt.Sprintf(format, nextInt), nil } + +func uploadAndGenerateFileUrl(input e.UploadDto, event *pl.Event) (string, error) { + pl.SetLogInfo(event, input, "started", "uploadAndGenerateFileUrl") + bucket := string(input.Code) + ext := strings.ToLower(filepath.Ext(input.Filename)) + + if !puh.IsValidFileType(ext, bucket) { + return "", fmt.Errorf("invalid file type: %s", input.Filename) + } + objectName := fmt.Sprintf("%s%d%s", input.MedRecNumber, time.Now().UnixNano(), ext) + + uploadInput := pmh.UploadReaderInput{ + BucketName: bucket, + Name: objectName, + File: input.File, + Size: input.Size, + ContentType: input.MimeType, + } + + _, err := pmh.I.PutObject(uploadInput) + if err != nil { + return "", err + } + + // Build URL for access + publicUrl := pmh.I.GenerateUrl(bucket, objectName) + + pl.SetLogInfo(event, nil, "complete") + return publicUrl, nil +} diff --git a/pkg/minio-helper/minio-helper.go b/pkg/minio-helper/minio-helper.go index 637ca1b6..4c5c3952 100644 --- a/pkg/minio-helper/minio-helper.go +++ b/pkg/minio-helper/minio-helper.go @@ -27,17 +27,35 @@ func (repo *minioRepository) createBucket(bucketName string, region string) erro if err != nil { return err } - if exist { - return nil - } - if err := repo.client.MakeBucket(context.Background(), bucketName, minio.MakeBucketOptions{Region: region, ObjectLocking: true}); err != nil { - return err + if !exist { + // create bucket + if err := repo.client.MakeBucket(context.Background(), bucketName, minio.MakeBucketOptions{Region: region}); err != nil { + return err + } + + // set bucket policy to public read + policy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] + }`, bucketName) + + if err := repo.client.SetBucketPolicy(context.Background(), bucketName, policy); err != nil { + return err + } } + return nil } // Upload file reader to MinIO -func (repo *minioRepository) PutObject(input m.UploadReaderInput) (*minio.UploadInfo, error) { +func (repo *minioRepository) PutObject(input UploadReaderInput) (*minio.UploadInfo, error) { if err := repo.createBucket(input.BucketName, m.O.GetRegion()); err != nil { return nil, err } @@ -55,7 +73,7 @@ func (repo *minioRepository) PutObject(input m.UploadReaderInput) (*minio.Upload } // Upload file path to MinIO -func (repo *minioRepository) FPutObject(input m.UploadPathInput) (*minio.UploadInfo, error) { +func (repo *minioRepository) FPutObject(input UploadPathInput) (*minio.UploadInfo, error) { if err := repo.createBucket(input.BucketName, m.O.GetRegion()); err != nil { return nil, err } @@ -120,7 +138,7 @@ func (repo *minioRepository) GeneratePresignedPost(policy *minio.PostPolicy) (*u } // create presigned url to get object -func (repo *minioRepository) GeneratePresignedGetObject(input m.PresignedGetInput) (*url.URL, error) { +func (repo *minioRepository) GeneratePresignedGetObject(input PresignedGetInput) (*url.URL, error) { presignedUrl, err := repo.client.PresignedGetObject(context.Background(), input.Bucket, input.Object, input.Expiry, input.ReqParams) if err != nil { return nil, err diff --git a/pkg/minio-helper/tycovar.go b/pkg/minio-helper/tycovar.go new file mode 100644 index 00000000..a9ac20dc --- /dev/null +++ b/pkg/minio-helper/tycovar.go @@ -0,0 +1,29 @@ +package miniohelper + +import ( + "io" + "net/url" + "time" +) + +type UploadReaderInput struct { + File io.Reader + Name string + Size int64 + ContentType string + BucketName string +} + +type UploadPathInput struct { + BucketName string + ContentType string + Name string + Path string +} + +type PresignedGetInput struct { + Bucket string + Object string + Expiry time.Duration + ReqParams url.Values +} diff --git a/pkg/upload-helper/upload-helper.go b/pkg/upload-helper/upload-helper.go index 3c80a176..0ce72ff1 100644 --- a/pkg/upload-helper/upload-helper.go +++ b/pkg/upload-helper/upload-helper.go @@ -38,9 +38,8 @@ func getValidFileTypesForBucket(bucketName string) []string { } // isValidFileType checks if the uploaded file type is allowed for the specific bucket -func isValidFileType(filename, bucketName string) bool { +func IsValidFileType(ext, bucketName string) bool { allowedTypes := getValidFileTypesForBucket(bucketName) - ext := strings.ToLower(filepath.Ext(filename)) for _, allowedExt := range allowedTypes { if ext == allowedExt {