init push
This commit is contained in:
70
internal/repository/mysql/action_signoff_repo.go
Normal file
70
internal/repository/mysql/action_signoff_repo.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
actionsignoff "wucher/internal/domain/action_signoff"
|
||||
)
|
||||
|
||||
type ActionSignoffRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewActionSignoffRepository(db *gorm.DB) *ActionSignoffRepository {
|
||||
return &ActionSignoffRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ActionSignoffRepository) GetByFlight(ctx context.Context, flightID []byte) (*actionsignoff.ActionSignoff, error) {
|
||||
var row actionsignoff.ActionSignoff
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("flight_id = ? AND complaint_id IS NULL", flightID).
|
||||
Order("created_at DESC").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *ActionSignoffRepository) GetByComplaint(ctx context.Context, complaintID []byte) (*actionsignoff.ActionSignoff, error) {
|
||||
var row actionsignoff.ActionSignoff
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("complaint_id = ?", complaintID).
|
||||
Order("created_at DESC").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *ActionSignoffRepository) GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*actionsignoff.ActionSignoff, error) {
|
||||
out := make(map[string]*actionsignoff.ActionSignoff, len(complaintIDs))
|
||||
if len(complaintIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []actionsignoff.ActionSignoff
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("complaint_id IN ?", complaintIDs).
|
||||
Order("created_at DESC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
key := string(rows[i].ComplaintID)
|
||||
if _, ok := out[key]; !ok {
|
||||
out[key] = &rows[i]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *ActionSignoffRepository) Create(ctx context.Context, row *actionsignoff.ActionSignoff) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *ActionSignoffRepository) Update(ctx context.Context, row *actionsignoff.ActionSignoff) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
11
internal/repository/mysql/actor_user.go
Normal file
11
internal/repository/mysql/actor_user.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"wucher/internal/shared/pkg/appctx"
|
||||
)
|
||||
|
||||
func actorUserIDFromContext(ctx context.Context) []byte {
|
||||
return appctx.GetUserID(ctx)
|
||||
}
|
||||
72
internal/repository/mysql/after_flight_inspection_repo.go
Normal file
72
internal/repository/mysql/after_flight_inspection_repo.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
afterflightinspection "wucher/internal/domain/after_flight_inspection"
|
||||
flightinspection "wucher/internal/domain/flight_inspection"
|
||||
"wucher/internal/domain/helicopter"
|
||||
)
|
||||
|
||||
type AfterFlightInspectionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAfterFlightInspectionRepository(db *gorm.DB) *AfterFlightInspectionRepository {
|
||||
return &AfterFlightInspectionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AfterFlightInspectionRepository) Upsert(ctx context.Context, row *afterflightinspection.AfterFlightInspection) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *AfterFlightInspectionRepository) GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*afterflightinspection.AfterFlightInspection, error) {
|
||||
var rows []afterflightinspection.AfterFlightInspection
|
||||
err := r.db.WithContext(ctx).Where("flight_inspection_id = ?", flightInspectionID).Limit(1).Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
func (r *AfterFlightInspectionRepository) DeleteByFlightInspectionID(ctx context.Context, flightInspectionID []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("flight_inspection_id = ?", flightInspectionID).
|
||||
Delete(&afterflightinspection.AfterFlightInspection{}).Error
|
||||
}
|
||||
|
||||
func (r *AfterFlightInspectionRepository) FlightInspectionExists(ctx context.Context, flightInspectionID []byte) (bool, error) {
|
||||
if len(flightInspectionID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flightinspection.FlightInspection{}).
|
||||
Where("id = ?", flightInspectionID).
|
||||
Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func (r *AfterFlightInspectionRepository) GetHelicopterCapabilities(ctx context.Context, flightInspectionID []byte) (*afterflightinspection.HelicopterCapabilities, error) {
|
||||
var caps afterflightinspection.HelicopterCapabilities
|
||||
tx := r.db.WithContext(ctx).
|
||||
Model(&helicopter.Helicopter{}).
|
||||
Joins("JOIN reserve_acs ra ON ra.helicopter_id = helicopters.id").
|
||||
Where("ra.inspection_id = ? AND ra.deleted_at IS NULL", flightInspectionID).
|
||||
Select("helicopters.nr1, helicopters.nr2, helicopters.lh, helicopters.rh, helicopters.mgb, helicopters.igb, helicopters.tgb").
|
||||
Limit(1).
|
||||
Scan(&caps)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return nil, errors.New("helicopter not found for flight inspection")
|
||||
}
|
||||
return &caps, nil
|
||||
}
|
||||
325
internal/repository/mysql/air_rescuer_checklist_repo.go
Normal file
325
internal/repository/mysql/air_rescuer_checklist_repo.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
airrescuechecklist "wucher/internal/domain/air_rescue_checklist"
|
||||
basedomain "wucher/internal/domain/base"
|
||||
)
|
||||
|
||||
type AirRescuerChecklistRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAirRescuerChecklistRepository(db *gorm.DB) *AirRescuerChecklistRepository {
|
||||
return &AirRescuerChecklistRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) InTx(ctx context.Context, fn func(txRepo airrescuechecklist.TxRepository) error) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return fn(&AirRescuerChecklistRepository{db: tx})
|
||||
})
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) CreateChecklist(ctx context.Context, row *airrescuechecklist.AirRescuerChecklist) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) NextChecklistPosition(ctx context.Context, hemsBaseID []byte, scopeCode string) (int, error) {
|
||||
var maxPos int
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("air_rescuer_checklists").
|
||||
Select("COALESCE(MAX(position), 0)").
|
||||
Where("hems_base_id = ? AND scope_code = ? AND deleted_at IS NULL", hemsBaseID, scopeCode).
|
||||
Scan(&maxPos).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return maxPos + 1, nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) LockChecklistByID(ctx context.Context, id []byte) (*airrescuechecklist.AirRescuerChecklist, error) {
|
||||
row := &airrescuechecklist.AirRescuerChecklist{}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Take(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) UpdateChecklist(ctx context.Context, row *airrescuechecklist.AirRescuerChecklist) error {
|
||||
updates := map[string]any{
|
||||
"scope_code": row.ScopeCode,
|
||||
"title": row.Title,
|
||||
"position": row.Position,
|
||||
"updated_by": row.UpdatedBy,
|
||||
}
|
||||
res := r.db.WithContext(ctx).Model(&airrescuechecklist.AirRescuerChecklist{}).
|
||||
Where("id = ? AND deleted_at IS NULL", row.ID).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) SoftDeleteChecklist(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
res := r.db.WithContext(ctx).Model(&airrescuechecklist.AirRescuerChecklist{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) SoftDeleteItemsByChecklistID(ctx context.Context, checklistID []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
return r.db.WithContext(ctx).Model(&airrescuechecklist.AirRescuerChecklistItem{}).
|
||||
Where("checklist_id = ? AND deleted_at IS NULL", checklistID).
|
||||
Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy}).Error
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) CreateChecklistItem(ctx context.Context, row *airrescuechecklist.AirRescuerChecklistItem) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) CreateChecklistItems(ctx context.Context, rows []airrescuechecklist.AirRescuerChecklistItem) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(&rows).Error
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) NextChecklistItemPosition(ctx context.Context, checklistID []byte) (int, error) {
|
||||
var maxPos int
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("air_rescuer_checklist_items").
|
||||
Select("COALESCE(MAX(position), 0)").
|
||||
Where("checklist_id = ? AND deleted_at IS NULL", checklistID).
|
||||
Scan(&maxPos).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return maxPos + 1, nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) LockChecklistItemByID(ctx context.Context, id []byte) (*airrescuechecklist.AirRescuerChecklistItem, error) {
|
||||
row := &airrescuechecklist.AirRescuerChecklistItem{}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Take(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) UpdateChecklistItem(ctx context.Context, row *airrescuechecklist.AirRescuerChecklistItem) error {
|
||||
updates := map[string]any{
|
||||
"name": row.Name,
|
||||
"position": row.Position,
|
||||
"updated_by": row.UpdatedBy,
|
||||
}
|
||||
res := r.db.WithContext(ctx).Model(&airrescuechecklist.AirRescuerChecklistItem{}).
|
||||
Where("id = ? AND deleted_at IS NULL", row.ID).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) SoftDeleteChecklistItem(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
res := r.db.WithContext(ctx).Model(&airrescuechecklist.AirRescuerChecklistItem{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) GetChecklistByID(ctx context.Context, id []byte) (*airrescuechecklist.ChecklistHeaderView, error) {
|
||||
out := &airrescuechecklist.ChecklistHeaderView{}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("air_rescuer_checklists c").
|
||||
Select(`
|
||||
c.id,
|
||||
c.hems_base_id,
|
||||
hb.base AS base_name,
|
||||
hb.base_abbreviation,
|
||||
bc.key AS base_category,
|
||||
c.scope_code,
|
||||
c.title,
|
||||
c.position,
|
||||
c.created_at,
|
||||
c.created_by,
|
||||
c.updated_at`).
|
||||
Joins("LEFT JOIN bases hb ON hb.id = c.hems_base_id").
|
||||
Joins("LEFT JOIN base_categories bc ON bc.id = hb.base_category_id").
|
||||
Where("c.id = ? AND c.deleted_at IS NULL", id).
|
||||
Where("hb.id IS NOT NULL").
|
||||
Take(out).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) GetChecklistItemByID(ctx context.Context, id []byte) (*airrescuechecklist.ChecklistItemView, error) {
|
||||
out := &airrescuechecklist.ChecklistItemView{}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("air_rescuer_checklist_items i").
|
||||
Select(`
|
||||
i.id,
|
||||
i.checklist_id,
|
||||
i.name,
|
||||
i.position,
|
||||
i.created_at,
|
||||
i.created_by,
|
||||
i.updated_at`).
|
||||
Joins("JOIN air_rescuer_checklists c ON c.id = i.checklist_id AND c.deleted_at IS NULL").
|
||||
Where("i.id = ? AND i.deleted_at IS NULL", id).
|
||||
Take(out).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) ListChecklistHeaders(ctx context.Context, filter airrescuechecklist.ChecklistHeaderFilter) ([]airrescuechecklist.ChecklistHeaderView, error) {
|
||||
rows := make([]airrescuechecklist.ChecklistHeaderView, 0)
|
||||
q := r.db.WithContext(ctx).
|
||||
Table("air_rescuer_checklists c").
|
||||
Select(`
|
||||
c.id,
|
||||
c.hems_base_id,
|
||||
hb.base AS base_name,
|
||||
hb.base_abbreviation,
|
||||
bc.key AS base_category,
|
||||
c.scope_code,
|
||||
c.title,
|
||||
c.position,
|
||||
c.created_at,
|
||||
c.created_by,
|
||||
c.updated_at`).
|
||||
Joins("LEFT JOIN bases hb ON hb.id = c.hems_base_id").
|
||||
Joins("LEFT JOIN base_categories bc ON bc.id = hb.base_category_id").
|
||||
Where("c.deleted_at IS NULL").
|
||||
Where("hb.id IS NOT NULL")
|
||||
|
||||
if len(filter.HEMSBaseID) == 16 {
|
||||
q = q.Where("c.hems_base_id = ?", filter.HEMSBaseID)
|
||||
}
|
||||
if scopeCode := airrescuechecklist.CanonicalizeAirRescuerChecklistScope(filter.ScopeCode); scopeCode != "" {
|
||||
q = q.Where("UPPER(c.scope_code) = ?", scopeCode)
|
||||
}
|
||||
if categoryType, ok := basedomain.NormalizeCategoryType(filter.CategoryType); ok && strings.TrimSpace(filter.CategoryType) != "" {
|
||||
q = q.Where("bc.key = ?", categoryType)
|
||||
}
|
||||
q = applyChecklistBaseFilters(q, "hb", filter.BaseName, filter.BaseAbbreviation)
|
||||
|
||||
err := q.Order("CASE WHEN hb.sortkey IS NULL THEN 1 ELSE 0 END ASC, hb.sortkey ASC, hb.base ASC, c.position ASC, c.title ASC").Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) ListChecklistItemsByChecklistIDs(ctx context.Context, checklistIDs [][]byte) ([]airrescuechecklist.ChecklistItemView, error) {
|
||||
if len(checklistIDs) == 0 {
|
||||
return []airrescuechecklist.ChecklistItemView{}, nil
|
||||
}
|
||||
rows := make([]airrescuechecklist.ChecklistItemView, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("air_rescuer_checklist_items i").
|
||||
Select(`
|
||||
i.id,
|
||||
i.checklist_id,
|
||||
i.name,
|
||||
i.position,
|
||||
i.created_at,
|
||||
i.created_by,
|
||||
i.updated_at`).
|
||||
Joins("JOIN air_rescuer_checklists c ON c.id = i.checklist_id AND c.deleted_at IS NULL").
|
||||
Where("i.deleted_at IS NULL AND i.checklist_id IN ?", checklistIDs).
|
||||
Order("i.checklist_id ASC, i.position ASC, i.name ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *AirRescuerChecklistRepository) ListHEMSBases(ctx context.Context, filter airrescuechecklist.HEMSBaseFilter) ([]airrescuechecklist.HEMSBaseView, error) {
|
||||
rows := make([]airrescuechecklist.HEMSBaseView, 0)
|
||||
q := r.db.WithContext(ctx).
|
||||
Table("bases b").
|
||||
Select(`
|
||||
b.id,
|
||||
b.base AS base_name,
|
||||
b.base_abbreviation,
|
||||
bc.key AS base_category,
|
||||
COALESCE(b.sortkey, 0) AS position`).
|
||||
Joins("JOIN base_categories bc ON bc.id = b.base_category_id").
|
||||
Where("b.is_active = ?", true).
|
||||
Where("b.checklist = ?", true)
|
||||
|
||||
if len(filter.HEMSBaseID) == 16 {
|
||||
q = q.Where("b.id = ?", filter.HEMSBaseID)
|
||||
}
|
||||
if categoryType, ok := basedomain.NormalizeCategoryType(filter.CategoryType); ok && strings.TrimSpace(filter.CategoryType) != "" {
|
||||
q = q.Where("bc.key = ?", categoryType)
|
||||
}
|
||||
q = applyChecklistBaseFilters(q, "b", filter.BaseName, filter.BaseAbbreviation)
|
||||
|
||||
if err := q.Order("CASE WHEN b.sortkey IS NULL THEN 1 ELSE 0 END ASC, b.sortkey ASC, b.base ASC").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func applyChecklistBaseFilters(q *gorm.DB, alias, baseName, baseAbbreviation string) *gorm.DB {
|
||||
baseCol := "base"
|
||||
abbrCol := "base_abbreviation"
|
||||
if strings.TrimSpace(alias) != "" {
|
||||
baseCol = strings.TrimSpace(alias) + ".base"
|
||||
abbrCol = strings.TrimSpace(alias) + ".base_abbreviation"
|
||||
}
|
||||
if needle := strings.ToLower(strings.TrimSpace(baseName)); needle != "" {
|
||||
q = q.Where("LOWER("+baseCol+") LIKE ?", "%"+needle+"%")
|
||||
}
|
||||
if needle := strings.ToLower(strings.TrimSpace(baseAbbreviation)); needle != "" {
|
||||
q = q.Where("LOWER("+abbrCol+") LIKE ?", "%"+needle+"%")
|
||||
}
|
||||
return q
|
||||
}
|
||||
660
internal/repository/mysql/air_rescuer_checklist_repo_test.go
Normal file
660
internal/repository/mysql/air_rescuer_checklist_repo_test.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
airrescuechecklist "wucher/internal/domain/air_rescue_checklist"
|
||||
basedomain "wucher/internal/domain/base"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openAirRescuerChecklistRepoTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:air_rescuer_checklist_repo_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
createBaseCategory := "CREATE TABLE base_categories (\n\tid BLOB PRIMARY KEY,\n\t`key` TEXT UNIQUE,\n\tname TEXT NOT NULL\n);"
|
||||
if err := db.Exec(createBaseCategory).Error; err != nil {
|
||||
t.Fatalf("create base_categories: %v", err)
|
||||
}
|
||||
|
||||
createBase := `
|
||||
CREATE TABLE bases (
|
||||
id BLOB PRIMARY KEY,
|
||||
base_category_id BLOB NOT NULL,
|
||||
base TEXT NOT NULL,
|
||||
base_abbreviation TEXT NULL,
|
||||
sortkey INTEGER NULL,
|
||||
checklist INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
);`
|
||||
if err := db.Exec(createBase).Error; err != nil {
|
||||
t.Fatalf("create bases: %v", err)
|
||||
}
|
||||
|
||||
createChecklist := `
|
||||
CREATE TABLE air_rescuer_checklists (
|
||||
id BLOB PRIMARY KEY,
|
||||
hems_base_id BLOB NOT NULL,
|
||||
scope_code TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NULL,
|
||||
created_by BLOB NULL,
|
||||
updated_at DATETIME NULL,
|
||||
updated_by BLOB NULL,
|
||||
deleted_at DATETIME NULL,
|
||||
deleted_by BLOB NULL
|
||||
);`
|
||||
if err := db.Exec(createChecklist).Error; err != nil {
|
||||
t.Fatalf("create air_rescuer_checklists: %v", err)
|
||||
}
|
||||
|
||||
createItems := `
|
||||
CREATE TABLE air_rescuer_checklist_items (
|
||||
id BLOB PRIMARY KEY,
|
||||
checklist_id BLOB NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NULL,
|
||||
created_by BLOB NULL,
|
||||
updated_at DATETIME NULL,
|
||||
updated_by BLOB NULL,
|
||||
deleted_at DATETIME NULL,
|
||||
deleted_by BLOB NULL
|
||||
);`
|
||||
if err := db.Exec(createItems).Error; err != nil {
|
||||
t.Fatalf("create air_rescuer_checklist_items: %v", err)
|
||||
}
|
||||
regularCategoryID, hemsCategoryID := airRescuerChecklistCategoryIDs()
|
||||
toBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(
|
||||
"INSERT INTO base_categories(id, `key`, name) VALUES(%s, 'regular', 'Regular'),(%s, 'hems', 'HEMS')",
|
||||
toBlob(regularCategoryID),
|
||||
toBlob(hemsCategoryID),
|
||||
),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed base_categories: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func airRescuerChecklistCategoryIDs() (regularID, hemsID []byte) {
|
||||
return []byte("catregular000001"), []byte("cathems000000001")
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistRepositoryListChecklistHeaders_SortsByPositionThenTitle(t *testing.T) {
|
||||
db := openAirRescuerChecklistRepoTestDB(t)
|
||||
repo := NewAirRescuerChecklistRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
baseID := uuidv7.MustBytes()
|
||||
now := time.Now().UTC()
|
||||
asBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
_, hemsCategoryID := airRescuerChecklistCategoryIDs()
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(`INSERT INTO bases(id, base_category_id, base, base_abbreviation, sortkey, checklist, is_active) VALUES(%s, %s, ?, ?, ?, 1, ?)`, asBlob(baseID), asBlob(hemsCategoryID)),
|
||||
"Gallus 1 - Zurs Lech", "G1", 1, 1,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert bases: %v", err)
|
||||
}
|
||||
|
||||
// Seed deliberately out of desired display order. Duplicate position=1 is allowed.
|
||||
rows := []struct {
|
||||
id []byte
|
||||
scope string
|
||||
title string
|
||||
position int
|
||||
}{
|
||||
{id: uuidv7.MustBytes(), scope: airrescuechecklist.AirRescuerChecklistScopeTA, title: "Zulu", position: 2},
|
||||
{id: uuidv7.MustBytes(), scope: airrescuechecklist.AirRescuerChecklistScopeTA, title: "Bravo", position: 1},
|
||||
{id: uuidv7.MustBytes(), scope: airrescuechecklist.AirRescuerChecklistScopeTA, title: "Alpha", position: 1},
|
||||
}
|
||||
for i := range rows {
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(`INSERT INTO air_rescuer_checklists(id, hems_base_id, scope_code, title, position, created_at, updated_at, deleted_at) VALUES (%s, %s, ?, ?, ?, ?, ?, NULL)`,
|
||||
asBlob(rows[i].id), asBlob(baseID)),
|
||||
rows[i].scope, rows[i].title, rows[i].position, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert checklist[%d]: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := repo.ListChecklistHeaders(ctx, airrescuechecklist.ChecklistHeaderFilter{
|
||||
HEMSBaseID: baseID,
|
||||
ScopeCode: airrescuechecklist.AirRescuerChecklistScopeTA,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListChecklistHeaders: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d", len(got))
|
||||
}
|
||||
|
||||
wantTitles := []string{"Alpha", "Bravo", "Zulu"}
|
||||
wantPos := []int{1, 1, 2}
|
||||
for i := range got {
|
||||
if got[i].Title != wantTitles[i] || got[i].Position != wantPos[i] {
|
||||
t.Fatalf("unexpected row[%d]: got title=%q pos=%d, want title=%q pos=%d", i, got[i].Title, got[i].Position, wantTitles[i], wantPos[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistRepositoryListChecklistItemsByChecklistIDs_SortsByPositionThenName(t *testing.T) {
|
||||
db := openAirRescuerChecklistRepoTestDB(t)
|
||||
repo := NewAirRescuerChecklistRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
baseID := uuidv7.MustBytes()
|
||||
checklistID := uuidv7.MustBytes()
|
||||
now := time.Now().UTC()
|
||||
asBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
_, hemsCategoryID := airRescuerChecklistCategoryIDs()
|
||||
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(`INSERT INTO bases(id, base_category_id, base, base_abbreviation, sortkey, checklist, is_active) VALUES(%s, %s, ?, ?, ?, 1, ?)`, asBlob(baseID), asBlob(hemsCategoryID)),
|
||||
"Gallus 1 - Zurs Lech", "G1", 1, 1,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert bases: %v", err)
|
||||
}
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(`INSERT INTO air_rescuer_checklists(id, hems_base_id, scope_code, title, position, created_at, updated_at, deleted_at) VALUES (%s, %s, ?, ?, ?, ?, ?, NULL)`,
|
||||
asBlob(checklistID), asBlob(baseID)),
|
||||
airrescuechecklist.AirRescuerChecklistScopeTA, "Daily Checks", 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert checklist: %v", err)
|
||||
}
|
||||
|
||||
// Seed deliberately out of display order. Duplicate position=2 is allowed.
|
||||
items := []struct {
|
||||
id []byte
|
||||
name string
|
||||
position int
|
||||
}{
|
||||
{id: uuidv7.MustBytes(), name: "Radio", position: 2},
|
||||
{id: uuidv7.MustBytes(), name: "Battery", position: 2},
|
||||
{id: uuidv7.MustBytes(), name: "Oxygen", position: 1},
|
||||
}
|
||||
for i := range items {
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(`INSERT INTO air_rescuer_checklist_items(id, checklist_id, name, position, created_at, updated_at, deleted_at) VALUES (%s, %s, ?, ?, ?, ?, NULL)`,
|
||||
asBlob(items[i].id), asBlob(checklistID)),
|
||||
items[i].name, items[i].position, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert item[%d]: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := repo.ListChecklistItemsByChecklistIDs(ctx, [][]byte{checklistID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListChecklistItemsByChecklistIDs: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d", len(got))
|
||||
}
|
||||
|
||||
wantNames := []string{"Oxygen", "Battery", "Radio"}
|
||||
wantPos := []int{1, 2, 2}
|
||||
for i := range got {
|
||||
if got[i].Name != wantNames[i] || got[i].Position != wantPos[i] {
|
||||
t.Fatalf("unexpected row[%d]: got name=%q pos=%d, want name=%q pos=%d", i, got[i].Name, got[i].Position, wantNames[i], wantPos[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistRepositoryInTxCommitAndRollback(t *testing.T) {
|
||||
db := openAirRescuerChecklistRepoTestDB(t)
|
||||
repo := NewAirRescuerChecklistRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
baseID := uuidv7.MustBytes()
|
||||
mustInsertHEMSBase(t, db, baseID, "Gallus 1 - Zurs Lech", "G1", 1, true)
|
||||
|
||||
checklistID := uuidv7.MustBytes()
|
||||
if err := repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
|
||||
return txRepo.CreateChecklist(ctx, &airrescuechecklist.AirRescuerChecklist{
|
||||
ID: append([]byte(nil), checklistID...),
|
||||
HEMSBaseID: append([]byte(nil), baseID...),
|
||||
ScopeCode: airrescuechecklist.AirRescuerChecklistScopeTA,
|
||||
Title: "Daily Tasks",
|
||||
Position: 1,
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatalf("InTx commit: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.LockChecklistByID(ctx, checklistID); err != nil {
|
||||
t.Fatalf("expected committed checklist to exist: %v", err)
|
||||
}
|
||||
|
||||
rollbackID := uuidv7.MustBytes()
|
||||
rollbackErr := errors.New("rollback me")
|
||||
err := repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
|
||||
if err := txRepo.CreateChecklist(ctx, &airrescuechecklist.AirRescuerChecklist{
|
||||
ID: append([]byte(nil), rollbackID...),
|
||||
HEMSBaseID: append([]byte(nil), baseID...),
|
||||
ScopeCode: airrescuechecklist.AirRescuerChecklistScopeMO,
|
||||
Title: "Monday Tasks",
|
||||
Position: 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return rollbackErr
|
||||
})
|
||||
if !errors.Is(err, rollbackErr) {
|
||||
t.Fatalf("expected rollback error, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.LockChecklistByID(ctx, rollbackID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("expected rolled back checklist missing, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistRepositoryChecklistCRUD(t *testing.T) {
|
||||
db := openAirRescuerChecklistRepoTestDB(t)
|
||||
repo := NewAirRescuerChecklistRepository(db)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
baseID := uuidv7.MustBytes()
|
||||
mustInsertHEMSBase(t, db, baseID, "Gallus 1 - Zurs Lech", "G1", 1, true)
|
||||
|
||||
checklistID := uuidv7.MustBytes()
|
||||
if err := repo.CreateChecklist(ctx, &airrescuechecklist.AirRescuerChecklist{
|
||||
ID: append([]byte(nil), checklistID...),
|
||||
HEMSBaseID: append([]byte(nil), baseID...),
|
||||
ScopeCode: airrescuechecklist.AirRescuerChecklistScopeTA,
|
||||
Title: "Original",
|
||||
Position: 2,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateChecklist: %v", err)
|
||||
}
|
||||
|
||||
locked, err := repo.LockChecklistByID(ctx, checklistID)
|
||||
if err != nil {
|
||||
t.Fatalf("LockChecklistByID: %v", err)
|
||||
}
|
||||
if locked.Title != "Original" {
|
||||
t.Fatalf("unexpected title after lock: %s", locked.Title)
|
||||
}
|
||||
|
||||
updatedBy := uuidv7.MustBytes()
|
||||
if err := repo.UpdateChecklist(ctx, &airrescuechecklist.AirRescuerChecklist{
|
||||
ID: append([]byte(nil), checklistID...),
|
||||
ScopeCode: airrescuechecklist.AirRescuerChecklistScopeMO,
|
||||
Title: "Updated",
|
||||
Position: 1,
|
||||
UpdatedBy: updatedBy,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateChecklist: %v", err)
|
||||
}
|
||||
|
||||
afterUpdate, err := repo.GetChecklistByID(ctx, checklistID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChecklistByID after update: %v", err)
|
||||
}
|
||||
if afterUpdate == nil || afterUpdate.ScopeCode != airrescuechecklist.AirRescuerChecklistScopeMO || afterUpdate.Title != "Updated" || afterUpdate.Position != 1 {
|
||||
t.Fatalf("unexpected checklist after update: %+v", afterUpdate)
|
||||
}
|
||||
|
||||
notFoundID := uuidv7.MustBytes()
|
||||
if err := repo.UpdateChecklist(ctx, &airrescuechecklist.AirRescuerChecklist{ID: notFoundID}); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("expected not found on update, got %v", err)
|
||||
}
|
||||
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
if err := repo.SoftDeleteChecklist(ctx, checklistID, deletedBy); err != nil {
|
||||
t.Fatalf("SoftDeleteChecklist: %v", err)
|
||||
}
|
||||
if err := repo.SoftDeleteChecklist(ctx, checklistID, deletedBy); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("expected not found on double delete, got %v", err)
|
||||
}
|
||||
if _, err := repo.LockChecklistByID(ctx, checklistID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("expected deleted checklist cannot be locked, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistRepositoryChecklistItemCRUD(t *testing.T) {
|
||||
db := openAirRescuerChecklistRepoTestDB(t)
|
||||
repo := NewAirRescuerChecklistRepository(db)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
baseID := uuidv7.MustBytes()
|
||||
checklistID := uuidv7.MustBytes()
|
||||
mustInsertHEMSBase(t, db, baseID, "Gallus 1 - Zurs Lech", "G1", 1, true)
|
||||
mustInsertChecklist(t, db, checklistID, baseID, airrescuechecklist.AirRescuerChecklistScopeTA, "Daily", 1, now)
|
||||
|
||||
itemID := uuidv7.MustBytes()
|
||||
if err := repo.CreateChecklistItem(ctx, &airrescuechecklist.AirRescuerChecklistItem{
|
||||
ID: append([]byte(nil), itemID...),
|
||||
ChecklistID: append([]byte(nil), checklistID...),
|
||||
Name: "Radio",
|
||||
Position: 2,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateChecklistItem: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.CreateChecklistItems(ctx, nil); err != nil {
|
||||
t.Fatalf("CreateChecklistItems empty: %v", err)
|
||||
}
|
||||
|
||||
secondID := uuidv7.MustBytes()
|
||||
thirdID := uuidv7.MustBytes()
|
||||
if err := repo.CreateChecklistItems(ctx, []airrescuechecklist.AirRescuerChecklistItem{
|
||||
{ID: append([]byte(nil), secondID...), ChecklistID: append([]byte(nil), checklistID...), Name: "Battery", Position: 1, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: append([]byte(nil), thirdID...), ChecklistID: append([]byte(nil), checklistID...), Name: "Oxygen", Position: 3, CreatedAt: now, UpdatedAt: now},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateChecklistItems: %v", err)
|
||||
}
|
||||
|
||||
locked, err := repo.LockChecklistItemByID(ctx, itemID)
|
||||
if err != nil {
|
||||
t.Fatalf("LockChecklistItemByID: %v", err)
|
||||
}
|
||||
if locked.Name != "Radio" {
|
||||
t.Fatalf("unexpected locked item: %+v", locked)
|
||||
}
|
||||
|
||||
if err := repo.UpdateChecklistItem(ctx, &airrescuechecklist.AirRescuerChecklistItem{
|
||||
ID: append([]byte(nil), itemID...),
|
||||
Name: "Radio Updated",
|
||||
Position: 5,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateChecklistItem: %v", err)
|
||||
}
|
||||
gotItem, err := repo.GetChecklistItemByID(ctx, itemID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChecklistItemByID after update: %v", err)
|
||||
}
|
||||
if gotItem == nil || gotItem.Name != "Radio Updated" || gotItem.Position != 5 {
|
||||
t.Fatalf("unexpected item after update: %+v", gotItem)
|
||||
}
|
||||
|
||||
notFoundID := uuidv7.MustBytes()
|
||||
if err := repo.UpdateChecklistItem(ctx, &airrescuechecklist.AirRescuerChecklistItem{ID: notFoundID}); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("expected not found on update item, got %v", err)
|
||||
}
|
||||
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
if err := repo.SoftDeleteChecklistItem(ctx, itemID, deletedBy); err != nil {
|
||||
t.Fatalf("SoftDeleteChecklistItem: %v", err)
|
||||
}
|
||||
if err := repo.SoftDeleteChecklistItem(ctx, itemID, deletedBy); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("expected not found on double item delete, got %v", err)
|
||||
}
|
||||
if _, err := repo.LockChecklistItemByID(ctx, itemID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("expected deleted item cannot be locked, got %v", err)
|
||||
}
|
||||
|
||||
if err := repo.SoftDeleteItemsByChecklistID(ctx, checklistID, deletedBy); err != nil {
|
||||
t.Fatalf("SoftDeleteItemsByChecklistID: %v", err)
|
||||
}
|
||||
remaining, err := repo.ListChecklistItemsByChecklistIDs(ctx, [][]byte{checklistID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListChecklistItemsByChecklistIDs after soft delete all: %v", err)
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
t.Fatalf("expected no active items after bulk soft delete, got %d", len(remaining))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistRepositoryGetAndListFilters(t *testing.T) {
|
||||
db := openAirRescuerChecklistRepoTestDB(t)
|
||||
repo := NewAirRescuerChecklistRepository(db)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
baseA := uuidv7.MustBytes()
|
||||
baseB := uuidv7.MustBytes()
|
||||
baseRegular := uuidv7.MustBytes()
|
||||
baseInactive := uuidv7.MustBytes()
|
||||
mustInsertHEMSBase(t, db, baseA, "Gallus 1 - Zurs Lech", "G1", 2, true)
|
||||
mustInsertHEMSBase(t, db, baseB, "Gallus 2 - Innsbruck", "G2", 1, true)
|
||||
mustInsertRegularBase(t, db, baseRegular, "Regular Base", "RB", 4, true, true)
|
||||
mustInsertHEMSBase(t, db, baseInactive, "Inactive Base", "IN", 3, false)
|
||||
|
||||
checklistA1 := uuidv7.MustBytes()
|
||||
checklistA2 := uuidv7.MustBytes()
|
||||
checklistB := uuidv7.MustBytes()
|
||||
checklistRegular := uuidv7.MustBytes()
|
||||
mustInsertChecklist(t, db, checklistA1, baseA, airrescuechecklist.AirRescuerChecklistScopeTA, "Alpha", 1, now)
|
||||
mustInsertChecklist(t, db, checklistA2, baseA, airrescuechecklist.AirRescuerChecklistScopeTA, "Bravo", 2, now)
|
||||
mustInsertChecklist(t, db, checklistB, baseB, airrescuechecklist.AirRescuerChecklistScopeMO, "Monday", 1, now)
|
||||
mustInsertChecklist(t, db, checklistRegular, baseRegular, airrescuechecklist.AirRescuerChecklistScopeTA, "Regular Daily", 1, now)
|
||||
|
||||
itemA := uuidv7.MustBytes()
|
||||
itemB := uuidv7.MustBytes()
|
||||
mustInsertChecklistItem(t, db, itemA, checklistA1, "Battery", 1, now)
|
||||
mustInsertChecklistItem(t, db, itemB, checklistA1, "Radio", 2, now)
|
||||
|
||||
got, err := repo.GetChecklistByID(ctx, checklistA1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChecklistByID: %v", err)
|
||||
}
|
||||
if got == nil || got.BaseName != "Gallus 1 - Zurs Lech" {
|
||||
t.Fatalf("unexpected checklist header view: %+v", got)
|
||||
}
|
||||
missingChecklist, err := repo.GetChecklistByID(ctx, uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("GetChecklistByID missing: %v", err)
|
||||
}
|
||||
if missingChecklist != nil {
|
||||
t.Fatalf("expected nil for missing checklist, got %+v", missingChecklist)
|
||||
}
|
||||
|
||||
gotItem, err := repo.GetChecklistItemByID(ctx, itemA)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChecklistItemByID: %v", err)
|
||||
}
|
||||
if gotItem == nil || gotItem.Name != "Battery" {
|
||||
t.Fatalf("unexpected checklist item view: %+v", gotItem)
|
||||
}
|
||||
missingItem, err := repo.GetChecklistItemByID(ctx, uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("GetChecklistItemByID missing: %v", err)
|
||||
}
|
||||
if missingItem != nil {
|
||||
t.Fatalf("expected nil for missing item, got %+v", missingItem)
|
||||
}
|
||||
|
||||
headersByScope, err := repo.ListChecklistHeaders(ctx, airrescuechecklist.ChecklistHeaderFilter{
|
||||
HEMSBaseID: append([]byte(nil), baseA...),
|
||||
ScopeCode: strings.ToLower(airrescuechecklist.AirRescuerChecklistScopeTA),
|
||||
BaseName: "zurs",
|
||||
BaseAbbreviation: "g1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListChecklistHeaders by scope/base filters: %v", err)
|
||||
}
|
||||
if len(headersByScope) != 2 {
|
||||
t.Fatalf("expected 2 TA headers for baseA, got %d", len(headersByScope))
|
||||
}
|
||||
|
||||
headersRegular, err := repo.ListChecklistHeaders(ctx, airrescuechecklist.ChecklistHeaderFilter{
|
||||
CategoryType: basedomain.CategoryKeyRegular,
|
||||
ScopeCode: airrescuechecklist.AirRescuerChecklistScopeTA,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListChecklistHeaders regular category: %v", err)
|
||||
}
|
||||
if len(headersRegular) != 1 || headersRegular[0].Title != "Regular Daily" {
|
||||
t.Fatalf("expected regular checklist header only, got %+v", headersRegular)
|
||||
}
|
||||
|
||||
orderedBases, err := repo.ListHEMSBases(ctx, airrescuechecklist.HEMSBaseFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListHEMSBases all: %v", err)
|
||||
}
|
||||
if len(orderedBases) != 3 {
|
||||
t.Fatalf("expected only active bases, got %d", len(orderedBases))
|
||||
}
|
||||
if orderedBases[0].BaseName != "Gallus 2 - Innsbruck" || orderedBases[1].BaseName != "Gallus 1 - Zurs Lech" || orderedBases[2].BaseName != "Regular Base" {
|
||||
t.Fatalf("unexpected base ordering: %+v", orderedBases)
|
||||
}
|
||||
|
||||
hemsOnly, err := repo.ListHEMSBases(ctx, airrescuechecklist.HEMSBaseFilter{CategoryType: basedomain.CategoryKeyHEMS})
|
||||
if err != nil {
|
||||
t.Fatalf("ListHEMSBases hems only: %v", err)
|
||||
}
|
||||
if len(hemsOnly) != 2 {
|
||||
t.Fatalf("expected 2 hems bases, got %d", len(hemsOnly))
|
||||
}
|
||||
|
||||
regularOnly, err := repo.ListHEMSBases(ctx, airrescuechecklist.HEMSBaseFilter{CategoryType: basedomain.CategoryKeyRegular})
|
||||
if err != nil {
|
||||
t.Fatalf("ListHEMSBases regular only: %v", err)
|
||||
}
|
||||
if len(regularOnly) != 1 || regularOnly[0].BaseName != "Regular Base" {
|
||||
t.Fatalf("expected 1 regular base, got %+v", regularOnly)
|
||||
}
|
||||
|
||||
filteredBase, err := repo.ListHEMSBases(ctx, airrescuechecklist.HEMSBaseFilter{
|
||||
HEMSBaseID: append([]byte(nil), baseA...),
|
||||
BaseName: "zurs",
|
||||
BaseAbbreviation: "g1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListHEMSBases filtered: %v", err)
|
||||
}
|
||||
if len(filteredBase) != 1 || filteredBase[0].ID == nil {
|
||||
t.Fatalf("unexpected filtered base rows: %+v", filteredBase)
|
||||
}
|
||||
|
||||
emptyItems, err := repo.ListChecklistItemsByChecklistIDs(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChecklistItemsByChecklistIDs empty ids: %v", err)
|
||||
}
|
||||
if len(emptyItems) != 0 {
|
||||
t.Fatalf("expected empty items for empty checklist ids, got %d", len(emptyItems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistRepositoryDatabaseErrorBranches(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
id := uuidv7.MustBytes()
|
||||
|
||||
t.Run("update checklist db error", func(t *testing.T) {
|
||||
db := openAirRescuerChecklistRepoTestDB(t)
|
||||
repo := NewAirRescuerChecklistRepository(db)
|
||||
if err := db.Exec(`DROP TABLE air_rescuer_checklists`).Error; err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
if err := repo.UpdateChecklist(ctx, &airrescuechecklist.AirRescuerChecklist{ID: id, Title: "x"}); err == nil {
|
||||
t.Fatalf("expected db error")
|
||||
}
|
||||
if err := repo.SoftDeleteChecklist(ctx, id, uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected db error")
|
||||
}
|
||||
if _, err := repo.GetChecklistByID(ctx, id); err == nil {
|
||||
t.Fatalf("expected db error")
|
||||
}
|
||||
if _, err := repo.ListChecklistHeaders(ctx, airrescuechecklist.ChecklistHeaderFilter{}); err == nil {
|
||||
t.Fatalf("expected db error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item db error", func(t *testing.T) {
|
||||
db := openAirRescuerChecklistRepoTestDB(t)
|
||||
repo := NewAirRescuerChecklistRepository(db)
|
||||
if err := db.Exec(`DROP TABLE air_rescuer_checklist_items`).Error; err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
if err := repo.UpdateChecklistItem(ctx, &airrescuechecklist.AirRescuerChecklistItem{ID: id, Name: "x"}); err == nil {
|
||||
t.Fatalf("expected db error")
|
||||
}
|
||||
if err := repo.SoftDeleteChecklistItem(ctx, id, uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected db error")
|
||||
}
|
||||
if _, err := repo.GetChecklistItemByID(ctx, id); err == nil {
|
||||
t.Fatalf("expected db error")
|
||||
}
|
||||
if _, err := repo.ListChecklistItemsByChecklistIDs(ctx, [][]byte{id}); err == nil {
|
||||
t.Fatalf("expected db error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list hems bases db error", func(t *testing.T) {
|
||||
db := openAirRescuerChecklistRepoTestDB(t)
|
||||
repo := NewAirRescuerChecklistRepository(db)
|
||||
if err := db.Exec(`DROP TABLE bases`).Error; err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
if _, err := repo.ListHEMSBases(ctx, airrescuechecklist.HEMSBaseFilter{}); err == nil {
|
||||
t.Fatalf("expected db error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustInsertHEMSBase(t *testing.T, db *gorm.DB, id []byte, name, abbr string, position int, active bool) {
|
||||
t.Helper()
|
||||
toBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
_, hemsCategoryID := airRescuerChecklistCategoryIDs()
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(`INSERT INTO bases(id, base_category_id, base, base_abbreviation, sortkey, checklist, is_active) VALUES (%s, %s, ?, ?, ?, 1, ?)`, toBlob(id), toBlob(hemsCategoryID)),
|
||||
name, abbr, position, active,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert hems base: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustInsertRegularBase(t *testing.T, db *gorm.DB, id []byte, name, abbr string, position int, active bool, checklist bool) {
|
||||
t.Helper()
|
||||
toBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
regularCategoryID, _ := airRescuerChecklistCategoryIDs()
|
||||
checklistVal := 0
|
||||
if checklist {
|
||||
checklistVal = 1
|
||||
}
|
||||
activeVal := 0
|
||||
if active {
|
||||
activeVal = 1
|
||||
}
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(`INSERT INTO bases(id, base_category_id, base, base_abbreviation, sortkey, checklist, is_active) VALUES (%s, %s, ?, ?, ?, ?, ?)`, toBlob(id), toBlob(regularCategoryID)),
|
||||
name, abbr, position, checklistVal, activeVal,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert regular base: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustInsertChecklist(t *testing.T, db *gorm.DB, id, baseID []byte, scopeCode, title string, position int, now time.Time) {
|
||||
t.Helper()
|
||||
toBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(`INSERT INTO air_rescuer_checklists(id, hems_base_id, scope_code, title, position, created_at, updated_at, deleted_at) VALUES (%s, %s, ?, ?, ?, ?, ?, NULL)`, toBlob(id), toBlob(baseID)),
|
||||
scopeCode, title, position, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert checklist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustInsertChecklistItem(t *testing.T, db *gorm.DB, id, checklistID []byte, name string, position int, now time.Time) {
|
||||
t.Helper()
|
||||
toBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(`INSERT INTO air_rescuer_checklist_items(id, checklist_id, name, position, created_at, updated_at, deleted_at) VALUES (%s, %s, ?, ?, ?, ?, NULL)`, toBlob(id), toBlob(checklistID)),
|
||||
name, position, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert checklist item: %v", err)
|
||||
}
|
||||
}
|
||||
166
internal/repository/mysql/audit_repo.go
Normal file
166
internal/repository/mysql/audit_repo.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/audit"
|
||||
)
|
||||
|
||||
type AuditRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAuditRepository(db *gorm.DB) *AuditRepository {
|
||||
return &AuditRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AuditRepository) Create(ctx context.Context, entry *audit.AuditLog) error {
|
||||
return r.db.WithContext(ctx).Create(entry).Error
|
||||
}
|
||||
|
||||
func (r *AuditRepository) List(ctx context.Context, filter string, methods []string, modules []string, actions []string, sort string, limit, offset int) ([]audit.AuditLog, int64, error) {
|
||||
q := r.db.WithContext(ctx).Model(&audit.AuditLog{})
|
||||
filter = strings.TrimSpace(filter)
|
||||
if filter != "" {
|
||||
like := "%" + strings.ToLower(filter) + "%"
|
||||
q = q.Where(
|
||||
`LOWER(request_id) LIKE ? OR LOWER(layer) LIKE ? OR LOWER(action) LIKE ? OR LOWER(method) LIKE ? OR LOWER(path) LIKE ? OR LOWER(message) LIKE ?`,
|
||||
like, like, like, like, like, like,
|
||||
)
|
||||
}
|
||||
if len(methods) > 0 {
|
||||
q = q.Where("UPPER(method) IN ?", methods)
|
||||
}
|
||||
if len(modules) > 0 {
|
||||
pathPrefixes := modulePathPrefixes(modules)
|
||||
if len(pathPrefixes) == 0 {
|
||||
q = q.Where("LOWER(module) IN ?", modules)
|
||||
} else {
|
||||
pathConds := make([]string, 0, len(pathPrefixes))
|
||||
pathArgs := make([]any, 0, len(pathPrefixes))
|
||||
for _, prefix := range pathPrefixes {
|
||||
pathConds = append(pathConds, "LOWER(path) LIKE ?")
|
||||
pathArgs = append(pathArgs, strings.ToLower(prefix)+"%")
|
||||
}
|
||||
cond := fmt.Sprintf("(LOWER(module) IN ? OR ((module IS NULL OR module = '') AND (%s)))", strings.Join(pathConds, " OR "))
|
||||
args := make([]any, 0, 2+len(pathArgs))
|
||||
args = append(args, modules)
|
||||
args = append(args, pathArgs...)
|
||||
q = q.Where(cond, args...)
|
||||
}
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
q = q.Where("LOWER(action) IN ?", actions)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(sort) == "" {
|
||||
sort = "created_at DESC"
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var out []audit.AuditLog
|
||||
query := q.Order(sort)
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&out).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func modulePathPrefixes(modules []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(modules))
|
||||
for _, module := range modules {
|
||||
var prefix string
|
||||
switch strings.ToLower(strings.TrimSpace(module)) {
|
||||
case "air_rescuer_checklist":
|
||||
prefix = "/api/v1/air-rescuer-checklist/"
|
||||
case "audit_log":
|
||||
prefix = "/api/v1/audit-logs/"
|
||||
case "auth":
|
||||
prefix = "/api/v1/auth/"
|
||||
case "base":
|
||||
prefix = "/api/v1/bases/"
|
||||
case "branding":
|
||||
prefix = "/api/v1/branding/"
|
||||
case "contact":
|
||||
prefix = "/api/v1/contacts/"
|
||||
case "duty_roster":
|
||||
prefix = "/api/v1/duty-roster/"
|
||||
case "dul":
|
||||
prefix = "/api/v1/dul/"
|
||||
case "facility":
|
||||
prefix = "/api/v1/facilities/"
|
||||
case "federal_state":
|
||||
prefix = "/api/v1/federal-state/"
|
||||
case "file_manager":
|
||||
prefix = "/api/v1/file-manager/"
|
||||
case "flight_data":
|
||||
prefix = "/api/v1/flight-data/"
|
||||
case "flight":
|
||||
prefix = "/api/v1/flights/"
|
||||
case "forces_present":
|
||||
prefix = "/api/v1/forces-present/"
|
||||
case "health_insurance_company":
|
||||
prefix = "/api/v1/health-insurance-companies/"
|
||||
case "helicopter_file":
|
||||
prefix = "/api/v1/helicopter-files/"
|
||||
case "helicopter":
|
||||
prefix = "/api/v1/helicopters/"
|
||||
case "hospital":
|
||||
prefix = "/api/v1/hospital/"
|
||||
case "icao":
|
||||
prefix = "/api/v1/icao/"
|
||||
case "insurance_patient_data":
|
||||
prefix = "/api/v1/insurance-patient-data/"
|
||||
case "land":
|
||||
prefix = "/api/v1/land/"
|
||||
case "master_setting":
|
||||
prefix = "/api/v1/master-settings/"
|
||||
case "medicine":
|
||||
prefix = "/api/v1/medicine/"
|
||||
case "mission":
|
||||
prefix = "/api/v1/mission/"
|
||||
case "opc":
|
||||
prefix = "/api/v1/opc/"
|
||||
case "patient_data":
|
||||
prefix = "/api/v1/patient-data/"
|
||||
case "reserve_ac":
|
||||
prefix = "/api/v1/reserve-acs/"
|
||||
case "role":
|
||||
prefix = "/api/v1/roles/"
|
||||
case "user":
|
||||
prefix = "/api/v1/users/"
|
||||
case "vocation":
|
||||
prefix = "/api/v1/vocation/"
|
||||
}
|
||||
if prefix == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[prefix]; ok {
|
||||
continue
|
||||
}
|
||||
seen[prefix] = struct{}{}
|
||||
out = append(out, prefix)
|
||||
}
|
||||
return out
|
||||
}
|
||||
595
internal/repository/mysql/auth_repo.go
Normal file
595
internal/repository/mysql/auth_repo.go
Normal file
@@ -0,0 +1,595 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"wucher/internal/domain/auth"
|
||||
)
|
||||
|
||||
type AuthRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAuthRepository(db *gorm.DB) *AuthRepository {
|
||||
return &AuthRepository{db: db}
|
||||
}
|
||||
|
||||
// Users
|
||||
|
||||
func (r *AuthRepository) CreateUser(ctx context.Context, user *auth.User) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
user.CreatedBy = actor
|
||||
user.UpdatedBy = actor
|
||||
}
|
||||
if err := tx.Create(user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceUserRolesTx(ctx, tx, user.ID, user.RoleID, user.RoleIDs)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *AuthRepository) GetUserByID(ctx context.Context, id []byte) (*auth.User, error) {
|
||||
var user auth.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("ProfileAttachment").
|
||||
Preload("ProfileAttachment.File").
|
||||
First(&user, "id = ?", id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := attachAuthUserRoles(ctx, r.db, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) GetUserByEmail(ctx context.Context, email string) (*auth.User, error) {
|
||||
var user auth.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("ProfileAttachment").
|
||||
Preload("ProfileAttachment.File").
|
||||
Where("email = ?", email).
|
||||
First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := attachAuthUserRoles(ctx, r.db, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) GetUserBySSOEmail(ctx context.Context, ssoEmail string) (*auth.User, error) {
|
||||
var user auth.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("ProfileAttachment").
|
||||
Preload("ProfileAttachment.File").
|
||||
Where("sso_email = ?", ssoEmail).
|
||||
First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := attachAuthUserRoles(ctx, r.db, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) GetUserByUsername(ctx context.Context, username string) (*auth.User, error) {
|
||||
var user auth.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("ProfileAttachment").
|
||||
Preload("ProfileAttachment.File").
|
||||
Where("username = ?", username).
|
||||
First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := attachAuthUserRoles(ctx, r.db, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) UpdateUserTimezone(ctx context.Context, id []byte, timezone string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.User{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"timezone": timezone,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) SetUserPassword(ctx context.Context, id []byte, passwordHash, salt []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.User{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"password_hash": passwordHash,
|
||||
"salt_value": salt,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) SetUserSecurityPIN(ctx context.Context, id []byte, pinHash []byte, setAt time.Time) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.User{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"security_pin_hash": pinHash,
|
||||
"security_pin_set_at": setAt,
|
||||
"updated_at": setAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) MarkEmailVerified(ctx context.Context, id []byte) error {
|
||||
now := time.Now().UTC()
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.User{}).
|
||||
Where("id = ?", id).
|
||||
Update("email_verified_at", &now).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CreatePasswordResetToken(ctx context.Context, token *auth.PasswordResetToken) error {
|
||||
return r.db.WithContext(ctx).Create(token).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) InvalidateActivePasswordResetTokensByUserID(ctx context.Context, userID []byte, usedAt time.Time) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.PasswordResetToken{}).
|
||||
Where("user_id = ? AND used_at IS NULL AND expires_at > ?", userID, usedAt).
|
||||
Updates(map[string]any{
|
||||
"used_at": usedAt,
|
||||
"updated_at": usedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) ConsumePasswordResetTokenAndSetPassword(
|
||||
ctx context.Context,
|
||||
tokenHash, passwordHash, salt []byte,
|
||||
usedAt time.Time,
|
||||
) ([]byte, bool, error) {
|
||||
var token auth.PasswordResetToken
|
||||
consumed := false
|
||||
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("token_hash = ? AND used_at IS NULL AND expires_at > ?", tokenHash, usedAt).
|
||||
First(&token).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
consumed = true
|
||||
|
||||
res := tx.Model(&auth.User{}).
|
||||
Where("id = ?", token.UserID).
|
||||
Updates(map[string]any{
|
||||
"password_hash": passwordHash,
|
||||
"salt_value": salt,
|
||||
"updated_at": usedAt,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
if err := tx.Model(&auth.PasswordResetToken{}).
|
||||
Where("id = ?", token.ID).
|
||||
Updates(map[string]any{
|
||||
"used_at": usedAt,
|
||||
"updated_at": usedAt,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&auth.PasswordResetToken{}).
|
||||
Where("user_id = ? AND id <> ? AND used_at IS NULL AND expires_at > ?", token.UserID, token.ID, usedAt).
|
||||
Updates(map[string]any{
|
||||
"used_at": usedAt,
|
||||
"updated_at": usedAt,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if !consumed {
|
||||
return nil, false, nil
|
||||
}
|
||||
return token.UserID, true, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CreateSecurityPINResetToken(ctx context.Context, token *auth.SecurityPINResetToken) error {
|
||||
return r.db.WithContext(ctx).Create(token).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) InvalidateActiveSecurityPINResetTokensByUserID(ctx context.Context, userID []byte, usedAt time.Time) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.SecurityPINResetToken{}).
|
||||
Where("user_id = ? AND used_at IS NULL AND expires_at > ?", userID, usedAt).
|
||||
Updates(map[string]any{
|
||||
"used_at": usedAt,
|
||||
"updated_at": usedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) GetActiveSecurityPINResetTokenUser(ctx context.Context, tokenHash []byte, now time.Time) (*auth.User, error) {
|
||||
var user auth.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("security_pin_reset_tokens AS token").
|
||||
Select("users.*").
|
||||
Joins("JOIN users ON users.id = token.user_id").
|
||||
Where("token.token_hash = ? AND token.used_at IS NULL AND token.expires_at > ?", tokenHash, now).
|
||||
First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *AuthRepository) ConsumeSecurityPINResetTokenAndSetPIN(
|
||||
ctx context.Context,
|
||||
tokenHash, pinHash []byte,
|
||||
usedAt time.Time,
|
||||
) ([]byte, bool, error) {
|
||||
var token auth.SecurityPINResetToken
|
||||
consumed := false
|
||||
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("token_hash = ? AND used_at IS NULL AND expires_at > ?", tokenHash, usedAt).
|
||||
First(&token).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
consumed = true
|
||||
|
||||
res := tx.Model(&auth.User{}).
|
||||
Where("id = ?", token.UserID).
|
||||
Updates(map[string]any{
|
||||
"security_pin_hash": pinHash,
|
||||
"security_pin_set_at": usedAt,
|
||||
"updated_at": usedAt,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
if err := tx.Model(&auth.SecurityPINResetToken{}).
|
||||
Where("id = ?", token.ID).
|
||||
Updates(map[string]any{
|
||||
"used_at": usedAt,
|
||||
"updated_at": usedAt,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&auth.SecurityPINResetToken{}).
|
||||
Where("user_id = ? AND id <> ? AND used_at IS NULL AND expires_at > ?", token.UserID, token.ID, usedAt).
|
||||
Updates(map[string]any{
|
||||
"used_at": usedAt,
|
||||
"updated_at": usedAt,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if !consumed {
|
||||
return nil, false, nil
|
||||
}
|
||||
return token.UserID, true, nil
|
||||
}
|
||||
|
||||
// Identities (SSO)
|
||||
|
||||
func (r *AuthRepository) UpsertIdentity(ctx context.Context, identity *auth.UserIdentity) error {
|
||||
// Ensure we keep a single row per provider+subject
|
||||
return r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "provider"}, {Name: "provider_subject"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"user_id", "email", "metadata", "updated_at", "updated_by"}),
|
||||
}).
|
||||
Create(identity).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CreateIdentity(ctx context.Context, identity *auth.UserIdentity) error {
|
||||
return r.db.WithContext(ctx).Create(identity).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) GetIdentityByProviderSubject(ctx context.Context, provider, subject string) (*auth.UserIdentity, error) {
|
||||
var identity auth.UserIdentity
|
||||
tx := r.db.WithContext(ctx).
|
||||
Where("provider = ? AND provider_subject = ?", provider, subject).
|
||||
Limit(1).
|
||||
Find(&identity)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) GetIdentityByUserID(ctx context.Context, userID []byte) (*auth.UserIdentity, error) {
|
||||
var identity auth.UserIdentity
|
||||
tx := r.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Limit(1).
|
||||
Find(&identity)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) DeleteIdentityByUserIDProvider(ctx context.Context, userID []byte, provider string) (bool, error) {
|
||||
tx := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND provider = ?", userID, provider).
|
||||
Delete(&auth.UserIdentity{})
|
||||
if tx.Error != nil {
|
||||
return false, tx.Error
|
||||
}
|
||||
return tx.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) UpsertMicrosoftOAuthToken(ctx context.Context, token *auth.UserMicrosoftOAuthToken) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "provider"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"tenant_id",
|
||||
"microsoft_object_id",
|
||||
"microsoft_subject",
|
||||
"refresh_token_encrypted",
|
||||
"access_token_encrypted",
|
||||
"id_token_encrypted",
|
||||
"scope",
|
||||
"token_type",
|
||||
"access_token_expires_at",
|
||||
"refresh_token_expires_at",
|
||||
"refresh_token_rotated_at",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
}),
|
||||
}).
|
||||
Create(token).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) GetMicrosoftOAuthTokenByUserIDProvider(ctx context.Context, userID []byte, provider string) (*auth.UserMicrosoftOAuthToken, error) {
|
||||
var token auth.UserMicrosoftOAuthToken
|
||||
tx := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND provider = ?", userID, provider).
|
||||
Limit(1).
|
||||
Find(&token)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) DeleteMicrosoftOAuthTokenByUserIDProvider(ctx context.Context, userID []byte, provider string) (bool, error) {
|
||||
tx := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND provider = ?", userID, provider).
|
||||
Delete(&auth.UserMicrosoftOAuthToken{})
|
||||
if tx.Error != nil {
|
||||
return false, tx.Error
|
||||
}
|
||||
return tx.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// TOTP
|
||||
|
||||
func (r *AuthRepository) GetUserTOTP(ctx context.Context, userID []byte) (*auth.UserTOTP, error) {
|
||||
var totp auth.UserTOTP
|
||||
tx := r.db.WithContext(ctx).Where("user_id = ?", userID).Limit(1).Find(&totp)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &totp, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) UpsertUserTOTP(ctx context.Context, totp *auth.UserTOTP) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"secret_encrypted", "enabled", "updated_at", "updated_by"}),
|
||||
}).
|
||||
Create(totp).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) DisableUserTOTP(ctx context.Context, userID []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.UserTOTP{}).
|
||||
Where("user_id = ?", userID).
|
||||
Updates(map[string]any{
|
||||
"enabled": false,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) DeleteUserTOTPSetup(ctx context.Context, userID []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Delete(&auth.UserTOTP{}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) UpdateUserTOTPLastUsed(ctx context.Context, userID []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.UserTOTP{}).
|
||||
Where("user_id = ? AND enabled = 1", userID).
|
||||
Update("last_used_at", time.Now().UTC()).Error
|
||||
}
|
||||
|
||||
// Email login OTP
|
||||
|
||||
func (r *AuthRepository) GetActiveUserEmailLoginOTP(ctx context.Context, userID []byte, now time.Time) (*auth.UserEmailLoginOTP, error) {
|
||||
var row auth.UserEmailLoginOTP
|
||||
tx := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND used_at IS NULL AND expires_at > ?", userID, now.UTC()).
|
||||
Order("created_at DESC").
|
||||
Limit(1).
|
||||
Find(&row)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CountUserEmailLoginOTPSince(ctx context.Context, userID []byte, since time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&auth.UserEmailLoginOTP{}).
|
||||
Where("user_id = ? AND created_at >= ?", userID, since.UTC()).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CreateUserEmailLoginOTP(ctx context.Context, otp *auth.UserEmailLoginOTP) error {
|
||||
return r.db.WithContext(ctx).Create(otp).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) InvalidateActiveUserEmailLoginOTPs(ctx context.Context, userID []byte, usedAt time.Time) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.UserEmailLoginOTP{}).
|
||||
Where("user_id = ? AND used_at IS NULL AND expires_at > ?", userID, usedAt.UTC()).
|
||||
Updates(map[string]any{
|
||||
"used_at": usedAt.UTC(),
|
||||
"updated_at": usedAt.UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) ConsumeUserEmailLoginOTP(ctx context.Context, userID, otpHash []byte, now time.Time) (bool, int, error) {
|
||||
success := false
|
||||
attemptsLeft := 0
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var row auth.UserEmailLoginOTP
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("user_id = ? AND used_at IS NULL AND expires_at > ?", userID, now.UTC()).
|
||||
Order("created_at DESC").
|
||||
Limit(1).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if string(row.OTPHash) == string(otpHash) {
|
||||
success = true
|
||||
attemptsLeft = max(0, row.MaxAttempts-row.AttemptCount)
|
||||
if err := tx.Model(&auth.UserEmailLoginOTP{}).
|
||||
Where("id = ?", row.ID).
|
||||
Updates(map[string]any{
|
||||
"used_at": now.UTC(),
|
||||
"updated_at": now.UTC(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&auth.UserEmailLoginOTP{}).
|
||||
Where("user_id = ? AND id <> ? AND used_at IS NULL AND expires_at > ?", userID, row.ID, now.UTC()).
|
||||
Updates(map[string]any{
|
||||
"used_at": now.UTC(),
|
||||
"updated_at": now.UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
nextAttempts := row.AttemptCount + 1
|
||||
attemptsLeft = max(0, row.MaxAttempts-nextAttempts)
|
||||
updates := map[string]any{
|
||||
"attempt_count": nextAttempts,
|
||||
"updated_at": now.UTC(),
|
||||
}
|
||||
if nextAttempts >= row.MaxAttempts {
|
||||
updates["used_at"] = now.UTC()
|
||||
}
|
||||
return tx.Model(&auth.UserEmailLoginOTP{}).
|
||||
Where("id = ?", row.ID).
|
||||
Updates(updates).Error
|
||||
})
|
||||
return success, attemptsLeft, err
|
||||
}
|
||||
|
||||
// WebAuthn
|
||||
|
||||
func (r *AuthRepository) ListUserWebAuthnCredentials(ctx context.Context, userID []byte) ([]auth.UserWebAuthnCredential, error) {
|
||||
var credentials []auth.UserWebAuthnCredential
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Order("created_at ASC").
|
||||
Find(&credentials).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CreateUserWebAuthnCredential(ctx context.Context, credential *auth.UserWebAuthnCredential) error {
|
||||
return r.db.WithContext(ctx).Create(credential).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) UpdateUserWebAuthnCredential(ctx context.Context, userID, credentialID, credentialJSON []byte, usedAt time.Time) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.UserWebAuthnCredential{}).
|
||||
Where("user_id = ? AND credential_id = ?", userID, credentialID).
|
||||
Updates(map[string]any{
|
||||
"credential_json": credentialJSON,
|
||||
"last_used_at": usedAt,
|
||||
"updated_at": usedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) DeleteAllUserWebAuthnCredentials(ctx context.Context, userID []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Delete(&auth.UserWebAuthnCredential{}).Error
|
||||
}
|
||||
642
internal/repository/mysql/base_repo.go
Normal file
642
internal/repository/mysql/base_repo.go
Normal file
@@ -0,0 +1,642 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"wucher/internal/domain/base"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type BaseRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type binaryValue struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (b binaryValue) Value() (driver.Value, error) {
|
||||
if len(b.data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return append([]byte(nil), b.data...), nil
|
||||
}
|
||||
|
||||
func NewBaseRepository(db *gorm.DB) *BaseRepository {
|
||||
return &BaseRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *BaseRepository) CreateBase(ctx context.Context, row *base.Base, categoryType string) error {
|
||||
categoryID, err := r.categoryIDByType(ctx, categoryType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.BaseCategoryID = categoryID
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
row.CreatedBy = actor
|
||||
row.UpdatedBy = actor
|
||||
}
|
||||
row.DefaultStartTimeType = normalizeBaseShiftTimeType(row.DefaultStartTimeType)
|
||||
row.DefaultEndTimeType = normalizeBaseShiftTimeType(row.DefaultEndTimeType)
|
||||
row.DefaultShiftStart = baseClockStorageValue(row.DefaultShiftStart)
|
||||
row.DefaultShiftEnd = baseClockStorageValue(row.DefaultShiftEnd)
|
||||
requestedIsActive := row.IsActive
|
||||
operationalShiftTimes := row.OperationalShiftTimes
|
||||
row.OperationalShiftTimes = nil
|
||||
db := r.db.WithContext(ctx)
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Omit(clause.Associations).Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&base.Base{}).Where("id = ?", row.ID).UpdateColumn("is_active", requestedIsActive).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.replaceBaseContactRolesTx(ctx, tx, row.ID, row.HEMSEDCContactIDs, row.MedPaxContactIDs, row.ResponsiblePilotContactIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.replaceBaseOperationalShiftTimesTx(ctx, tx, row.ID, operationalShiftTimes)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *BaseRepository) UpdateBase(ctx context.Context, row *base.Base, categoryType string) error {
|
||||
categoryID, err := r.categoryIDByType(ctx, categoryType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.BaseCategoryID = categoryID
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
row.UpdatedBy = actor
|
||||
}
|
||||
row.DefaultStartTimeType = normalizeBaseShiftTimeType(row.DefaultStartTimeType)
|
||||
row.DefaultEndTimeType = normalizeBaseShiftTimeType(row.DefaultEndTimeType)
|
||||
row.DefaultShiftStart = baseClockStorageValue(row.DefaultShiftStart)
|
||||
row.DefaultShiftEnd = baseClockStorageValue(row.DefaultShiftEnd)
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Use an explicit UPDATE statement so the base shift columns are stored
|
||||
// in the same datetime shape that exists in the local schema while still
|
||||
// accepting clock-only inputs from the API layer.
|
||||
if err := tx.Exec(
|
||||
fmt.Sprintf(`UPDATE bases
|
||||
SET base = ?,
|
||||
base_category_id = ?,
|
||||
foto_attachment_id = ?,
|
||||
base_abbreviation = ?,
|
||||
address = ?,
|
||||
latitude = ?,
|
||||
longitude = ?,
|
||||
landline_number = ?,
|
||||
mobile_number = ?,
|
||||
email = ?,
|
||||
sortkey = ?,
|
||||
sms_alert = ?,
|
||||
checklist = ?,
|
||||
leg_time = ?,
|
||||
default_start_time_type = ?,
|
||||
default_end_time_type = ?,
|
||||
default_shift_start = ?,
|
||||
default_shift_end = ?,
|
||||
utc = ?,
|
||||
dry = ?,
|
||||
control_center = ?,
|
||||
dul = ?,
|
||||
notes = ?,
|
||||
is_active = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL`),
|
||||
row.BaseName,
|
||||
binaryValue{data: row.BaseCategoryID},
|
||||
binaryValue{data: row.FotoAttachmentID},
|
||||
row.BaseAbbreviation,
|
||||
row.Address,
|
||||
row.Latitude,
|
||||
row.Longitude,
|
||||
row.LandlineNumber,
|
||||
row.MobileNumber,
|
||||
row.Email,
|
||||
row.SortKey,
|
||||
row.SMSAlert,
|
||||
row.Checklist,
|
||||
row.LegTime,
|
||||
row.DefaultStartTimeType,
|
||||
row.DefaultEndTimeType,
|
||||
row.DefaultShiftStart,
|
||||
row.DefaultShiftEnd,
|
||||
row.UTC,
|
||||
row.Dry,
|
||||
row.ControlCenter,
|
||||
row.DUL,
|
||||
row.Notes,
|
||||
row.IsActive,
|
||||
binaryValue{data: row.UpdatedBy},
|
||||
time.Now().UTC(),
|
||||
binaryValue{data: row.ID},
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.replaceBaseContactRolesTx(ctx, tx, row.ID, row.HEMSEDCContactIDs, row.MedPaxContactIDs, row.ResponsiblePilotContactIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if row.OperationalShiftTimes == nil {
|
||||
return nil
|
||||
}
|
||||
return r.replaceBaseOperationalShiftTimesTx(ctx, tx, row.ID, row.OperationalShiftTimes)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *BaseRepository) DeleteBase(ctx context.Context, id []byte, categoryType string) error {
|
||||
categoryID, err := r.categoryIDByType(ctx, categoryType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "bases", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
}
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
updates["deleted_by"] = actor
|
||||
updates["updated_by"] = actor
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&base.Base{}).
|
||||
Where("id = ? AND base_category_id = ? AND deleted_at IS NULL", id, categoryID).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *BaseRepository) GetBaseByID(ctx context.Context, id []byte, categoryType string) (*base.Base, error) {
|
||||
categoryID, err := r.categoryIDByType(ctx, categoryType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var row base.Base
|
||||
err = r.db.WithContext(ctx).
|
||||
Preload("FotoAttachment").
|
||||
Preload("FotoAttachment.File").
|
||||
Preload("OperationalShiftTimes", preloadBaseOperationalShiftTimes).
|
||||
Where("id = ? AND base_category_id = ? AND deleted_at IS NULL", id, categoryID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err == nil {
|
||||
if loadErr := r.loadBaseContactRoles(ctx, []*base.Base{&row}); loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *BaseRepository) ListBases(ctx context.Context, filter, sort string, limit, offset int, categoryType string, dul *bool) ([]base.Base, int64, error) {
|
||||
var rows []base.Base
|
||||
var total int64
|
||||
|
||||
baseQuery := r.db.WithContext(ctx).
|
||||
Model(&base.Base{}).
|
||||
Where("deleted_at IS NULL")
|
||||
if strings.TrimSpace(categoryType) != "" {
|
||||
categoryID, err := r.categoryIDByType(ctx, categoryType)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
baseQuery = baseQuery.Where("base_category_id = ?", categoryID)
|
||||
}
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
baseQuery = baseQuery.Where(
|
||||
"base LIKE ? OR base_abbreviation LIKE ? OR address LIKE ? OR email LIKE ? OR landline_number LIKE ? OR mobile_number LIKE ?",
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
)
|
||||
}
|
||||
if dul != nil {
|
||||
baseQuery = baseQuery.Where("dul = ?", *dul)
|
||||
}
|
||||
|
||||
query := baseQuery
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("bases", "is_active", "sortkey", "base", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := baseQuery.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
query = query.Preload("BaseCategory")
|
||||
query = query.Preload("FotoAttachment").Preload("FotoAttachment.File")
|
||||
query = query.Preload("OperationalShiftTimes", preloadBaseOperationalShiftTimes)
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ptrs := make([]*base.Base, 0, len(rows))
|
||||
for i := range rows {
|
||||
ptrs = append(ptrs, &rows[i])
|
||||
}
|
||||
if err := r.loadBaseContactRoles(ctx, ptrs); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func preloadBaseOperationalShiftTimes(db *gorm.DB) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
q := db.Where("deleted_at IS NULL").Order("date_start ASC, date_end ASC")
|
||||
if db.Dialector != nil && strings.EqualFold(db.Dialector.Name(), "mysql") {
|
||||
q = q.Select(`
|
||||
id,
|
||||
base_id,
|
||||
date_start,
|
||||
date_end,
|
||||
start_time_type,
|
||||
end_time_type,
|
||||
CASE WHEN shift_start IS NULL THEN '' ELSE TIME_FORMAT(shift_start, '%H:%i:%s') END AS shift_start,
|
||||
CASE WHEN shift_end IS NULL THEN '' ELSE TIME_FORMAT(shift_end, '%H:%i:%s') END AS shift_end,
|
||||
created_at,
|
||||
created_by,
|
||||
updated_at,
|
||||
updated_by,
|
||||
deleted_at,
|
||||
deleted_by
|
||||
`)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (r *BaseRepository) replaceBaseContactRolesTx(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
baseID []byte,
|
||||
hemsEDCIDs [][]byte,
|
||||
medPaxIDs [][]byte,
|
||||
pilotIDs [][]byte,
|
||||
) error {
|
||||
if len(baseID) != 16 {
|
||||
return errors.New("invalid base id")
|
||||
}
|
||||
all := append(copyIDs(hemsEDCIDs), medPaxIDs...)
|
||||
all = append(all, pilotIDs...)
|
||||
if err := ensureContactsExistTx(tx, all); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("base_id = ?", baseID).Delete(&base.BaseContactRole{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]base.BaseContactRole, 0, len(hemsEDCIDs)+len(medPaxIDs)+len(pilotIDs))
|
||||
actor := actorUserIDFromContext(ctx)
|
||||
for _, contactID := range hemsEDCIDs {
|
||||
if len(contactID) != 16 {
|
||||
return fmt.Errorf("invalid hems_edc contact id")
|
||||
}
|
||||
rows = append(rows, base.BaseContactRole{
|
||||
BaseID: baseID,
|
||||
ContactID: contactID,
|
||||
RoleCode: base.BaseContactRoleHEMSEDC,
|
||||
CreatedBy: actor,
|
||||
UpdatedBy: actor,
|
||||
})
|
||||
}
|
||||
for _, contactID := range medPaxIDs {
|
||||
if len(contactID) != 16 {
|
||||
return fmt.Errorf("invalid med_pax contact id")
|
||||
}
|
||||
rows = append(rows, base.BaseContactRole{
|
||||
BaseID: baseID,
|
||||
ContactID: contactID,
|
||||
RoleCode: base.BaseContactRoleMedPax,
|
||||
CreatedBy: actor,
|
||||
UpdatedBy: actor,
|
||||
})
|
||||
}
|
||||
for _, contactID := range pilotIDs {
|
||||
if len(contactID) != 16 {
|
||||
return fmt.Errorf("invalid responsible_pilot contact id")
|
||||
}
|
||||
rows = append(rows, base.BaseContactRole{
|
||||
BaseID: baseID,
|
||||
ContactID: contactID,
|
||||
RoleCode: base.BaseContactRoleResponsiblePilot,
|
||||
CreatedBy: actor,
|
||||
UpdatedBy: actor,
|
||||
})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(&rows).Error
|
||||
}
|
||||
|
||||
func (r *BaseRepository) replaceBaseOperationalShiftTimesTx(ctx context.Context, tx *gorm.DB, baseID []byte, items []base.BaseOperationalShiftTime) error {
|
||||
if len(baseID) != 16 {
|
||||
return errors.New("invalid base id")
|
||||
}
|
||||
if items == nil {
|
||||
return nil
|
||||
}
|
||||
actor := actorUserIDFromContext(ctx)
|
||||
if err := tx.Where("base_id = ?", baseID).Delete(&base.BaseOperationalShiftTime{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for i := range items {
|
||||
row := items[i]
|
||||
if row.DateStart == nil || row.DateEnd == nil || row.DateStart.IsZero() || row.DateEnd.IsZero() {
|
||||
return errors.New("invalid operational shift date range")
|
||||
}
|
||||
if row.DateEnd.Before(*row.DateStart) {
|
||||
return errors.New("operational shift date_end must be >= date_start")
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
row.ID = uuidv7.MustBytes()
|
||||
}
|
||||
dateStart := row.DateStart.UTC().Format("2006-01-02")
|
||||
dateEnd := row.DateEnd.UTC().Format("2006-01-02")
|
||||
var createdBy any
|
||||
var updatedBy any
|
||||
if len(actor) > 0 {
|
||||
createdBy = binaryValue{data: actor}
|
||||
updatedBy = binaryValue{data: actor}
|
||||
}
|
||||
shiftStart := shiftTimeValue(row.ShiftStart)
|
||||
shiftEnd := shiftTimeValue(row.ShiftEnd)
|
||||
if err := tx.Exec(
|
||||
`INSERT INTO base_operational_shift_times
|
||||
(id, base_id, date_start, date_end, start_time_type, end_time_type, shift_start, shift_end, created_at, created_by, updated_at, updated_by, deleted_at, deleted_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
binaryValue{data: row.ID},
|
||||
binaryValue{data: baseID},
|
||||
dateStart,
|
||||
dateEnd,
|
||||
row.StartTimeType,
|
||||
row.EndTimeType,
|
||||
shiftStart,
|
||||
shiftEnd,
|
||||
now,
|
||||
createdBy,
|
||||
now,
|
||||
updatedBy,
|
||||
nil,
|
||||
nil,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shiftTimeValue(raw string) any {
|
||||
clock, ok := extractClockValue(raw)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return clock
|
||||
}
|
||||
|
||||
func baseClockStorageValue(raw string) string {
|
||||
clock, ok := extractClockValue(raw)
|
||||
if !ok {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
return "2000-01-01 " + clock
|
||||
}
|
||||
|
||||
func normalizeBaseShiftTimeType(raw string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "", base.ShiftTimeTypeFixed:
|
||||
return base.ShiftTimeTypeFixed
|
||||
case base.ShiftTimeTypeBMCT:
|
||||
return base.ShiftTimeTypeBMCT
|
||||
case base.ShiftTimeTypeECET:
|
||||
return base.ShiftTimeTypeECET
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func extractClockValue(raw string) (string, bool) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return "", false
|
||||
}
|
||||
layouts := []string{
|
||||
"15:04:05",
|
||||
"15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, s); err == nil {
|
||||
return parsed.UTC().Format("15:04:05"), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func ensureContactsExistTx(tx *gorm.DB, ids [][]byte) error {
|
||||
valid := make([][]byte, 0, len(ids))
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
if len(id) != 16 {
|
||||
return errors.New("invalid contact id")
|
||||
}
|
||||
key := string(id)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
valid = append(valid, id)
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Table("users").Where("id IN ?", valid).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != int64(len(valid)) {
|
||||
return errors.New("one or more contact ids not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *BaseRepository) loadBaseContactRoles(ctx context.Context, bases []*base.Base) error {
|
||||
if len(bases) == 0 {
|
||||
return nil
|
||||
}
|
||||
baseIDs := make([][]byte, 0, len(bases))
|
||||
index := make(map[string]*base.Base, len(bases))
|
||||
for _, b := range bases {
|
||||
if b == nil || len(b.ID) != 16 {
|
||||
continue
|
||||
}
|
||||
baseIDs = append(baseIDs, b.ID)
|
||||
index[string(b.ID)] = b
|
||||
b.HEMSEDCContactIDs = nil
|
||||
b.MedPaxContactIDs = nil
|
||||
b.ResponsiblePilotContactIDs = nil
|
||||
b.HEMSEDCs = nil
|
||||
b.MedPax = nil
|
||||
b.ResponsiblePilots = nil
|
||||
}
|
||||
if len(baseIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var rels []base.BaseContactRole
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("base_id IN ?", baseIDs).
|
||||
Order("created_at ASC").
|
||||
Find(&rels).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range rels {
|
||||
b := index[string(rels[i].BaseID)]
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
switch rels[i].RoleCode {
|
||||
case base.BaseContactRoleHEMSEDC:
|
||||
b.HEMSEDCContactIDs = append(b.HEMSEDCContactIDs, append([]byte(nil), rels[i].ContactID...))
|
||||
case base.BaseContactRoleMedPax:
|
||||
b.MedPaxContactIDs = append(b.MedPaxContactIDs, append([]byte(nil), rels[i].ContactID...))
|
||||
case base.BaseContactRoleResponsiblePilot:
|
||||
b.ResponsiblePilotContactIDs = append(b.ResponsiblePilotContactIDs, append([]byte(nil), rels[i].ContactID...))
|
||||
}
|
||||
}
|
||||
var hemsEDCContactIDs [][]byte
|
||||
var medPaxContactIDs [][]byte
|
||||
var pilotContactIDs [][]byte
|
||||
for _, b := range bases {
|
||||
for i := range b.HEMSEDCContactIDs {
|
||||
hemsEDCContactIDs = append(hemsEDCContactIDs, b.HEMSEDCContactIDs[i])
|
||||
}
|
||||
for i := range b.MedPaxContactIDs {
|
||||
medPaxContactIDs = append(medPaxContactIDs, b.MedPaxContactIDs[i])
|
||||
}
|
||||
for i := range b.ResponsiblePilotContactIDs {
|
||||
pilotContactIDs = append(pilotContactIDs, b.ResponsiblePilotContactIDs[i])
|
||||
}
|
||||
}
|
||||
allContactIDs := append(append(hemsEDCContactIDs, medPaxContactIDs...), pilotContactIDs...)
|
||||
contactMap, err := r.contactNameMap(ctx, allContactIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range bases {
|
||||
for i := range b.HEMSEDCContactIDs {
|
||||
id := b.HEMSEDCContactIDs[i]
|
||||
p := contactMap[string(id)]
|
||||
p.ContactID = append([]byte(nil), id...)
|
||||
b.HEMSEDCs = append(b.HEMSEDCs, p)
|
||||
}
|
||||
for i := range b.MedPaxContactIDs {
|
||||
id := b.MedPaxContactIDs[i]
|
||||
p := contactMap[string(id)]
|
||||
p.ContactID = append([]byte(nil), id...)
|
||||
b.MedPax = append(b.MedPax, p)
|
||||
}
|
||||
for i := range b.ResponsiblePilotContactIDs {
|
||||
id := b.ResponsiblePilotContactIDs[i]
|
||||
p := contactMap[string(id)]
|
||||
p.ContactID = append([]byte(nil), id...)
|
||||
b.ResponsiblePilots = append(b.ResponsiblePilots, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *BaseRepository) contactNameMap(ctx context.Context, contactIDs [][]byte) (map[string]base.BaseContactPerson, error) {
|
||||
uniq := make([][]byte, 0, len(contactIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range contactIDs {
|
||||
if len(id) != 16 {
|
||||
continue
|
||||
}
|
||||
key := string(id)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
uniq = append(uniq, id)
|
||||
}
|
||||
if len(uniq) == 0 {
|
||||
return map[string]base.BaseContactPerson{}, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
FirstName string `gorm:"column:first_name"`
|
||||
LastName string `gorm:"column:last_name"`
|
||||
}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("users").
|
||||
Select("id, first_name, last_name").
|
||||
Where("id IN ?", uniq).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]base.BaseContactPerson, len(rows))
|
||||
for i := range rows {
|
||||
out[string(rows[i].ID)] = base.BaseContactPerson{
|
||||
FirstName: rows[i].FirstName,
|
||||
LastName: rows[i].LastName,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func copyIDs(src [][]byte) [][]byte {
|
||||
out := make([][]byte, 0, len(src))
|
||||
for _, id := range src {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *BaseRepository) categoryIDByType(ctx context.Context, categoryType string) ([]byte, error) {
|
||||
normalized, ok := base.NormalizeCategoryType(categoryType)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid base category type")
|
||||
}
|
||||
return r.categoryIDByKey(ctx, normalized)
|
||||
}
|
||||
|
||||
func (r *BaseRepository) categoryIDByKey(ctx context.Context, categoryKey string) ([]byte, error) {
|
||||
var row struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("base_categories").
|
||||
Select("id").
|
||||
Where("`key` = ?", strings.ToLower(strings.TrimSpace(categoryKey))).
|
||||
Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]byte(nil), row.ID...), nil
|
||||
}
|
||||
375
internal/repository/mysql/base_repo_test.go
Normal file
375
internal/repository/mysql/base_repo_test.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/base"
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/service"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openBaseTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:base_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&base.BaseCategory{}, &filemanager.Folder{}, &filemanager.File{}, &filemanager.Attachment{}, &base.Base{}, &base.BaseContactRole{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&base.BaseOperationalShiftTime{}); err != nil {
|
||||
t.Fatalf("auto migrate base operational shift time: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestBaseRepositoryUpdateBase_ReplacesFotoAttachmentID(t *testing.T) {
|
||||
db := openBaseTestDB(t)
|
||||
repo := NewBaseRepository(db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
category := &base.BaseCategory{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Key: base.CategoryKeyRegular,
|
||||
Name: "Regular",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(category).Error; err != nil {
|
||||
t.Fatalf("create category: %v", err)
|
||||
}
|
||||
|
||||
folder := &filemanager.Folder{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Name: "photos",
|
||||
NameNormalized: "photos",
|
||||
Depth: 0,
|
||||
PathCache: "/photos",
|
||||
NameSlot: "live",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(folder).Error; err != nil {
|
||||
t.Fatalf("create folder: %v", err)
|
||||
}
|
||||
|
||||
makeFile := func(name, key string) *filemanager.File {
|
||||
return &filemanager.File{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FolderID: folder.ID,
|
||||
Name: name,
|
||||
NameNormalized: strings.ToLower(name),
|
||||
Extension: "webp",
|
||||
SizeBytes: 1234,
|
||||
MimeType: "image/webp",
|
||||
Bucket: "bucket",
|
||||
ObjectKey: key,
|
||||
Status: filemanager.FileStatusReady,
|
||||
NameSlot: "live",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
oldFile := makeFile("old.webp", "objects/old.webp")
|
||||
newFile := makeFile("new.webp", "objects/new.webp")
|
||||
if err := db.Create(oldFile).Error; err != nil {
|
||||
t.Fatalf("create old file: %v", err)
|
||||
}
|
||||
if err := db.Create(newFile).Error; err != nil {
|
||||
t.Fatalf("create new file: %v", err)
|
||||
}
|
||||
|
||||
oldAttachment := &filemanager.Attachment{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FileID: oldFile.ID,
|
||||
RefType: "base_photo",
|
||||
RefID: "base-1",
|
||||
IsPrimary: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
newAttachment := &filemanager.Attachment{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FileID: newFile.ID,
|
||||
RefType: "base_photo",
|
||||
RefID: "base-1",
|
||||
IsPrimary: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(oldAttachment).Error; err != nil {
|
||||
t.Fatalf("create old attachment: %v", err)
|
||||
}
|
||||
if err := db.Create(newAttachment).Error; err != nil {
|
||||
t.Fatalf("create new attachment: %v", err)
|
||||
}
|
||||
|
||||
row := &base.Base{
|
||||
ID: uuidv7.MustBytes(),
|
||||
BaseCategoryID: category.ID,
|
||||
BaseName: "Base Old",
|
||||
FotoAttachmentID: oldAttachment.ID,
|
||||
FotoAttachment: oldAttachment,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("seed create base: %v", err)
|
||||
}
|
||||
|
||||
row.FotoAttachmentID = newAttachment.ID
|
||||
row.FotoAttachment = oldAttachment
|
||||
if err := repo.UpdateBase(context.Background(), row, base.CategoryKeyRegular); err != nil {
|
||||
t.Fatalf("update base: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := repo.GetBaseByID(context.Background(), row.ID, base.CategoryKeyRegular)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after update: %v", err)
|
||||
}
|
||||
if loaded == nil {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
if string(loaded.FotoAttachmentID) != string(newAttachment.ID) {
|
||||
t.Fatalf("expected foto_attachment_id replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRepositoryListBases_DefaultOrderActiveSortkey(t *testing.T) {
|
||||
db := openBaseTestDB(t)
|
||||
repo := NewBaseRepository(db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
category := &base.BaseCategory{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Key: base.CategoryKeyRegular,
|
||||
Name: "Regular",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(category).Error; err != nil {
|
||||
t.Fatalf("create category: %v", err)
|
||||
}
|
||||
|
||||
seed := []base.Base{
|
||||
{ID: uuidv7.MustBytes(), BaseName: "Gamma", IsActive: true},
|
||||
{ID: uuidv7.MustBytes(), BaseName: "Beta", SortKey: intPtrBaseRepo(0), IsActive: true},
|
||||
{ID: uuidv7.MustBytes(), BaseName: "Charlie", SortKey: intPtrBaseRepo(2), IsActive: true},
|
||||
{ID: uuidv7.MustBytes(), BaseName: "Alpha", SortKey: intPtrBaseRepo(1), IsActive: true},
|
||||
{ID: uuidv7.MustBytes(), BaseName: "Zulu", IsActive: false},
|
||||
{ID: uuidv7.MustBytes(), BaseName: "Bravo", SortKey: intPtrBaseRepo(9), IsActive: false},
|
||||
}
|
||||
for i := range seed {
|
||||
if err := repo.CreateBase(context.Background(), &seed[i], base.CategoryKeyRegular); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, total, err := repo.ListBases(context.Background(), "", "", 0, 0, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("list bases: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{rows[0].BaseName, rows[1].BaseName, rows[2].BaseName, rows[3].BaseName, rows[4].BaseName, rows[5].BaseName}
|
||||
wantOrder := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRepositoryCreateAndGetOperationalShiftTimes(t *testing.T) {
|
||||
db := openBaseTestDB(t)
|
||||
repo := NewBaseRepository(db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
category := &base.BaseCategory{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Key: base.CategoryKeyRegular,
|
||||
Name: "Regular",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(category).Error; err != nil {
|
||||
t.Fatalf("create category: %v", err)
|
||||
}
|
||||
start := time.Date(1900, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||
end := time.Date(1900, 1, 1, 18, 0, 0, 0, time.UTC)
|
||||
date := time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC)
|
||||
row := &base.Base{
|
||||
ID: uuidv7.MustBytes(),
|
||||
BaseCategoryID: category.ID,
|
||||
BaseName: "Base Ops",
|
||||
OperationalShiftTimes: []base.BaseOperationalShiftTime{
|
||||
{
|
||||
DateStart: &date,
|
||||
DateEnd: &date,
|
||||
StartTimeType: base.ShiftTimeTypeFixed,
|
||||
EndTimeType: base.ShiftTimeTypeFixed,
|
||||
ShiftStart: start.Format("15:04:05"),
|
||||
ShiftEnd: end.Format("15:04:05"),
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := repo.CreateBase(context.Background(), row, base.CategoryKeyRegular); err != nil {
|
||||
t.Fatalf("create base: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetBaseByID(context.Background(), row.ID, base.CategoryKeyRegular)
|
||||
if err != nil {
|
||||
t.Fatalf("get base: %v", err)
|
||||
}
|
||||
if loaded == nil || len(loaded.OperationalShiftTimes) != 1 {
|
||||
t.Fatalf("expected operational shift time to preload")
|
||||
}
|
||||
if got := loaded.OperationalShiftTimes[0].DateStart.Format("2006-01-02"); got != "2026-04-12" {
|
||||
t.Fatalf("unexpected date start: %s", got)
|
||||
}
|
||||
if got := loaded.OperationalShiftTimes[0].DateEnd.Format("2006-01-02"); got != "2026-04-12" {
|
||||
t.Fatalf("unexpected date end: %s", got)
|
||||
}
|
||||
if got := loaded.OperationalShiftTimes[0].ShiftStart; got != "09:00:00" {
|
||||
t.Fatalf("unexpected shift start: %#v", got)
|
||||
}
|
||||
if got := loaded.OperationalShiftTimes[0].StartTimeType; got != base.ShiftTimeTypeFixed {
|
||||
t.Fatalf("unexpected start_time_type: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRepositoryCreateOperationalShiftTimes_AllowsNilShiftForNonFixedTypes(t *testing.T) {
|
||||
db := openBaseTestDB(t)
|
||||
repo := NewBaseRepository(db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
category := &base.BaseCategory{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Key: base.CategoryKeyRegular,
|
||||
Name: "Regular",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(category).Error; err != nil {
|
||||
t.Fatalf("create category: %v", err)
|
||||
}
|
||||
|
||||
date := time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC)
|
||||
row := &base.Base{
|
||||
ID: uuidv7.MustBytes(),
|
||||
BaseCategoryID: category.ID,
|
||||
BaseName: "Base Ops Empty Shift",
|
||||
OperationalShiftTimes: []base.BaseOperationalShiftTime{
|
||||
{
|
||||
DateStart: &date,
|
||||
DateEnd: &date,
|
||||
StartTimeType: base.ShiftTimeTypeBMCT,
|
||||
EndTimeType: base.ShiftTimeTypeECET,
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := repo.CreateBase(context.Background(), row, base.CategoryKeyRegular); err != nil {
|
||||
t.Fatalf("create base: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := repo.GetBaseByID(context.Background(), row.ID, base.CategoryKeyRegular)
|
||||
if err != nil {
|
||||
t.Fatalf("get base: %v", err)
|
||||
}
|
||||
if loaded == nil || len(loaded.OperationalShiftTimes) != 1 {
|
||||
t.Fatalf("expected operational shift time to preload")
|
||||
}
|
||||
if got := loaded.OperationalShiftTimes[0].StartTimeType; got != base.ShiftTimeTypeBMCT {
|
||||
t.Fatalf("unexpected start_time_type: %#v", got)
|
||||
}
|
||||
if got := loaded.OperationalShiftTimes[0].EndTimeType; got != base.ShiftTimeTypeECET {
|
||||
t.Fatalf("unexpected end_time_type: %#v", got)
|
||||
}
|
||||
if got := loaded.OperationalShiftTimes[0].ShiftStart; got != "" {
|
||||
t.Fatalf("expected empty shift start for non-fixed type, got %#v", got)
|
||||
}
|
||||
if got := loaded.OperationalShiftTimes[0].ShiftEnd; got != "" {
|
||||
t.Fatalf("expected empty shift end for non-fixed type, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseServiceCreateDetailed_PersistsFixedOperationalShiftTimes(t *testing.T) {
|
||||
db := openBaseTestDB(t)
|
||||
repo := NewBaseRepository(db)
|
||||
svc := service.NewBaseService(repo)
|
||||
now := time.Now().UTC()
|
||||
|
||||
category := &base.BaseCategory{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Key: base.CategoryKeyRegular,
|
||||
Name: "Regular",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(category).Error; err != nil {
|
||||
t.Fatalf("create category: %v", err)
|
||||
}
|
||||
|
||||
start := time.Date(2000, 1, 1, 3, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2000, 1, 1, 5, 0, 0, 0, time.UTC)
|
||||
date := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
res, err := svc.CreateDetailed(context.Background(), service.BaseCreateInput{
|
||||
ID: uuidv7.MustBytes(),
|
||||
CategoryType: base.CategoryKeyRegular,
|
||||
BaseName: "Base Ops Fixed",
|
||||
Latitude: 47.3769,
|
||||
Longitude: 8.5417,
|
||||
DefaultStartTimeType: base.ShiftTimeTypeFixed,
|
||||
DefaultEndTimeType: base.ShiftTimeTypeFixed,
|
||||
DefaultShiftStart: &start,
|
||||
DefaultShiftEnd: &end,
|
||||
OperationalShiftTimes: []service.BaseOperationalShiftTimeInput{
|
||||
{
|
||||
DateStart: date,
|
||||
DateEnd: date,
|
||||
StartTimeType: base.ShiftTimeTypeFixed,
|
||||
EndTimeType: base.ShiftTimeTypeFixed,
|
||||
ShiftStart: &start,
|
||||
ShiftEnd: &end,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create detailed: %v", err)
|
||||
}
|
||||
if res == nil || res.Row == nil {
|
||||
t.Fatalf("expected create detailed result row")
|
||||
}
|
||||
|
||||
loaded, err := repo.GetBaseByID(context.Background(), res.Row.ID, base.CategoryKeyRegular)
|
||||
if err != nil {
|
||||
t.Fatalf("get base: %v", err)
|
||||
}
|
||||
if loaded == nil || len(loaded.OperationalShiftTimes) != 1 {
|
||||
t.Fatalf("expected operational shift time to preload")
|
||||
}
|
||||
if got := loaded.OperationalShiftTimes[0].ShiftStart; got != "03:00:00" {
|
||||
t.Fatalf("unexpected shift start stored: %#v", got)
|
||||
}
|
||||
if got := loaded.OperationalShiftTimes[0].ShiftEnd; got != "05:00:00" {
|
||||
t.Fatalf("unexpected shift end stored: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtrBaseRepo(v int) *int { return &v }
|
||||
66
internal/repository/mysql/before_flight_inspection_repo.go
Normal file
66
internal/repository/mysql/before_flight_inspection_repo.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
beforeflightinspection "wucher/internal/domain/before_flight_inspection"
|
||||
flightinspection "wucher/internal/domain/flight_inspection"
|
||||
"wucher/internal/domain/helicopter"
|
||||
)
|
||||
|
||||
type BeforeFlightInspectionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewBeforeFlightInspectionRepository(db *gorm.DB) *BeforeFlightInspectionRepository {
|
||||
return &BeforeFlightInspectionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *BeforeFlightInspectionRepository) Upsert(ctx context.Context, row *beforeflightinspection.BeforeFlightInspection) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *BeforeFlightInspectionRepository) GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*beforeflightinspection.BeforeFlightInspection, error) {
|
||||
var rows []beforeflightinspection.BeforeFlightInspection
|
||||
err := r.db.WithContext(ctx).Where("flight_inspection_id = ?", flightInspectionID).Limit(1).Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
func (r *BeforeFlightInspectionRepository) FlightInspectionExists(ctx context.Context, flightInspectionID []byte) (bool, error) {
|
||||
if len(flightInspectionID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flightinspection.FlightInspection{}).
|
||||
Where("id = ?", flightInspectionID).
|
||||
Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func (r *BeforeFlightInspectionRepository) GetHelicopterCapabilities(ctx context.Context, flightInspectionID []byte) (*beforeflightinspection.HelicopterCapabilities, error) {
|
||||
var caps beforeflightinspection.HelicopterCapabilities
|
||||
tx := r.db.WithContext(ctx).
|
||||
Model(&helicopter.Helicopter{}).
|
||||
Joins("JOIN reserve_acs ra ON ra.helicopter_id = helicopters.id").
|
||||
Where("ra.inspection_id = ? AND ra.deleted_at IS NULL", flightInspectionID).
|
||||
Select("helicopters.nr1, helicopters.nr2, helicopters.lh, helicopters.rh, helicopters.mgb, helicopters.igb, helicopters.tgb").
|
||||
Limit(1).
|
||||
Scan(&caps)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return nil, errors.New("helicopter not found for flight inspection")
|
||||
}
|
||||
return &caps, nil
|
||||
}
|
||||
99
internal/repository/mysql/complaint_fleet_visible_test.go
Normal file
99
internal/repository/mysql/complaint_fleet_visible_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/complaint"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
// TestOpenByHelicopterIDsKeepsLatestFlightFixed verifies the fleet rule: every OPEN defect
|
||||
// plus FIXED defects from the helicopter's latest flight; fixed defects from older flights
|
||||
// drop off.
|
||||
func TestOpenByHelicopterIDsKeepsLatestFlightFixed(t *testing.T) {
|
||||
dsn := fmt.Sprintf("file:complaint_fleet_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{NowFunc: func() time.Time { return time.Now().UTC() }})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&complaint.Complaint{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
heli := uuidv7.MustBytes()
|
||||
flightOld := uuidv7.MustBytes()
|
||||
flightNew := uuidv7.MustBytes()
|
||||
reporter := uuidv7.MustBytes()
|
||||
now := time.Now().UTC()
|
||||
fixedAt := now
|
||||
|
||||
seed := func(desc string, flightID []byte, reportedAt time.Time, fixed bool) []byte {
|
||||
id := uuidv7.MustBytes()
|
||||
row := &complaint.Complaint{
|
||||
ID: id,
|
||||
HelicopterID: heli,
|
||||
FlightID: flightID,
|
||||
Description: desc,
|
||||
ReportedBy: reporter,
|
||||
ReportedAt: reportedAt,
|
||||
}
|
||||
if fixed {
|
||||
row.FixedAt = &fixedAt
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("seed %s: %v", desc, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Older flight: one fixed (must drop off), one open (must stay).
|
||||
oldFixed := seed("old-fixed", flightOld, now.Add(-2*time.Hour), true)
|
||||
oldOpen := seed("old-open", flightOld, now.Add(-2*time.Hour), false)
|
||||
// Latest flight: one fixed (must stay), one open (must stay).
|
||||
newFixed := seed("new-fixed", flightNew, now.Add(-1*time.Minute), true)
|
||||
newOpen := seed("new-open", flightNew, now, false)
|
||||
|
||||
// NOTE: mattn-sqlite can't scan the model's datetime(3) columns back into time.Time
|
||||
// (MySQL handles datetime(3) fine), so exercise the exact fleet predicate selecting ids
|
||||
// only. This validates the WHERE that OpenByHelicopterIDs uses.
|
||||
_ = ctx
|
||||
latestFlightSub := "flight_id = (SELECT c2.flight_id FROM complaints c2 WHERE c2.helicopter_id = complaints.helicopter_id AND c2.deleted_at IS NULL AND c2.flight_id IS NOT NULL ORDER BY c2.reported_at DESC, c2.id DESC LIMIT 1)"
|
||||
type idRow struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
}
|
||||
var idRows []idRow
|
||||
if err := db.Model(&complaint.Complaint{}).
|
||||
Select("id").
|
||||
Where("deleted_at IS NULL AND helicopter_id = ?", heli).
|
||||
Where("fixed_at IS NULL OR " + latestFlightSub).
|
||||
Scan(&idRows).Error; err != nil {
|
||||
t.Fatalf("predicate query: %v", err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for i := range idRows {
|
||||
got[string(idRows[i].ID)] = true
|
||||
}
|
||||
rows := idRows
|
||||
if !got[string(oldOpen)] {
|
||||
t.Errorf("old-open should be visible (open always shows)")
|
||||
}
|
||||
if !got[string(newOpen)] {
|
||||
t.Errorf("new-open should be visible")
|
||||
}
|
||||
if !got[string(newFixed)] {
|
||||
t.Errorf("new-fixed should be visible (fixed from latest flight)")
|
||||
}
|
||||
if got[string(oldFixed)] {
|
||||
t.Errorf("old-fixed should be HIDDEN (fixed from an older flight)")
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected 3 visible complaints, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
194
internal/repository/mysql/complaint_repo.go
Normal file
194
internal/repository/mysql/complaint_repo.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
complaint "wucher/internal/domain/complaint"
|
||||
)
|
||||
|
||||
type ComplaintRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewComplaintRepository(db *gorm.DB) *ComplaintRepository { return &ComplaintRepository{db: db} }
|
||||
|
||||
func (r *ComplaintRepository) Create(ctx context.Context, row *complaint.Complaint) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *ComplaintRepository) Update(ctx context.Context, row *complaint.Complaint) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *ComplaintRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
return r.db.WithContext(ctx).Model(&complaint.Complaint{}).Where("id = ? AND deleted_at IS NULL", id).Updates(map[string]any{"deleted_at": gorm.Expr("NOW(3)"), "deleted_by": deletedBy, "updated_by": deletedBy}).Error
|
||||
}
|
||||
|
||||
func (r *ComplaintRepository) GetByID(ctx context.Context, id []byte) (*complaint.Complaint, error) {
|
||||
var row complaint.Complaint
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *ComplaintRepository) ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]complaint.Complaint, int64, error) {
|
||||
rows := make([]complaint.Complaint, 0)
|
||||
var total int64
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&complaint.Complaint{}).
|
||||
Where("helicopter_id = ? AND deleted_at IS NULL", helicopterID)
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := base.Order("reported_at DESC, created_at DESC")
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit).Offset(offset)
|
||||
}
|
||||
err := q.Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func (r *ComplaintRepository) List(ctx context.Context, filter complaint.ListFilter, sort string, limit, offset int) ([]complaint.Complaint, int64, error) {
|
||||
rows := make([]complaint.Complaint, 0)
|
||||
var total int64
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&complaint.Complaint{}).
|
||||
Where("complaints.deleted_at IS NULL")
|
||||
if len(filter.HelicopterID) == 16 {
|
||||
base = base.Where("complaints.helicopter_id = ?", filter.HelicopterID)
|
||||
}
|
||||
if len(filter.FlightID) == 16 {
|
||||
base = base.Where("complaints.flight_id = ?", filter.FlightID)
|
||||
}
|
||||
if filter.HoldItemsOnly {
|
||||
base = base.Where("complaints.fixed_at IS NULL AND complaints.mel_severity > ?", 0)
|
||||
}
|
||||
if filter.Search != "" {
|
||||
like := "%" + filter.Search + "%"
|
||||
base = base.Where("complaints.description LIKE ? OR CAST(complaints.mel_severity AS CHAR) LIKE ?", like, like)
|
||||
}
|
||||
if filter.DateFrom != "" {
|
||||
base = base.Where("complaints.reported_at >= ?", filter.DateFrom+" 00:00:00")
|
||||
}
|
||||
if filter.DateTo != "" {
|
||||
base = base.Where("complaints.reported_at <= ?", filter.DateTo+" 23:59:59.999")
|
||||
}
|
||||
if filter.Person != "" {
|
||||
like := "%" + filter.Person + "%"
|
||||
base = base.Where(
|
||||
"EXISTS (SELECT 1 FROM users u WHERE u.id IN (complaints.reported_by, complaints.mel_classified_by, complaints.nsr_decided_by) "+
|
||||
"AND TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))) LIKE ?)",
|
||||
like,
|
||||
)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := base
|
||||
if sort != "" {
|
||||
q = q.Order(sort)
|
||||
} else {
|
||||
q = q.Order("reported_at DESC, created_at DESC")
|
||||
}
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *ComplaintRepository) HelicopterHasOpenComplaint(ctx context.Context, helicopterID []byte) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&complaint.Complaint{}).
|
||||
Where("helicopter_id = ? AND deleted_at IS NULL AND fixed_at IS NULL", helicopterID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// OpenByHelicopterIDs returns the complaints the fleet-status Complaints column shows for
|
||||
// the given helicopters, in one query (no N+1). That is every OPEN defect (fixed_at IS
|
||||
// NULL) — including NSR-deferred and MEL-within-grace items, which are still open hold
|
||||
// items — PLUS the FIXED defects from each helicopter's latest flight (the flight of its
|
||||
// most recently reported complaint), so a just-resolved defect stays visible as the last
|
||||
// state until a newer flight reports. Fixed defects from older flights drop off here but
|
||||
// remain retrievable via the complaint history. A nil/empty helicopterIDs means "all".
|
||||
func (r *ComplaintRepository) OpenByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error) {
|
||||
rows := make([]complaint.Complaint, 0)
|
||||
q := r.db.WithContext(ctx).
|
||||
Model(&complaint.Complaint{}).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("fixed_at IS NULL OR flight_id = (SELECT c2.flight_id FROM complaints c2 WHERE c2.helicopter_id = complaints.helicopter_id AND c2.deleted_at IS NULL AND c2.flight_id IS NOT NULL ORDER BY c2.reported_at DESC, c2.id DESC LIMIT 1)")
|
||||
if len(helicopterIDs) > 0 {
|
||||
q = q.Where("helicopter_id IN ?", helicopterIDs)
|
||||
}
|
||||
err := q.Order("reported_at DESC, created_at DESC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *ComplaintRepository) ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error) {
|
||||
rows := make([]complaint.Complaint, 0)
|
||||
// Grounding candidates: open (not fixed), not released (not NSR), and already
|
||||
// classified (mel_classified_at set). A pending complaint is not yet a grounding
|
||||
// defect — it neither grounds the aircraft nor blocks the EASA release gate until it
|
||||
// is classified. action_taken is intentionally NOT excluded — recording corrective
|
||||
// action no longer lifts the grounding (only an NSR or a signed EASA release does),
|
||||
// so IsGrounding decides. A nil/empty helicopterIDs means "all helicopters".
|
||||
q := r.db.WithContext(ctx).
|
||||
Model(&complaint.Complaint{}).
|
||||
Where("deleted_at IS NULL AND is_nsr = 0 AND fixed_at IS NULL AND mel_classified_at IS NOT NULL")
|
||||
if len(helicopterIDs) > 0 {
|
||||
q = q.Where("helicopter_id IN ?", helicopterIDs)
|
||||
}
|
||||
err := q.Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *ComplaintRepository) HelicopterHasUnactionedComplaint(ctx context.Context, helicopterID []byte) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&complaint.Complaint{}).
|
||||
Where("helicopter_id = ? AND deleted_at IS NULL AND fixed_at IS NULL AND is_nsr = 0 AND (action_taken IS NULL OR action_taken = '')", helicopterID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *ComplaintRepository) MarkFixedByHelicopter(ctx context.Context, helicopterID []byte, easaID []byte, actor []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&complaint.Complaint{}).
|
||||
Where("helicopter_id = ? AND deleted_at IS NULL AND fixed_at IS NULL", helicopterID).
|
||||
Updates(map[string]any{"fixed_at": gorm.Expr("NOW(3)"), "fixed_by_easa_id": easaID, "updated_by": actor}).Error
|
||||
}
|
||||
|
||||
// MarkFixedByComplaint closes a single complaint (by id) — used when a release is
|
||||
// scoped to one specific defect via complaint_id. No-op if already fixed/deleted.
|
||||
func (r *ComplaintRepository) MarkFixedByComplaint(ctx context.Context, complaintID []byte, easaID []byte, actor []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&complaint.Complaint{}).
|
||||
Where("id = ? AND deleted_at IS NULL AND fixed_at IS NULL", complaintID).
|
||||
Updates(map[string]any{"fixed_at": gorm.Expr("NOW(3)"), "fixed_by_easa_id": easaID, "updated_by": actor}).Error
|
||||
}
|
||||
|
||||
// MarkFixedByFlight closes every open complaint reported on a flight — used when a
|
||||
// release is scoped to a flight via flight_id. No-op for already fixed/deleted rows.
|
||||
func (r *ComplaintRepository) MarkFixedByFlight(ctx context.Context, flightID []byte, easaID []byte, actor []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&complaint.Complaint{}).
|
||||
Where("flight_id = ? AND deleted_at IS NULL AND fixed_at IS NULL", flightID).
|
||||
Updates(map[string]any{"fixed_at": gorm.Expr("NOW(3)"), "fixed_by_easa_id": easaID, "updated_by": actor}).Error
|
||||
}
|
||||
1309
internal/repository/mysql/contact_repo.go
Normal file
1309
internal/repository/mysql/contact_repo.go
Normal file
File diff suppressed because it is too large
Load Diff
967
internal/repository/mysql/contact_repo_test.go
Normal file
967
internal/repository/mysql/contact_repo_test.go
Normal file
@@ -0,0 +1,967 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/domain/contact"
|
||||
"wucher/internal/shared/pkg/appctx"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func TestContactDetailSelectSQL_IncludesProfileAttachmentID(t *testing.T) {
|
||||
sql := contactDetailSelectSQL()
|
||||
if !strings.Contains(sql, "users.profile_attachment_id AS profile_attachment_id") {
|
||||
t.Fatalf("expected detail select to include users.profile_attachment_id alias")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactListSelectSQL_IncludesProfileAttachmentID(t *testing.T) {
|
||||
sql := contactListSelectSQL()
|
||||
if !strings.Contains(sql, "users.profile_attachment_id AS profile_attachment_id") {
|
||||
t.Fatalf("expected list select to include users.profile_attachment_id alias")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryUpdateContactClearSSOEmailUnlinksMicrosoftIdentity(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
repo := NewContactRepository(db)
|
||||
|
||||
adminRoleID := uuidv7.MustBytes()
|
||||
pilotRoleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: adminRoleID, Code: "admin", Name: "Admin"}).Error; err != nil {
|
||||
t.Fatalf("create admin role: %v", err)
|
||||
}
|
||||
if err := db.Create(&auth.Role{ID: pilotRoleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil {
|
||||
t.Fatalf("create pilot role: %v", err)
|
||||
}
|
||||
|
||||
adminUser := &auth.User{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Email: "admin3@example.com",
|
||||
FirstName: "Admin",
|
||||
LastName: "User",
|
||||
Timezone: "UTC",
|
||||
RoleID: adminRoleID,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(adminUser).Error; err != nil {
|
||||
t.Fatalf("create admin user: %v", err)
|
||||
}
|
||||
ctx := appctx.WithUserID(context.Background(), adminUser.ID)
|
||||
|
||||
sso := "a@microsoft.com"
|
||||
target := &auth.User{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Email: "target@example.com",
|
||||
SSOEmail: &sso,
|
||||
FirstName: "Target",
|
||||
LastName: "User",
|
||||
Timezone: "UTC",
|
||||
RoleID: pilotRoleID,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(target).Error; err != nil {
|
||||
t.Fatalf("create target user: %v", err)
|
||||
}
|
||||
if err := db.Create(&auth.UserIdentity{
|
||||
ID: uuidv7.MustBytes(),
|
||||
UserID: target.ID,
|
||||
Provider: "microsoft",
|
||||
ProviderSubject: "subject-a",
|
||||
Email: "a@microsoft.com",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create identity: %v", err)
|
||||
}
|
||||
|
||||
empty := " "
|
||||
if err := repo.UpdateContact(ctx, contact.UpdateInput{
|
||||
UserID: target.ID,
|
||||
User: contact.UserPatch{
|
||||
SSOEmail: &empty,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("update contact clear sso email: %v", err)
|
||||
}
|
||||
|
||||
var refreshed auth.User
|
||||
if err := db.Where("id = ?", target.ID).Take(&refreshed).Error; err != nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if refreshed.SSOEmail != nil {
|
||||
t.Fatalf("expected user sso_email NULL, got %v", *refreshed.SSOEmail)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Table("user_identities").
|
||||
Where("user_id = ? AND provider = ?", target.ID, "microsoft").
|
||||
Count(&count).Error; err != nil {
|
||||
t.Fatalf("count identities: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected microsoft identity unlinked, got %d rows", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryDeleteContactRemovesUserAndProfiles(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
username := "pilot.delete"
|
||||
user := &auth.User{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Email: "pilot-delete@example.com",
|
||||
Username: &username,
|
||||
FirstName: "Pilot",
|
||||
LastName: "Delete",
|
||||
Timezone: "Asia/Jakarta",
|
||||
RoleID: roleID,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := db.Create(&auth.PilotProfile{
|
||||
UserID: user.ID,
|
||||
PilotCategory: auth.PilotCategoryRegular,
|
||||
ShortName: "PD",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create pilot profile: %v", err)
|
||||
}
|
||||
if err := db.Create(&auth.DoctorProfile{
|
||||
UserID: user.ID,
|
||||
ShortName: "Dr PD",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create extra profile: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteContact(context.Background(), user.ID); err != nil {
|
||||
t.Fatalf("delete contact: %v", err)
|
||||
}
|
||||
|
||||
var userCount int64
|
||||
if err := db.Table("users").Where("id = ?", user.ID).Count(&userCount).Error; err != nil {
|
||||
t.Fatalf("count users: %v", err)
|
||||
}
|
||||
if userCount != 0 {
|
||||
t.Fatalf("expected user deleted, got %d rows", userCount)
|
||||
}
|
||||
|
||||
for _, table := range contactProfileTables {
|
||||
var count int64
|
||||
if err := db.Table(table).Where("user_id = ?", user.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count %s: %v", table, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected %s deleted, got %d rows", table, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryDeleteContactBlockedWhenUsedByBase(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
user := &auth.User{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Email: "pilot-base-ref@example.com",
|
||||
FirstName: "Pilot",
|
||||
LastName: "Ref",
|
||||
Timezone: "UTC",
|
||||
RoleID: roleID,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`CREATE TABLE IF NOT EXISTS base_contact_roles (
|
||||
id BLOB PRIMARY KEY,
|
||||
base_id BLOB NOT NULL,
|
||||
contact_id BLOB NOT NULL,
|
||||
role_code TEXT NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create base_contact_roles: %v", err)
|
||||
}
|
||||
repo := NewContactRepository(db)
|
||||
if err := db.Table("base_contact_roles").Create(map[string]any{
|
||||
"id": uuidv7.MustBytes(),
|
||||
"base_id": uuidv7.MustBytes(),
|
||||
"contact_id": user.ID,
|
||||
"role_code": "responsible_flight_rescuer",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed base_contact_roles: %v", err)
|
||||
}
|
||||
|
||||
err := repo.DeleteContact(context.Background(), user.ID)
|
||||
if err == nil {
|
||||
t.Fatalf("expected delete to be blocked")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "bases") {
|
||||
t.Fatalf("expected bases dependency in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryUpdateContactSameValuesDoesNotReturnNotFound(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
issuedAt := time.Date(2004, 10, 3, 0, 0, 0, 0, time.UTC)
|
||||
expiredAt := time.Date(2029, 3, 27, 0, 0, 0, 0, time.UTC)
|
||||
weight := 0.0
|
||||
username := "pilot_test_2"
|
||||
user := &auth.User{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Email: "pilot2@test.com",
|
||||
Username: &username,
|
||||
FirstName: "a",
|
||||
LastName: "a",
|
||||
MobilePhone: "2",
|
||||
Timezone: "Asia/Jakarta",
|
||||
RoleID: roleID,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := db.Create(&auth.PilotProfile{
|
||||
UserID: user.ID,
|
||||
PilotCategory: auth.PilotCategoryRegular,
|
||||
ShortName: "a",
|
||||
LicenseNo: "a",
|
||||
LicenseIssuedAt: &issuedAt,
|
||||
LicenseExpiredAt: &expiredAt,
|
||||
TechnicianLicenseNo: "",
|
||||
HasIFRQualification: true,
|
||||
IsChiefPilot: false,
|
||||
IsDUL: true,
|
||||
TotalFlightMinutes: 2,
|
||||
ResponsiblePilotMinutes: 2,
|
||||
SecondPilotMinutes: 2,
|
||||
DoubleCommandMinutes: 2,
|
||||
FlightInstructorMinutes: 0,
|
||||
NightFlightMinutes: 2,
|
||||
IFRFlightMinutes: 2,
|
||||
Location: "string",
|
||||
Postcode: "2",
|
||||
StreetLine: "2",
|
||||
WeightKG: &weight,
|
||||
PhotoFileID: "a",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create pilot profile: %v", err)
|
||||
}
|
||||
|
||||
email := "pilot2@test.com"
|
||||
firstName := "a"
|
||||
lastName := "a"
|
||||
mobilePhone := "2"
|
||||
timezone := "Asia/Jakarta"
|
||||
isActive := true
|
||||
pilotCategory := auth.PilotCategoryRegular
|
||||
shortName := "a"
|
||||
licenseNo := "a"
|
||||
technicianLicenseNo := ""
|
||||
hasIFRQualification := true
|
||||
isChiefPilot := false
|
||||
isDUL := true
|
||||
totalFlightMinutes := 2
|
||||
responsiblePilotMinutes := 2
|
||||
secondPilotMinutes := 2
|
||||
doubleCommandMinutes := 2
|
||||
flightInstructorMinutes := 0
|
||||
nightFlightMinutes := 2
|
||||
ifrFlightMinutes := 2
|
||||
location := "string"
|
||||
postcode := "2"
|
||||
streetLine := "2"
|
||||
photoFileID := "a"
|
||||
|
||||
err := repo.UpdateContact(context.Background(), contact.UpdateInput{
|
||||
UserID: user.ID,
|
||||
User: contact.UserPatch{
|
||||
Email: &email,
|
||||
Username: &username,
|
||||
FirstName: &firstName,
|
||||
LastName: &lastName,
|
||||
MobilePhone: &mobilePhone,
|
||||
Timezone: &timezone,
|
||||
IsActive: &isActive,
|
||||
},
|
||||
Profile: contact.ProfilePatch{
|
||||
PilotCategory: &pilotCategory,
|
||||
ShortName: &shortName,
|
||||
LicenseNo: &licenseNo,
|
||||
LicenseIssuedAt: &issuedAt,
|
||||
LicenseExpiredAt: &expiredAt,
|
||||
TechnicianLicenseNo: &technicianLicenseNo,
|
||||
HasIFRQualification: &hasIFRQualification,
|
||||
IsChiefPilot: &isChiefPilot,
|
||||
IsDUL: &isDUL,
|
||||
TotalFlightMinutes: &totalFlightMinutes,
|
||||
ResponsiblePilotMinutes: &responsiblePilotMinutes,
|
||||
SecondPilotMinutes: &secondPilotMinutes,
|
||||
DoubleCommandMinutes: &doubleCommandMinutes,
|
||||
FlightInstructorMinutes: &flightInstructorMinutes,
|
||||
NightFlightMinutes: &nightFlightMinutes,
|
||||
IFRFlightMinutes: &ifrFlightMinutes,
|
||||
Location: &location,
|
||||
Postcode: &postcode,
|
||||
StreetLine: &streetLine,
|
||||
WeightKG: &weight,
|
||||
PhotoFileID: &photoFileID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update contact with same values: %v", err)
|
||||
}
|
||||
|
||||
var pilotProfileCount int64
|
||||
if err := db.Table("pilot_profiles").Where("user_id = ?", user.ID).Count(&pilotProfileCount).Error; err != nil {
|
||||
t.Fatalf("count pilot profiles: %v", err)
|
||||
}
|
||||
if pilotProfileCount != 1 {
|
||||
t.Fatalf("expected single pilot profile row after update, got %d", pilotProfileCount)
|
||||
}
|
||||
|
||||
got, err := repo.GetContactByID(context.Background(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get contact after update: %v", err)
|
||||
}
|
||||
if got == nil || got.RoleCode != auth.RoleCodePilot {
|
||||
t.Fatalf("expected pilot contact after update, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryUpdateContactClearsAirRescuerProfileStringFields(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodeAirRescuer, Name: "Air Rescuer"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
user := &auth.User{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Email: "air-rescuer@test.com",
|
||||
FirstName: "Air",
|
||||
LastName: "Rescuer",
|
||||
Timezone: "UTC",
|
||||
RoleID: roleID,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := db.Create(&auth.AirRescuerProfile{
|
||||
UserID: user.ID,
|
||||
ShortName: "CINDY",
|
||||
Location: "12",
|
||||
Postcode: "12",
|
||||
StreetLine: "12",
|
||||
Note: "12",
|
||||
ResponsibleFlightRescuer: false,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create air rescuer profile: %v", err)
|
||||
}
|
||||
|
||||
empty := ""
|
||||
if err := repo.UpdateContact(context.Background(), contact.UpdateInput{
|
||||
UserID: user.ID,
|
||||
Profile: contact.ProfilePatch{
|
||||
ShortName: &empty,
|
||||
Location: &empty,
|
||||
Postcode: &empty,
|
||||
StreetLine: &empty,
|
||||
Note: &empty,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("update contact clear profile strings: %v", err)
|
||||
}
|
||||
|
||||
var profile auth.AirRescuerProfile
|
||||
if err := db.Where("user_id = ?", user.ID).Take(&profile).Error; err != nil {
|
||||
t.Fatalf("reload air rescuer profile: %v", err)
|
||||
}
|
||||
if profile.ShortName != "" || profile.Location != "" || profile.Postcode != "" || profile.StreetLine != "" || profile.Note != "" {
|
||||
t.Fatalf("expected cleared string fields, got %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryCreateContactRejectsDuplicateUsername(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
username := "pilot.duplicate"
|
||||
first, err := repo.CreateContact(context.Background(), contact.CreateInput{
|
||||
RoleID: roleID,
|
||||
User: contact.UserPatch{
|
||||
Email: strPtrContactRepo("first@example.com"),
|
||||
Username: &username,
|
||||
FirstName: strPtrContactRepo("First"),
|
||||
LastName: strPtrContactRepo("User"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create first contact: %v", err)
|
||||
}
|
||||
if len(first) != 16 {
|
||||
t.Fatalf("expected first user id, got len=%d", len(first))
|
||||
}
|
||||
|
||||
secondUsername := "pilot.duplicate"
|
||||
_, err = repo.CreateContact(context.Background(), contact.CreateInput{
|
||||
RoleID: roleID,
|
||||
User: contact.UserPatch{
|
||||
Email: strPtrContactRepo("second@example.com"),
|
||||
Username: &secondUsername,
|
||||
FirstName: strPtrContactRepo("Second"),
|
||||
LastName: strPtrContactRepo("User"),
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, contact.ErrUsernameAlreadyExists) {
|
||||
t.Fatalf("expected duplicate username error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryUpdateContactRejectsDuplicateUsername(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
firstUsername := "pilot.update.1"
|
||||
firstID, err := repo.CreateContact(context.Background(), contact.CreateInput{
|
||||
RoleID: roleID,
|
||||
User: contact.UserPatch{
|
||||
Email: strPtrContactRepo("first-update@example.com"),
|
||||
Username: &firstUsername,
|
||||
FirstName: strPtrContactRepo("First"),
|
||||
LastName: strPtrContactRepo("User"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create first contact: %v", err)
|
||||
}
|
||||
|
||||
secondUsername := "pilot.update.2"
|
||||
secondID, err := repo.CreateContact(context.Background(), contact.CreateInput{
|
||||
RoleID: roleID,
|
||||
User: contact.UserPatch{
|
||||
Email: strPtrContactRepo("second-update@example.com"),
|
||||
Username: &secondUsername,
|
||||
FirstName: strPtrContactRepo("Second"),
|
||||
LastName: strPtrContactRepo("User"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create second contact: %v", err)
|
||||
}
|
||||
|
||||
newUsername := firstUsername
|
||||
err = repo.UpdateContact(context.Background(), contact.UpdateInput{
|
||||
UserID: secondID,
|
||||
User: contact.UserPatch{
|
||||
Username: &newUsername,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, contact.ErrUsernameAlreadyExists) {
|
||||
t.Fatalf("expected duplicate username error on update, got %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetContactByID(context.Background(), firstID)
|
||||
if err != nil {
|
||||
t.Fatalf("get first contact: %v", err)
|
||||
}
|
||||
if got == nil || got.Username != firstUsername {
|
||||
t.Fatalf("unexpected first contact after duplicate update: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryListContactsDefaultSortByActiveAndSortKey(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
seedContactStaffUser(t, db, roleID, "Gamma", nil, true, "g-note")
|
||||
seedContactStaffUser(t, db, roleID, "Beta", intPtrContactRepo(0), true, "b-note")
|
||||
seedContactStaffUser(t, db, roleID, "Charlie", intPtrContactRepo(2), true, "c-note")
|
||||
seedContactStaffUser(t, db, roleID, "Alpha", intPtrContactRepo(1), true, "a-note")
|
||||
seedContactStaffUser(t, db, roleID, "Zulu", nil, false, "z-note")
|
||||
seedContactStaffUser(t, db, roleID, "Bravo", intPtrContactRepo(9), false, "br-note")
|
||||
|
||||
rows, total, err := repo.ListContacts(context.Background(), contact.ListFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("list contacts: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
got := []string{rows[0].FirstName, rows[1].FirstName, rows[2].FirstName, rows[3].FirstName, rows[4].FirstName, rows[5].FirstName}
|
||||
want := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected order got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryListContactsSearchByNote(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
seedContactStaffUser(t, db, roleID, "Alpha", nil, true, "dispatcher")
|
||||
seedContactStaffUser(t, db, roleID, "Bravo", nil, true, "maintenance")
|
||||
|
||||
rows, total, err := repo.ListContacts(context.Background(), contact.ListFilter{Search: "dispatch"})
|
||||
if err != nil {
|
||||
t.Fatalf("list contacts: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
if rows[0].FirstName != "Alpha" || rows[0].Note != "dispatcher" {
|
||||
t.Fatalf("unexpected row %+v", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryListContactsIncludesPilotProfileFields(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
userID := uuidv7.MustBytes()
|
||||
username := "pilot_list_detail"
|
||||
issuedAt := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
|
||||
expiredAt := time.Date(2029, 4, 20, 0, 0, 0, 0, time.UTC)
|
||||
user := &auth.User{
|
||||
ID: userID,
|
||||
Email: "pilot.list@example.com",
|
||||
Username: &username,
|
||||
FirstName: "Pilot",
|
||||
LastName: "List",
|
||||
Timezone: "UTC",
|
||||
RoleID: roleID,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := db.Create(&auth.PilotProfile{
|
||||
UserID: userID,
|
||||
PilotCategory: auth.PilotCategoryFreelance,
|
||||
LicenseIssuedAt: &issuedAt,
|
||||
LicenseExpiredAt: &expiredAt,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create pilot profile: %v", err)
|
||||
}
|
||||
|
||||
rows, total, err := repo.ListContacts(context.Background(), contact.ListFilter{Role: auth.RoleCodePilot})
|
||||
if err != nil {
|
||||
t.Fatalf("list contacts: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
if rows[0].PilotLicenseIssuedAt == nil || !rows[0].PilotLicenseIssuedAt.Equal(issuedAt) {
|
||||
t.Fatalf("expected pilot license_issued_at returned, got %#v", rows[0].PilotLicenseIssuedAt)
|
||||
}
|
||||
if rows[0].PilotLicenseExpiredAt == nil || !rows[0].PilotLicenseExpiredAt.Equal(expiredAt) {
|
||||
t.Fatalf("expected pilot license_expired_at returned, got %#v", rows[0].PilotLicenseExpiredAt)
|
||||
}
|
||||
if rows[0].PilotCategory != auth.PilotCategoryFreelance {
|
||||
t.Fatalf("expected pilot category returned, got %q", rows[0].PilotCategory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryUpdateContactRoleAssignsRoleWhenCurrentRoleIsEmpty(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
staffRoleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: staffRoleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
userID := uuidv7.MustBytes()
|
||||
user := &auth.User{
|
||||
ID: userID,
|
||||
Email: "no-role@example.com",
|
||||
FirstName: "No",
|
||||
LastName: "Role",
|
||||
Timezone: "UTC",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.UpdateContactRole(context.Background(), userID, auth.RoleCodeStaff); err != nil {
|
||||
t.Fatalf("update contact role: %v", err)
|
||||
}
|
||||
|
||||
var refreshed auth.User
|
||||
if err := db.Where("id = ?", userID).Take(&refreshed).Error; err != nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if len(refreshed.RoleID) != 16 || string(refreshed.RoleID) != string(staffRoleID) {
|
||||
t.Fatalf("expected role_id updated to staff, got %x", refreshed.RoleID)
|
||||
}
|
||||
|
||||
roleIDs, err := listUserRoleIDsTx(db, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("list user roles: %v", err)
|
||||
}
|
||||
if len(roleIDs) != 1 || string(roleIDs[0]) != string(staffRoleID) {
|
||||
t.Fatalf("expected user_roles to contain only staff role, got %d rows", len(roleIDs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryUpdateContact_IsAdminTruePersistsAdminRoleWhenUpdatingRoles(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
adminRoleID := uuidv7.MustBytes()
|
||||
pilotRoleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: adminRoleID, Code: "admin", Name: "Admin"}).Error; err != nil {
|
||||
t.Fatalf("create admin role: %v", err)
|
||||
}
|
||||
if err := db.Create(&auth.Role{ID: pilotRoleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil {
|
||||
t.Fatalf("create pilot role: %v", err)
|
||||
}
|
||||
|
||||
user := &auth.User{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Email: "admin-sync@example.com",
|
||||
FirstName: "Admin",
|
||||
LastName: "Sync",
|
||||
Timezone: "UTC",
|
||||
RoleID: pilotRoleID,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := replaceUserRolesTx(context.Background(), db, user.ID, pilotRoleID, [][]byte{pilotRoleID}); err != nil {
|
||||
t.Fatalf("seed user roles: %v", err)
|
||||
}
|
||||
|
||||
trueVal := true
|
||||
if err := repo.UpdateContact(context.Background(), contact.UpdateInput{
|
||||
UserID: user.ID,
|
||||
UpdateRoles: true,
|
||||
RoleID: pilotRoleID,
|
||||
RoleIDs: [][]byte{pilotRoleID},
|
||||
User: contact.UserPatch{
|
||||
IsAdmin: &trueVal,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("update contact: %v", err)
|
||||
}
|
||||
|
||||
roleIDs, err := listUserRoleIDsTx(db, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list user roles: %v", err)
|
||||
}
|
||||
if len(roleIDs) != 2 {
|
||||
t.Fatalf("expected 2 roles (pilot + admin), got %d", len(roleIDs))
|
||||
}
|
||||
foundAdmin := false
|
||||
for i := range roleIDs {
|
||||
if string(roleIDs[i]) == string(adminRoleID) {
|
||||
foundAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundAdmin {
|
||||
t.Fatalf("expected admin role assigned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryUpdateContact_StaffCanBeAdmin(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
adminRoleID := uuidv7.MustBytes()
|
||||
staffRoleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: adminRoleID, Code: "admin", Name: "Admin"}).Error; err != nil {
|
||||
t.Fatalf("create admin role: %v", err)
|
||||
}
|
||||
if err := db.Create(&auth.Role{ID: staffRoleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil {
|
||||
t.Fatalf("create staff role: %v", err)
|
||||
}
|
||||
|
||||
user := &auth.User{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Email: "staff-admin@example.com",
|
||||
FirstName: "Staff",
|
||||
LastName: "Admin",
|
||||
Timezone: "UTC",
|
||||
RoleID: staffRoleID,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := replaceUserRolesTx(context.Background(), db, user.ID, staffRoleID, [][]byte{staffRoleID}); err != nil {
|
||||
t.Fatalf("seed user roles: %v", err)
|
||||
}
|
||||
|
||||
trueVal := true
|
||||
if err := repo.UpdateContact(context.Background(), contact.UpdateInput{
|
||||
UserID: user.ID,
|
||||
User: contact.UserPatch{
|
||||
IsAdmin: &trueVal,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("update contact: %v", err)
|
||||
}
|
||||
|
||||
roleIDs, err := listUserRoleIDsTx(db, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list user roles: %v", err)
|
||||
}
|
||||
if len(roleIDs) != 2 {
|
||||
t.Fatalf("expected 2 roles (staff + admin), got %d", len(roleIDs))
|
||||
}
|
||||
foundAdmin := false
|
||||
for i := range roleIDs {
|
||||
if string(roleIDs[i]) == string(adminRoleID) {
|
||||
foundAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundAdmin {
|
||||
t.Fatalf("expected admin role assigned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryUpdateContactNilSortKeyDoesNotClearWithoutSetFlag(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
sortKey := 7
|
||||
user := &auth.User{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Email: "sortkey-clear@example.com",
|
||||
FirstName: "Sort",
|
||||
LastName: "Key",
|
||||
Timezone: "UTC",
|
||||
RoleID: roleID,
|
||||
SortKey: &sortKey,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
firstName := "Sorted"
|
||||
if err := repo.UpdateContact(context.Background(), contact.UpdateInput{
|
||||
UserID: user.ID,
|
||||
User: contact.UserPatch{
|
||||
FirstName: &firstName,
|
||||
SortKey: nil,
|
||||
SortKeySet: false,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("update contact: %v", err)
|
||||
}
|
||||
|
||||
var refreshed auth.User
|
||||
if err := db.Where("id = ?", user.ID).Take(&refreshed).Error; err != nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if refreshed.SortKey == nil || *refreshed.SortKey != 7 {
|
||||
t.Fatalf("expected sortkey to remain 7, got %#v", refreshed.SortKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactRepositoryUpdateContactNullSortKeyClearsValue(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
repo := NewContactRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
sortKey := 7
|
||||
user := &auth.User{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Email: "sortkey-null@example.com",
|
||||
FirstName: "Sort",
|
||||
LastName: "Key",
|
||||
Timezone: "UTC",
|
||||
RoleID: roleID,
|
||||
SortKey: &sortKey,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.UpdateContact(context.Background(), contact.UpdateInput{
|
||||
UserID: user.ID,
|
||||
User: contact.UserPatch{
|
||||
SortKey: nil,
|
||||
SortKeySet: true,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("update contact: %v", err)
|
||||
}
|
||||
|
||||
var refreshed auth.User
|
||||
if err := db.Where("id = ?", user.ID).Take(&refreshed).Error; err != nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if refreshed.SortKey != nil {
|
||||
t.Fatalf("expected sortkey NULL, got %d", *refreshed.SortKey)
|
||||
}
|
||||
}
|
||||
|
||||
func seedContactStaffUser(t *testing.T, db *gorm.DB, roleID []byte, firstName string, sortKey *int, isActive bool, note string) []byte {
|
||||
t.Helper()
|
||||
|
||||
userID := uuidv7.MustBytes()
|
||||
username := fmt.Sprintf("%s_user_%d", firstName, time.Now().UnixNano())
|
||||
user := &auth.User{
|
||||
ID: userID,
|
||||
Email: fmt.Sprintf("%s@example.com", username),
|
||||
Username: &username,
|
||||
FirstName: firstName,
|
||||
LastName: "Contact",
|
||||
Timezone: "UTC",
|
||||
RoleID: roleID,
|
||||
SortKey: sortKey,
|
||||
IsActive: isActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := db.Model(&auth.User{}).Where("id = ?", userID).UpdateColumn("is_active", isActive).Error; err != nil {
|
||||
t.Fatalf("sync user is_active: %v", err)
|
||||
}
|
||||
if err := db.Create(&auth.StaffProfile{
|
||||
UserID: userID,
|
||||
ShortName: firstName,
|
||||
Note: note,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create staff profile: %v", err)
|
||||
}
|
||||
|
||||
return userID
|
||||
}
|
||||
|
||||
func intPtrContactRepo(v int) *int { return &v }
|
||||
|
||||
func strPtrContactRepo(v string) *string { return &v }
|
||||
828
internal/repository/mysql/db.go
Normal file
828
internal/repository/mysql/db.go
Normal file
@@ -0,0 +1,828 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"wucher/internal/config"
|
||||
actionsignoff "wucher/internal/domain/action_signoff"
|
||||
otherperson "wucher/internal/domain/other_person"
|
||||
afterflightinspection "wucher/internal/domain/after_flight_inspection"
|
||||
airrescuechecklist "wucher/internal/domain/air_rescue_checklist"
|
||||
"wucher/internal/domain/audit"
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/domain/base"
|
||||
beforeflightinspection "wucher/internal/domain/before_flight_inspection"
|
||||
complaint "wucher/internal/domain/complaint"
|
||||
"wucher/internal/domain/dul"
|
||||
dutyroster "wucher/internal/domain/duty_roster"
|
||||
easarelease "wucher/internal/domain/easa_release"
|
||||
"wucher/internal/domain/facility"
|
||||
"wucher/internal/domain/federal_state"
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
fleethistory "wucher/internal/domain/fleet_history"
|
||||
fleetstatus "wucher/internal/domain/fleet_status"
|
||||
"wucher/internal/domain/flight"
|
||||
flightdata "wucher/internal/domain/flight_data"
|
||||
flightinspection "wucher/internal/domain/flight_inspection"
|
||||
flightinspectionfilechecklist "wucher/internal/domain/flight_inspection_file_checklist"
|
||||
flightprepcheck "wucher/internal/domain/flight_prep_check"
|
||||
fmreport "wucher/internal/domain/fm_report"
|
||||
"wucher/internal/domain/forces_present"
|
||||
"wucher/internal/domain/health_insurance_companies"
|
||||
"wucher/internal/domain/helicopter"
|
||||
helicopterfile "wucher/internal/domain/helicopter_file"
|
||||
helicopterusage "wucher/internal/domain/helicopter_usage"
|
||||
"wucher/internal/domain/hospital"
|
||||
"wucher/internal/domain/icao"
|
||||
insurancepatientdata "wucher/internal/domain/insurance_patient_data"
|
||||
"wucher/internal/domain/land"
|
||||
mastersettings "wucher/internal/domain/master_settings"
|
||||
"wucher/internal/domain/mcf"
|
||||
"wucher/internal/domain/medicine"
|
||||
"wucher/internal/domain/mission"
|
||||
"wucher/internal/domain/opc"
|
||||
"wucher/internal/domain/operation"
|
||||
patientdata "wucher/internal/domain/patient_data"
|
||||
reserveac "wucher/internal/domain/reserve_ac"
|
||||
takeover "wucher/internal/domain/takeover"
|
||||
"wucher/internal/domain/transient"
|
||||
"wucher/internal/domain/vocation"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func Connect(cfg config.MySQLConfig) (*gorm.DB, error) {
|
||||
if cfg.DSN == "" {
|
||||
return nil, errors.New("MYSQL_DSN is required")
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{
|
||||
Logger: logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: 200 * time.Millisecond,
|
||||
LogLevel: logger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
),
|
||||
NowFunc: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
sqlDB.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
probe := newSchemaCache(db)
|
||||
|
||||
if db.Dialector.Name() == "mysql" &&
|
||||
db.Migrator().HasColumn("flight_data", "hook_realeases") &&
|
||||
!db.Migrator().HasColumn("flight_data", "hook_releases") {
|
||||
if err := db.Exec("ALTER TABLE flight_data RENAME COLUMN hook_realeases TO hook_releases").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&auth.Role{},
|
||||
&auth.Permission{},
|
||||
&auth.User{},
|
||||
&auth.UserRole{},
|
||||
&auth.EmailOutboxMessage{},
|
||||
&auth.PilotProfile{},
|
||||
&auth.DoctorProfile{},
|
||||
&auth.AirRescuerProfile{},
|
||||
&auth.TechnicianProfile{},
|
||||
&auth.FlightAssistantProfile{},
|
||||
&auth.StaffProfile{},
|
||||
&auth.RolePermission{},
|
||||
&auth.UserIdentity{},
|
||||
&auth.UserMicrosoftOAuthToken{},
|
||||
&auth.UserTOTP{},
|
||||
&auth.UserEmailLoginOTP{},
|
||||
&auth.UserWebAuthnCredential{},
|
||||
&auth.PasswordResetToken{},
|
||||
&auth.SecurityPINResetToken{},
|
||||
&transient.TokenEntry{},
|
||||
&transient.QueueIdempotencyRecord{},
|
||||
&helicopter.Helicopter{},
|
||||
&helicopterusage.HelicopterUsage{},
|
||||
&flightinspection.FlightInspection{},
|
||||
&flight.Flight{},
|
||||
&flightdata.FlightData{},
|
||||
&flightdata.HESLOFlight{},
|
||||
&flightdata.HESLOSling{},
|
||||
&flightdata.LoggingSling{},
|
||||
&flightdata.HECFlight{},
|
||||
&flightdata.HECSling{},
|
||||
&flightdata.HECLoads{},
|
||||
&mission.Mission{},
|
||||
&mission.MissionFile{},
|
||||
&reserveac.ReserveAc{},
|
||||
&helicopterfile.HelicopterFile{},
|
||||
&beforeflightinspection.BeforeFlightInspection{},
|
||||
&afterflightinspection.AfterFlightInspection{},
|
||||
&fmreport.Report{},
|
||||
&fmreport.FleetHistory{},
|
||||
&flightprepcheck.FlightPrepCheck{},
|
||||
&flightinspectionfilechecklist.FlightInspectionFileChecklist{},
|
||||
&takeover.TakeoverAc{},
|
||||
&takeover.TakeoverRosterCrew{},
|
||||
&takeover.TakeoverOtherPerson{},
|
||||
&otherperson.OtherPerson{},
|
||||
&takeover.TakeoverFile{},
|
||||
&complaint.Complaint{},
|
||||
&easarelease.EASARelease{},
|
||||
&actionsignoff.ActionSignoff{},
|
||||
&mcf.MaintenanceCheckFlight{},
|
||||
&fleethistory.FleetHistory{},
|
||||
&fleetstatus.FleetStatus{},
|
||||
&fleetstatus.MaintenanceSchedule{},
|
||||
&fleetstatus.FleetStatusFile{},
|
||||
&fleetstatus.FleetStatusServiceLog{},
|
||||
&facility.Facility{},
|
||||
&filemanager.Folder{},
|
||||
&filemanager.File{},
|
||||
&filemanager.Attachment{},
|
||||
&filemanager.FileUploadIntent{},
|
||||
&filemanager.FileProcessingOutboxMessage{},
|
||||
&filemanager.FileStatusRealtimeEvent{},
|
||||
&base.BaseCategory{},
|
||||
&base.Base{},
|
||||
&base.BaseOperationalShiftTime{},
|
||||
&base.BaseContactRole{},
|
||||
&dul.DUL{},
|
||||
&medicine.MedicineGroup{},
|
||||
&medicine.MotorReaction{},
|
||||
&medicine.MedicineMotorReaction{},
|
||||
&medicine.Medicine{},
|
||||
&vocation.Vocation{},
|
||||
&opc.Opc{},
|
||||
&forces_present.ForcesPresent{},
|
||||
&hospital.Hospital{},
|
||||
&health_insurance_companies.HealthInsuranceCompany{},
|
||||
&federal_state.FederalState{},
|
||||
&icao.ICAO{},
|
||||
&land.Land{},
|
||||
&operation.HEMSOperationalData{},
|
||||
&operation.OperationalDataForcePresent{},
|
||||
&operation.OperationalFile{},
|
||||
&operation.HEMSOperationCategory{},
|
||||
&operation.HEMSOperation{},
|
||||
&mastersettings.MasterSettings{},
|
||||
&mastersettings.MasterSettingValue{},
|
||||
&dutyroster.DutyRoster{},
|
||||
&dutyroster.DutyRosterCrew{},
|
||||
&airrescuechecklist.AirRescuerChecklist{},
|
||||
&airrescuechecklist.AirRescuerChecklistItem{},
|
||||
&audit.AuditLog{},
|
||||
&insurancepatientdata.InsurancePatientData{},
|
||||
&patientdata.PatientData{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
if err := ensureBaseOperationalShiftTimeColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureBaseDefaultShiftColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureHelicopterColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureHelicopterFileAttachmentColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureActionSignoffStandaloneFlightUnique(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := backfillFacilityCategories(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// AutoMigrate won't remove columns; explicitly drop deprecated columns.
|
||||
deprecatedColumns := []struct {
|
||||
model any
|
||||
column string
|
||||
}{
|
||||
{model: &helicopter.Helicopter{}, column: "maintenance"},
|
||||
{model: &helicopter.Helicopter{}, column: "counter"},
|
||||
{model: &flightinspection.FlightInspection{}, column: "helicopter_id"},
|
||||
{model: &flightinspection.FlightInspection{}, column: "operation_id"},
|
||||
{model: "flights", column: "mission_type"},
|
||||
{model: &complaint.Complaint{}, column: "mcf_required"},
|
||||
{model: &complaint.Complaint{}, column: "mcf_decided_at"},
|
||||
{model: &complaint.Complaint{}, column: "mcf_decided_by"},
|
||||
{model: &complaint.Complaint{}, column: "mcf_completed_at"},
|
||||
{model: &complaint.Complaint{}, column: "mcf_result"},
|
||||
{model: &complaint.Complaint{}, column: "mcf_notes"},
|
||||
{model: &mcf.MaintenanceCheckFlight{}, column: "flight_inspection_id"},
|
||||
{model: &complaint.Complaint{}, column: "flight_inspection_id"},
|
||||
{model: &easarelease.EASARelease{}, column: "flight_inspection_id"},
|
||||
{model: &complaint.Complaint{}, column: "action_signed_at"},
|
||||
{model: &complaint.Complaint{}, column: "action_signed_by"},
|
||||
{model: &actionsignoff.ActionSignoff{}, column: "action_taken"},
|
||||
{model: &actionsignoff.ActionSignoff{}, column: "created_by"},
|
||||
{model: &actionsignoff.ActionSignoff{}, column: "updated_by"},
|
||||
{model: &actionsignoff.ActionSignoff{}, column: "deleted_at"},
|
||||
{model: &actionsignoff.ActionSignoff{}, column: "deleted_by"},
|
||||
// EASA maintenance program field removed from the release + its fm-report snapshot.
|
||||
{model: &easarelease.EASARelease{}, column: "easa_maintenance_program"},
|
||||
{model: &fmreport.FleetHistory{}, column: "easa_maintenance_program"},
|
||||
// medical_license_no, license_issued_at, and license_expired_at removed from doctor_profiles;
|
||||
// is_chief_physician_in_charge removed from air_rescuer_profiles.
|
||||
{model: &auth.DoctorProfile{}, column: "medical_license_no"},
|
||||
{model: &auth.DoctorProfile{}, column: "license_issued_at"},
|
||||
{model: &auth.DoctorProfile{}, column: "license_expired_at"},
|
||||
{model: &auth.AirRescuerProfile{}, column: "is_chief_physician_in_charge"},
|
||||
}
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
|
||||
deprecatedConstraints := []struct{ table, name string }{
|
||||
{"complaints", "fk_complaints_flight_inspection"},
|
||||
{"easa_releases", "fk_easa_releases_flight_inspection"},
|
||||
{"maintenance_check_flights", "fk_maintenance_check_flights_flight_inspection"},
|
||||
}
|
||||
for _, fc := range deprecatedConstraints {
|
||||
if db.Migrator().HasConstraint(fc.table, fc.name) {
|
||||
if err := db.Migrator().DropConstraint(fc.table, fc.name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range deprecatedColumns {
|
||||
if !probe.HasColumn(item.model, item.column) {
|
||||
continue
|
||||
}
|
||||
if err := db.Migrator().DropColumn(item.model, item.column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasConstraint("patient_data", "fk_patient_data_place") {
|
||||
if err := db.Migrator().DropConstraint("patient_data", "fk_patient_data_place"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if probe.HasColumn("patient_data", "place_id") {
|
||||
if err := db.Migrator().DropColumn("patient_data", "place_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if probe.HasTable("places") {
|
||||
if err := db.Migrator().DropTable("places"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Flight prep checks refactor: move from dynamic item rows to static boolean fields.
|
||||
if db.Migrator().HasConstraint(&flightprepcheck.FlightPrepCheck{}, "fk_flight_prep_checks_flight_prep_item") {
|
||||
if err := db.Migrator().DropConstraint(&flightprepcheck.FlightPrepCheck{}, "fk_flight_prep_checks_flight_prep_item"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasIndex(&flightprepcheck.FlightPrepCheck{}, "uk_flight_prep_checks_inspection_item") {
|
||||
if err := db.Migrator().DropIndex(&flightprepcheck.FlightPrepCheck{}, "uk_flight_prep_checks_inspection_item"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasIndex(&flightprepcheck.FlightPrepCheck{}, "idx_flight_prep_checks_flight_inspection_sorted") {
|
||||
if err := db.Migrator().DropIndex(&flightprepcheck.FlightPrepCheck{}, "idx_flight_prep_checks_flight_inspection_sorted"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if probe.HasColumn(&flightprepcheck.FlightPrepCheck{}, "flight_prep_item_id") {
|
||||
if err := db.Migrator().DropColumn(&flightprepcheck.FlightPrepCheck{}, "flight_prep_item_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if probe.HasColumn(&flightprepcheck.FlightPrepCheck{}, "is_done") {
|
||||
if err := db.Migrator().DropColumn(&flightprepcheck.FlightPrepCheck{}, "is_done"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if probe.HasColumn(&flightprepcheck.FlightPrepCheck{}, "note") {
|
||||
if err := db.Migrator().DropColumn(&flightprepcheck.FlightPrepCheck{}, "note"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !probe.HasColumn(&flightprepcheck.FlightPrepCheck{}, "notam_briefing") {
|
||||
if err := db.Migrator().AddColumn(&flightprepcheck.FlightPrepCheck{}, "NOTAMBriefing"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !probe.HasColumn(&flightprepcheck.FlightPrepCheck{}, "weather_briefing") {
|
||||
if err := db.Migrator().AddColumn(&flightprepcheck.FlightPrepCheck{}, "WeatherBriefing"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !probe.HasColumn(&flightprepcheck.FlightPrepCheck{}, "operational_flight_plan") {
|
||||
if err := db.Migrator().AddColumn(&flightprepcheck.FlightPrepCheck{}, "OperationalFlightPlan"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasIndex(&flightprepcheck.FlightPrepCheck{}, "uk_flight_prep_checks_inspection") {
|
||||
if err := db.Migrator().CreateIndex(&flightprepcheck.FlightPrepCheck{}, "uk_flight_prep_checks_inspection"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Rename legacy duty roster tables to neutral names.
|
||||
if probe.HasTable("hems_duty_rosters") && !probe.HasTable("duty_rosters") {
|
||||
if err := db.Exec("RENAME TABLE hems_duty_rosters TO duty_rosters").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if probe.HasTable("hems_duty_roster_crews") && !probe.HasTable("duty_roster_crews") {
|
||||
if err := db.Exec("RENAME TABLE hems_duty_roster_crews TO duty_roster_crews").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Rename legacy duty roster base column to general name.
|
||||
if probe.HasColumn(&dutyroster.DutyRoster{}, "hems_base_id") && !probe.HasColumn(&dutyroster.DutyRoster{}, "base_id") {
|
||||
if err := db.Exec("ALTER TABLE duty_rosters RENAME COLUMN hems_base_id TO base_id").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Exec("UPDATE users SET username = NULL WHERE username IS NOT NULL AND TRIM(username) = ''").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Dialector.Name() == "mysql" && probe.HasColumn(&auth.User{}, "username") {
|
||||
if err := db.Exec("ALTER TABLE users MODIFY COLUMN username varchar(100) NULL").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := backfillUserRoles(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillBaseCategories(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillMissionTypes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillPermissionRequiresPIN(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := alignFlightDataHospitalConstraints(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureFlightDataPilotNullable(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureFlightDataStatusColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureMissionTimeColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
if err := db.Exec("ALTER TABLE flights MODIFY COLUMN mission_code varchar(64) NULL").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("UPDATE flights SET mission_code = NULL WHERE mission_code IS NOT NULL AND TRIM(mission_code) = ''").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureActionSignoffStandaloneFlightUnique enforces one standalone (complaint-independent)
|
||||
// sign-off per flight via a functional unique index: the key is flight_id for standalone
|
||||
// rows (complaint_id IS NULL) and NULL for complaint-linked rows (which are deduped by
|
||||
// uidx_action_signoffs_complaint_id instead). Requires MySQL 8.0.13+ (functional index).
|
||||
func ensureActionSignoffStandaloneFlightUnique(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&actionsignoff.ActionSignoff{}) {
|
||||
return nil
|
||||
}
|
||||
if db.Migrator().HasIndex(&actionsignoff.ActionSignoff{}, "uidx_action_signoffs_standalone_flight") {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(
|
||||
"CREATE UNIQUE INDEX uidx_action_signoffs_standalone_flight " +
|
||||
"ON action_signoffs ((IF(complaint_id IS NULL, flight_id, NULL)))",
|
||||
).Error
|
||||
}
|
||||
|
||||
func ensureBaseOperationalShiftTimeColumns(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&base.BaseOperationalShiftTime{}) {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "DateStart") {
|
||||
if err := db.Exec(`ALTER TABLE base_operational_shift_times ADD COLUMN date_start DATE NULL`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "DateEnd") {
|
||||
if err := db.Exec(`ALTER TABLE base_operational_shift_times ADD COLUMN date_end DATE NULL`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasColumn("base_operational_shift_times", "operation_date") {
|
||||
if err := db.Exec(`
|
||||
UPDATE base_operational_shift_times
|
||||
SET date_start = COALESCE(date_start, operation_date),
|
||||
date_end = COALESCE(date_end, operation_date)
|
||||
WHERE deleted_at IS NULL
|
||||
`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Migrator().HasConstraint(&base.BaseOperationalShiftTime{}, "fk_base_operational_shift_times_base") {
|
||||
if err := db.Exec(`ALTER TABLE base_operational_shift_times DROP FOREIGN KEY fk_base_operational_shift_times_base`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasIndex(&base.BaseOperationalShiftTime{}, "idx_base_operational_shift_times_base_date") {
|
||||
if err := db.Migrator().DropIndex(&base.BaseOperationalShiftTime{}, "idx_base_operational_shift_times_base_date"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := db.Exec(`ALTER TABLE base_operational_shift_times DROP COLUMN operation_date`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Migrator().CreateIndex(&base.BaseOperationalShiftTime{}, "idx_base_operational_shift_times_base_date"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec(`
|
||||
ALTER TABLE base_operational_shift_times
|
||||
ADD CONSTRAINT fk_base_operational_shift_times_base
|
||||
FOREIGN KEY (base_id) REFERENCES bases(id)
|
||||
ON UPDATE CASCADE
|
||||
ON DELETE CASCADE
|
||||
`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Exec(`
|
||||
ALTER TABLE base_operational_shift_times
|
||||
MODIFY COLUMN date_start DATE NULL,
|
||||
MODIFY COLUMN date_end DATE NULL,
|
||||
MODIFY COLUMN shift_start TIME NULL,
|
||||
MODIFY COLUMN shift_end TIME NULL
|
||||
`).Error
|
||||
}
|
||||
|
||||
func ensureBaseDefaultShiftColumns(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&base.Base{}) {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.Base{}, "DefaultStartTimeType") {
|
||||
if err := db.Exec(`ALTER TABLE bases ADD COLUMN default_start_time_type VARCHAR(16) NOT NULL DEFAULT 'FIXED'`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.Base{}, "DefaultEndTimeType") {
|
||||
if err := db.Exec(`ALTER TABLE bases ADD COLUMN default_end_time_type VARCHAR(16) NOT NULL DEFAULT 'FIXED'`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Exec(`
|
||||
ALTER TABLE bases
|
||||
MODIFY COLUMN default_shift_start TIME NULL,
|
||||
MODIFY COLUMN default_shift_end TIME NULL
|
||||
`).Error
|
||||
}
|
||||
|
||||
func alignFlightDataHospitalConstraints(db *gorm.DB) error {
|
||||
if db == nil || db.Dialector.Name() != "mysql" {
|
||||
return nil
|
||||
}
|
||||
if err := alignFlightDataHospitalConstraint(db, "from_hospital_id", "FromHospital", "fk_flight_data_from_hospital"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := alignFlightDataHospitalConstraint(db, "to_hospital_id", "ToHospital", "fk_flight_data_to_hospital"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func alignFlightDataHospitalConstraint(db *gorm.DB, column, association, constraintName string) error {
|
||||
type foreignKeyInfo struct {
|
||||
ConstraintName string `gorm:"column:constraint_name"`
|
||||
ReferencedTableName string `gorm:"column:referenced_table_name"`
|
||||
}
|
||||
|
||||
var info foreignKeyInfo
|
||||
err := db.Table("information_schema.key_column_usage").
|
||||
Select("constraint_name, referenced_table_name").
|
||||
Where("table_schema = DATABASE() AND table_name = ? AND column_name = ? AND referenced_table_name IS NOT NULL", "flight_data", column).
|
||||
Order("ordinal_position ASC").
|
||||
Limit(1).
|
||||
Scan(&info).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.ReferencedTableName == "no_icao_codes" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if info.ConstraintName != "" {
|
||||
if err := db.Exec("ALTER TABLE `flight_data` DROP FOREIGN KEY `" + info.ConstraintName + "`").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return db.Migrator().CreateConstraint(&flightdata.FlightData{}, association)
|
||||
}
|
||||
|
||||
func ensureFlightDataPilotNullable(db *gorm.DB) error {
|
||||
if db == nil || db.Dialector.Name() != "mysql" {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasTable(&flightdata.FlightData{}) {
|
||||
return nil
|
||||
}
|
||||
type columnInfo struct {
|
||||
IsNullable string `gorm:"column:is_nullable"`
|
||||
}
|
||||
var info columnInfo
|
||||
if err := db.Table("information_schema.columns").
|
||||
Select("is_nullable").
|
||||
Where("table_schema = DATABASE() AND table_name = ? AND column_name = ?", "flight_data", "co_pilot_id").
|
||||
Limit(1).
|
||||
Scan(&info).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Column missing (e.g. not yet created by migrations) or already nullable: nothing to do.
|
||||
if strings.TrimSpace(info.IsNullable) == "" || strings.EqualFold(strings.TrimSpace(info.IsNullable), "YES") {
|
||||
return nil
|
||||
}
|
||||
return db.Exec("ALTER TABLE flight_data MODIFY COLUMN co_pilot_id binary(16) NULL").Error
|
||||
}
|
||||
|
||||
func ensureFlightDataStatusColumn(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasTable(&flightdata.FlightData{}) {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasColumn(&flightdata.FlightData{}, "status") {
|
||||
addColumnSQL := "ALTER TABLE flight_data ADD COLUMN status varchar(20) NOT NULL DEFAULT 'in_progress'"
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
addColumnSQL = "ALTER TABLE flight_data ADD COLUMN status varchar(20) NOT NULL DEFAULT 'in_progress' AFTER id"
|
||||
}
|
||||
if err := db.Exec(addColumnSQL).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := db.Exec("UPDATE flight_data SET status = 'in_progress' WHERE status IS NULL OR TRIM(status) = '' OR status IN ('on_going', 'on_progress')").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec("UPDATE flight_data SET status = 'completed' WHERE status = 'complete'").Error
|
||||
}
|
||||
|
||||
func ensureMissionTimeColumns(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasTable(&mission.Mission{}) {
|
||||
return nil
|
||||
}
|
||||
if db.Dialector.Name() != "mysql" {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(`
|
||||
ALTER TABLE missions
|
||||
MODIFY COLUMN start_time TIME NULL,
|
||||
MODIFY COLUMN end_time TIME NULL
|
||||
`).Error
|
||||
}
|
||||
|
||||
func ensureHelicopterColumns(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&helicopter.Helicopter{}) {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasColumn(&helicopter.Helicopter{}, "DeletedAt") {
|
||||
if err := db.Exec(`ALTER TABLE helicopters ADD COLUMN deleted_at DATETIME(6) NULL AFTER updated_at`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureHelicopterFileAttachmentColumns(db *gorm.DB) error {
|
||||
if db == nil || db.Dialector.Name() != "mysql" {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasTable(&helicopterfile.HelicopterFile{}) {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasColumn(&helicopterfile.HelicopterFile{}, "SourceFileID") {
|
||||
if err := db.Exec("ALTER TABLE helicopter_files ADD COLUMN source_file_id binary(16) NULL AFTER file_attachment_id").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Exec("ALTER TABLE helicopter_files MODIFY COLUMN file_attachment_id binary(16) NULL").Error
|
||||
}
|
||||
|
||||
func backfillBaseCategories(db *gorm.DB) error {
|
||||
now := time.Now().UTC()
|
||||
seeds := []struct {
|
||||
key string
|
||||
name string
|
||||
}{
|
||||
{key: base.CategoryKeyRegular, name: "Regular"},
|
||||
{key: base.CategoryKeyHEMS, name: "HEMS"},
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
if err := db.Exec(
|
||||
`INSERT INTO base_categories (id, `+"`key`"+`, name, created_at, updated_at)
|
||||
SELECT ?, ?, ?, ?, ?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM base_categories WHERE `+"`key`"+` = ?
|
||||
)`,
|
||||
uuidv7.MustBytes(),
|
||||
seed.key,
|
||||
seed.name,
|
||||
now,
|
||||
now,
|
||||
seed.key,
|
||||
).Error; err != nil {
|
||||
return fmt.Errorf("seed base category %q: %w", seed.key, err)
|
||||
}
|
||||
}
|
||||
|
||||
var regular struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
}
|
||||
if err := db.Table("base_categories").
|
||||
Select("id").
|
||||
Where("`key` = ?", base.CategoryKeyRegular).
|
||||
Take(®ular).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Exec(
|
||||
"UPDATE bases SET base_category_id = ? WHERE base_category_id IS NULL OR LENGTH(base_category_id) = 0",
|
||||
regular.ID,
|
||||
).Error
|
||||
}
|
||||
|
||||
func backfillFacilityCategories(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
mappings := map[string]string{
|
||||
"fuel-truck": "heslo-rope-label",
|
||||
"heslo-sling": "heslo-rope-length",
|
||||
"logging-sling": "heslo-hook",
|
||||
"hec-sling": "hec-rope-label",
|
||||
"hems-hec-sling": "hec-rope-length",
|
||||
}
|
||||
|
||||
for oldValue, newValue := range mappings {
|
||||
if err := db.Exec(
|
||||
"UPDATE facilities SET category = ? WHERE LOWER(TRIM(category)) = ?",
|
||||
newValue,
|
||||
oldValue,
|
||||
).Error; err != nil {
|
||||
return fmt.Errorf("backfill facility category %q -> %q: %w", oldValue, newValue, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillUserRoles(db *gorm.DB) error {
|
||||
var users []struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
RoleID []byte `gorm:"column:role_id"`
|
||||
}
|
||||
if err := db.Table("users").
|
||||
Select("id, role_id").
|
||||
Where("role_id IS NOT NULL").
|
||||
Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := make([]auth.UserRole, 0, len(users))
|
||||
for i := range users {
|
||||
if len(users[i].ID) != 16 || len(users[i].RoleID) != 16 {
|
||||
continue
|
||||
}
|
||||
items = append(items, auth.UserRole{
|
||||
UserID: append([]byte(nil), users[i].ID...),
|
||||
RoleID: append([]byte(nil), users[i].RoleID...),
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "role_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(&items).Error
|
||||
}
|
||||
|
||||
func backfillPermissionRequiresPIN(db *gorm.DB) error {
|
||||
// Intentionally no-op.
|
||||
// Do not override existing permissions.requires_pin values on startup.
|
||||
// Source of truth for requires_pin is the database row itself.
|
||||
_ = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillMissionTypes(db *gorm.DB) error {
|
||||
categories := []struct {
|
||||
key string
|
||||
name string
|
||||
id []byte
|
||||
}{
|
||||
{key: "HEMS", name: "HEMS", id: uuidv7.MustBytes()},
|
||||
{key: "CAT", name: "CAT", id: uuidv7.MustBytes()},
|
||||
{key: "SPO", name: "SPO", id: uuidv7.MustBytes()},
|
||||
{key: "NCO", name: "NCO", id: uuidv7.MustBytes()},
|
||||
}
|
||||
|
||||
for i := range categories {
|
||||
if err := db.Exec(
|
||||
`INSERT INTO mission_categories (id, code_type, name)
|
||||
SELECT ?, ?, ?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM mission_categories WHERE code_type = ?
|
||||
)`,
|
||||
categories[i].id,
|
||||
categories[i].key,
|
||||
categories[i].name,
|
||||
categories[i].key,
|
||||
).Error; err != nil {
|
||||
return fmt.Errorf("seed mission category %q: %w", categories[i].key, err)
|
||||
}
|
||||
}
|
||||
|
||||
var hems struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
}
|
||||
if err := db.Table("mission_categories").
|
||||
Select("id").
|
||||
Where("code_type = ?", "HEMS").
|
||||
Take(&hems).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subtypes := []struct {
|
||||
code string
|
||||
name string
|
||||
taskName string
|
||||
id []byte
|
||||
}{
|
||||
{code: "PRIM", name: "Primary mission", taskName: "Primary mission task", id: uuidv7.MustBytes()},
|
||||
{code: "SECONDARY", name: "Secondary mission", taskName: "Secondary mission task", id: uuidv7.MustBytes()},
|
||||
}
|
||||
for i := range subtypes {
|
||||
if err := db.Exec(
|
||||
`INSERT INTO mission_subcategories (id, mission_category_id, sub_type, name, task_name)
|
||||
SELECT ?, ?, ?, ?, ?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM mission_subcategories WHERE mission_category_id = ? AND sub_type = ?
|
||||
)`,
|
||||
subtypes[i].id,
|
||||
hems.ID,
|
||||
subtypes[i].code,
|
||||
subtypes[i].name,
|
||||
subtypes[i].taskName,
|
||||
hems.ID,
|
||||
subtypes[i].code,
|
||||
).Error; err != nil {
|
||||
return fmt.Errorf("seed mission subtype %q: %w", subtypes[i].code, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
332
internal/repository/mysql/db_test.go
Normal file
332
internal/repository/mysql/db_test.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/domain/base"
|
||||
facility "wucher/internal/domain/facility"
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
flightdata "wucher/internal/domain/flight_data"
|
||||
"wucher/internal/domain/helicopter"
|
||||
"wucher/internal/domain/mission"
|
||||
"wucher/internal/domain/transient"
|
||||
)
|
||||
|
||||
func findMySQLDSNForTest() (string, bool) {
|
||||
if v := strings.TrimSpace(os.Getenv("MYSQL_DSN")); v != "" {
|
||||
return v, true
|
||||
}
|
||||
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
raw, err := os.ReadFile(envPath)
|
||||
if err == nil {
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "MYSQL_DSN=") {
|
||||
v := strings.TrimSpace(strings.TrimPrefix(line, "MYSQL_DSN="))
|
||||
v = strings.Trim(v, `"'`)
|
||||
if v != "" {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func openDBTestSQLite(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:db_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestConnect(t *testing.T) {
|
||||
t.Run("missing dsn", func(t *testing.T) {
|
||||
if _, err := Connect(config.MySQLConfig{}); err == nil {
|
||||
t.Fatalf("expected missing dsn error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid dsn", func(t *testing.T) {
|
||||
_, err := Connect(config.MySQLConfig{
|
||||
DSN: "root:pass@tcp(127.0.0.1:1)/wucher?loc=%",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected connect error for invalid DSN")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
dsn, ok := findMySQLDSNForTest()
|
||||
if !ok {
|
||||
t.Skip("MYSQL_DSN not found in env or .env")
|
||||
}
|
||||
|
||||
cfg := config.MySQLConfig{
|
||||
DSN: dsn,
|
||||
MaxOpenConns: 7,
|
||||
MaxIdleConns: 5,
|
||||
ConnMaxLifetime: 2 * time.Minute,
|
||||
ConnMaxIdleTime: time.Minute,
|
||||
}
|
||||
db, err := Connect(cfg)
|
||||
if err != nil {
|
||||
t.Skipf("skip connect success branch (mysql unavailable): %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB error: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if err := sqlDB.PingContext(context.Background()); err != nil {
|
||||
t.Fatalf("ping mysql: %v", err)
|
||||
}
|
||||
if got := sqlDB.Stats().MaxOpenConnections; got != cfg.MaxOpenConns {
|
||||
t.Fatalf("unexpected max open conns, got=%d want=%d", got, cfg.MaxOpenConns)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAutoMigrate(t *testing.T) {
|
||||
t.Run("success and base category backfill", func(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
|
||||
if err := db.Exec(`CREATE TABLE helicopters (
|
||||
id BLOB PRIMARY KEY,
|
||||
designation TEXT,
|
||||
identifier TEXT,
|
||||
report_sequence INTEGER DEFAULT 0,
|
||||
type TEXT,
|
||||
counter INTEGER DEFAULT 0,
|
||||
maintenance TEXT
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create legacy helicopters table: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE bases (
|
||||
id BLOB PRIMARY KEY,
|
||||
base TEXT
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create legacy bases table: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO bases(id, base) VALUES (X'00112233445566778899aabbccddeeff', 'Legacy Base')`).Error; err != nil {
|
||||
t.Fatalf("seed legacy bases row: %v", err)
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
if db.Migrator().HasColumn(&helicopter.Helicopter{}, "maintenance") {
|
||||
t.Fatalf("expected deprecated helicopters.maintenance dropped")
|
||||
}
|
||||
if db.Migrator().HasColumn(&helicopter.Helicopter{}, "counter") {
|
||||
t.Fatalf("expected deprecated helicopters.counter dropped")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&helicopter.Helicopter{}, "foto_attachment_id") {
|
||||
t.Fatalf("expected helicopters.foto_attachment_id migrated")
|
||||
}
|
||||
if !db.Migrator().HasTable(&base.BaseCategory{}) {
|
||||
t.Fatalf("expected base_categories table migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.Base{}, "base_category_id") {
|
||||
t.Fatalf("expected bases.base_category_id migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.Base{}, "latitude") {
|
||||
t.Fatalf("expected bases.latitude migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.Base{}, "longitude") {
|
||||
t.Fatalf("expected bases.longitude migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.Base{}, "default_shift_start") {
|
||||
t.Fatalf("expected bases.default_shift_start migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.Base{}, "default_shift_end") {
|
||||
t.Fatalf("expected bases.default_shift_end migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.Base{}, "foto_attachment_id") {
|
||||
t.Fatalf("expected bases.foto_attachment_id migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.Base{}, "deleted_at") {
|
||||
t.Fatalf("expected bases.deleted_at migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.Base{}, "deleted_by") {
|
||||
t.Fatalf("expected bases.deleted_by migrated")
|
||||
}
|
||||
if !db.Migrator().HasTable(&base.BaseOperationalShiftTime{}) {
|
||||
t.Fatalf("expected base_operational_shift_times table migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "base_id") {
|
||||
t.Fatalf("expected base_operational_shift_times.base_id migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "date_start") {
|
||||
t.Fatalf("expected base_operational_shift_times.date_start migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "date_end") {
|
||||
t.Fatalf("expected base_operational_shift_times.date_end migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "start_time_type") {
|
||||
t.Fatalf("expected base_operational_shift_times.start_time_type migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "end_time_type") {
|
||||
t.Fatalf("expected base_operational_shift_times.end_time_type migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&mission.Mission{}, "start_time") {
|
||||
t.Fatalf("expected missions.start_time migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&mission.Mission{}, "end_time") {
|
||||
t.Fatalf("expected missions.end_time migrated")
|
||||
}
|
||||
var missingCategory int64
|
||||
if err := db.Table("bases").
|
||||
Where("base_category_id IS NULL OR LENGTH(base_category_id) = 0").
|
||||
Count(&missingCategory).Error; err != nil {
|
||||
t.Fatalf("count missing base category: %v", err)
|
||||
}
|
||||
if missingCategory != 0 {
|
||||
t.Fatalf("expected all bases to have category, missing=%d", missingCategory)
|
||||
}
|
||||
var seededCategories int64
|
||||
if err := db.Table("base_categories").
|
||||
Where("`key` IN (?, ?)", base.CategoryKeyRegular, base.CategoryKeyHEMS).
|
||||
Count(&seededCategories).Error; err != nil {
|
||||
t.Fatalf("count seeded categories: %v", err)
|
||||
}
|
||||
if seededCategories != 2 {
|
||||
t.Fatalf("expected regular+hems seeded categories, got=%d", seededCategories)
|
||||
}
|
||||
if !db.Migrator().HasTable(&auth.User{}) {
|
||||
t.Fatalf("expected core tables migrated")
|
||||
}
|
||||
if !db.Migrator().HasTable(&auth.UserRole{}) {
|
||||
t.Fatalf("expected user_roles table migrated")
|
||||
}
|
||||
if !db.Migrator().HasTable(&transient.TokenEntry{}) || !db.Migrator().HasTable(&transient.QueueIdempotencyRecord{}) {
|
||||
t.Fatalf("expected transient runtime tables migrated")
|
||||
}
|
||||
if !db.Migrator().HasTable(&filemanager.Folder{}) || !db.Migrator().HasTable(&filemanager.File{}) {
|
||||
t.Fatalf("expected file manager tables migrated")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&auth.Permission{}, "requires_pin") {
|
||||
t.Fatalf("expected permissions.requires_pin migrated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("facility category backfill", func(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := db.AutoMigrate(&facility.Facility{}); err != nil {
|
||||
t.Fatalf("auto migrate facilities: %v", err)
|
||||
}
|
||||
for _, category := range []string{
|
||||
"fuel-truck",
|
||||
"heslo-sling",
|
||||
"logging-sling",
|
||||
"hec-sling",
|
||||
"hems-hec-sling",
|
||||
} {
|
||||
if err := db.Create(&facility.Facility{Category: category, Name: category, Type: "Sling"}).Error; err != nil {
|
||||
t.Fatalf("seed facility category %q: %v", category, err)
|
||||
}
|
||||
}
|
||||
if err := backfillFacilityCategories(db); err != nil {
|
||||
t.Fatalf("backfillFacilityCategories: %v", err)
|
||||
}
|
||||
expected := []string{
|
||||
"heslo-rope-label",
|
||||
"heslo-rope-length",
|
||||
"heslo-hook",
|
||||
"hec-rope-label",
|
||||
"hec-rope-length",
|
||||
}
|
||||
for _, category := range expected {
|
||||
var count int64
|
||||
if err := db.Table("facilities").Where("category = ?", category).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count category %q: %v", category, err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected one row for category %q, got %d", category, count)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auto migrate error when db closed", func(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB: %v", err)
|
||||
}
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if err := AutoMigrate(db); err == nil {
|
||||
t.Fatalf("expected auto migrate error on closed DB")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("drop deprecated maintenance from legacy composite key schema", func(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
|
||||
// Keep maintenance as part of PK to force drop-column failure on SQLite.
|
||||
if err := db.Exec(`CREATE TABLE helicopters (
|
||||
id BLOB NOT NULL,
|
||||
maintenance TEXT NOT NULL,
|
||||
designation TEXT,
|
||||
identifier TEXT,
|
||||
type TEXT,
|
||||
report_sequence INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (id, maintenance)
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create legacy helicopters table: %v", err)
|
||||
}
|
||||
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("unexpected auto migrate error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ensure flight data status column on legacy table", func(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := db.Exec(`CREATE TABLE flight_data (
|
||||
id BLOB PRIMARY KEY
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create legacy flight_data table: %v", err)
|
||||
}
|
||||
if err := ensureFlightDataStatusColumn(db); err != nil {
|
||||
t.Fatalf("ensure status column: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasColumn(&flightdata.FlightData{}, "status") {
|
||||
t.Fatal("expected flight_data.status column to exist")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
106
internal/repository/mysql/delete_guard.go
Normal file
106
internal/repository/mysql/delete_guard.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/apperrors"
|
||||
)
|
||||
|
||||
type fkReferenceRow struct {
|
||||
TableName string `gorm:"column:table_name"`
|
||||
ColumnName string `gorm:"column:column_name"`
|
||||
}
|
||||
|
||||
var mysqlDeleteRefTableRegexp = regexp.MustCompile("fails \\(`[^`]+`\\.`([^`]+)`")
|
||||
|
||||
func ensureNoReferenceBeforeDelete(ctx context.Context, db *gorm.DB, tableName string, id []byte) error {
|
||||
if db == nil || db.Dialector == nil || db.Dialector.Name() != "mysql" {
|
||||
return nil
|
||||
}
|
||||
if len(id) != 16 || strings.TrimSpace(tableName) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
refs := make([]fkReferenceRow, 0)
|
||||
err := db.WithContext(ctx).Raw(`
|
||||
SELECT kcu.TABLE_NAME AS table_name, kcu.COLUMN_NAME AS column_name
|
||||
FROM information_schema.KEY_COLUMN_USAGE kcu
|
||||
WHERE kcu.TABLE_SCHEMA = DATABASE()
|
||||
AND kcu.REFERENCED_TABLE_SCHEMA = DATABASE()
|
||||
AND kcu.REFERENCED_TABLE_NAME = ?
|
||||
AND kcu.REFERENCED_COLUMN_NAME = 'id'
|
||||
`, tableName).Scan(&refs).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
related := make([]string, 0)
|
||||
for i := range refs {
|
||||
query := fmt.Sprintf("SELECT 1 FROM `%s` WHERE `%s` = ? LIMIT 1", refs[i].TableName, refs[i].ColumnName)
|
||||
var one int
|
||||
rowErr := db.WithContext(ctx).Raw(query, id).Scan(&one).Error
|
||||
if rowErr != nil {
|
||||
return rowErr
|
||||
}
|
||||
if one == 1 {
|
||||
related = append(related, moduleNameFromTable(refs[i].TableName))
|
||||
}
|
||||
}
|
||||
|
||||
if len(related) > 0 {
|
||||
return apperrors.NewDeleteConflictError(related...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapDeleteConstraintError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if apperrors.IsDeleteConflict(err) {
|
||||
return err
|
||||
}
|
||||
var mysqlErr *mysqldriver.MySQLError
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "foreign key") && !strings.Contains(strings.ToLower(err.Error()), "referenced") && !strings.Contains(strings.ToLower(err.Error()), "constraint") && !strings.Contains(strings.ToLower(err.Error()), "delete") {
|
||||
return err
|
||||
}
|
||||
if !asMySQLError(err, &mysqlErr) || mysqlErr.Number != 1451 {
|
||||
return err
|
||||
}
|
||||
if matches := mysqlDeleteRefTableRegexp.FindStringSubmatch(mysqlErr.Message); len(matches) > 1 {
|
||||
return apperrors.NewDeleteConflictError(moduleNameFromTable(matches[1]))
|
||||
}
|
||||
return apperrors.NewDeleteConflictError()
|
||||
}
|
||||
|
||||
func asMySQLError(err error, target **mysqldriver.MySQLError) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return errors.As(err, target)
|
||||
}
|
||||
|
||||
func moduleNameFromTable(tableName string) string {
|
||||
tableName = strings.TrimSpace(tableName)
|
||||
if tableName == "" {
|
||||
return "another module"
|
||||
}
|
||||
switch tableName {
|
||||
case "takeover_acs":
|
||||
return "takeover"
|
||||
case "base_operational_shift_times":
|
||||
return "base operational shift times"
|
||||
case "base_contact_roles":
|
||||
return "base contact roles"
|
||||
case "duls":
|
||||
return "dul"
|
||||
}
|
||||
return strings.ReplaceAll(tableName, "_", " ")
|
||||
}
|
||||
22
internal/repository/mysql/delete_guard_test.go
Normal file
22
internal/repository/mysql/delete_guard_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package mysql
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestModuleNameFromTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
table string
|
||||
want string
|
||||
}{
|
||||
{table: "takeover_acs", want: "takeover"},
|
||||
{table: "base_operational_shift_times", want: "base operational shift times"},
|
||||
{table: "base_contact_roles", want: "base contact roles"},
|
||||
{table: "duls", want: "dul"},
|
||||
{table: "takeover_other_people", want: "takeover other people"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := moduleNameFromTable(tc.table); got != tc.want {
|
||||
t.Fatalf("moduleNameFromTable(%q) = %q, want %q", tc.table, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
145
internal/repository/mysql/dul_repo.go
Normal file
145
internal/repository/mysql/dul_repo.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"wucher/internal/domain/dul"
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type DULRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDULRepository(db *gorm.DB) *DULRepository {
|
||||
return &DULRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *DULRepository) WithTransaction(ctx context.Context, fn func(repo dul.Repository) error) error {
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return fn(&DULRepository{db: tx})
|
||||
})
|
||||
}
|
||||
|
||||
func (r *DULRepository) Create(ctx context.Context, row *dul.DUL) error {
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
row.CreatedBy = actor
|
||||
row.UpdatedBy = actor
|
||||
}
|
||||
return r.db.WithContext(ctx).Omit(clause.Associations).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *DULRepository) Update(ctx context.Context, row *dul.DUL) error {
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
row.UpdatedBy = actor
|
||||
}
|
||||
return r.db.WithContext(ctx).Omit(clause.Associations).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *DULRepository) Delete(ctx context.Context, id []byte) error {
|
||||
updates := map[string]any{"deleted_at": time.Now().UTC()}
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
updates["deleted_by"] = actor
|
||||
updates["updated_by"] = actor
|
||||
}
|
||||
return r.db.WithContext(ctx).Model(&dul.DUL{}).Where("id = ? AND deleted_at IS NULL", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *DULRepository) GetByID(ctx context.Context, id []byte) (*dul.DUL, error) {
|
||||
var row dul.DUL
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Base").
|
||||
Preload("Base.BaseCategory").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = r.loadImages(ctx, []*dul.DUL{&row})
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *DULRepository) List(ctx context.Context, filter, sort string, limit, offset int, baseIDs [][]byte) ([]dul.DUL, int64, error) {
|
||||
var rows []dul.DUL
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&dul.DUL{}).Where("deleted_at IS NULL")
|
||||
if len(baseIDs) > 0 {
|
||||
query = query.Where("base_id IN ?", baseIDs)
|
||||
}
|
||||
if s := strings.TrimSpace(filter); s != "" {
|
||||
like := "%" + s + "%"
|
||||
query = query.Where("name LIKE ? OR info LIKE ?", like, like)
|
||||
}
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if strings.TrimSpace(sort) != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
query = query.Order("base_id ASC").Order("no ASC")
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Preload("Base").Preload("Base.BaseCategory").Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ptrs := make([]*dul.DUL, 0, len(rows))
|
||||
for i := range rows {
|
||||
ptrs = append(ptrs, &rows[i])
|
||||
}
|
||||
_ = r.loadImages(ctx, ptrs)
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *DULRepository) loadImages(ctx context.Context, rows []*dul.DUL) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
refIDs := make([]string, 0, len(rows))
|
||||
byRef := map[string]*dul.DUL{}
|
||||
for _, row := range rows {
|
||||
if row == nil || len(row.ID) != 16 {
|
||||
continue
|
||||
}
|
||||
refID, _ := uuidv7.BytesToString(row.ID)
|
||||
refIDs = append(refIDs, refID)
|
||||
byRef[refID] = row
|
||||
row.Images = nil
|
||||
}
|
||||
if len(refIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var attachments []filemanager.Attachment
|
||||
if err := r.db.WithContext(ctx).
|
||||
Preload("File").
|
||||
Where("ref_type = ? AND ref_id IN ?", "dul", refIDs).
|
||||
Order("sort_order ASC, created_at ASC").
|
||||
Find(&attachments).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range attachments {
|
||||
att := attachments[i]
|
||||
row := byRef[att.RefID]
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
clone := att
|
||||
row.Images = append(row.Images, &clone)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
631
internal/repository/mysql/duty_roster_repo.go
Normal file
631
internal/repository/mysql/duty_roster_repo.go
Normal file
@@ -0,0 +1,631 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
basedomain "wucher/internal/domain/base"
|
||||
dutyroster "wucher/internal/domain/duty_roster"
|
||||
)
|
||||
|
||||
type DutyRosterRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDutyRosterRepository(db *gorm.DB) *DutyRosterRepository {
|
||||
return &DutyRosterRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) InTx(ctx context.Context, fn func(txRepo dutyroster.TxRepository) error) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return fn(&DutyRosterRepository{db: tx})
|
||||
})
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) GetRosterByID(ctx context.Context, id []byte) (*dutyroster.DutyRoster, error) {
|
||||
row := &dutyroster.DutyRoster{}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Take(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) LockRosterByID(ctx context.Context, id []byte) (*dutyroster.DutyRoster, error) {
|
||||
row := &dutyroster.DutyRoster{}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Take(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) CreateHeader(ctx context.Context, row *dutyroster.DutyRoster) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) UpdateHeader(ctx context.Context, row *dutyroster.DutyRoster) error {
|
||||
updates := map[string]any{
|
||||
"base_id": row.BaseID,
|
||||
"duty_date": row.DutyDate,
|
||||
"updated_by": row.UpdatedBy,
|
||||
}
|
||||
res := r.db.WithContext(ctx).Model(&dutyroster.DutyRoster{}).
|
||||
Where("id = ? AND deleted_at IS NULL", row.ID).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) SoftDeleteHeader(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
res := r.db.WithContext(ctx).Model(&dutyroster.DutyRoster{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) SoftDeleteCrewsByRosterID(ctx context.Context, rosterID []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
return r.db.WithContext(ctx).Model(&dutyroster.DutyRosterCrew{}).
|
||||
Where("roster_id = ? AND deleted_at IS NULL", rosterID).
|
||||
Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy}).Error
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) HardDeleteHeader(ctx context.Context, id []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("id = ?", id).
|
||||
Delete(&dutyroster.DutyRoster{}).Error
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) HardDeleteCrewsByRosterID(ctx context.Context, rosterID []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("roster_id = ?", rosterID).
|
||||
Delete(&dutyroster.DutyRosterCrew{}).Error
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) ListActiveCrewsByRosterID(ctx context.Context, rosterID []byte) ([]dutyroster.DutyRosterCrew, error) {
|
||||
rows := make([]dutyroster.DutyRosterCrew, 0)
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("roster_id = ? AND deleted_at IS NULL", rosterID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) InsertCrew(ctx context.Context, row *dutyroster.DutyRosterCrew) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) CrewExists(ctx context.Context, row dutyroster.DutyRosterCrew) (bool, error) {
|
||||
return crewExists(r.db.WithContext(ctx), row)
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) SoftDeleteCrewByUser(ctx context.Context, rosterID []byte, roleCode, crewType string, userID, updatedBy []byte) error {
|
||||
return softReplaceCrewByUser(r.db.WithContext(ctx), rosterID, roleCode, crewType, userID, updatedBy)
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) FindRosterIDByBaseDate(ctx context.Context, baseID []byte, dutyDate time.Time) ([]byte, error) {
|
||||
date := dateOnlyUTC(dutyDate)
|
||||
var row dutyroster.DutyRoster
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("base_id = ? AND duty_date = ? AND deleted_at IS NULL", baseID, date).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) FindRosterIDsByBaseDate(ctx context.Context, baseID []byte, dutyDate time.Time) ([][]byte, error) {
|
||||
return findRosterIDsByBaseAndDate(r.db.WithContext(ctx), baseID, dutyDate)
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) MatchCrewByFilter(ctx context.Context, rosterIDs [][]byte, crew dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) {
|
||||
return matchCrewByFilter(r.db.WithContext(ctx), rosterIDs, crew)
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) SoftDeleteCrewRows(ctx context.Context, rows []dutyroster.DeletedCrewResult, deletedBy []byte) error {
|
||||
return softDeleteCrewByIDs(r.db.WithContext(ctx), rows, time.Now().UTC(), deletedBy)
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) SoftDeleteCrewForDate(ctx context.Context, baseID []byte, dutyDate time.Time, crew dutyroster.DutyRosterCrew, updatedBy []byte) error {
|
||||
return softDeleteCrewForDate(r.db.WithContext(ctx), baseID, dutyDate, crew, time.Now().UTC(), updatedBy)
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) GetBaseDefaultShift(ctx context.Context, baseID []byte, baseType string) (string, error) {
|
||||
hasDefaultShiftTypes := r.hasBaseDefaultShiftTypeColumns()
|
||||
q := r.db.WithContext(ctx).
|
||||
Table("bases b").
|
||||
Where("b.id = ?", baseID)
|
||||
if hasDefaultShiftTypes {
|
||||
q = q.Select("b.default_start_time_type, b.default_end_time_type, b.default_shift_start, b.default_shift_end")
|
||||
} else {
|
||||
q = q.Select("b.default_shift_start, b.default_shift_end")
|
||||
}
|
||||
if strings.TrimSpace(baseType) != "" {
|
||||
q = q.Joins("JOIN base_categories bc ON bc.id = b.base_category_id").
|
||||
Where("bc.key = ?", rosterBaseCategoryKey(baseType))
|
||||
}
|
||||
var out struct {
|
||||
DefaultStartTimeType string
|
||||
DefaultEndTimeType string
|
||||
DefaultShiftStart string
|
||||
DefaultShiftEnd string
|
||||
}
|
||||
err := q.Take(&out).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
start := ""
|
||||
end := ""
|
||||
if !hasDefaultShiftTypes || normalizeBaseShiftTimeType(out.DefaultStartTimeType) == basedomain.ShiftTimeTypeFixed {
|
||||
start = strings.TrimSpace(out.DefaultShiftStart)
|
||||
}
|
||||
if !hasDefaultShiftTypes || normalizeBaseShiftTimeType(out.DefaultEndTimeType) == basedomain.ShiftTimeTypeFixed {
|
||||
end = strings.TrimSpace(out.DefaultShiftEnd)
|
||||
}
|
||||
if start != "" || end != "" {
|
||||
switch {
|
||||
case start != "" && end != "":
|
||||
return start + "-" + end, nil
|
||||
case start != "":
|
||||
return start, nil
|
||||
default:
|
||||
return end, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) GetHeaderByID(ctx context.Context, id []byte) (*dutyroster.HeaderView, error) {
|
||||
return r.GetHeaderByIDByBaseType(ctx, id, "hems")
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) GetHeaderByIDByBaseType(ctx context.Context, id []byte, baseType string) (*dutyroster.HeaderView, error) {
|
||||
categoryKey := rosterBaseCategoryKey(baseType)
|
||||
out := &dutyroster.HeaderView{}
|
||||
defaultShiftStartExpr := r.baseDefaultShiftDisplayExpr("hb", "default_shift_start", "default_start_time_type")
|
||||
defaultShiftEndExpr := r.baseDefaultShiftDisplayExpr("hb", "default_shift_end", "default_end_time_type")
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("duty_rosters hdr").
|
||||
Select(`
|
||||
hdr.id,
|
||||
hdr.base_id AS base_id,
|
||||
hb.base AS base_name,
|
||||
hb.base_abbreviation,
|
||||
`+defaultShiftStartExpr+` AS default_shift_start,
|
||||
`+defaultShiftEndExpr+` AS default_shift_end,
|
||||
'' AS helicopter_id,
|
||||
'' AS helicopter_type,
|
||||
'' AS helicopter_designation,
|
||||
'' AS helicopter_identifier,
|
||||
hdr.duty_date,
|
||||
TIME_FORMAT(hdr.shift_start, '%H:%i:%s') AS shift_start,
|
||||
TIME_FORMAT(hdr.shift_end, '%H:%i:%s') AS shift_end,
|
||||
hdr.created_at,
|
||||
hdr.created_by,
|
||||
hdr.updated_at`).
|
||||
Joins("LEFT JOIN bases hb ON hb.id = hdr.base_id").
|
||||
Joins("LEFT JOIN base_categories bc ON bc.id = hb.base_category_id").
|
||||
Where("hdr.id = ? AND hdr.deleted_at IS NULL", id)
|
||||
err := query.Where("bc.key = ?", categoryKey).
|
||||
Where("hb.id IS NOT NULL").
|
||||
Take(out).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || len(out.ID) == 0 {
|
||||
out = &dutyroster.HeaderView{}
|
||||
err = r.db.WithContext(ctx).
|
||||
Table("duty_rosters hdr").
|
||||
Select(`
|
||||
hdr.id,
|
||||
hdr.base_id AS base_id,
|
||||
hb.base AS base_name,
|
||||
hb.base_abbreviation,
|
||||
`+defaultShiftStartExpr+` AS default_shift_start,
|
||||
`+defaultShiftEndExpr+` AS default_shift_end,
|
||||
'' AS helicopter_id,
|
||||
'' AS helicopter_type,
|
||||
'' AS helicopter_designation,
|
||||
'' AS helicopter_identifier,
|
||||
hdr.duty_date,
|
||||
TIME_FORMAT(hdr.shift_start, '%H:%i:%s') AS shift_start,
|
||||
TIME_FORMAT(hdr.shift_end, '%H:%i:%s') AS shift_end,
|
||||
hdr.created_at,
|
||||
hdr.created_by,
|
||||
hdr.updated_at`).
|
||||
Joins("LEFT JOIN bases hb ON hb.id = hdr.base_id").
|
||||
Where("hdr.id = ? AND hdr.deleted_at IS NULL", id).
|
||||
Where("hb.id IS NOT NULL").
|
||||
Take(out).Error
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) hasBaseDefaultShiftTypeColumns() bool {
|
||||
if r == nil || r.db == nil {
|
||||
return false
|
||||
}
|
||||
return r.db.Migrator().HasColumn("bases", "default_start_time_type") &&
|
||||
r.db.Migrator().HasColumn("bases", "default_end_time_type")
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) baseDefaultShiftDisplayExpr(alias, shiftColumn, typeColumn string) string {
|
||||
qualifiedShift := alias + "." + shiftColumn
|
||||
if !r.hasBaseDefaultShiftTypeColumns() {
|
||||
return "CASE WHEN " + qualifiedShift + " IS NULL THEN '' ELSE TIME_FORMAT(" + qualifiedShift + ", '%H:%i:%s') END"
|
||||
}
|
||||
qualifiedType := alias + "." + typeColumn
|
||||
return "CASE WHEN COALESCE(" + qualifiedType + ", 'FIXED') <> 'FIXED' OR " + qualifiedShift + " IS NULL THEN '' ELSE TIME_FORMAT(" + qualifiedShift + ", '%H:%i:%s') END"
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) GetCrewsByRosterIDs(ctx context.Context, rosterIDs [][]byte) ([]dutyroster.CrewView, error) {
|
||||
if len(rosterIDs) == 0 {
|
||||
return []dutyroster.CrewView{}, nil
|
||||
}
|
||||
rows := make([]dutyroster.CrewView, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("duty_roster_crews c").
|
||||
Select(`
|
||||
c.id,
|
||||
c.roster_id,
|
||||
c.user_id,
|
||||
COALESCE(NULLIF(c.name_label, ''), TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))), '') AS name,
|
||||
COALESCE(NULLIF(r.name, ''), c.role_code) AS role,
|
||||
c.role_code,
|
||||
c.crew_type,
|
||||
COALESCE(NULLIF(c.mobile_phone, ''), u.mobile_phone, '') AS mobile_phone,
|
||||
COALESCE(NULLIF(c.email, ''), u.email, '') AS email,
|
||||
c.date_start,
|
||||
c.date_end,
|
||||
CASE
|
||||
WHEN c.shift_start IS NULL OR TIME_FORMAT(c.shift_start, '%H:%i:%s') = '00:00:00' THEN ''
|
||||
ELSE TIME_FORMAT(c.shift_start, '%H:%i:%s')
|
||||
END AS shift_start,
|
||||
CASE
|
||||
WHEN c.shift_end IS NULL OR TIME_FORMAT(c.shift_end, '%H:%i:%s') = '00:00:00' THEN ''
|
||||
ELSE TIME_FORMAT(c.shift_end, '%H:%i:%s')
|
||||
END AS shift_end,
|
||||
c.flight_instructor,
|
||||
c.line_checker,
|
||||
c.supervisor,
|
||||
c.examiner,
|
||||
c.co_pilot`).
|
||||
Joins("LEFT JOIN users u ON u.id = c.user_id").
|
||||
Joins("LEFT JOIN roles r ON r.id = u.role_id").
|
||||
Where("c.roster_id IN ? AND c.deleted_at IS NULL", rosterIDs).
|
||||
Order("c.role_code ASC, c.crew_type ASC, c.created_at ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) ListHeadersByRange(ctx context.Context, baseID []byte, from, to time.Time) ([]dutyroster.HeaderView, error) {
|
||||
return r.ListHeadersByRangeByBaseType(ctx, baseID, from, to, "hems")
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) ListHeadersByRangeByBaseType(ctx context.Context, baseID []byte, from, to time.Time, baseType string) ([]dutyroster.HeaderView, error) {
|
||||
categoryKey := rosterBaseCategoryKey(baseType)
|
||||
rows := make([]dutyroster.HeaderView, 0)
|
||||
q := r.db.WithContext(ctx).
|
||||
Table("duty_rosters hdr").
|
||||
Select(`
|
||||
hdr.id,
|
||||
hdr.base_id AS base_id,
|
||||
hb.base AS base_name,
|
||||
hb.base_abbreviation,
|
||||
'' AS helicopter_id,
|
||||
'' AS helicopter_type,
|
||||
'' AS helicopter_designation,
|
||||
'' AS helicopter_identifier,
|
||||
hdr.duty_date,
|
||||
TIME_FORMAT(hdr.shift_start, '%H:%i:%s') AS shift_start,
|
||||
TIME_FORMAT(hdr.shift_end, '%H:%i:%s') AS shift_end,
|
||||
hdr.created_at,
|
||||
hdr.created_by,
|
||||
hdr.updated_at`).
|
||||
Joins("LEFT JOIN bases hb ON hb.id = hdr.base_id").
|
||||
Joins("LEFT JOIN base_categories bc ON bc.id = hb.base_category_id").
|
||||
Where("hdr.deleted_at IS NULL AND hdr.duty_date BETWEEN ? AND ?", from, to).
|
||||
Where("bc.key = ?", categoryKey).
|
||||
Where("hb.id IS NOT NULL")
|
||||
if len(baseID) == 16 {
|
||||
q = q.Where("hdr.base_id = ?", baseID)
|
||||
}
|
||||
err := q.Order("hb.base ASC, hdr.duty_date ASC").Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) ListHEMSBases(ctx context.Context, baseID []byte) ([]dutyroster.BaseView, error) {
|
||||
return r.ListBasesByType(ctx, baseID, "hems")
|
||||
}
|
||||
|
||||
func (r *DutyRosterRepository) ListBasesByType(ctx context.Context, baseID []byte, baseType string) ([]dutyroster.BaseView, error) {
|
||||
categoryKey := rosterBaseCategoryKey(baseType)
|
||||
rows := make([]dutyroster.BaseView, 0)
|
||||
q := r.db.WithContext(ctx).
|
||||
Table("bases b").
|
||||
Select(`
|
||||
b.id,
|
||||
b.base AS base_name,
|
||||
b.base_abbreviation`).
|
||||
Joins("JOIN base_categories bc ON bc.id = b.base_category_id").
|
||||
Where("b.is_active = ?", true).
|
||||
Where("bc.key = ?", categoryKey)
|
||||
|
||||
if len(baseID) == 16 {
|
||||
q = q.Where("b.id = ?", baseID)
|
||||
}
|
||||
|
||||
if err := q.Order("CASE WHEN b.sortkey IS NULL THEN 1 ELSE 0 END ASC, b.sortkey ASC, b.base ASC").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func rosterBaseCategoryKey(baseType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(baseType)) {
|
||||
case "base":
|
||||
return basedomain.CategoryKeyRegular
|
||||
default:
|
||||
return basedomain.CategoryKeyHEMS
|
||||
}
|
||||
}
|
||||
|
||||
func dateOnlyUTC(t time.Time) time.Time {
|
||||
return time.Date(t.UTC().Year(), t.UTC().Month(), t.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func softReplaceCrewByUser(tx *gorm.DB, rosterID []byte, roleCode, crewType string, userID []byte, updatedBy []byte) error {
|
||||
if len(rosterID) == 0 || len(userID) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return tx.Model(&dutyroster.DutyRosterCrew{}).
|
||||
Where("roster_id = ? AND role_code = ? AND crew_type = ? AND user_id = ? AND deleted_at IS NULL", rosterID, roleCode, crewType, userID).
|
||||
Updates(map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": updatedBy,
|
||||
"updated_by": updatedBy,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func crewExists(tx *gorm.DB, row dutyroster.DutyRosterCrew) (bool, error) {
|
||||
q := tx.Model(&dutyroster.DutyRosterCrew{}).
|
||||
Where("roster_id = ? AND role_code = ? AND crew_type = ? AND deleted_at IS NULL", row.RosterID, row.RoleCode, row.CrewType).
|
||||
Where("name_label = ? AND mobile_phone = ? AND email = ?", row.NameLabel, row.MobilePhone, row.Email).
|
||||
Where("shift_start = ? AND shift_end = ?", row.ShiftStart, row.ShiftEnd)
|
||||
|
||||
if len(row.UserID) == 0 {
|
||||
q = q.Where("user_id IS NULL")
|
||||
} else {
|
||||
q = q.Where("user_id = ?", row.UserID)
|
||||
}
|
||||
if row.DateStart == nil {
|
||||
q = q.Where("date_start IS NULL")
|
||||
} else {
|
||||
q = q.Where("date_start = ?", dateOnlyUTC(*row.DateStart))
|
||||
}
|
||||
if row.DateEnd == nil {
|
||||
q = q.Where("date_end IS NULL")
|
||||
} else {
|
||||
q = q.Where("date_end = ?", dateOnlyUTC(*row.DateEnd))
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func softDeleteCrewForDate(
|
||||
tx *gorm.DB,
|
||||
baseID []byte,
|
||||
dutyDate time.Time,
|
||||
crew dutyroster.DutyRosterCrew,
|
||||
now time.Time,
|
||||
updatedBy []byte,
|
||||
) error {
|
||||
date := dateOnlyUTC(dutyDate)
|
||||
var rosterIDs [][]byte
|
||||
if err := tx.
|
||||
Table("duty_rosters").
|
||||
Select("id").
|
||||
Where("base_id = ? AND duty_date = ? AND deleted_at IS NULL", baseID, date).
|
||||
Scan(&rosterIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rosterIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
q := tx.Model(&dutyroster.DutyRosterCrew{}).
|
||||
Where("roster_id IN ? AND role_code = ? AND crew_type = ? AND deleted_at IS NULL", rosterIDs, crew.RoleCode, crew.CrewType).
|
||||
Where("name_label = ? AND mobile_phone = ? AND email = ? AND shift_start = ? AND shift_end = ?",
|
||||
crew.NameLabel,
|
||||
crew.MobilePhone,
|
||||
crew.Email,
|
||||
crew.ShiftStart,
|
||||
crew.ShiftEnd,
|
||||
)
|
||||
if len(crew.UserID) == 0 {
|
||||
q = q.Where("user_id IS NULL")
|
||||
} else {
|
||||
q = q.Where("user_id = ?", crew.UserID)
|
||||
}
|
||||
if crew.DateStart == nil {
|
||||
q = q.Where("date_start IS NULL")
|
||||
} else {
|
||||
q = q.Where("date_start = ?", dateOnlyUTC(*crew.DateStart))
|
||||
}
|
||||
if crew.DateEnd == nil {
|
||||
q = q.Where("date_end IS NULL")
|
||||
} else {
|
||||
q = q.Where("date_end = ?", dateOnlyUTC(*crew.DateEnd))
|
||||
}
|
||||
|
||||
return q.Updates(map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": updatedBy,
|
||||
"updated_by": updatedBy,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func findRosterIDsByBaseAndDate(tx *gorm.DB, baseID []byte, dutyDate time.Time) ([][]byte, error) {
|
||||
type rowID struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
}
|
||||
date := dateOnlyUTC(dutyDate)
|
||||
rows := make([]rowID, 0)
|
||||
if err := tx.
|
||||
Table("duty_rosters").
|
||||
Select("id").
|
||||
Where("base_id = ? AND duty_date = ? AND deleted_at IS NULL", baseID, date).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([][]byte, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, rows[i].ID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func softDeleteCrewByFilter(
|
||||
tx *gorm.DB,
|
||||
rosterIDs [][]byte,
|
||||
crew dutyroster.DutyRosterCrew,
|
||||
now time.Time,
|
||||
updatedBy []byte,
|
||||
) error {
|
||||
if len(rosterIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
q := tx.Model(&dutyroster.DutyRosterCrew{}).
|
||||
Table("duty_roster_crews c").
|
||||
Where("c.roster_id IN ? AND c.role_code = ? AND c.deleted_at IS NULL", rosterIDs, crew.RoleCode)
|
||||
|
||||
if strings.TrimSpace(crew.CrewType) != "" {
|
||||
q = q.Where("c.crew_type = ?", crew.CrewType)
|
||||
}
|
||||
if len(crew.UserID) > 0 {
|
||||
q = q.Where("c.user_id = ?", crew.UserID)
|
||||
} else if strings.TrimSpace(crew.NameLabel) != "" {
|
||||
q = q.Where("c.name_label = ?", crew.NameLabel)
|
||||
}
|
||||
if crew.DateStart != nil {
|
||||
q = q.Where("c.date_start = ?", dateOnlyUTC(*crew.DateStart))
|
||||
}
|
||||
if crew.DateEnd != nil {
|
||||
q = q.Where("c.date_end = ?", dateOnlyUTC(*crew.DateEnd))
|
||||
}
|
||||
if strings.TrimSpace(crew.ShiftStart) != "" {
|
||||
q = q.Where("c.shift_start = ?", crew.ShiftStart)
|
||||
}
|
||||
if strings.TrimSpace(crew.ShiftEnd) != "" {
|
||||
q = q.Where("c.shift_end = ?", crew.ShiftEnd)
|
||||
}
|
||||
|
||||
return q.Updates(map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": updatedBy,
|
||||
"updated_by": updatedBy,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func matchCrewByFilter(tx *gorm.DB, rosterIDs [][]byte, crew dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) {
|
||||
if len(rosterIDs) == 0 {
|
||||
return []dutyroster.DeletedCrewResult{}, nil
|
||||
}
|
||||
q := tx.Model(&dutyroster.DutyRosterCrew{}).
|
||||
Select(`
|
||||
c.id,
|
||||
c.roster_id,
|
||||
c.user_id,
|
||||
COALESCE(NULLIF(c.name_label, ''), TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))), '') AS name,
|
||||
c.role_code,
|
||||
c.crew_type,
|
||||
c.date_start,
|
||||
c.date_end`).
|
||||
Table("duty_roster_crews c").
|
||||
Joins("LEFT JOIN users u ON u.id = c.user_id").
|
||||
Where("roster_id IN ? AND role_code = ? AND deleted_at IS NULL", rosterIDs, crew.RoleCode)
|
||||
|
||||
if strings.TrimSpace(crew.CrewType) != "" {
|
||||
q = q.Where("crew_type = ?", crew.CrewType)
|
||||
}
|
||||
if len(crew.UserID) > 0 {
|
||||
q = q.Where("user_id = ?", crew.UserID)
|
||||
} else if strings.TrimSpace(crew.NameLabel) != "" {
|
||||
q = q.Where("name_label = ?", crew.NameLabel)
|
||||
}
|
||||
if crew.DateStart != nil {
|
||||
q = q.Where("date_start = ?", dateOnlyUTC(*crew.DateStart))
|
||||
}
|
||||
if crew.DateEnd != nil {
|
||||
q = q.Where("date_end = ?", dateOnlyUTC(*crew.DateEnd))
|
||||
}
|
||||
if strings.TrimSpace(crew.ShiftStart) != "" {
|
||||
q = q.Where("shift_start = ?", crew.ShiftStart)
|
||||
}
|
||||
if strings.TrimSpace(crew.ShiftEnd) != "" {
|
||||
q = q.Where("shift_end = ?", crew.ShiftEnd)
|
||||
}
|
||||
|
||||
out := make([]dutyroster.DeletedCrewResult, 0)
|
||||
if err := q.Scan(&out).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func softDeleteCrewByIDs(tx *gorm.DB, rows []dutyroster.DeletedCrewResult, now time.Time, updatedBy []byte) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([][]byte, 0, len(rows))
|
||||
for i := range rows {
|
||||
ids = append(ids, rows[i].ID)
|
||||
}
|
||||
return tx.Model(&dutyroster.DutyRosterCrew{}).
|
||||
Where("id IN ? AND deleted_at IS NULL", ids).
|
||||
Updates(map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": updatedBy,
|
||||
"updated_by": updatedBy,
|
||||
}).Error
|
||||
}
|
||||
749
internal/repository/mysql/duty_roster_repo_test.go
Normal file
749
internal/repository/mysql/duty_roster_repo_test.go
Normal file
@@ -0,0 +1,749 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
dutyroster "wucher/internal/domain/duty_roster"
|
||||
"wucher/internal/service"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openDutyRosterRepoTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:duty_roster_repo_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
createRoster := `
|
||||
CREATE TABLE duty_rosters (
|
||||
id BLOB PRIMARY KEY,
|
||||
flight_id BLOB NULL,
|
||||
base_id BLOB NOT NULL,
|
||||
duty_date DATETIME NOT NULL,
|
||||
shift_start DATETIME NULL,
|
||||
shift_end DATETIME NULL,
|
||||
helicopter_id BLOB NULL,
|
||||
created_at DATETIME NULL,
|
||||
created_by BLOB NULL,
|
||||
updated_at DATETIME NULL,
|
||||
updated_by BLOB NULL,
|
||||
deleted_at DATETIME NULL,
|
||||
deleted_by BLOB NULL
|
||||
);`
|
||||
if err := db.Exec(createRoster).Error; err != nil {
|
||||
t.Fatalf("create duty_rosters: %v", err)
|
||||
}
|
||||
if err := db.Exec("CREATE UNIQUE INDEX idx_roster_base_date ON duty_rosters(base_id, duty_date)").Error; err != nil {
|
||||
t.Fatalf("create idx_roster_base_date: %v", err)
|
||||
}
|
||||
|
||||
createCrew := `
|
||||
CREATE TABLE duty_roster_crews (
|
||||
id BLOB PRIMARY KEY,
|
||||
roster_id BLOB NOT NULL,
|
||||
takeover_ac_id BLOB NULL,
|
||||
user_id BLOB NULL,
|
||||
role_code TEXT NOT NULL,
|
||||
crew_type TEXT NOT NULL,
|
||||
name_label TEXT NULL,
|
||||
mobile_phone TEXT NULL,
|
||||
email TEXT NULL,
|
||||
confirm_at DATETIME NULL,
|
||||
date_start DATE NULL,
|
||||
date_end DATE NULL,
|
||||
shift_start DATETIME NULL,
|
||||
shift_end DATETIME NULL,
|
||||
flight_instructor INTEGER NOT NULL DEFAULT 0,
|
||||
line_checker INTEGER NOT NULL DEFAULT 0,
|
||||
supervisor INTEGER NOT NULL DEFAULT 0,
|
||||
examiner INTEGER NOT NULL DEFAULT 0,
|
||||
co_pilot INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NULL,
|
||||
created_by BLOB NULL,
|
||||
updated_at DATETIME NULL,
|
||||
updated_by BLOB NULL,
|
||||
deleted_at DATETIME NULL,
|
||||
deleted_by BLOB NULL
|
||||
);`
|
||||
if err := db.Exec(createCrew).Error; err != nil {
|
||||
t.Fatalf("create duty_roster_crews: %v", err)
|
||||
}
|
||||
registerSQLiteTimeFormatCompat(t, db)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func registerSQLiteTimeFormatCompat(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB: %v", err)
|
||||
}
|
||||
conn, err := sqlDB.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("sqlDB.Conn: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.Raw(func(driverConn any) error {
|
||||
raw, ok := driverConn.(*sqlite3.SQLiteConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected sqlite conn type %T", driverConn)
|
||||
}
|
||||
return raw.RegisterFunc("TIME_FORMAT", func(v any, pattern string) string {
|
||||
if pattern != "%H:%i:%s" {
|
||||
return ""
|
||||
}
|
||||
var rawVal string
|
||||
switch x := v.(type) {
|
||||
case time.Time:
|
||||
return x.UTC().Format("15:04:05")
|
||||
case []byte:
|
||||
rawVal = string(x)
|
||||
case string:
|
||||
rawVal = x
|
||||
default:
|
||||
rawVal = fmt.Sprint(x)
|
||||
}
|
||||
rawVal = strings.TrimSpace(rawVal)
|
||||
if rawVal == "" {
|
||||
return ""
|
||||
}
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02 15:04:05",
|
||||
time.RFC3339,
|
||||
"15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if tm, err := time.Parse(layout, rawVal); err == nil {
|
||||
return tm.UTC().Format("15:04:05")
|
||||
}
|
||||
}
|
||||
if len(rawVal) >= 8 {
|
||||
return rawVal[len(rawVal)-8:]
|
||||
}
|
||||
return ""
|
||||
}, true)
|
||||
}); err != nil {
|
||||
t.Fatalf("register TIME_FORMAT: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func dutyRosterTestCategoryIDs() (regularID, hemsID []byte) {
|
||||
return []byte("catregular000001"), []byte("cathems000000001")
|
||||
}
|
||||
|
||||
func TestDutyRosterRepositoryUpdate_ReplaceSameUserAppendDifferentUser(t *testing.T) {
|
||||
db := openDutyRosterRepoTestDB(t)
|
||||
repo := NewDutyRosterRepository(db)
|
||||
svc := service.NewDutyRosterService(repo)
|
||||
ctx := context.Background()
|
||||
baseID := uuidv7.MustBytes()
|
||||
actor := uuidv7.MustBytes()
|
||||
userMain := uuidv7.MustBytes()
|
||||
userAdditional := uuidv7.MustBytes()
|
||||
date := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
row := &dutyroster.DutyRoster{
|
||||
BaseID: baseID,
|
||||
DutyDate: date,
|
||||
CreatedBy: actor,
|
||||
UpdatedBy: actor,
|
||||
}
|
||||
if err := svc.Create(ctx, row, nil); err != nil {
|
||||
t.Fatalf("seed create roster: %v", err)
|
||||
}
|
||||
if err := db.Create(&dutyroster.DutyRosterCrew{
|
||||
RosterID: row.ID,
|
||||
UserID: userMain,
|
||||
RoleCode: "pilot",
|
||||
CrewType: "main",
|
||||
CreatedBy: actor,
|
||||
UpdatedBy: actor,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed old crew: %v", err)
|
||||
}
|
||||
|
||||
updateRow := &dutyroster.DutyRoster{
|
||||
ID: row.ID,
|
||||
BaseID: baseID,
|
||||
DutyDate: date,
|
||||
UpdatedBy: actor,
|
||||
}
|
||||
updateCrews := []dutyroster.DutyRosterCrew{
|
||||
{
|
||||
UserID: userMain,
|
||||
RoleCode: "pilot",
|
||||
CrewType: "main",
|
||||
},
|
||||
{
|
||||
UserID: userAdditional,
|
||||
RoleCode: "pilot",
|
||||
CrewType: "additional",
|
||||
},
|
||||
}
|
||||
if err := svc.Update(ctx, updateRow, updateCrews); err != nil {
|
||||
t.Fatalf("update roster: %v", err)
|
||||
}
|
||||
|
||||
var activeMain []dutyroster.DutyRosterCrew
|
||||
if err := db.Where("roster_id = ? AND user_id = ? AND role_code = 'pilot' AND crew_type = 'main' AND deleted_at IS NULL", row.ID, userMain).
|
||||
Find(&activeMain).Error; err != nil {
|
||||
t.Fatalf("query active main: %v", err)
|
||||
}
|
||||
if len(activeMain) != 1 {
|
||||
t.Fatalf("expected 1 active main crew, got %d", len(activeMain))
|
||||
}
|
||||
|
||||
var historicalMain int64
|
||||
if err := db.Model(&dutyroster.DutyRosterCrew{}).
|
||||
Where("roster_id = ? AND user_id = ? AND role_code = 'pilot' AND crew_type = 'main'", row.ID, userMain).
|
||||
Count(&historicalMain).Error; err != nil {
|
||||
t.Fatalf("count main history: %v", err)
|
||||
}
|
||||
if historicalMain != 2 {
|
||||
t.Fatalf("expected 2 historical main rows (old deleted + new active), got %d", historicalMain)
|
||||
}
|
||||
|
||||
var activeAdditional int64
|
||||
if err := db.Model(&dutyroster.DutyRosterCrew{}).
|
||||
Where("roster_id = ? AND user_id = ? AND role_code = 'pilot' AND crew_type = 'additional' AND deleted_at IS NULL", row.ID, userAdditional).
|
||||
Count(&activeAdditional).Error; err != nil {
|
||||
t.Fatalf("count active additional: %v", err)
|
||||
}
|
||||
if activeAdditional != 1 {
|
||||
t.Fatalf("expected appended additional crew, got %d", activeAdditional)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDutyRosterRepositoryUpdate_RangeShrinkRemovesOutOfRangeAssignments(t *testing.T) {
|
||||
db := openDutyRosterRepoTestDB(t)
|
||||
repo := NewDutyRosterRepository(db)
|
||||
svc := service.NewDutyRosterService(repo)
|
||||
ctx := context.Background()
|
||||
baseID := uuidv7.MustBytes()
|
||||
actor := uuidv7.MustBytes()
|
||||
userID := uuidv7.MustBytes()
|
||||
d20 := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
|
||||
d21 := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC)
|
||||
d22 := time.Date(2026, 3, 22, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
row20 := &dutyroster.DutyRoster{BaseID: baseID, DutyDate: d20, CreatedBy: actor, UpdatedBy: actor}
|
||||
row21 := &dutyroster.DutyRoster{BaseID: baseID, DutyDate: d21, CreatedBy: actor, UpdatedBy: actor}
|
||||
row22 := &dutyroster.DutyRoster{BaseID: baseID, DutyDate: d22, CreatedBy: actor, UpdatedBy: actor}
|
||||
if err := svc.Create(ctx, row20, nil); err != nil {
|
||||
t.Fatalf("create roster d20: %v", err)
|
||||
}
|
||||
if err := svc.Create(ctx, row21, nil); err != nil {
|
||||
t.Fatalf("create roster d21: %v", err)
|
||||
}
|
||||
if err := svc.Create(ctx, row22, nil); err != nil {
|
||||
t.Fatalf("create roster d22: %v", err)
|
||||
}
|
||||
|
||||
startOld := d20
|
||||
endOld := d22
|
||||
seedCrew := func(rosterID []byte) {
|
||||
if err := db.Create(&dutyroster.DutyRosterCrew{
|
||||
RosterID: rosterID,
|
||||
UserID: userID,
|
||||
RoleCode: "pilot",
|
||||
CrewType: "main",
|
||||
DateStart: &startOld,
|
||||
DateEnd: &endOld,
|
||||
CreatedBy: actor,
|
||||
UpdatedBy: actor,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed crew: %v", err)
|
||||
}
|
||||
}
|
||||
seedCrew(row20.ID)
|
||||
seedCrew(row21.ID)
|
||||
seedCrew(row22.ID)
|
||||
|
||||
startNew := d20
|
||||
endNew := d21
|
||||
updateRow := &dutyroster.DutyRoster{
|
||||
ID: row20.ID,
|
||||
BaseID: baseID,
|
||||
DutyDate: d20,
|
||||
UpdatedBy: actor,
|
||||
}
|
||||
updateCrews := []dutyroster.DutyRosterCrew{
|
||||
{
|
||||
UserID: userID,
|
||||
RoleCode: "pilot",
|
||||
CrewType: "main",
|
||||
DateStart: &startNew,
|
||||
DateEnd: &endNew,
|
||||
},
|
||||
}
|
||||
if err := svc.Update(ctx, updateRow, updateCrews); err != nil {
|
||||
t.Fatalf("update range shrink: %v", err)
|
||||
}
|
||||
|
||||
var active22 int64
|
||||
if err := db.Model(&dutyroster.DutyRosterCrew{}).
|
||||
Where("roster_id = ? AND user_id = ? AND role_code = 'pilot' AND crew_type = 'main' AND deleted_at IS NULL", row22.ID, userID).
|
||||
Count(&active22).Error; err != nil {
|
||||
t.Fatalf("count active d22: %v", err)
|
||||
}
|
||||
if active22 != 1 {
|
||||
t.Fatalf("expected active assignment to remain on 2026-03-22 after shrink, got %d", active22)
|
||||
}
|
||||
|
||||
var active21 int64
|
||||
if err := db.Model(&dutyroster.DutyRosterCrew{}).
|
||||
Where("roster_id = ? AND user_id = ? AND role_code = 'pilot' AND crew_type = 'main' AND deleted_at IS NULL", row21.ID, userID).
|
||||
Count(&active21).Error; err != nil {
|
||||
t.Fatalf("count active d21: %v", err)
|
||||
}
|
||||
if active21 != 1 {
|
||||
t.Fatalf("expected active assignment remains on 2026-03-21, got %d", active21)
|
||||
}
|
||||
}
|
||||
|
||||
func openDutyRosterRepoCoverageDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:duty_roster_repo_cov_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
stmts := []string{
|
||||
`CREATE TABLE duty_rosters (
|
||||
id BLOB PRIMARY KEY,
|
||||
base_id BLOB NOT NULL,
|
||||
duty_date DATETIME NOT NULL,
|
||||
shift_start DATETIME NULL,
|
||||
shift_end DATETIME NULL,
|
||||
helicopter_id BLOB NULL,
|
||||
created_at DATETIME NULL,
|
||||
created_by BLOB NULL,
|
||||
updated_at DATETIME NULL,
|
||||
updated_by BLOB NULL,
|
||||
deleted_at DATETIME NULL,
|
||||
deleted_by BLOB NULL
|
||||
);`,
|
||||
`CREATE UNIQUE INDEX idx_roster_base_date ON duty_rosters(base_id, duty_date);`,
|
||||
`CREATE TABLE duty_roster_crews (
|
||||
id BLOB PRIMARY KEY,
|
||||
roster_id BLOB NOT NULL,
|
||||
takeover_ac_id BLOB NULL,
|
||||
user_id BLOB NULL,
|
||||
role_code TEXT NOT NULL,
|
||||
crew_type TEXT NOT NULL,
|
||||
name_label TEXT NULL,
|
||||
mobile_phone TEXT NULL,
|
||||
email TEXT NULL,
|
||||
confirm_at DATETIME NULL,
|
||||
date_start DATE NULL,
|
||||
date_end DATE NULL,
|
||||
shift_start DATETIME NULL,
|
||||
shift_end DATETIME NULL,
|
||||
flight_instructor INTEGER NOT NULL DEFAULT 0,
|
||||
line_checker INTEGER NOT NULL DEFAULT 0,
|
||||
supervisor INTEGER NOT NULL DEFAULT 0,
|
||||
examiner INTEGER NOT NULL DEFAULT 0,
|
||||
co_pilot INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NULL,
|
||||
created_by BLOB NULL,
|
||||
updated_at DATETIME NULL,
|
||||
updated_by BLOB NULL,
|
||||
deleted_at DATETIME NULL,
|
||||
deleted_by BLOB NULL
|
||||
);`,
|
||||
"CREATE TABLE base_categories (\n\t\t\tid BLOB PRIMARY KEY,\n\t\t\t`key` TEXT UNIQUE,\n\t\t\tname TEXT\n\t\t);",
|
||||
`CREATE TABLE bases (
|
||||
id BLOB PRIMARY KEY,
|
||||
base_category_id BLOB NOT NULL,
|
||||
base TEXT,
|
||||
base_abbreviation TEXT,
|
||||
sortkey INTEGER NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
default_shift_start TEXT NULL,
|
||||
default_shift_end TEXT NULL,
|
||||
default_shift_time TEXT
|
||||
);`,
|
||||
`CREATE TABLE helicopters (
|
||||
id BLOB PRIMARY KEY,
|
||||
identifier TEXT,
|
||||
designation TEXT
|
||||
);`,
|
||||
`CREATE TABLE roles (
|
||||
id BLOB PRIMARY KEY,
|
||||
name TEXT
|
||||
);`,
|
||||
`CREATE TABLE users (
|
||||
id BLOB PRIMARY KEY,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
mobile_phone TEXT,
|
||||
email TEXT,
|
||||
role_id BLOB NULL
|
||||
);`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
t.Fatalf("create stmt failed: %v\nsql=%s", err, stmt)
|
||||
}
|
||||
}
|
||||
regularCategoryID, hemsCategoryID := dutyRosterTestCategoryIDs()
|
||||
asBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(
|
||||
"INSERT INTO base_categories(id, `key`, name) VALUES(%s, 'regular', 'Regular'),(%s, 'hems', 'HEMS')",
|
||||
asBlob(regularCategoryID),
|
||||
asBlob(hemsCategoryID),
|
||||
),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed base_categories: %v", err)
|
||||
}
|
||||
registerSQLiteTimeFormatCompat(t, db)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDutyRosterRepository_ReadListAndDeleteFlows(t *testing.T) {
|
||||
db := openDutyRosterRepoCoverageDB(t)
|
||||
repo := NewDutyRosterRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
hemsBaseID := []byte("hemsbase00000001")
|
||||
baseID := []byte("basebase00000001")
|
||||
regularCategoryID, hemsCategoryID := dutyRosterTestCategoryIDs()
|
||||
heliID := []byte("heliheli00000001")
|
||||
roleID := []byte("rolerole00000001")
|
||||
userID := []byte("useruser00000001")
|
||||
rosterID := []byte("rosterro00000001")
|
||||
crewID := []byte("crewcrew00000001")
|
||||
now := time.Now().UTC()
|
||||
dutyDate := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
mustExec := func(sql string, args ...any) {
|
||||
if err := db.Exec(sql, args...).Error; err != nil {
|
||||
t.Fatalf("exec failed: %v, sql=%s", err, sql)
|
||||
}
|
||||
}
|
||||
asBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
mustExec(fmt.Sprintf(`INSERT INTO bases(id, base_category_id, base, base_abbreviation, sortkey, is_active, default_shift_time) VALUES(%s, %s, ?, ?, 1, 1, ?)`, asBlob(hemsBaseID), asBlob(hemsCategoryID)),
|
||||
"HEMS A", "HA", "06:00-21:00")
|
||||
mustExec(fmt.Sprintf(`INSERT INTO bases(id, base_category_id, base, base_abbreviation, sortkey, is_active, default_shift_time) VALUES(%s, %s, ?, ?, 1, 1, ?)`, asBlob(baseID), asBlob(regularCategoryID)),
|
||||
"BASE A", "BA", "07:00-19:00")
|
||||
mustExec(fmt.Sprintf(`INSERT INTO helicopters(id, identifier, designation) VALUES(%s, ?, ?)`, asBlob(heliID)), "H145", "H145")
|
||||
mustExec(fmt.Sprintf(`INSERT INTO roles(id, name) VALUES(%s, ?)`, asBlob(roleID)), "pilot")
|
||||
mustExec(fmt.Sprintf(`INSERT INTO users(id, first_name, last_name, mobile_phone, email, role_id) VALUES(%s, ?, ?, ?, ?, %s)`, asBlob(userID), asBlob(roleID)),
|
||||
"Pilot", "One", "+62000", "pilot@example.com")
|
||||
mustExec(fmt.Sprintf(`INSERT INTO duty_rosters(id, base_id, duty_date, shift_start, shift_end, helicopter_id, created_at, updated_at) VALUES(%s, %s, ?, ?, ?, %s, ?, ?)`,
|
||||
asBlob(rosterID), asBlob(hemsBaseID), asBlob(heliID)),
|
||||
dutyDate, "2000-01-01 06:00:00", "2000-01-01 21:00:00", now, now)
|
||||
mustExec(fmt.Sprintf(`INSERT INTO duty_roster_crews(id, roster_id, user_id, role_code, crew_type, name_label, mobile_phone, email, date_start, date_end, shift_start, shift_end, created_at, updated_at)
|
||||
VALUES(%s, %s, %s, 'pilot', 'main', '', '', '', ?, ?, ?, ?, ?, ?)`, asBlob(crewID), asBlob(rosterID), asBlob(userID)),
|
||||
dutyDate, dutyDate, "2000-01-01 06:00:00", "2000-01-01 21:00:00", now, now)
|
||||
|
||||
if _, err := repo.GetRosterByID(ctx, rosterID); err != nil {
|
||||
t.Fatalf("GetRosterByID: %v", err)
|
||||
}
|
||||
if _, err := repo.LockRosterByID(ctx, rosterID); err != nil {
|
||||
t.Fatalf("LockRosterByID: %v", err)
|
||||
}
|
||||
if _, err := repo.ListActiveCrewsByRosterID(ctx, rosterID); err != nil {
|
||||
t.Fatalf("ListActiveCrewsByRosterID: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.FindRosterIDsByBaseDate(ctx, hemsBaseID, dutyDate); err != nil {
|
||||
t.Fatalf("FindRosterIDsByBaseDate: %v", err)
|
||||
}
|
||||
rows, err := repo.MatchCrewByFilter(ctx, [][]byte{rosterID}, dutyroster.DutyRosterCrew{
|
||||
RoleCode: "pilot",
|
||||
CrewType: "main",
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MatchCrewByFilter: %v", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
t.Fatalf("expected matched rows")
|
||||
}
|
||||
if err := repo.SoftDeleteCrewRows(ctx, rows, uuidv7.MustBytes()); err != nil {
|
||||
t.Fatalf("SoftDeleteCrewRows: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.GetBaseDefaultShift(ctx, hemsBaseID, "hems"); err != nil {
|
||||
t.Fatalf("GetBaseDefaultShift hems: %v", err)
|
||||
}
|
||||
if _, err := repo.GetBaseDefaultShift(ctx, baseID, "base"); err != nil {
|
||||
t.Fatalf("GetBaseDefaultShift base: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.GetHeaderByID(ctx, rosterID); err != nil {
|
||||
t.Fatalf("GetHeaderByID: %v", err)
|
||||
}
|
||||
if _, err := repo.GetHeaderByIDByBaseType(ctx, rosterID, "base"); err != nil {
|
||||
t.Fatalf("GetHeaderByIDByBaseType base: %v", err)
|
||||
}
|
||||
if _, err := repo.GetCrewsByRosterIDs(ctx, [][]byte{rosterID}); err != nil {
|
||||
t.Fatalf("GetCrewsByRosterIDs: %v", err)
|
||||
}
|
||||
if _, err := repo.ListHeadersByRange(ctx, hemsBaseID, dutyDate.AddDate(0, 0, -1), dutyDate.AddDate(0, 0, 1)); err != nil {
|
||||
t.Fatalf("ListHeadersByRange: %v", err)
|
||||
}
|
||||
if _, err := repo.ListHeadersByRangeByBaseType(ctx, baseID, dutyDate.AddDate(0, 0, -1), dutyDate.AddDate(0, 0, 1), "base"); err != nil {
|
||||
t.Fatalf("ListHeadersByRangeByBaseType: %v", err)
|
||||
}
|
||||
if _, err := repo.ListHEMSBases(ctx, nil); err != nil {
|
||||
t.Fatalf("ListHEMSBases: %v", err)
|
||||
}
|
||||
if _, err := repo.ListBasesByType(ctx, nil, "base"); err != nil {
|
||||
t.Fatalf("ListBasesByType: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.SoftDeleteCrewsByRosterID(ctx, rosterID, uuidv7.MustBytes()); err != nil {
|
||||
t.Fatalf("SoftDeleteCrewsByRosterID: %v", err)
|
||||
}
|
||||
if err := repo.SoftDeleteHeader(ctx, rosterID, uuidv7.MustBytes()); err != nil {
|
||||
t.Fatalf("SoftDeleteHeader: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDutyRosterRepository_HelperCoverage(t *testing.T) {
|
||||
if key := rosterBaseCategoryKey("base"); key != "regular" {
|
||||
t.Fatalf("unexpected base category key")
|
||||
}
|
||||
if key := rosterBaseCategoryKey("hems"); key != "hems" {
|
||||
t.Fatalf("unexpected hems category key")
|
||||
}
|
||||
if got := dateOnlyUTC(time.Date(2026, 3, 20, 9, 10, 11, 0, time.FixedZone("X", 7*3600))); got.Hour() != 0 {
|
||||
t.Fatalf("expected date only UTC, got %v", got)
|
||||
}
|
||||
if err := softDeleteCrewByIDs(nil, nil, time.Now().UTC(), nil); err != nil {
|
||||
t.Fatalf("softDeleteCrewByIDs empty should be nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDutyRosterRepository_HelperCoverageExtra(t *testing.T) {
|
||||
db := openDutyRosterRepoCoverageDB(t)
|
||||
repo := NewDutyRosterRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := repo.GetCrewsByRosterIDs(ctx, nil); err != nil {
|
||||
t.Fatalf("GetCrewsByRosterIDs empty should not error: %v", err)
|
||||
}
|
||||
if _, err := repo.ListBasesByType(ctx, nil, "unknown"); err != nil {
|
||||
t.Fatalf("ListBasesByType unknown should not error: %v", err)
|
||||
}
|
||||
if _, err := repo.FindRosterIDByBaseDate(ctx, []byte("base-does-not-exist"), time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Fatalf("FindRosterIDByBaseDate empty should not error: %v", err)
|
||||
}
|
||||
if _, err := repo.MatchCrewByFilter(ctx, nil, dutyroster.DutyRosterCrew{RoleCode: "pilot"}); err != nil {
|
||||
t.Fatalf("MatchCrewByFilter empty rosterIDs should not error: %v", err)
|
||||
}
|
||||
|
||||
if err := softDeleteCrewByFilter(db, nil, dutyroster.DutyRosterCrew{RoleCode: "pilot"}, time.Now().UTC(), uuidv7.MustBytes()); err != nil {
|
||||
t.Fatalf("softDeleteCrewByFilter empty rosterIDs should not error: %v", err)
|
||||
}
|
||||
|
||||
rosterID := []byte("roster-softdel-01")
|
||||
userID := []byte("user-softdel-001")
|
||||
now := time.Now().UTC()
|
||||
asBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_rosters(id, base_id, duty_date, shift_start, shift_end, created_at, updated_at) VALUES(%s, %s, ?, ?, ?, ?, ?)`,
|
||||
asBlob(rosterID), asBlob([]byte("hems-base-softdel"))),
|
||||
time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC), "2000-01-01 06:00:00", "2000-01-01 21:00:00", now, now).Error; err != nil {
|
||||
t.Fatalf("seed roster for softDeleteCrewByFilter: %v", err)
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_roster_crews(id, roster_id, user_id, role_code, crew_type, date_start, date_end, shift_start, shift_end, created_at, updated_at)
|
||||
VALUES(%s, %s, %s, 'pilot', 'main', ?, ?, ?, ?, ?, ?)`,
|
||||
asBlob([]byte("crew-softdel-001")), asBlob(rosterID), asBlob(userID)),
|
||||
time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC),
|
||||
"2000-01-01 08:00:00", "2000-01-01 22:00:00",
|
||||
now, now).Error; err != nil {
|
||||
t.Fatalf("seed crew for softDeleteCrewByFilter: %v", err)
|
||||
}
|
||||
filterDate := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC)
|
||||
err := softDeleteCrewByFilter(db, [][]byte{rosterID}, dutyroster.DutyRosterCrew{
|
||||
RoleCode: "pilot",
|
||||
CrewType: "main",
|
||||
UserID: userID,
|
||||
DateStart: &filterDate,
|
||||
DateEnd: &filterDate,
|
||||
}, now, uuidv7.MustBytes())
|
||||
if err == nil {
|
||||
t.Fatalf("expected sqlite dialect error for UPDATE alias query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDutyRosterRepository_NotFoundAndRowsAffectedBranches(t *testing.T) {
|
||||
db := openDutyRosterRepoCoverageDB(t)
|
||||
repo := NewDutyRosterRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
missingID := []byte("missing-roster-01")
|
||||
baseID := []byte("base-not-found-1")
|
||||
dutyDate := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if _, err := repo.GetRosterByID(ctx, missingID); err == nil {
|
||||
t.Fatalf("expected GetRosterByID not found error")
|
||||
}
|
||||
if _, err := repo.LockRosterByID(ctx, missingID); err == nil {
|
||||
t.Fatalf("expected LockRosterByID not found error")
|
||||
}
|
||||
|
||||
if err := repo.UpdateHeader(ctx, &dutyroster.DutyRoster{
|
||||
ID: missingID,
|
||||
BaseID: baseID,
|
||||
DutyDate: dutyDate,
|
||||
UpdatedBy: uuidv7.MustBytes(),
|
||||
}); err == nil {
|
||||
t.Fatalf("expected UpdateHeader not found error")
|
||||
}
|
||||
|
||||
if err := repo.SoftDeleteHeader(ctx, missingID, uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected SoftDeleteHeader not found error")
|
||||
}
|
||||
if _, err := repo.GetBaseDefaultShift(ctx, baseID, "base"); err == nil {
|
||||
t.Fatalf("expected GetBaseDefaultShift not found error")
|
||||
}
|
||||
|
||||
// with non-empty baseID filter branch on list bases
|
||||
if rows, err := repo.ListHEMSBases(ctx, baseID); err != nil || len(rows) != 0 {
|
||||
t.Fatalf("expected empty ListHEMSBases filtered result, got len=%d err=%v", len(rows), err)
|
||||
}
|
||||
if rows, err := repo.ListBasesByType(ctx, baseID, "base"); err != nil || len(rows) != 0 {
|
||||
t.Fatalf("expected empty ListBasesByType filtered result, got len=%d err=%v", len(rows), err)
|
||||
}
|
||||
|
||||
if err := db.Exec("DROP TABLE duty_rosters").Error; err != nil {
|
||||
t.Fatalf("drop duty_rosters: %v", err)
|
||||
}
|
||||
if _, err := repo.FindRosterIDByBaseDate(ctx, baseID, dutyDate); err == nil {
|
||||
t.Fatalf("expected query error after dropping roster table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDutyRosterRepository_LowLevelHelperBranches(t *testing.T) {
|
||||
db := openDutyRosterRepoCoverageDB(t)
|
||||
now := time.Now().UTC()
|
||||
asBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) }
|
||||
|
||||
baseID := []byte("hems-base-helper01")
|
||||
_, hemsCategoryID := dutyRosterTestCategoryIDs()
|
||||
rosterID := []byte("roster-helper-001")
|
||||
userID := []byte("user-helper-0001")
|
||||
crewIDUser := []byte("crew-helper-user1")
|
||||
crewIDGuest := []byte("crew-helper-guest")
|
||||
dutyDate := time.Date(2026, 3, 22, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := db.Exec(fmt.Sprintf(`INSERT INTO bases(id, base_category_id, base, base_abbreviation, sortkey, is_active, default_shift_time) VALUES(%s, %s, ?, ?, 1, 1, ?)`, asBlob(baseID), asBlob(hemsCategoryID)),
|
||||
"HEMS H", "HH", "06:00-21:00").Error; err != nil {
|
||||
t.Fatalf("seed base: %v", err)
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_rosters(id, base_id, duty_date, shift_start, shift_end, created_at, updated_at) VALUES(%s, %s, ?, ?, ?, ?, ?)`,
|
||||
asBlob(rosterID), asBlob(baseID)),
|
||||
dutyDate, "2000-01-01 06:00:00", "2000-01-01 21:00:00", now, now).Error; err != nil {
|
||||
t.Fatalf("seed roster: %v", err)
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_roster_crews(id, roster_id, user_id, role_code, crew_type, name_label, mobile_phone, email, date_start, date_end, shift_start, shift_end, created_at, updated_at)
|
||||
VALUES(%s, %s, %s, 'pilot', 'main', '', '', '', ?, ?, ?, ?, ?, ?)`,
|
||||
asBlob(crewIDUser), asBlob(rosterID), asBlob(userID)),
|
||||
dutyDate, dutyDate, "2000-01-01 08:00:00", "2000-01-01 22:00:00", now, now).Error; err != nil {
|
||||
t.Fatalf("seed user crew: %v", err)
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_roster_crews(id, roster_id, user_id, role_code, crew_type, name_label, mobile_phone, email, shift_start, shift_end, created_at, updated_at)
|
||||
VALUES(%s, %s, NULL, 'other_person', 'main', 'Guest', '+62000', 'g@example.com', ?, ?, ?, ?)`,
|
||||
asBlob(crewIDGuest), asBlob(rosterID)),
|
||||
"2000-01-01 09:00:00", "2000-01-01 18:00:00", now, now).Error; err != nil {
|
||||
t.Fatalf("seed guest crew: %v", err)
|
||||
}
|
||||
|
||||
// softReplaceCrewByUser branches
|
||||
if err := softReplaceCrewByUser(db, nil, "pilot", "main", userID, uuidv7.MustBytes()); err != nil {
|
||||
t.Fatalf("softReplaceCrewByUser empty roster should be nil: %v", err)
|
||||
}
|
||||
if err := softReplaceCrewByUser(db, rosterID, "pilot", "main", userID, uuidv7.MustBytes()); err != nil {
|
||||
t.Fatalf("softReplaceCrewByUser update branch failed: %v", err)
|
||||
}
|
||||
|
||||
// crewExists branches (user/date nil and set)
|
||||
existsGuest, err := crewExists(db, dutyroster.DutyRosterCrew{
|
||||
RosterID: rosterID,
|
||||
RoleCode: "other_person",
|
||||
CrewType: "main",
|
||||
NameLabel: "Guest",
|
||||
MobilePhone: "+62000",
|
||||
Email: "g@example.com",
|
||||
ShiftStart: "2000-01-01 09:00:00",
|
||||
ShiftEnd: "2000-01-01 18:00:00",
|
||||
})
|
||||
if err != nil || !existsGuest {
|
||||
t.Fatalf("expected guest crew exists, got exists=%v err=%v", existsGuest, err)
|
||||
}
|
||||
|
||||
start := dutyDate
|
||||
end := dutyDate
|
||||
existsUser, err := crewExists(db, dutyroster.DutyRosterCrew{
|
||||
RosterID: rosterID,
|
||||
UserID: userID,
|
||||
RoleCode: "pilot",
|
||||
CrewType: "main",
|
||||
DateStart: &start,
|
||||
DateEnd: &end,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("crewExists user branch err: %v", err)
|
||||
}
|
||||
if existsUser {
|
||||
t.Fatalf("expected false after softReplaceCrewByUser (row should be deleted)")
|
||||
}
|
||||
|
||||
// softDeleteCrewForDate branches
|
||||
if err := softDeleteCrewForDate(db, []byte("base-not-exist"), dutyDate, dutyroster.DutyRosterCrew{
|
||||
RoleCode: "pilot", CrewType: "main",
|
||||
}, now, uuidv7.MustBytes()); err != nil {
|
||||
t.Fatalf("softDeleteCrewForDate no roster branch should be nil: %v", err)
|
||||
}
|
||||
if err := softDeleteCrewForDate(db, baseID, dutyDate, dutyroster.DutyRosterCrew{
|
||||
RoleCode: "other_person",
|
||||
CrewType: "main",
|
||||
NameLabel: "Guest",
|
||||
MobilePhone: "+62000",
|
||||
Email: "g@example.com",
|
||||
}, now, uuidv7.MustBytes()); err != nil {
|
||||
t.Fatalf("softDeleteCrewForDate guest branch failed: %v", err)
|
||||
}
|
||||
|
||||
// matchCrewByFilter name_label/date/shift filter branch
|
||||
if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_roster_crews(id, roster_id, user_id, role_code, crew_type, name_label, mobile_phone, email, shift_start, shift_end, created_at, updated_at)
|
||||
VALUES(%s, %s, NULL, 'other_person', 'main', 'Guest', '+62000', 'g@example.com', ?, ?, ?, ?)`,
|
||||
asBlob([]byte("crew-helper-guest2")), asBlob(rosterID)),
|
||||
"2000-01-01 09:00:00", "2000-01-01 18:00:00", now, now).Error; err != nil {
|
||||
t.Fatalf("seed guest2 crew: %v", err)
|
||||
}
|
||||
rows, err := matchCrewByFilter(db, [][]byte{rosterID}, dutyroster.DutyRosterCrew{
|
||||
RoleCode: "other_person",
|
||||
CrewType: "main",
|
||||
NameLabel: "Guest",
|
||||
MobilePhone: "+62000",
|
||||
Email: "g@example.com",
|
||||
})
|
||||
if err != nil || len(rows) == 0 {
|
||||
t.Fatalf("expected matchCrewByFilter by name_label branch, got len=%d err=%v", len(rows), err)
|
||||
}
|
||||
}
|
||||
104
internal/repository/mysql/easa_release_repo.go
Normal file
104
internal/repository/mysql/easa_release_repo.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
easarelease "wucher/internal/domain/easa_release"
|
||||
)
|
||||
|
||||
type EASAReleaseRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewEASAReleaseRepository(db *gorm.DB) *EASAReleaseRepository {
|
||||
return &EASAReleaseRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *EASAReleaseRepository) Create(ctx context.Context, row *easarelease.EASARelease) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *EASAReleaseRepository) Update(ctx context.Context, row *easarelease.EASARelease) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *EASAReleaseRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
return r.db.WithContext(ctx).Model(&easarelease.EASARelease{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{"deleted_at": gorm.Expr("NOW(3)"), "deleted_by": deletedBy, "updated_by": deletedBy}).Error
|
||||
}
|
||||
|
||||
func (r *EASAReleaseRepository) GetByID(ctx context.Context, id []byte) (*easarelease.EASARelease, error) {
|
||||
var row easarelease.EASARelease
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *EASAReleaseRepository) GetByFlightID(ctx context.Context, flightID []byte) (*easarelease.EASARelease, error) {
|
||||
var row easarelease.EASARelease
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("flight_id = ? AND deleted_at IS NULL", flightID).
|
||||
Order("created_at DESC").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *EASAReleaseRepository) GetByComplaintID(ctx context.Context, complaintID []byte) (*easarelease.EASARelease, error) {
|
||||
var row easarelease.EASARelease
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("complaint_id = ? AND deleted_at IS NULL", complaintID).
|
||||
Order("created_at DESC").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *EASAReleaseRepository) ListByHelicopter(ctx context.Context, helicopterID []byte) ([]easarelease.EASARelease, error) {
|
||||
rows := make([]easarelease.EASARelease, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("helicopter_id = ? AND deleted_at IS NULL", helicopterID).
|
||||
Order("created_at DESC").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *EASAReleaseRepository) GetLatestByHelicopter(ctx context.Context, helicopterID []byte) (*easarelease.EASARelease, error) {
|
||||
var row easarelease.EASARelease
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("helicopter_id = ? AND deleted_at IS NULL", helicopterID).
|
||||
Order("created_at DESC").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *EASAReleaseRepository) GetActiveByHelicopter(ctx context.Context, helicopterID []byte) (*easarelease.EASARelease, error) {
|
||||
var row easarelease.EASARelease
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("helicopter_id = ? AND signed_at IS NOT NULL AND deleted_at IS NULL", helicopterID).
|
||||
Order("signed_at DESC").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *EASAReleaseRepository) VoidByID(ctx context.Context, id []byte, reason string, actor []byte) error {
|
||||
return r.db.WithContext(ctx).Model(&easarelease.EASARelease{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{"deleted_at": gorm.Expr("NOW(3)"), "deleted_by": actor, "updated_by": actor}).Error
|
||||
}
|
||||
165
internal/repository/mysql/email_outbox_repo.go
Normal file
165
internal/repository/mysql/email_outbox_repo.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"wucher/internal/domain/auth"
|
||||
)
|
||||
|
||||
func (r *AuthRepository) WithTransaction(ctx context.Context, fn func(repo auth.Repository, outbox auth.EmailOutboxWriter) error) error {
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := &AuthRepository{db: tx}
|
||||
return fn(txRepo, txRepo)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CreateEmailOutboxMessage(ctx context.Context, message *auth.EmailOutboxMessage) error {
|
||||
if message == nil {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(message).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) ClaimPendingEmailOutboxMessages(
|
||||
ctx context.Context,
|
||||
limit int,
|
||||
now time.Time,
|
||||
lockTTL time.Duration,
|
||||
workerID string,
|
||||
) ([]auth.EmailOutboxMessage, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
if lockTTL <= 0 {
|
||||
lockTTL = time.Minute
|
||||
}
|
||||
now = now.UTC()
|
||||
staleBefore := now.Add(-lockTTL)
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
workerID = "worker"
|
||||
}
|
||||
|
||||
var messages []auth.EmailOutboxMessage
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.
|
||||
Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||
Where(
|
||||
"((status = ? AND available_at <= ?) OR (status = ? AND locked_at IS NOT NULL AND locked_at < ?))",
|
||||
auth.EmailOutboxStatusPending,
|
||||
now,
|
||||
auth.EmailOutboxStatusProcessing,
|
||||
staleBefore,
|
||||
).
|
||||
Order("available_at ASC, created_at ASC").
|
||||
Limit(limit).
|
||||
Find(&messages).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([][]byte, 0, len(messages))
|
||||
for i := range messages {
|
||||
ids = append(ids, messages[i].ID)
|
||||
}
|
||||
|
||||
if err := tx.Model(&auth.EmailOutboxMessage{}).
|
||||
Where("id IN ?", ids).
|
||||
Updates(map[string]any{
|
||||
"status": auth.EmailOutboxStatusProcessing,
|
||||
"locked_at": now,
|
||||
"locked_by": workerID,
|
||||
"updated_at": now,
|
||||
"attempts": gorm.Expr("attempts + ?", 1),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range messages {
|
||||
messages[i].Status = auth.EmailOutboxStatusProcessing
|
||||
messages[i].Attempts++
|
||||
ts := now
|
||||
messages[i].LockedAt = &ts
|
||||
messages[i].LockedBy = workerID
|
||||
messages[i].UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return messages, err
|
||||
}
|
||||
|
||||
func (r *AuthRepository) MarkEmailOutboxMessagePublished(ctx context.Context, id []byte, publishedAt time.Time) error {
|
||||
publishedAt = publishedAt.UTC()
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.EmailOutboxMessage{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": auth.EmailOutboxStatusPublished,
|
||||
"published_at": publishedAt,
|
||||
"locked_at": nil,
|
||||
"locked_by": "",
|
||||
"last_error": "",
|
||||
"updated_at": publishedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) MarkEmailOutboxMessageRetry(ctx context.Context, id []byte, availableAt time.Time, lastErr string) error {
|
||||
availableAt = availableAt.UTC()
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.EmailOutboxMessage{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": auth.EmailOutboxStatusPending,
|
||||
"available_at": availableAt,
|
||||
"locked_at": nil,
|
||||
"locked_by": "",
|
||||
"last_error": truncateOutboxError(strings.TrimSpace(lastErr), 4096),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) MarkEmailOutboxMessageDead(ctx context.Context, id []byte, failedAt time.Time, lastErr string) error {
|
||||
failedAt = failedAt.UTC()
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&auth.EmailOutboxMessage{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": auth.EmailOutboxStatusDead,
|
||||
"available_at": failedAt,
|
||||
"locked_at": nil,
|
||||
"locked_by": "",
|
||||
"last_error": truncateOutboxError(strings.TrimSpace(lastErr), 4096),
|
||||
"updated_at": failedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CountPendingEmailOutboxMessages(ctx context.Context, now time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&auth.EmailOutboxMessage{}).
|
||||
Where(
|
||||
"(status = ? AND available_at <= ?) OR status = ?",
|
||||
auth.EmailOutboxStatusPending,
|
||||
now.UTC(),
|
||||
auth.EmailOutboxStatusProcessing,
|
||||
).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func truncateOutboxError(v string, max int) string {
|
||||
if max <= 0 || len(v) <= max {
|
||||
return v
|
||||
}
|
||||
return v[:max]
|
||||
}
|
||||
91
internal/repository/mysql/facility_repo.go
Normal file
91
internal/repository/mysql/facility_repo.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/facility"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
)
|
||||
|
||||
type FacilityRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFacilityRepository(db *gorm.DB) *FacilityRepository {
|
||||
return &FacilityRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FacilityRepository) Create(ctx context.Context, f *facility.Facility) error {
|
||||
requestedIsActive := f.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(f).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&facility.Facility{}).Where("id = ?", f.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *FacilityRepository) Update(ctx context.Context, f *facility.Facility) error {
|
||||
return r.db.WithContext(ctx).Save(f).Error
|
||||
}
|
||||
|
||||
func (r *FacilityRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "facilities", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&facility.Facility{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *FacilityRepository) GetByID(ctx context.Context, id []byte) (*facility.Facility, error) {
|
||||
var f facility.Facility
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&f).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &f, err
|
||||
}
|
||||
|
||||
func (r *FacilityRepository) List(ctx context.Context, filter, category, sort string, limit, offset int) ([]facility.Facility, int64, error) {
|
||||
var facilities []facility.Facility
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&facility.Facility{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("name LIKE ? OR type LIKE ? OR length LIKE ? OR weight LIKE ?", like, like, like, like)
|
||||
}
|
||||
if category != "" {
|
||||
base = base.Where("category = ?", category)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("facilities", "is_active", "sortkey", "name", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&facilities).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return facilities, total, nil
|
||||
}
|
||||
252
internal/repository/mysql/facility_repo_test.go
Normal file
252
internal/repository/mysql/facility_repo_test.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/facility"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openFacilityTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:facility_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&facility.Facility{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewFacilityRepository(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacilityRepositoryCreate(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
row := &facility.Facility{Category: "HEMS", Name: "Stretcher", Type: "Cabin", Length: "2m", Weight: "5kg"}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
inactive := &facility.Facility{Category: "HEMS", Name: "Inactive", Type: "Cabin", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
gotInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get inactive: %v", err)
|
||||
}
|
||||
if gotInactive == nil || gotInactive.IsActive {
|
||||
t.Fatalf("expected inactive facility persisted as false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &facility.Facility{Category: "HEMS", Name: "AfterClose", Type: "X"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacilityRepositoryUpdate(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
row := &facility.Facility{Category: "HEMS", Name: "Old", Type: "Cabin"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Name = "New"
|
||||
row.Type = "External"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.Name != "New" || loaded.Type != "External" {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacilityRepositoryDelete(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
row := &facility.Facility{Category: "HEMS", Name: "DeleteMe", Type: "Cabin"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected row deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacilityRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
row := &facility.Facility{Category: "HEMS", Name: "Found", Type: "Cabin"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "Found" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFacilityRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "HEMS", Name: "A", Type: "Cabin", Length: "1m", Weight: "1kg"})
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "Dry", Name: "B", Type: "External", Length: "2m", Weight: "2kg"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter category sort and limit", func(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "HEMS", Name: "Main Basket", Type: "Basket", Length: "12", Weight: "30"})
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "Dry", Name: "Dry Basket", Type: "Basket", Length: "10", Weight: "20"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "Main", "HEMS", "name DESC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Name != "Main Basket" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
if err := db.Migrator().DropTable(&facility.Facility{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "HEMS", Name: "Main", Type: "Basket"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", "name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active sortkey first and inactive last", func(t *testing.T) {
|
||||
db := openFacilityTestDB(t)
|
||||
repo := NewFacilityRepository(db)
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "HEMS", Name: "Gamma", Type: "A", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "HEMS", Name: "Beta", Type: "A", SortKey: intPtrFacility(0), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "HEMS", Name: "Charlie", Type: "A", SortKey: intPtrFacility(2), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "HEMS", Name: "Alpha", Type: "A", SortKey: intPtrFacility(1), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "HEMS", Name: "Zulu", Type: "A", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &facility.Facility{Category: "HEMS", Name: "Bravo", Type: "A", SortKey: intPtrFacility(9), IsActive: false})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{
|
||||
rows[0].Name,
|
||||
rows[1].Name,
|
||||
rows[2].Name,
|
||||
rows[3].Name,
|
||||
rows[4].Name,
|
||||
rows[5].Name,
|
||||
}
|
||||
wantOrder := []string{
|
||||
"Beta",
|
||||
"Alpha",
|
||||
"Charlie",
|
||||
"Gamma",
|
||||
"Bravo",
|
||||
"Zulu",
|
||||
}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func intPtrFacility(v int) *int { return &v }
|
||||
241
internal/repository/mysql/federal_state_repo.go
Normal file
241
internal/repository/mysql/federal_state_repo.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/federal_state"
|
||||
"wucher/internal/shared/pkg/apperrors"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
)
|
||||
|
||||
type FederalStateRepository struct {
|
||||
db *gorm.DB
|
||||
schema federalStateSchema
|
||||
}
|
||||
|
||||
func NewFederalStateRepository(db *gorm.DB) *FederalStateRepository {
|
||||
probe := newSchemaCache(db)
|
||||
schema := federalStateSchema{}
|
||||
|
||||
schema.hasFederalStatesTable = probe.HasTable("federal_states")
|
||||
schema.federalStatesHasName = schema.hasFederalStatesTable && probe.HasColumn("federal_states", "name")
|
||||
|
||||
schema.hasInsurancePatientDataTable = probe.HasTable("insurance_patient_data")
|
||||
if schema.hasInsurancePatientDataTable {
|
||||
schema.insurancePatientDataHasFederalStateID = probe.HasColumn("insurance_patient_data", "federal_state_id")
|
||||
schema.insurancePatientDataHasState = probe.HasColumn("insurance_patient_data", "state")
|
||||
schema.insurancePatientDataHasDeletedAt = probe.HasColumn("insurance_patient_data", "deleted_at")
|
||||
}
|
||||
|
||||
schema.hasHealthInsuranceCompaniesTable = probe.HasTable("health_insurance_companies")
|
||||
if schema.hasHealthInsuranceCompaniesTable {
|
||||
schema.healthInsuranceCompaniesHasFederalStateID = probe.HasColumn("health_insurance_companies", "federal_state_id")
|
||||
schema.healthInsuranceCompaniesHasState = probe.HasColumn("health_insurance_companies", "state")
|
||||
schema.healthInsuranceCompaniesHasDeletedAt = probe.HasColumn("health_insurance_companies", "deleted_at")
|
||||
}
|
||||
|
||||
schema.hasIcaosTable = probe.HasTable("icaos")
|
||||
if schema.hasIcaosTable {
|
||||
schema.icaosHasFederalStateID = probe.HasColumn("icaos", "federal_state_id")
|
||||
schema.icaosHasDeletedAt = probe.HasColumn("icaos", "deleted_at")
|
||||
}
|
||||
|
||||
return &FederalStateRepository{db: db, schema: schema}
|
||||
}
|
||||
|
||||
func (r *FederalStateRepository) Create(ctx context.Context, row *federal_state.FederalState) error {
|
||||
requestedIsActive := row.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&federal_state.FederalState{}).Where("id = ?", row.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *FederalStateRepository) Update(ctx context.Context, row *federal_state.FederalState) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *FederalStateRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "federal_states", id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureNoFederalStateUsage(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&federal_state.FederalState{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *FederalStateRepository) ensureNoFederalStateUsage(ctx context.Context, id []byte) error {
|
||||
if len(id) != 16 {
|
||||
return nil
|
||||
}
|
||||
related := make([]string, 0, 3)
|
||||
db := r.db.WithContext(ctx)
|
||||
stateName := ""
|
||||
if r.schema.hasFederalStatesTable && r.schema.federalStatesHasName {
|
||||
_ = db.Table("federal_states").Select("name").Where("id = ?", id).Limit(1).Scan(&stateName).Error
|
||||
}
|
||||
stateName = strings.TrimSpace(stateName)
|
||||
|
||||
if r.schema.hasInsurancePatientDataTable {
|
||||
conditions := make([]string, 0, 2)
|
||||
args := make([]any, 0, 2)
|
||||
if r.schema.insurancePatientDataHasFederalStateID {
|
||||
conditions = append(conditions, "federal_state_id = ?")
|
||||
args = append(args, id)
|
||||
}
|
||||
if stateName != "" && r.schema.insurancePatientDataHasState {
|
||||
conditions = append(conditions, "LOWER(TRIM(state)) = LOWER(TRIM(?))")
|
||||
args = append(args, stateName)
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
goto checkHealthInsuranceCompanies
|
||||
}
|
||||
query := db.Table("insurance_patient_data").Where("("+strings.Join(conditions, " OR ")+")", args...)
|
||||
if r.schema.insurancePatientDataHasDeletedAt {
|
||||
query = query.Where("deleted_at IS NULL")
|
||||
}
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
related = append(related, "insurance patient data")
|
||||
}
|
||||
}
|
||||
|
||||
checkHealthInsuranceCompanies:
|
||||
if r.schema.hasHealthInsuranceCompaniesTable {
|
||||
conditions := make([]string, 0, 2)
|
||||
args := make([]any, 0, 2)
|
||||
if r.schema.healthInsuranceCompaniesHasFederalStateID {
|
||||
conditions = append(conditions, "federal_state_id = ?")
|
||||
args = append(args, id)
|
||||
}
|
||||
if stateName != "" && r.schema.healthInsuranceCompaniesHasState {
|
||||
conditions = append(conditions, "LOWER(TRIM(state)) = LOWER(TRIM(?))")
|
||||
args = append(args, stateName)
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
goto checkICAOs
|
||||
}
|
||||
query := db.Table("health_insurance_companies").Where("("+strings.Join(conditions, " OR ")+")", args...)
|
||||
if r.schema.healthInsuranceCompaniesHasDeletedAt {
|
||||
query = query.Where("deleted_at IS NULL")
|
||||
}
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
related = append(related, "health insurance companies")
|
||||
}
|
||||
}
|
||||
|
||||
checkICAOs:
|
||||
if r.schema.hasIcaosTable {
|
||||
if !r.schema.icaosHasFederalStateID {
|
||||
goto finalize
|
||||
}
|
||||
var count int64
|
||||
query := db.Table("icaos").Where("federal_state_id = ?", id)
|
||||
if r.schema.icaosHasDeletedAt {
|
||||
query = query.Where("deleted_at IS NULL")
|
||||
}
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
related = append(related, "icaos")
|
||||
}
|
||||
}
|
||||
|
||||
finalize:
|
||||
if len(related) > 0 {
|
||||
return apperrors.NewDeleteConflictError(related...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type federalStateSchema struct {
|
||||
hasFederalStatesTable bool
|
||||
federalStatesHasName bool
|
||||
hasInsurancePatientDataTable bool
|
||||
insurancePatientDataHasFederalStateID bool
|
||||
insurancePatientDataHasState bool
|
||||
insurancePatientDataHasDeletedAt bool
|
||||
hasHealthInsuranceCompaniesTable bool
|
||||
healthInsuranceCompaniesHasFederalStateID bool
|
||||
healthInsuranceCompaniesHasState bool
|
||||
healthInsuranceCompaniesHasDeletedAt bool
|
||||
hasIcaosTable bool
|
||||
icaosHasFederalStateID bool
|
||||
icaosHasDeletedAt bool
|
||||
}
|
||||
|
||||
func (r *FederalStateRepository) GetByID(ctx context.Context, id []byte) (*federal_state.FederalState, error) {
|
||||
var row federal_state.FederalState
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Land", "deleted_at IS NULL").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FederalStateRepository) List(ctx context.Context, filter, sort string, limit, offset int, landID []byte, landName string) ([]federal_state.FederalState, int64, error) {
|
||||
var rows []federal_state.FederalState
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&federal_state.FederalState{}).Where("federal_states.deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("federal_states.name LIKE ?", like)
|
||||
}
|
||||
if len(landID) > 0 {
|
||||
base = base.Where("federal_states.land_id = ?", landID)
|
||||
}
|
||||
if trimmedLandName := strings.TrimSpace(landName); trimmedLandName != "" {
|
||||
likeLandName := "%" + trimmedLandName + "%"
|
||||
base = base.Joins("JOIN lands ON lands.id = federal_states.land_id AND lands.deleted_at IS NULL").
|
||||
Where("lands.name LIKE ?", likeLandName)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("federal_states", "is_active", "sortkey", "name", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
query = query.Preload("Land", "deleted_at IS NULL")
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
372
internal/repository/mysql/federal_state_repo_test.go
Normal file
372
internal/repository/mysql/federal_state_repo_test.go
Normal file
@@ -0,0 +1,372 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/federal_state"
|
||||
"wucher/internal/domain/land"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openFederalStateTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:federal_state_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&land.Land{}); err != nil {
|
||||
t.Fatalf("auto migrate land: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&federal_state.FederalState{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewFederalStateRepository(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederalStateRepositoryCreate(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
row := &federal_state.FederalState{Name: "Main"}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
inactive := &federal_state.FederalState{Name: "Inactive", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
gotInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get inactive: %v", err)
|
||||
}
|
||||
if gotInactive == nil || gotInactive.IsActive {
|
||||
t.Fatalf("expected inactive federal state persisted as false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &federal_state.FederalState{Name: "AfterClose"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederalStateRepositoryUpdate(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
row := &federal_state.FederalState{Name: "Old"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Name = "New"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.Name != "New" {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederalStateRepositoryDelete(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
row := &federal_state.FederalState{Name: "DeleteMe"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft deleted row hidden from GetByID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederalStateRepositoryDelete_BlockedWhenUsedByInsurancePatientData(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
|
||||
if err := db.Exec(`CREATE TABLE IF NOT EXISTS insurance_patient_data (
|
||||
id BLOB PRIMARY KEY,
|
||||
federal_state_id BLOB NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create insurance_patient_data table: %v", err)
|
||||
}
|
||||
repo := NewFederalStateRepository(db)
|
||||
|
||||
row := &federal_state.FederalState{Name: "InUse"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
if err := db.Table("insurance_patient_data").Create(map[string]any{
|
||||
"id": uuidv7.MustBytes(),
|
||||
"federal_state_id": row.ID,
|
||||
"deleted_at": nil,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed insurance_patient_data: %v", err)
|
||||
}
|
||||
|
||||
err := repo.Delete(context.Background(), row.ID, uuidv7.MustBytes())
|
||||
if err == nil {
|
||||
t.Fatalf("expected delete to be blocked")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "insurance patient data") {
|
||||
t.Fatalf("expected dependency detail in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederalStateRepositoryDelete_BlockedWhenUsedByICAO(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
|
||||
if err := db.Exec(`CREATE TABLE IF NOT EXISTS icaos (
|
||||
id BLOB PRIMARY KEY,
|
||||
federal_state_id BLOB NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create icaos table: %v", err)
|
||||
}
|
||||
repo := NewFederalStateRepository(db)
|
||||
|
||||
row := &federal_state.FederalState{Name: "InUseByICAO"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
if err := db.Table("icaos").Create(map[string]any{
|
||||
"id": uuidv7.MustBytes(),
|
||||
"federal_state_id": row.ID,
|
||||
"deleted_at": nil,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed icaos: %v", err)
|
||||
}
|
||||
|
||||
err := repo.Delete(context.Background(), row.ID, uuidv7.MustBytes())
|
||||
if err == nil {
|
||||
t.Fatalf("expected delete to be blocked")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "icaos") {
|
||||
t.Fatalf("expected dependency detail in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederalStateRepositoryDelete_BlockedWhenUsedByLegacyStateColumn(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
|
||||
if err := db.Exec(`CREATE TABLE IF NOT EXISTS health_insurance_companies (
|
||||
id BLOB PRIMARY KEY,
|
||||
state TEXT NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create health_insurance_companies table: %v", err)
|
||||
}
|
||||
repo := NewFederalStateRepository(db)
|
||||
|
||||
row := &federal_state.FederalState{Name: "Bayern"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
if err := db.Table("health_insurance_companies").Create(map[string]any{
|
||||
"id": uuidv7.MustBytes(),
|
||||
"state": "Bayern",
|
||||
"deleted_at": nil,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed health_insurance_companies: %v", err)
|
||||
}
|
||||
|
||||
err := repo.Delete(context.Background(), row.ID, uuidv7.MustBytes())
|
||||
if err == nil {
|
||||
t.Fatalf("expected delete to be blocked")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "health insurance companies") {
|
||||
t.Fatalf("expected dependency detail in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederalStateRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
row := &federal_state.FederalState{Name: "Found"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "Found" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFederalStateRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "C"})
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "A"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Main Base"})
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Backup"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "Main", "name DESC", 1, 0, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Name != "Main Base" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
if err := db.Migrator().DropTable(&federal_state.FederalState{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", 10, 0, nil, ""); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Main"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "name ASC, )", 10, 0, nil, ""); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active sortkey first and inactive last", func(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Gamma", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Beta", SortKey: intPtrFederalStateRepo(0), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Charlie", SortKey: intPtrFederalStateRepo(2), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Alpha", SortKey: intPtrFederalStateRepo(1), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Zulu", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Bravo", SortKey: intPtrFederalStateRepo(9), IsActive: false})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{rows[0].Name, rows[1].Name, rows[2].Name, rows[3].Name, rows[4].Name, rows[5].Name}
|
||||
wantOrder := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with land_id and land_name filter", func(t *testing.T) {
|
||||
db := openFederalStateTestDB(t)
|
||||
repo := NewFederalStateRepository(db)
|
||||
deLand := &land.Land{Name: "Germany", LandISOCode: "DE"}
|
||||
frLand := &land.Land{Name: "France", LandISOCode: "FR"}
|
||||
if err := db.Create(deLand).Error; err != nil {
|
||||
t.Fatalf("create de land: %v", err)
|
||||
}
|
||||
if err := db.Create(frLand).Error; err != nil {
|
||||
t.Fatalf("create fr land: %v", err)
|
||||
}
|
||||
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Bayern", LandID: deLand.ID})
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Berlin", LandID: deLand.ID})
|
||||
_ = repo.Create(context.Background(), &federal_state.FederalState{Name: "Paris", LandID: frLand.ID})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "federal_states.name ASC", 10, 0, deLand.ID, "Ger")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("unexpected filtered result total=%d len=%d", total, len(rows))
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Name == "Paris" {
|
||||
t.Fatalf("expected france row excluded")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func intPtrFederalStateRepo(v int) *int { return &v }
|
||||
104
internal/repository/mysql/file_manager_attachment_repo.go
Normal file
104
internal/repository/mysql/file_manager_attachment_repo.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
)
|
||||
|
||||
type FileManagerAttachmentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFileManagerAttachmentRepository(db *gorm.DB) *FileManagerAttachmentRepository {
|
||||
return &FileManagerAttachmentRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FileManagerAttachmentRepository) Create(ctx context.Context, row *filemanager.Attachment) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FileManagerAttachmentRepository) Delete(ctx context.Context, id []byte) error {
|
||||
res := r.db.WithContext(ctx).Where("id = ?", id).Delete(&filemanager.Attachment{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileManagerAttachmentRepository) GetByID(ctx context.Context, id []byte) (*filemanager.Attachment, error) {
|
||||
var row filemanager.Attachment
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("File").
|
||||
Where("id = ?", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FileManagerAttachmentRepository) GetByRefAndFile(ctx context.Context, refType, refID string, fileID []byte) (*filemanager.Attachment, error) {
|
||||
refType = strings.ToLower(strings.TrimSpace(refType))
|
||||
refID = strings.TrimSpace(refID)
|
||||
if refType == "" || refID == "" || len(fileID) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var row filemanager.Attachment
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("ref_type = ? AND ref_id = ? AND file_id = ?", refType, refID, fileID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FileManagerAttachmentRepository) ListByReference(ctx context.Context, filter filemanager.AttachmentListFilter) ([]filemanager.Attachment, int64, error) {
|
||||
refType := strings.ToLower(strings.TrimSpace(filter.RefType))
|
||||
refID := strings.TrimSpace(filter.RefID)
|
||||
if refType == "" || refID == "" {
|
||||
return []filemanager.Attachment{}, 0, nil
|
||||
}
|
||||
|
||||
var rows []filemanager.Attachment
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&filemanager.Attachment{}).
|
||||
Preload("File").
|
||||
Where("ref_type = ? AND ref_id = ?", refType, refID)
|
||||
|
||||
if filter.Category != nil {
|
||||
category := strings.TrimSpace(*filter.Category)
|
||||
if category == "" {
|
||||
base = base.Where("category IS NULL")
|
||||
} else {
|
||||
base = base.Where("category = ?", category)
|
||||
}
|
||||
}
|
||||
|
||||
query := base
|
||||
if strings.TrimSpace(filter.Sort) != "" {
|
||||
query = query.Order(filter.Sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if filter.Limit > 0 {
|
||||
query = query.Limit(filter.Limit).Offset(filter.Offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
492
internal/repository/mysql/file_manager_file_repo.go
Normal file
492
internal/repository/mysql/file_manager_file_repo.go
Normal file
@@ -0,0 +1,492 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type FileManagerFileRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFileManagerFileRepository(db *gorm.DB) *FileManagerFileRepository {
|
||||
return &FileManagerFileRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) Create(ctx context.Context, row *filemanager.File) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) Update(ctx context.Context, row *filemanager.File) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) TransitionStatus(ctx context.Context, params filemanager.FileStatusTransitionParams) error {
|
||||
expectedFrom := filemanager.NormalizeFileStatus(params.ExpectedFrom)
|
||||
toStatus := filemanager.NormalizeFileStatus(params.To)
|
||||
if expectedFrom == "" || toStatus == "" {
|
||||
return filemanager.ErrInvalidFileStatusTransition
|
||||
}
|
||||
if !filemanager.CanTransitionFileStatus(expectedFrom, toStatus) {
|
||||
return filemanager.ErrInvalidFileStatusTransition
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"status": toStatus,
|
||||
"updated_by": params.UpdatedBy,
|
||||
}
|
||||
switch toStatus {
|
||||
case filemanager.FileStatusProcessing:
|
||||
startedAt := now
|
||||
if params.ProcessingStartedAt != nil {
|
||||
startedAt = params.ProcessingStartedAt.UTC()
|
||||
}
|
||||
updates["processing_started_at"] = startedAt
|
||||
updates["processed_at"] = nil
|
||||
updates["validated_at"] = nil
|
||||
updates["failed_at"] = nil
|
||||
updates["failure_reason"] = nil
|
||||
updates["upload_error"] = ""
|
||||
case filemanager.FileStatusValidated:
|
||||
processedAt := now
|
||||
if params.ProcessedAt != nil {
|
||||
processedAt = params.ProcessedAt.UTC()
|
||||
}
|
||||
validatedAt := processedAt
|
||||
if params.ValidatedAt != nil {
|
||||
validatedAt = params.ValidatedAt.UTC()
|
||||
}
|
||||
updates["processed_at"] = processedAt
|
||||
updates["validated_at"] = validatedAt
|
||||
updates["failed_at"] = nil
|
||||
updates["failure_reason"] = nil
|
||||
updates["upload_error"] = ""
|
||||
case filemanager.FileStatusFailed:
|
||||
processedAt := now
|
||||
if params.ProcessedAt != nil {
|
||||
processedAt = params.ProcessedAt.UTC()
|
||||
}
|
||||
failedAt := now
|
||||
if params.FailedAt != nil {
|
||||
failedAt = params.FailedAt.UTC()
|
||||
}
|
||||
reason := "file processing failed"
|
||||
if params.FailureReason != nil {
|
||||
clean := strings.TrimSpace(*params.FailureReason)
|
||||
if clean != "" {
|
||||
reason = clean
|
||||
}
|
||||
}
|
||||
updates["processed_at"] = processedAt
|
||||
updates["failed_at"] = failedAt
|
||||
updates["validated_at"] = nil
|
||||
updates["failure_reason"] = reason
|
||||
updates["upload_error"] = reason
|
||||
}
|
||||
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&filemanager.File{}).
|
||||
Where("id = ? AND deleted_at IS NULL AND status = ?", params.ID, expectedFrom).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) Finalize(ctx context.Context, params filemanager.FileFinalizeParams) error {
|
||||
expectedFrom := filemanager.NormalizeFileStatus(params.ExpectedFrom)
|
||||
if expectedFrom == "" {
|
||||
expectedFrom = filemanager.FileStatusValidated
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"folder_id": params.FolderID,
|
||||
"name": params.Name,
|
||||
"name_normalized": params.NameNormalized,
|
||||
"extension": params.Extension,
|
||||
"status": filemanager.FileStatusReady,
|
||||
"name_slot": "live",
|
||||
"upload_error": "",
|
||||
"failure_reason": nil,
|
||||
"updated_by": params.UpdatedBy,
|
||||
}
|
||||
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&filemanager.File{}).
|
||||
Where("id = ? AND deleted_at IS NULL AND status = ?", params.ID, expectedFrom).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) Delete(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error {
|
||||
now := time.Now().UTC()
|
||||
nameSlot := deletedNameSlot(id)
|
||||
updates := map[string]any{
|
||||
"status": filemanager.FileStatusTrashed,
|
||||
"trashed_at": now,
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
"name_slot": nameSlot,
|
||||
}
|
||||
if purgeAt != nil {
|
||||
v := purgeAt.UTC()
|
||||
updates["purge_at"] = v
|
||||
}
|
||||
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&filemanager.File{}).
|
||||
Where("id = ? AND deleted_at IS NULL AND status = ?", id, filemanager.FileStatusReady).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) Restore(ctx context.Context, id []byte, restoredBy []byte) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var row filemanager.File
|
||||
if err := tx.Model(&filemanager.File{}).Where("id = ?", id).First(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.DeletedAt == nil {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := tx.Model(&filemanager.File{}).
|
||||
Where("folder_id = ? AND name_normalized = ? AND name_slot = 'live' AND deleted_at IS NULL", row.FolderID, row.NameNormalized).
|
||||
Count(&total).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if total > 0 {
|
||||
return gorm.ErrDuplicatedKey
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"status": filemanager.FileStatusReady,
|
||||
"trashed_at": nil,
|
||||
"deleted_at": nil,
|
||||
"deleted_by": nil,
|
||||
"purge_at": nil,
|
||||
"updated_by": restoredBy,
|
||||
"name_slot": "live",
|
||||
}
|
||||
res := tx.Model(&filemanager.File{}).
|
||||
Where("id = ? AND deleted_at IS NOT NULL AND status = ?", id, filemanager.FileStatusTrashed).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) Purge(ctx context.Context, id []byte) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Where("id = ?", id).
|
||||
Delete(&filemanager.File{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) GetByID(ctx context.Context, id []byte) (*filemanager.File, error) {
|
||||
var row filemanager.File
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) GetByIDAny(ctx context.Context, id []byte) (*filemanager.File, error) {
|
||||
var row filemanager.File
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ?", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) GetByObjectKey(ctx context.Context, objectKey string) (*filemanager.File, error) {
|
||||
objectKey = strings.TrimSpace(objectKey)
|
||||
if objectKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var row filemanager.File
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("object_key = ? AND deleted_at IS NULL", objectKey).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) GetByFolderAndName(ctx context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
|
||||
var row filemanager.File
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("folder_id = ? AND name_normalized = ? AND name_slot = 'live' AND deleted_at IS NULL", folderID, nameNormalized).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) GetByTemplateAndTakeover(ctx context.Context, templateUUID, takeoverID []byte) (*filemanager.File, error) {
|
||||
if len(templateUUID) != 16 || len(takeoverID) != 16 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var row filemanager.File
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("template_uuid = ? AND takeover_id = ? AND deleted_at IS NULL", templateUUID, takeoverID).
|
||||
Order("updated_at DESC, created_at DESC, id DESC").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) ListEditedTemplatesByTakeoverID(ctx context.Context, takeoverID []byte) ([]filemanager.File, error) {
|
||||
if len(takeoverID) != 16 {
|
||||
return nil, nil
|
||||
}
|
||||
rows := make([]filemanager.File, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("takeover_id = ? AND template_uuid IS NOT NULL AND deleted_at IS NULL AND lifecycle_deleted_at IS NULL AND status != ?", takeoverID, filemanager.FileStatusTrashed).
|
||||
Order("created_at ASC, id ASC").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) ListByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) {
|
||||
var rows []filemanager.File
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&filemanager.File{}).
|
||||
Where("folder_id = ? AND deleted_at IS NULL AND name_slot = 'live'", folderID)
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) SearchByName(ctx context.Context, name string, sort string, limit, offset int) ([]filemanager.File, int64, error) {
|
||||
var rows []filemanager.File
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&filemanager.File{}).
|
||||
Where("deleted_at IS NULL AND name_slot = 'live' AND name LIKE ?", "%"+name+"%")
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) ListTrashByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) {
|
||||
var rows []filemanager.File
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&filemanager.File{}).
|
||||
Where("deleted_at IS NOT NULL")
|
||||
if len(folderID) != 0 {
|
||||
base = base.Where("folder_id = ?", folderID)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) UpdateLifecycle(ctx context.Context, params filemanager.FileLifecycleTransitionParams) error {
|
||||
status := filemanager.NormalizeFileLifecycleStatus(params.Status)
|
||||
if !filemanager.IsKnownFileLifecycleStatus(status) {
|
||||
return fmt.Errorf("invalid file lifecycle status: %s", params.Status)
|
||||
}
|
||||
updates := map[string]any{
|
||||
"lifecycle_status": status,
|
||||
"updated_by": params.UpdatedBy,
|
||||
}
|
||||
if params.AttachedAt != nil {
|
||||
updates["attached_at"] = params.AttachedAt.UTC()
|
||||
}
|
||||
if params.OrphanedAt != nil {
|
||||
updates["orphaned_at"] = params.OrphanedAt.UTC()
|
||||
}
|
||||
if params.ClearOrphanedAt {
|
||||
updates["orphaned_at"] = nil
|
||||
}
|
||||
if params.LifecycleDelete != nil {
|
||||
updates["lifecycle_deleted_at"] = params.LifecycleDelete.UTC()
|
||||
}
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&filemanager.File{}).
|
||||
Where("id = ?", params.ID).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) ListFilesByLifecycle(ctx context.Context, filter filemanager.FileLifecycleListFilter) ([]filemanager.File, error) {
|
||||
status := filemanager.NormalizeFileLifecycleStatus(filter.Status)
|
||||
if !filemanager.IsKnownFileLifecycleStatus(status) {
|
||||
return []filemanager.File{}, nil
|
||||
}
|
||||
query := r.db.WithContext(ctx).
|
||||
Model(&filemanager.File{}).
|
||||
Where("lifecycle_status = ?", status)
|
||||
if filter.OrphanedBeforeEq != nil {
|
||||
query = query.Where("orphaned_at IS NOT NULL AND orphaned_at <= ?", filter.OrphanedBeforeEq.UTC())
|
||||
}
|
||||
if filter.CreatedBeforeEq != nil {
|
||||
query = query.Where("created_at <= ?", filter.CreatedBeforeEq.UTC())
|
||||
}
|
||||
if filter.ExcludeInTrash {
|
||||
query = query.Where("deleted_at IS NULL")
|
||||
}
|
||||
if filter.ExcludeLifecycleDeleted {
|
||||
query = query.Where("lifecycle_deleted_at IS NULL")
|
||||
}
|
||||
if filter.Limit > 0 {
|
||||
query = query.Limit(filter.Limit)
|
||||
}
|
||||
rows := make([]filemanager.File, 0)
|
||||
if err := query.Order("orphaned_at ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) IsFileReferenced(ctx context.Context, fileID []byte) (bool, error) {
|
||||
if len(fileID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
queries := []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{sql: "SELECT COUNT(1) FROM attachments WHERE file_id = ?", args: []any{fileID}},
|
||||
{sql: "SELECT COUNT(1) FROM users u JOIN attachments a ON a.id = u.image_attachment_id WHERE a.file_id = ?", args: []any{fileID}},
|
||||
{sql: "SELECT COUNT(1) FROM bases b JOIN attachments a ON a.id = b.foto_attachment_id WHERE a.file_id = ? AND b.deleted_at IS NULL", args: []any{fileID}},
|
||||
{sql: "SELECT COUNT(1) FROM helicopters h JOIN attachments a ON a.id = h.foto_attachment_id WHERE a.file_id = ?", args: []any{fileID}},
|
||||
{sql: "SELECT COUNT(1) FROM helicopter_files hf JOIN attachments a ON a.id = hf.file_attachment_id WHERE a.file_id = ?", args: []any{fileID}},
|
||||
{sql: "SELECT COUNT(1) FROM hems_operational_files hof JOIN attachments a ON a.id = hof.file_attachment_id JOIN hems_operational_data hod ON hod.id = hof.operational_data_id WHERE a.file_id = ? AND hod.deleted_at IS NULL", args: []any{fileID}},
|
||||
{sql: "SELECT COUNT(1) FROM master_settings ms JOIN file_files f ON f.id = ms.logo_file_id WHERE f.id = ? AND ms.deleted_at IS NULL", args: []any{fileID}},
|
||||
{sql: "SELECT COUNT(1) FROM master_settings ms JOIN file_files f ON f.id = ms.cover_file_id WHERE f.id = ? AND ms.deleted_at IS NULL", args: []any{fileID}},
|
||||
}
|
||||
for i := range queries {
|
||||
total = 0
|
||||
if err := r.db.WithContext(ctx).Raw(queries[i].sql, queries[i].args...).Scan(&total).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if total > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
fileUUID, err := uuidv7.BytesToString(fileID)
|
||||
if err == nil && strings.TrimSpace(fileUUID) != "" {
|
||||
legacyQueries := []string{
|
||||
"SELECT COUNT(1) FROM pilot_profiles WHERE TRIM(photo_file_id) = ?",
|
||||
"SELECT COUNT(1) FROM doctor_profiles WHERE TRIM(photo_file_id) = ?",
|
||||
"SELECT COUNT(1) FROM air_rescuer_profiles WHERE TRIM(photo_file_id) = ?",
|
||||
"SELECT COUNT(1) FROM technician_profiles WHERE TRIM(photo_file_id) = ?",
|
||||
"SELECT COUNT(1) FROM flight_assistant_profiles WHERE TRIM(photo_file_id) = ?",
|
||||
"SELECT COUNT(1) FROM staff_profiles WHERE TRIM(photo_file_id) = ?",
|
||||
}
|
||||
for i := range legacyQueries {
|
||||
total = 0
|
||||
if err := r.db.WithContext(ctx).Raw(legacyQueries[i], fileUUID).Scan(&total).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if total > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
982
internal/repository/mysql/file_manager_file_repo_test.go
Normal file
982
internal/repository/mysql/file_manager_file_repo_test.go
Normal file
@@ -0,0 +1,982 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/queue"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openFileManagerFileRepoTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:file_manager_file_repo_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&filemanager.Folder{},
|
||||
&filemanager.File{},
|
||||
&filemanager.FileUploadIntent{},
|
||||
&filemanager.FileProcessingOutboxMessage{},
|
||||
&filemanager.FileStatusRealtimeEvent{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func newFileProcessingOutboxRecordForRepoTest(t *testing.T, fileID []byte, bucket, objectKey string, sizeBytes int64) *filemanager.FileProcessingOutboxMessage {
|
||||
t.Helper()
|
||||
fileUUID, err := uuidv7.BytesToString(fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("file uuid: %v", err)
|
||||
}
|
||||
serializer := queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, "")
|
||||
message, err := serializer.Serialize(context.Background(), queue.FileProcessingJob{
|
||||
FileUUID: fileUUID,
|
||||
Bucket: bucket,
|
||||
ObjectKey: objectKey,
|
||||
SizeBytes: sizeBytes,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("serialize outbox message: %v", err)
|
||||
}
|
||||
record, err := filemanager.NewFileProcessingOutboxMessage(time.Now().UTC(), &filemanager.FileProcessingOutboundMessage{
|
||||
ID: message.ID,
|
||||
Body: append([]byte(nil), message.Body...),
|
||||
Attributes: message.Attributes,
|
||||
MessageGroupID: message.MessageGroupID,
|
||||
DeduplicationID: message.DeduplicationID,
|
||||
})
|
||||
if err == nil {
|
||||
return record
|
||||
}
|
||||
t.Fatalf("new outbox message: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedFolderForFileRepo(t *testing.T, db *gorm.DB, name string) *filemanager.Folder {
|
||||
t.Helper()
|
||||
row := &filemanager.Folder{Name: name, NameNormalized: name, NameSlot: "live"}
|
||||
if err := db.WithContext(context.Background()).Create(row).Error; err != nil {
|
||||
t.Fatalf("seed folder: %v", err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func TestNewFileManagerFileRepository(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryCreateFileStatusRealtimeEvent(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
|
||||
fileID := uuidv7.MustBytes()
|
||||
userID := uuidv7.MustBytes()
|
||||
occurredAt := time.Now().UTC()
|
||||
err := repo.CreateFileStatusRealtimeEvent(context.Background(), filemanager.FileStatusRealtimeEventInput{
|
||||
FileID: fileID,
|
||||
UserID: userID,
|
||||
Status: filemanager.FileStatusUploaded,
|
||||
Name: "report.pdf",
|
||||
OccurredAt: occurredAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create realtime event: %v", err)
|
||||
}
|
||||
|
||||
var rows []filemanager.FileStatusRealtimeEvent
|
||||
if err := db.WithContext(context.Background()).
|
||||
Where("file_id = ? AND user_id = ? AND status = ?", fileID, userID, filemanager.FileStatusUploaded).
|
||||
Find(&rows).Error; err != nil {
|
||||
t.Fatalf("query realtime events: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected one realtime event row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].DeliveredAt != nil {
|
||||
t.Fatalf("expected new realtime event as undelivered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryUploadIntentLifecycle(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
|
||||
intent := &filemanager.FileUploadIntent{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Name: "report.pdf",
|
||||
NameNormalized: "report.pdf",
|
||||
Extension: "pdf",
|
||||
SizeBytes: 1024,
|
||||
MimeType: "application/pdf",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
Status: filemanager.FileUploadIntentStatusPending,
|
||||
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
|
||||
CreatedBy: uuidv7.MustBytes(),
|
||||
UpdatedBy: uuidv7.MustBytes(),
|
||||
}
|
||||
if err := repo.CreateUploadIntent(context.Background(), intent); err != nil {
|
||||
t.Fatalf("create upload intent: %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.GetUploadIntentByID(context.Background(), intent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get upload intent: %v", err)
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("expected upload intent")
|
||||
}
|
||||
if found.ObjectKey != intent.ObjectKey {
|
||||
t.Fatalf("unexpected object key, got %q want %q", found.ObjectKey, intent.ObjectKey)
|
||||
}
|
||||
|
||||
locked, err := repo.GetUploadIntentByIDForUpdate(context.Background(), intent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get upload intent for update: %v", err)
|
||||
}
|
||||
if locked == nil {
|
||||
t.Fatalf("expected locked upload intent")
|
||||
}
|
||||
|
||||
completedAt := time.Now().UTC()
|
||||
if err := repo.MarkUploadIntentCompleted(context.Background(), filemanager.FileUploadIntentCompleteParams{
|
||||
ID: intent.ID,
|
||||
UpdatedBy: intent.UpdatedBy,
|
||||
CompletedAt: completedAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("mark upload intent completed: %v", err)
|
||||
}
|
||||
|
||||
done, err := repo.GetUploadIntentByID(context.Background(), intent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get completed upload intent: %v", err)
|
||||
}
|
||||
if done == nil {
|
||||
t.Fatalf("expected completed upload intent")
|
||||
}
|
||||
if filemanager.NormalizeFileUploadIntentStatus(done.Status) != filemanager.FileUploadIntentStatusCompleted {
|
||||
t.Fatalf("expected completed status, got %q", done.Status)
|
||||
}
|
||||
if done.CompletedAt == nil {
|
||||
t.Fatalf("expected completed_at set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryMarkUploadIntentExpired(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
|
||||
intent := &filemanager.FileUploadIntent{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Name: "expired.pdf",
|
||||
NameNormalized: "expired.pdf",
|
||||
Extension: "pdf",
|
||||
SizeBytes: 12,
|
||||
MimeType: "application/pdf",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
Status: filemanager.FileUploadIntentStatusPending,
|
||||
ExpiresAt: time.Now().UTC().Add(-time.Minute),
|
||||
}
|
||||
if err := repo.CreateUploadIntent(context.Background(), intent); err != nil {
|
||||
t.Fatalf("create upload intent: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.MarkUploadIntentExpired(context.Background(), filemanager.FileUploadIntentExpireParams{
|
||||
ID: intent.ID,
|
||||
UpdatedBy: uuidv7.MustBytes(),
|
||||
ExpiredAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("mark upload intent expired: %v", err)
|
||||
}
|
||||
|
||||
expired, err := repo.GetUploadIntentByID(context.Background(), intent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get expired upload intent: %v", err)
|
||||
}
|
||||
if expired == nil {
|
||||
t.Fatalf("expected expired upload intent")
|
||||
}
|
||||
if filemanager.NormalizeFileUploadIntentStatus(expired.Status) != filemanager.FileUploadIntentStatusExpired {
|
||||
t.Fatalf("expected expired status, got %q", expired.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryUpdateUploadIntentSize(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
|
||||
intent := &filemanager.FileUploadIntent{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Name: "draft.xlsx",
|
||||
NameNormalized: "draft.xlsx",
|
||||
Extension: "xlsx",
|
||||
SizeBytes: 100,
|
||||
MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
Status: filemanager.FileUploadIntentStatusPending,
|
||||
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
|
||||
}
|
||||
if err := repo.CreateUploadIntent(context.Background(), intent); err != nil {
|
||||
t.Fatalf("create upload intent: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.UpdateUploadIntentSize(context.Background(), filemanager.FileUploadIntentSizeUpdateParams{
|
||||
ID: intent.ID,
|
||||
SizeBytes: 64,
|
||||
UpdatedBy: uuidv7.MustBytes(),
|
||||
}); err != nil {
|
||||
t.Fatalf("update upload intent size: %v", err)
|
||||
}
|
||||
|
||||
updated, err := repo.GetUploadIntentByID(context.Background(), intent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get updated upload intent: %v", err)
|
||||
}
|
||||
if updated == nil {
|
||||
t.Fatalf("expected upload intent")
|
||||
}
|
||||
if updated.SizeBytes != 64 {
|
||||
t.Fatalf("expected size_bytes 64, got %d", updated.SizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryWithTransaction_PersistFileAndOutbox(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
|
||||
row := &filemanager.File{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FolderID: folder.ID,
|
||||
Name: "upload.pdf",
|
||||
NameNormalized: "upload.pdf",
|
||||
Extension: "pdf",
|
||||
SizeBytes: 1024,
|
||||
MimeType: "application/pdf",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "tmp-slot",
|
||||
Status: filemanager.FileStatusUploaded,
|
||||
}
|
||||
outbox := newFileProcessingOutboxRecordForRepoTest(t, row.ID, row.Bucket, row.ObjectKey, row.SizeBytes)
|
||||
|
||||
err := repo.WithTransaction(context.Background(), func(fileRepo filemanager.FileRepository, outboxWriter filemanager.FileProcessingOutboxWriter) error {
|
||||
if err := fileRepo.Create(context.Background(), row); err != nil {
|
||||
return err
|
||||
}
|
||||
return outboxWriter.CreateFileProcessingOutboxMessage(context.Background(), outbox)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("with transaction: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected uploaded file persisted")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Model(&filemanager.FileProcessingOutboxMessage{}).Where("message_id = ?", outbox.MessageID).Count(&total).Error; err != nil {
|
||||
t.Fatalf("count outbox: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("expected one outbox row, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryWithTransaction_Rollback(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
|
||||
row := &filemanager.File{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FolderID: folder.ID,
|
||||
Name: "upload.pdf",
|
||||
NameNormalized: "upload.pdf",
|
||||
Extension: "pdf",
|
||||
SizeBytes: 1024,
|
||||
MimeType: "application/pdf",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "tmp-slot",
|
||||
Status: filemanager.FileStatusUploaded,
|
||||
}
|
||||
outbox := newFileProcessingOutboxRecordForRepoTest(t, row.ID, row.Bucket, row.ObjectKey, row.SizeBytes)
|
||||
rollbackErr := errors.New("force rollback")
|
||||
|
||||
err := repo.WithTransaction(context.Background(), func(fileRepo filemanager.FileRepository, outboxWriter filemanager.FileProcessingOutboxWriter) error {
|
||||
if err := fileRepo.Create(context.Background(), row); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := outboxWriter.CreateFileProcessingOutboxMessage(context.Background(), outbox); err != nil {
|
||||
return err
|
||||
}
|
||||
return rollbackErr
|
||||
})
|
||||
if !errors.Is(err, rollbackErr) {
|
||||
t.Fatalf("expected rollback error, got %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected file insert rolled back")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Model(&filemanager.FileProcessingOutboxMessage{}).Where("message_id = ?", outbox.MessageID).Count(&total).Error; err != nil {
|
||||
t.Fatalf("count outbox: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("expected outbox insert rolled back, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryCreate_AllowsMultipleNullThumbnailObjectKeys(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "images")
|
||||
|
||||
first := &filemanager.File{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FolderID: folder.ID,
|
||||
Name: "one.jpg",
|
||||
NameNormalized: "one.jpg",
|
||||
Extension: "jpg",
|
||||
SizeBytes: 10,
|
||||
MimeType: "image/jpeg",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d-one", time.Now().UnixNano()),
|
||||
NameSlot: "tmp-one",
|
||||
Status: filemanager.FileStatusUploaded,
|
||||
}
|
||||
second := &filemanager.File{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FolderID: folder.ID,
|
||||
Name: "two.jpg",
|
||||
NameNormalized: "two.jpg",
|
||||
Extension: "jpg",
|
||||
SizeBytes: 20,
|
||||
MimeType: "image/jpeg",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d-two", time.Now().UnixNano()),
|
||||
NameSlot: "tmp-two",
|
||||
Status: filemanager.FileStatusUploaded,
|
||||
}
|
||||
|
||||
if err := repo.Create(context.Background(), first); err != nil {
|
||||
t.Fatalf("create first file: %v", err)
|
||||
}
|
||||
if err := repo.Create(context.Background(), second); err != nil {
|
||||
t.Fatalf("create second file with nil thumbnail object key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryCreateAndGetters(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
templateUUID := uuidv7.MustBytes()
|
||||
takeoverID := uuidv7.MustBytes()
|
||||
|
||||
objectKey := fmt.Sprintf("fm/objects/%d", time.Now().UnixNano())
|
||||
row := &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "Policy.pdf",
|
||||
NameNormalized: "policy.pdf",
|
||||
Extension: "pdf",
|
||||
SizeBytes: 1024,
|
||||
MimeType: "application/pdf",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: objectKey,
|
||||
NameSlot: "live",
|
||||
Status: filemanager.FileStatusReady,
|
||||
TemplateUUID: templateUUID,
|
||||
TakeoverID: takeoverID,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("create file: %v", err)
|
||||
}
|
||||
if len(row.ID) != 16 {
|
||||
t.Fatalf("expected generated id")
|
||||
}
|
||||
|
||||
byID, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if byID == nil || byID.Name != "Policy.pdf" {
|
||||
t.Fatalf("expected by id result")
|
||||
}
|
||||
|
||||
byKey, err := repo.GetByObjectKey(context.Background(), objectKey)
|
||||
if err != nil {
|
||||
t.Fatalf("get by object key: %v", err)
|
||||
}
|
||||
if byKey == nil || !bytes.Equal(byKey.ID, row.ID) {
|
||||
t.Fatalf("expected by object key result")
|
||||
}
|
||||
|
||||
byName, err := repo.GetByFolderAndName(context.Background(), folder.ID, "policy.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("get by folder/name: %v", err)
|
||||
}
|
||||
if byName == nil || !bytes.Equal(byName.ID, row.ID) {
|
||||
t.Fatalf("expected by folder/name result")
|
||||
}
|
||||
|
||||
byTemplateTakeover, err := repo.GetByTemplateAndTakeover(context.Background(), templateUUID, takeoverID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by template/takeover: %v", err)
|
||||
}
|
||||
if byTemplateTakeover == nil || !bytes.Equal(byTemplateTakeover.ID, row.ID) {
|
||||
t.Fatalf("expected by template/takeover result")
|
||||
}
|
||||
|
||||
blankKey, err := repo.GetByObjectKey(context.Background(), " ")
|
||||
if err != nil {
|
||||
t.Fatalf("get blank object key: %v", err)
|
||||
}
|
||||
if blankKey != nil {
|
||||
t.Fatalf("expected nil for blank object key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryGetByTemplateAndTakeoverReturnsDraftRows(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
templateUUID := uuidv7.MustBytes()
|
||||
takeoverID := uuidv7.MustBytes()
|
||||
|
||||
row := &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "Draft.docx",
|
||||
NameNormalized: "draft.docx",
|
||||
Extension: "docx",
|
||||
SizeBytes: 2048,
|
||||
MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d-draft", time.Now().UnixNano()),
|
||||
NameSlot: "draft",
|
||||
Status: filemanager.FileStatusReady,
|
||||
TemplateUUID: templateUUID,
|
||||
TakeoverID: takeoverID,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("create draft file: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByTemplateAndTakeover(context.Background(), templateUUID, takeoverID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by template/takeover: %v", err)
|
||||
}
|
||||
if got == nil || !bytes.Equal(got.ID, row.ID) {
|
||||
t.Fatalf("expected draft row to be returned, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryUpdate(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
|
||||
row := &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "Old.txt",
|
||||
NameNormalized: "old.txt",
|
||||
MimeType: "text/plain",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "live",
|
||||
Status: filemanager.FileStatusReady,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Name = "New.txt"
|
||||
row.NameNormalized = "new.txt"
|
||||
row.SizeBytes = 88
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("update file: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after update: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "New.txt" || got.SizeBytes != 88 {
|
||||
t.Fatalf("unexpected updated row: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryTransitionStatus(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
|
||||
row := &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "upload.pdf",
|
||||
NameNormalized: "upload.pdf",
|
||||
MimeType: "application/pdf",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "tmp-slot",
|
||||
Status: filemanager.FileStatusUploaded,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
startedAt := time.Now().UTC().Truncate(time.Second)
|
||||
if err := repo.TransitionStatus(context.Background(), filemanager.FileStatusTransitionParams{
|
||||
ID: row.ID,
|
||||
ExpectedFrom: filemanager.FileStatusUploaded,
|
||||
To: filemanager.FileStatusProcessing,
|
||||
ProcessingStartedAt: &startedAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("to processing: %v", err)
|
||||
}
|
||||
|
||||
mid, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after processing: %v", err)
|
||||
}
|
||||
if mid == nil || mid.Status != filemanager.FileStatusProcessing {
|
||||
t.Fatalf("expected processing status")
|
||||
}
|
||||
if mid.ProcessingStartedAt == nil || !mid.ProcessingStartedAt.Equal(startedAt) {
|
||||
t.Fatalf("expected processing_started_at set")
|
||||
}
|
||||
|
||||
processedAt := time.Now().UTC().Truncate(time.Second)
|
||||
validatedAt := processedAt.Add(2 * time.Second)
|
||||
if err := repo.TransitionStatus(context.Background(), filemanager.FileStatusTransitionParams{
|
||||
ID: row.ID,
|
||||
ExpectedFrom: filemanager.FileStatusProcessing,
|
||||
To: filemanager.FileStatusValidated,
|
||||
ProcessedAt: &processedAt,
|
||||
ValidatedAt: &validatedAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("to validated: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.TransitionStatus(context.Background(), filemanager.FileStatusTransitionParams{
|
||||
ID: row.ID,
|
||||
ExpectedFrom: filemanager.FileStatusUploaded,
|
||||
To: filemanager.FileStatusReady,
|
||||
}); !errors.Is(err, filemanager.ErrInvalidFileStatusTransition) {
|
||||
t.Fatalf("expected invalid transition for uploaded->ready via transition api, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryTransitionStatus_FailedAndMismatch(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
|
||||
row := &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "processing.pdf",
|
||||
NameNormalized: "processing.pdf",
|
||||
MimeType: "application/pdf",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "tmp-slot",
|
||||
Status: filemanager.FileStatusProcessing,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
reason := "invalid payload"
|
||||
if err := repo.TransitionStatus(context.Background(), filemanager.FileStatusTransitionParams{
|
||||
ID: row.ID,
|
||||
ExpectedFrom: filemanager.FileStatusProcessing,
|
||||
To: filemanager.FileStatusFailed,
|
||||
FailureReason: &reason,
|
||||
}); err != nil {
|
||||
t.Fatalf("to failed: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after failed: %v", err)
|
||||
}
|
||||
if got == nil || got.Status != filemanager.FileStatusFailed {
|
||||
t.Fatalf("expected failed status")
|
||||
}
|
||||
if got.FailedAt == nil {
|
||||
t.Fatalf("expected failed_at set")
|
||||
}
|
||||
if got.FailureReason != reason {
|
||||
t.Fatalf("expected failure reason %q, got %q", reason, got.FailureReason)
|
||||
}
|
||||
|
||||
err = repo.TransitionStatus(context.Background(), filemanager.FileStatusTransitionParams{
|
||||
ID: row.ID,
|
||||
ExpectedFrom: filemanager.FileStatusProcessing,
|
||||
To: filemanager.FileStatusValidated,
|
||||
})
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("expected gorm.ErrRecordNotFound for status mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryFinalize(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
rootFolder := seedFolderForFileRepo(t, db, "root")
|
||||
targetFolder := seedFolderForFileRepo(t, db, "target")
|
||||
|
||||
row := &filemanager.File{
|
||||
FolderID: rootFolder.ID,
|
||||
Name: "upload.tmp",
|
||||
NameNormalized: "upload.tmp",
|
||||
Extension: "tmp",
|
||||
MimeType: "application/octet-stream",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "tmp-slot",
|
||||
Status: filemanager.FileStatusValidated,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Finalize(context.Background(), filemanager.FileFinalizeParams{
|
||||
ID: row.ID,
|
||||
ExpectedFrom: filemanager.FileStatusValidated,
|
||||
FolderID: targetFolder.ID,
|
||||
Name: "Final.pdf",
|
||||
NameNormalized: "final.pdf",
|
||||
Extension: "pdf",
|
||||
UpdatedBy: uuidv7.MustBytes(),
|
||||
}); err != nil {
|
||||
t.Fatalf("finalize file: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after finalize: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected finalized row")
|
||||
}
|
||||
if got.Status != filemanager.FileStatusReady {
|
||||
t.Fatalf("expected status ready, got %s", got.Status)
|
||||
}
|
||||
if got.NameSlot != "live" {
|
||||
t.Fatalf("expected name slot live, got %s", got.NameSlot)
|
||||
}
|
||||
if !bytes.Equal(got.FolderID, targetFolder.ID) {
|
||||
t.Fatalf("expected folder updated")
|
||||
}
|
||||
if got.Name != "Final.pdf" || got.NameNormalized != "final.pdf" || got.Extension != "pdf" {
|
||||
t.Fatalf("expected naming fields updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryFinalize_StatusMismatch(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
|
||||
row := &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "ready.txt",
|
||||
NameNormalized: "ready.txt",
|
||||
MimeType: "text/plain",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "live",
|
||||
Status: filemanager.FileStatusReady,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
err := repo.Finalize(context.Background(), filemanager.FileFinalizeParams{
|
||||
ID: row.ID,
|
||||
ExpectedFrom: filemanager.FileStatusValidated,
|
||||
FolderID: folder.ID,
|
||||
Name: "ready.txt",
|
||||
NameNormalized: "ready.txt",
|
||||
Extension: "txt",
|
||||
UpdatedBy: uuidv7.MustBytes(),
|
||||
})
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("expected gorm.ErrRecordNotFound for status mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryDelete(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
|
||||
row := &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "Delete.bin",
|
||||
NameNormalized: "delete.bin",
|
||||
MimeType: "application/octet-stream",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "live",
|
||||
Status: filemanager.FileStatusReady,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
purgeAt := time.Now().UTC().Add(72 * time.Hour).Truncate(time.Second)
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy, &purgeAt); err != nil {
|
||||
t.Fatalf("delete file: %v", err)
|
||||
}
|
||||
|
||||
active, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after delete: %v", err)
|
||||
}
|
||||
if active != nil {
|
||||
t.Fatalf("expected deleted row hidden from GetByID")
|
||||
}
|
||||
|
||||
var stored filemanager.File
|
||||
if err := db.WithContext(context.Background()).Unscoped().Where("id = ?", row.ID).First(&stored).Error; err != nil {
|
||||
t.Fatalf("unscoped find: %v", err)
|
||||
}
|
||||
if stored.DeletedAt == nil || stored.TrashedAt == nil {
|
||||
t.Fatalf("expected deleted_at and trashed_at populated")
|
||||
}
|
||||
if stored.Status != filemanager.FileStatusTrashed {
|
||||
t.Fatalf("expected status trashed, got %s", stored.Status)
|
||||
}
|
||||
if stored.PurgeAt == nil || !stored.PurgeAt.Equal(purgeAt) {
|
||||
t.Fatalf("expected purge_at set")
|
||||
}
|
||||
if !bytes.Equal(stored.DeletedBy, deletedBy) {
|
||||
t.Fatalf("expected deleted_by set")
|
||||
}
|
||||
if stored.NameSlot == "live" {
|
||||
t.Fatalf("expected name_slot switched from live on delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryDelete_OnlyReadyAllowed(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
|
||||
row := &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "NotReady.bin",
|
||||
NameNormalized: "notready.bin",
|
||||
MimeType: "application/octet-stream",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "tmp-slot",
|
||||
Status: filemanager.FileStatusValidated,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
err := repo.Delete(context.Background(), row.ID, uuidv7.MustBytes(), nil)
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("expected gorm.ErrRecordNotFound for non-ready delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryRestorePurgeAndTrashQueries(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "archive")
|
||||
|
||||
row := &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "Archive.txt",
|
||||
NameNormalized: "archive.txt",
|
||||
MimeType: "text/plain",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "live",
|
||||
Status: filemanager.FileStatusReady,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("create file: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy, nil); err != nil {
|
||||
t.Fatalf("delete file: %v", err)
|
||||
}
|
||||
|
||||
anyRow, err := repo.GetByIDAny(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id any: %v", err)
|
||||
}
|
||||
if anyRow == nil || anyRow.DeletedAt == nil {
|
||||
t.Fatalf("expected deleted row from GetByIDAny")
|
||||
}
|
||||
|
||||
trashRows, trashTotal, err := repo.ListTrashByFolder(context.Background(), folder.ID, "", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list trash by folder: %v", err)
|
||||
}
|
||||
if trashTotal != 1 || len(trashRows) != 1 {
|
||||
t.Fatalf("expected one trashed file")
|
||||
}
|
||||
|
||||
restoreBy := uuidv7.MustBytes()
|
||||
if err := repo.Restore(context.Background(), row.ID, restoreBy); err != nil {
|
||||
t.Fatalf("restore file: %v", err)
|
||||
}
|
||||
restored, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get restored file: %v", err)
|
||||
}
|
||||
if restored == nil || restored.DeletedAt != nil || restored.NameSlot != "live" {
|
||||
t.Fatalf("expected restored live file")
|
||||
}
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy, nil); err != nil {
|
||||
t.Fatalf("delete file 2: %v", err)
|
||||
}
|
||||
if err := repo.Purge(context.Background(), row.ID); err != nil {
|
||||
t.Fatalf("purge file: %v", err)
|
||||
}
|
||||
if got, err := repo.GetByIDAny(context.Background(), row.ID); err != nil || got != nil {
|
||||
t.Fatalf("expected purged file gone, got=%v err=%v", got, err)
|
||||
}
|
||||
if err := repo.Purge(context.Background(), row.ID); err == nil {
|
||||
t.Fatalf("expected purge not found on second call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryPurge_DeletesActiveFileRow(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "active")
|
||||
|
||||
row := &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "InProgress.txt",
|
||||
NameNormalized: "inprogress.txt",
|
||||
MimeType: "text/plain",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "live",
|
||||
Status: filemanager.FileStatusValidated,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("create file: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Purge(context.Background(), row.ID); err != nil {
|
||||
t.Fatalf("purge active file: %v", err)
|
||||
}
|
||||
if got, err := repo.GetByIDAny(context.Background(), row.ID); err != nil || got != nil {
|
||||
t.Fatalf("expected purged active file gone, got=%v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFileRepositoryListByFolder(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
|
||||
_ = repo.Create(context.Background(), &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "B.txt",
|
||||
NameNormalized: "b.txt",
|
||||
MimeType: "text/plain",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "live",
|
||||
Status: filemanager.FileStatusReady,
|
||||
})
|
||||
_ = repo.Create(context.Background(), &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "A.txt",
|
||||
NameNormalized: "a.txt",
|
||||
MimeType: "text/plain",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()+1),
|
||||
NameSlot: "live",
|
||||
Status: filemanager.FileStatusReady,
|
||||
})
|
||||
|
||||
rows, total, err := repo.ListByFolder(context.Background(), folder.ID, "name ASC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list files: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("expected total 2, got %d", total)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Name != "A.txt" {
|
||||
t.Fatalf("expected sorted+limited list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
if err := db.Migrator().DropTable(&filemanager.File{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
if _, _, err := repo.ListByFolder(context.Background(), folder.ID, "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openFileManagerFileRepoTestDB(t)
|
||||
repo := NewFileManagerFileRepository(db)
|
||||
folder := seedFolderForFileRepo(t, db, "docs")
|
||||
_ = repo.Create(context.Background(), &filemanager.File{
|
||||
FolderID: folder.ID,
|
||||
Name: "X.txt",
|
||||
NameNormalized: "x.txt",
|
||||
MimeType: "text/plain",
|
||||
Bucket: "wucher-file-dev",
|
||||
ObjectKey: fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
|
||||
NameSlot: "live",
|
||||
Status: filemanager.FileStatusReady,
|
||||
})
|
||||
if _, _, err := repo.ListByFolder(context.Background(), folder.ID, "name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error")
|
||||
}
|
||||
})
|
||||
}
|
||||
460
internal/repository/mysql/file_manager_folder_repo.go
Normal file
460
internal/repository/mysql/file_manager_folder_repo.go
Normal file
@@ -0,0 +1,460 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type FileManagerFolderRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFileManagerFolderRepository(db *gorm.DB) *FileManagerFolderRepository {
|
||||
return &FileManagerFolderRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) Create(ctx context.Context, row *filemanager.Folder) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) Update(ctx context.Context, row *filemanager.Folder) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) MoveSubtree(ctx context.Context, id []byte, targetParentID []byte, targetName string, targetNameNormalized string, updatedBy []byte) (*filemanager.Folder, error) {
|
||||
var moved filemanager.Folder
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current filemanager.Folder
|
||||
if err := tx.Model(&filemanager.Folder{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetDepth := 0
|
||||
targetParentPath := ""
|
||||
if len(targetParentID) != 0 {
|
||||
if folderRepoBytesEqual16(targetParentID, current.ID) {
|
||||
return filemanager.ErrInvalidMove
|
||||
}
|
||||
|
||||
var parent filemanager.Folder
|
||||
if err := tx.Model(&filemanager.Folder{}).
|
||||
Where("id = ? AND deleted_at IS NULL", targetParentID).
|
||||
First(&parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isDescendant, err := folderRepoIsDescendantFolder(tx, targetParentID, current.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isDescendant {
|
||||
return filemanager.ErrInvalidMove
|
||||
}
|
||||
|
||||
targetDepth = parent.Depth + 1
|
||||
targetParentPath = parent.PathCache
|
||||
}
|
||||
|
||||
var dupTotal int64
|
||||
if err := folderParentScope(
|
||||
tx.Model(&filemanager.Folder{}),
|
||||
targetParentID,
|
||||
).Where("name_normalized = ? AND name_slot = 'live' AND deleted_at IS NULL AND id <> ?", targetNameNormalized, id).Count(&dupTotal).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if dupTotal > 0 {
|
||||
return gorm.ErrDuplicatedKey
|
||||
}
|
||||
|
||||
oldPath := current.PathCache
|
||||
oldDepth := current.Depth
|
||||
newPath := folderRepoBuildPathCache(targetParentPath, targetName)
|
||||
|
||||
res := tx.Model(&filemanager.Folder{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{
|
||||
"parent_id": targetParentID,
|
||||
"name": targetName,
|
||||
"name_normalized": targetNameNormalized,
|
||||
"depth": targetDepth,
|
||||
"path_cache": newPath,
|
||||
"updated_by": updatedBy,
|
||||
"updated_at": time.Now().UTC(),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
depthDelta := targetDepth - oldDepth
|
||||
if oldPath != newPath || depthDelta != 0 {
|
||||
if err := folderRepoRewriteDescendantTree(tx, current.ID, oldPath, newPath, depthDelta, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
current.ParentID = targetParentID
|
||||
current.Name = targetName
|
||||
current.NameNormalized = targetNameNormalized
|
||||
current.Depth = targetDepth
|
||||
current.PathCache = newPath
|
||||
current.UpdatedBy = updatedBy
|
||||
moved = current
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &moved, nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) Delete(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error {
|
||||
now := time.Now().UTC()
|
||||
nameSlot := deletedNameSlot(id)
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
"name_slot": nameSlot,
|
||||
}
|
||||
if purgeAt != nil {
|
||||
v := purgeAt.UTC()
|
||||
updates["purge_at"] = v
|
||||
}
|
||||
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&filemanager.Folder{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) Restore(ctx context.Context, id []byte, restoredBy []byte) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var row filemanager.Folder
|
||||
if err := tx.Model(&filemanager.Folder{}).Where("id = ?", id).First(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.DeletedAt == nil {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := folderParentScope(
|
||||
tx.Model(&filemanager.Folder{}),
|
||||
row.ParentID,
|
||||
).Where("name_normalized = ? AND name_slot = 'live' AND deleted_at IS NULL", row.NameNormalized).Count(&total).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if total > 0 {
|
||||
return gorm.ErrDuplicatedKey
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"deleted_at": nil,
|
||||
"deleted_by": nil,
|
||||
"purge_at": nil,
|
||||
"updated_by": restoredBy,
|
||||
"name_slot": "live",
|
||||
}
|
||||
res := tx.Model(&filemanager.Folder{}).
|
||||
Where("id = ? AND deleted_at IS NOT NULL", id).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) Purge(ctx context.Context, id []byte) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Where("id = ? AND deleted_at IS NOT NULL", id).
|
||||
Delete(&filemanager.Folder{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) GetByID(ctx context.Context, id []byte) (*filemanager.Folder, error) {
|
||||
var row filemanager.Folder
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) GetByIDAny(ctx context.Context, id []byte) (*filemanager.Folder, error) {
|
||||
var row filemanager.Folder
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ?", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) GetByParentAndName(ctx context.Context, parentID []byte, nameNormalized string) (*filemanager.Folder, error) {
|
||||
var row filemanager.Folder
|
||||
q := folderParentScope(r.db.WithContext(ctx).Model(&filemanager.Folder{}), parentID).
|
||||
Where("name_normalized = ? AND name_slot = 'live' AND deleted_at IS NULL", nameNormalized)
|
||||
|
||||
err := q.First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) ListByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
|
||||
var rows []filemanager.Folder
|
||||
var total int64
|
||||
|
||||
base := folderParentScope(
|
||||
r.db.WithContext(ctx).
|
||||
Model(&filemanager.Folder{}).
|
||||
Where("deleted_at IS NULL AND name_slot = 'live'"),
|
||||
parentID,
|
||||
)
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) SearchByName(ctx context.Context, name string, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
|
||||
var rows []filemanager.Folder
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&filemanager.Folder{}).
|
||||
Where("deleted_at IS NULL AND name_slot = 'live' AND name LIKE ?", "%"+name+"%")
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) ListTrash(ctx context.Context, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
|
||||
var rows []filemanager.Folder
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&filemanager.Folder{}).
|
||||
Where("deleted_at IS NOT NULL")
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFolderRepository) ListTrashByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
|
||||
var rows []filemanager.Folder
|
||||
var total int64
|
||||
|
||||
base := folderParentScope(
|
||||
r.db.WithContext(ctx).
|
||||
Model(&filemanager.Folder{}).
|
||||
Where("deleted_at IS NOT NULL"),
|
||||
parentID,
|
||||
)
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func folderParentScope(tx *gorm.DB, parentID []byte) *gorm.DB {
|
||||
if len(parentID) == 0 {
|
||||
return tx.Where("parent_id IS NULL")
|
||||
}
|
||||
return tx.Where("parent_id = ?", parentID)
|
||||
}
|
||||
|
||||
func deletedNameSlot(id []byte) string {
|
||||
if len(id) == 16 {
|
||||
if v, err := uuidv7.BytesToString(id); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if v, err := uuidv7.BytesToString(uuidv7.MustBytes()); err == nil {
|
||||
return v
|
||||
}
|
||||
return "deleted"
|
||||
}
|
||||
|
||||
func folderRepoIsDescendantFolder(tx *gorm.DB, candidateID []byte, ancestorID []byte) (bool, error) {
|
||||
currentID := append([]byte(nil), candidateID...)
|
||||
for len(currentID) != 0 {
|
||||
if folderRepoBytesEqual16(currentID, ancestorID) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var current filemanager.Folder
|
||||
err := tx.Model(&filemanager.Folder{}).
|
||||
Where("id = ? AND deleted_at IS NULL", currentID).
|
||||
First(¤t).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(current.ParentID) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
currentID = append([]byte(nil), current.ParentID...)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func folderRepoRewriteDescendantTree(tx *gorm.DB, rootID []byte, oldPrefix string, newPrefix string, depthDelta int, updatedBy []byte) error {
|
||||
queue := [][]byte{append([]byte(nil), rootID...)}
|
||||
for len(queue) > 0 {
|
||||
parentID := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
var children []filemanager.Folder
|
||||
if err := tx.Model(&filemanager.Folder{}).
|
||||
Where("parent_id = ?", parentID).
|
||||
Find(&children).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range children {
|
||||
child := children[i]
|
||||
pathCache := child.PathCache
|
||||
if pathCache != "" {
|
||||
pathCache = folderRepoReplacePathPrefix(pathCache, oldPrefix, newPrefix)
|
||||
}
|
||||
depth := child.Depth + depthDelta
|
||||
if depth < 0 {
|
||||
depth = 0
|
||||
}
|
||||
|
||||
if err := tx.Model(&filemanager.Folder{}).
|
||||
Where("id = ?", child.ID).
|
||||
Updates(map[string]any{
|
||||
"path_cache": pathCache,
|
||||
"depth": depth,
|
||||
"updated_by": updatedBy,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
queue = append(queue, append([]byte(nil), child.ID...))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func folderRepoReplacePathPrefix(pathCache string, oldPrefix string, newPrefix string) string {
|
||||
if oldPrefix == "" {
|
||||
return pathCache
|
||||
}
|
||||
if pathCache == oldPrefix {
|
||||
return newPrefix
|
||||
}
|
||||
prefixWithSlash := oldPrefix + "/"
|
||||
if strings.HasPrefix(pathCache, prefixWithSlash) {
|
||||
return newPrefix + strings.TrimPrefix(pathCache, oldPrefix)
|
||||
}
|
||||
return pathCache
|
||||
}
|
||||
|
||||
func folderRepoBuildPathCache(parentPath, name string) string {
|
||||
parentPath = strings.TrimSpace(parentPath)
|
||||
if parentPath == "" {
|
||||
return "/" + name
|
||||
}
|
||||
parentPath = strings.TrimRight(parentPath, "/")
|
||||
return parentPath + "/" + name
|
||||
}
|
||||
|
||||
func folderRepoBytesEqual16(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
296
internal/repository/mysql/file_manager_folder_repo_test.go
Normal file
296
internal/repository/mysql/file_manager_folder_repo_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openFileManagerFolderRepoTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:file_manager_folder_repo_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&filemanager.Folder{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewFileManagerFolderRepository(t *testing.T) {
|
||||
db := openFileManagerFolderRepoTestDB(t)
|
||||
repo := NewFileManagerFolderRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFolderRepositoryCreateAndGetByID(t *testing.T) {
|
||||
db := openFileManagerFolderRepoTestDB(t)
|
||||
repo := NewFileManagerFolderRepository(db)
|
||||
|
||||
row := &filemanager.Folder{
|
||||
Name: "Operations",
|
||||
NameNormalized: "operations",
|
||||
NameSlot: "live",
|
||||
Depth: 0,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("create folder: %v", err)
|
||||
}
|
||||
if len(row.ID) != 16 {
|
||||
t.Fatalf("expected generated id")
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "Operations" {
|
||||
t.Fatalf("expected created folder")
|
||||
}
|
||||
|
||||
missing, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("get missing by id: %v", err)
|
||||
}
|
||||
if missing != nil {
|
||||
t.Fatalf("expected nil for missing folder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFolderRepositoryUpdate(t *testing.T) {
|
||||
db := openFileManagerFolderRepoTestDB(t)
|
||||
repo := NewFileManagerFolderRepository(db)
|
||||
|
||||
row := &filemanager.Folder{
|
||||
Name: "Old Name",
|
||||
NameNormalized: "old name",
|
||||
NameSlot: "live",
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Name = "New Name"
|
||||
row.NameNormalized = "new name"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("update folder: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after update: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "New Name" || got.NameNormalized != "new name" {
|
||||
t.Fatalf("unexpected updated row: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFolderRepositoryDelete(t *testing.T) {
|
||||
db := openFileManagerFolderRepoTestDB(t)
|
||||
repo := NewFileManagerFolderRepository(db)
|
||||
|
||||
row := &filemanager.Folder{
|
||||
Name: "Delete Me",
|
||||
NameNormalized: "delete me",
|
||||
NameSlot: "live",
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
purgeAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second)
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy, &purgeAt); err != nil {
|
||||
t.Fatalf("delete folder: %v", err)
|
||||
}
|
||||
|
||||
active, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after delete: %v", err)
|
||||
}
|
||||
if active != nil {
|
||||
t.Fatalf("expected deleted row hidden from GetByID")
|
||||
}
|
||||
|
||||
var stored filemanager.Folder
|
||||
if err := db.WithContext(context.Background()).Unscoped().Where("id = ?", row.ID).First(&stored).Error; err != nil {
|
||||
t.Fatalf("unscoped find: %v", err)
|
||||
}
|
||||
if stored.DeletedAt == nil {
|
||||
t.Fatalf("expected deleted_at populated")
|
||||
}
|
||||
if stored.PurgeAt == nil || !stored.PurgeAt.Equal(purgeAt) {
|
||||
t.Fatalf("expected purge_at set")
|
||||
}
|
||||
if !bytes.Equal(stored.DeletedBy, deletedBy) {
|
||||
t.Fatalf("expected deleted_by set")
|
||||
}
|
||||
if stored.NameSlot == "live" {
|
||||
t.Fatalf("expected name_slot switched from live on delete")
|
||||
}
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy, nil); err == nil {
|
||||
t.Fatalf("expected record not found on second delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFolderRepositoryRestorePurgeAndTrashQueries(t *testing.T) {
|
||||
db := openFileManagerFolderRepoTestDB(t)
|
||||
repo := NewFileManagerFolderRepository(db)
|
||||
|
||||
row := &filemanager.Folder{
|
||||
Name: "Archive",
|
||||
NameNormalized: "archive",
|
||||
NameSlot: "live",
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("create folder: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy, nil); err != nil {
|
||||
t.Fatalf("delete folder: %v", err)
|
||||
}
|
||||
|
||||
anyRow, err := repo.GetByIDAny(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id any: %v", err)
|
||||
}
|
||||
if anyRow == nil || anyRow.DeletedAt == nil {
|
||||
t.Fatalf("expected deleted row from GetByIDAny")
|
||||
}
|
||||
|
||||
trashRows, trashTotal, err := repo.ListTrashByParent(context.Background(), nil, "", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list trash by parent: %v", err)
|
||||
}
|
||||
if trashTotal != 1 || len(trashRows) != 1 {
|
||||
t.Fatalf("expected one trashed folder")
|
||||
}
|
||||
|
||||
restoreBy := uuidv7.MustBytes()
|
||||
if err := repo.Restore(context.Background(), row.ID, restoreBy); err != nil {
|
||||
t.Fatalf("restore folder: %v", err)
|
||||
}
|
||||
restored, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get restored folder: %v", err)
|
||||
}
|
||||
if restored == nil || restored.DeletedAt != nil || restored.NameSlot != "live" {
|
||||
t.Fatalf("expected restored live folder")
|
||||
}
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy, nil); err != nil {
|
||||
t.Fatalf("delete folder 2: %v", err)
|
||||
}
|
||||
if err := repo.Purge(context.Background(), row.ID); err != nil {
|
||||
t.Fatalf("purge folder: %v", err)
|
||||
}
|
||||
if got, err := repo.GetByIDAny(context.Background(), row.ID); err != nil || got != nil {
|
||||
t.Fatalf("expected purged folder gone, got=%v err=%v", got, err)
|
||||
}
|
||||
if err := repo.Purge(context.Background(), row.ID); err == nil {
|
||||
t.Fatalf("expected purge not found on second call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFolderRepositoryGetByParentAndName(t *testing.T) {
|
||||
db := openFileManagerFolderRepoTestDB(t)
|
||||
repo := NewFileManagerFolderRepository(db)
|
||||
|
||||
root := &filemanager.Folder{Name: "Root", NameNormalized: "root", NameSlot: "live"}
|
||||
if err := repo.Create(context.Background(), root); err != nil {
|
||||
t.Fatalf("create root: %v", err)
|
||||
}
|
||||
|
||||
child := &filemanager.Folder{
|
||||
ParentID: root.ID,
|
||||
Name: "Child",
|
||||
NameNormalized: "child",
|
||||
NameSlot: "live",
|
||||
Depth: 1,
|
||||
}
|
||||
if err := repo.Create(context.Background(), child); err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
|
||||
gotRoot, err := repo.GetByParentAndName(context.Background(), nil, "root")
|
||||
if err != nil {
|
||||
t.Fatalf("get root by parent/name: %v", err)
|
||||
}
|
||||
if gotRoot == nil || !bytes.Equal(gotRoot.ID, root.ID) {
|
||||
t.Fatalf("expected root by nil parent")
|
||||
}
|
||||
|
||||
gotChild, err := repo.GetByParentAndName(context.Background(), root.ID, "child")
|
||||
if err != nil {
|
||||
t.Fatalf("get child by parent/name: %v", err)
|
||||
}
|
||||
if gotChild == nil || !bytes.Equal(gotChild.ID, child.ID) {
|
||||
t.Fatalf("expected child by parent/name")
|
||||
}
|
||||
|
||||
notFound, err := repo.GetByParentAndName(context.Background(), root.ID, "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("get missing by parent/name: %v", err)
|
||||
}
|
||||
if notFound != nil {
|
||||
t.Fatalf("expected nil on missing folder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerFolderRepositoryListByParent(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
db := openFileManagerFolderRepoTestDB(t)
|
||||
repo := NewFileManagerFolderRepository(db)
|
||||
|
||||
rootA := &filemanager.Folder{Name: "B", NameNormalized: "b", NameSlot: "live"}
|
||||
rootB := &filemanager.Folder{Name: "A", NameNormalized: "a", NameSlot: "live"}
|
||||
_ = repo.Create(context.Background(), rootA)
|
||||
_ = repo.Create(context.Background(), rootB)
|
||||
|
||||
rows, total, err := repo.ListByParent(context.Background(), nil, "name ASC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list root folders: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("expected total 2, got %d", total)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Name != "A" {
|
||||
t.Fatalf("expected sorted+limited list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openFileManagerFolderRepoTestDB(t)
|
||||
repo := NewFileManagerFolderRepository(db)
|
||||
if err := db.Migrator().DropTable(&filemanager.Folder{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
if _, _, err := repo.ListByParent(context.Background(), nil, "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openFileManagerFolderRepoTestDB(t)
|
||||
repo := NewFileManagerFolderRepository(db)
|
||||
_ = repo.Create(context.Background(), &filemanager.Folder{Name: "X", NameNormalized: "x", NameSlot: "live"})
|
||||
if _, _, err := repo.ListByParent(context.Background(), nil, "name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error")
|
||||
}
|
||||
})
|
||||
}
|
||||
161
internal/repository/mysql/file_processing_outbox_repo.go
Normal file
161
internal/repository/mysql/file_processing_outbox_repo.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
)
|
||||
|
||||
func (r *FileManagerFileRepository) WithTransaction(
|
||||
ctx context.Context,
|
||||
fn func(repo filemanager.FileRepository, outbox filemanager.FileProcessingOutboxWriter) error,
|
||||
) error {
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := &FileManagerFileRepository{db: tx}
|
||||
return fn(txRepo, txRepo)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) CreateFileProcessingOutboxMessage(ctx context.Context, message *filemanager.FileProcessingOutboxMessage) error {
|
||||
if message == nil {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(message).Error
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) ClaimPendingFileProcessingOutboxMessages(
|
||||
ctx context.Context,
|
||||
limit int,
|
||||
now time.Time,
|
||||
lockTTL time.Duration,
|
||||
workerID string,
|
||||
) ([]filemanager.FileProcessingOutboxMessage, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
if lockTTL <= 0 {
|
||||
lockTTL = time.Minute
|
||||
}
|
||||
now = now.UTC()
|
||||
staleBefore := now.Add(-lockTTL)
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
workerID = "worker"
|
||||
}
|
||||
|
||||
var messages []filemanager.FileProcessingOutboxMessage
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.
|
||||
Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||
Where(
|
||||
"((status = ? AND available_at <= ?) OR (status = ? AND locked_at IS NOT NULL AND locked_at < ?))",
|
||||
filemanager.FileProcessingOutboxStatusPending,
|
||||
now,
|
||||
filemanager.FileProcessingOutboxStatusProcessing,
|
||||
staleBefore,
|
||||
).
|
||||
Order("available_at ASC, created_at ASC").
|
||||
Limit(limit).
|
||||
Find(&messages).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([][]byte, 0, len(messages))
|
||||
for i := range messages {
|
||||
ids = append(ids, messages[i].ID)
|
||||
}
|
||||
|
||||
if err := tx.Model(&filemanager.FileProcessingOutboxMessage{}).
|
||||
Where("id IN ?", ids).
|
||||
Updates(map[string]any{
|
||||
"status": filemanager.FileProcessingOutboxStatusProcessing,
|
||||
"locked_at": now,
|
||||
"locked_by": workerID,
|
||||
"updated_at": now,
|
||||
"attempts": gorm.Expr("attempts + ?", 1),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range messages {
|
||||
messages[i].Status = filemanager.FileProcessingOutboxStatusProcessing
|
||||
messages[i].Attempts++
|
||||
ts := now
|
||||
messages[i].LockedAt = &ts
|
||||
messages[i].LockedBy = workerID
|
||||
messages[i].UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return messages, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) MarkFileProcessingOutboxMessagePublished(ctx context.Context, id []byte, publishedAt time.Time) error {
|
||||
publishedAt = publishedAt.UTC()
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&filemanager.FileProcessingOutboxMessage{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": filemanager.FileProcessingOutboxStatusPublished,
|
||||
"published_at": publishedAt,
|
||||
"locked_at": nil,
|
||||
"locked_by": "",
|
||||
"last_error": "",
|
||||
"updated_at": publishedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) MarkFileProcessingOutboxMessageRetry(ctx context.Context, id []byte, availableAt time.Time, lastErr string) error {
|
||||
availableAt = availableAt.UTC()
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&filemanager.FileProcessingOutboxMessage{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": filemanager.FileProcessingOutboxStatusPending,
|
||||
"available_at": availableAt,
|
||||
"locked_at": nil,
|
||||
"locked_by": "",
|
||||
"last_error": truncateOutboxError(strings.TrimSpace(lastErr), 4096),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) MarkFileProcessingOutboxMessageDead(ctx context.Context, id []byte, failedAt time.Time, lastErr string) error {
|
||||
failedAt = failedAt.UTC()
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&filemanager.FileProcessingOutboxMessage{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": filemanager.FileProcessingOutboxStatusDead,
|
||||
"available_at": failedAt,
|
||||
"locked_at": nil,
|
||||
"locked_by": "",
|
||||
"last_error": truncateOutboxError(strings.TrimSpace(lastErr), 4096),
|
||||
"updated_at": failedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) CountPendingFileProcessingOutboxMessages(ctx context.Context, now time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&filemanager.FileProcessingOutboxMessage{}).
|
||||
Where(
|
||||
"(status = ? AND available_at <= ?) OR status = ?",
|
||||
filemanager.FileProcessingOutboxStatusPending,
|
||||
now.UTC(),
|
||||
filemanager.FileProcessingOutboxStatusProcessing,
|
||||
).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
24
internal/repository/mysql/file_status_realtime_event_repo.go
Normal file
24
internal/repository/mysql/file_status_realtime_event_repo.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
)
|
||||
|
||||
func (r *FileManagerFileRepository) CreateFileStatusRealtimeEvent(ctx context.Context, input filemanager.FileStatusRealtimeEventInput) error {
|
||||
if r == nil || r.db == nil {
|
||||
return gorm.ErrInvalidDB
|
||||
}
|
||||
if input.OccurredAt.IsZero() {
|
||||
input.OccurredAt = time.Now().UTC()
|
||||
}
|
||||
row, err := filemanager.NewFileStatusRealtimeEvent(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
107
internal/repository/mysql/file_upload_intent_repo.go
Normal file
107
internal/repository/mysql/file_upload_intent_repo.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
)
|
||||
|
||||
func (r *FileManagerFileRepository) CreateUploadIntent(ctx context.Context, row *filemanager.FileUploadIntent) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) GetUploadIntentByID(ctx context.Context, id []byte) (*filemanager.FileUploadIntent, error) {
|
||||
var row filemanager.FileUploadIntent
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&row).Error
|
||||
if err == nil {
|
||||
return &row, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) GetUploadIntentByIDForUpdate(ctx context.Context, id []byte) (*filemanager.FileUploadIntent, error) {
|
||||
var row filemanager.FileUploadIntent
|
||||
err := r.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", id).
|
||||
First(&row).Error
|
||||
if err == nil {
|
||||
return &row, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) MarkUploadIntentCompleted(ctx context.Context, params filemanager.FileUploadIntentCompleteParams) error {
|
||||
completedAt := params.CompletedAt
|
||||
if completedAt.IsZero() {
|
||||
completedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&filemanager.FileUploadIntent{}).
|
||||
Where("id = ? AND status = ?", params.ID, filemanager.FileUploadIntentStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": filemanager.FileUploadIntentStatusCompleted,
|
||||
"completed_at": completedAt.UTC(),
|
||||
"updated_by": params.UpdatedBy,
|
||||
"updated_at": completedAt.UTC(),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) MarkUploadIntentExpired(ctx context.Context, params filemanager.FileUploadIntentExpireParams) error {
|
||||
expiredAt := params.ExpiredAt
|
||||
if expiredAt.IsZero() {
|
||||
expiredAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&filemanager.FileUploadIntent{}).
|
||||
Where("id = ? AND status = ?", params.ID, filemanager.FileUploadIntentStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": filemanager.FileUploadIntentStatusExpired,
|
||||
"updated_by": params.UpdatedBy,
|
||||
"updated_at": expiredAt.UTC(),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileManagerFileRepository) UpdateUploadIntentSize(ctx context.Context, params filemanager.FileUploadIntentSizeUpdateParams) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&filemanager.FileUploadIntent{}).
|
||||
Where("id = ? AND status = ?", params.ID, filemanager.FileUploadIntentStatusPending).
|
||||
Updates(map[string]any{
|
||||
"size_bytes": params.SizeBytes,
|
||||
"updated_by": params.UpdatedBy,
|
||||
"updated_at": time.Now().UTC(),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
51
internal/repository/mysql/fleet_history_repo.go
Normal file
51
internal/repository/mysql/fleet_history_repo.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
fleethistory "wucher/internal/domain/fleet_history"
|
||||
)
|
||||
|
||||
type FleetHistoryRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewFleetHistoryRepository(db *gorm.DB) *FleetHistoryRepository {
|
||||
return &FleetHistoryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FleetHistoryRepository) Create(ctx context.Context, row *fleethistory.FleetHistory) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FleetHistoryRepository) ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]fleethistory.FleetHistory, int64, error) {
|
||||
rows := make([]fleethistory.FleetHistory, 0)
|
||||
var total int64
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&fleethistory.FleetHistory{}).
|
||||
Where("helicopter_id = ?", helicopterID)
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := base.Order("created_at DESC")
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit).Offset(offset)
|
||||
}
|
||||
err := q.Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func (r *FleetHistoryRepository) HelicopterIDByInspection(ctx context.Context, inspectionID []byte) ([]byte, error) {
|
||||
var out []byte
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("reserve_acs").
|
||||
Select("helicopter_id").
|
||||
Where("inspection_id = ?", inspectionID).
|
||||
Limit(1).
|
||||
Scan(&out).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
278
internal/repository/mysql/fleet_status_repo.go
Normal file
278
internal/repository/mysql/fleet_status_repo.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
fleetstatus "wucher/internal/domain/fleet_status"
|
||||
)
|
||||
|
||||
type FleetStatusRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFleetStatusRepository(db *gorm.DB) *FleetStatusRepository {
|
||||
return &FleetStatusRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) Create(ctx context.Context, row *fleetstatus.FleetStatus) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) CreateWithDB(ctx context.Context, db *gorm.DB, row *fleetstatus.FleetStatus) error {
|
||||
return db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) Update(ctx context.Context, row *fleetstatus.FleetStatus) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&fleetstatus.FleetStatus{}).
|
||||
Where("id = ? AND deleted_at IS NULL", row.ID).
|
||||
Updates(map[string]any{
|
||||
"helicopter_id": row.HelicopterID,
|
||||
"status": row.Status,
|
||||
"serviced_at": row.ServicedAt,
|
||||
"updated_by": row.UpdatedBy,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("fleet_status_id = ?", row.ID).Delete(&fleetstatus.MaintenanceSchedule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(row.MaintenanceSchedules) > 0 {
|
||||
for i := range row.MaintenanceSchedules {
|
||||
row.MaintenanceSchedules[i].FleetStatusID = row.ID
|
||||
}
|
||||
if err := tx.Create(&row.MaintenanceSchedules).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Where("fleet_status_id = ?", row.ID).Delete(&fleetstatus.FleetStatusFile{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(row.Files) > 0 {
|
||||
for i := range row.Files {
|
||||
row.Files[i].FleetStatusID = row.ID
|
||||
}
|
||||
if err := tx.Create(&row.Files).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&fleetstatus.FleetStatus{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy}).Error
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) GetByID(ctx context.Context, id []byte) (*fleetstatus.FleetStatus, error) {
|
||||
var row fleetstatus.FleetStatus
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Helicopter").
|
||||
Preload("MaintenanceSchedules").
|
||||
Preload("Files").
|
||||
Preload("Files.Attachment").
|
||||
Preload("Files.Attachment.File").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]fleetstatus.FleetStatus, int64, error) {
|
||||
rows := make([]fleetstatus.FleetStatus, 0)
|
||||
var total int64
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&fleetstatus.FleetStatus{}).
|
||||
Where("deleted_at IS NULL")
|
||||
if strings.TrimSpace(filter) != "" {
|
||||
like := "%" + strings.ToLower(strings.TrimSpace(filter)) + "%"
|
||||
base = base.Where("LOWER(HEX(id)) LIKE ? OR LOWER(HEX(helicopter_id)) LIKE ?", like, like)
|
||||
}
|
||||
query := base
|
||||
if strings.TrimSpace(sort) != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
query = query.Order("created_at DESC")
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Preload("Helicopter").
|
||||
Preload("MaintenanceSchedules").
|
||||
Preload("Files").
|
||||
Preload("Files.Attachment").
|
||||
Preload("Files.Attachment.File").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) MarkServiced(ctx context.Context, id []byte, servicedAt time.Time, actorID []byte) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var row fleetstatus.FleetStatus
|
||||
if err := tx.Preload("Helicopter").
|
||||
Preload("MaintenanceSchedules").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(row.MaintenanceSchedules) > 0 {
|
||||
logs := make([]fleetstatus.FleetStatusServiceLog, 0, len(row.MaintenanceSchedules))
|
||||
for i := range row.MaintenanceSchedules {
|
||||
item := row.MaintenanceSchedules[i]
|
||||
logs = append(logs, fleetstatus.FleetStatusServiceLog{
|
||||
FleetStatusID: row.ID,
|
||||
HelicopterID: row.HelicopterID,
|
||||
InspectionType: item.InspectionType,
|
||||
Type: item.Type,
|
||||
Due: item.Due,
|
||||
Ext: item.Ext,
|
||||
ServicedAt: servicedAt,
|
||||
CreatedBy: actorID,
|
||||
})
|
||||
}
|
||||
if err := tx.Create(&logs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Where("fleet_status_id = ?", row.ID).Delete(&fleetstatus.MaintenanceSchedule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&fleetstatus.FleetStatus{}).
|
||||
Where("id = ? AND deleted_at IS NULL", row.ID).
|
||||
Updates(map[string]any{
|
||||
"status": fleetstatus.StatusServiced,
|
||||
"serviced_at": servicedAt,
|
||||
"updated_by": actorID,
|
||||
}).
|
||||
Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) LatestByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]*fleetstatus.FleetStatus, error) {
|
||||
out := make(map[string]*fleetstatus.FleetStatus)
|
||||
|
||||
filtered := make([][]byte, 0, len(helicopterIDs))
|
||||
seen := make(map[string]struct{}, len(helicopterIDs))
|
||||
for _, id := range helicopterIDs {
|
||||
if len(id) != 16 {
|
||||
continue
|
||||
}
|
||||
key := string(id)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
filtered = append(filtered, append([]byte(nil), id...))
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
rows := make([]fleetstatus.FleetStatus, 0)
|
||||
// Ordered newest-first so the first row seen per helicopter is its latest.
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("helicopter_id IN ?", filtered).
|
||||
Order("created_at DESC, id DESC").
|
||||
Preload("Helicopter").
|
||||
Preload("MaintenanceSchedules").
|
||||
Preload("Files").
|
||||
Preload("Files.Attachment").
|
||||
Preload("Files.Attachment.File").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
key := string(rows[i].HelicopterID)
|
||||
if _, ok := out[key]; ok {
|
||||
continue
|
||||
}
|
||||
row := rows[i]
|
||||
out[key] = &row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) ListServiceHistoryByHelicopterID(ctx context.Context, helicopterID []byte, limit, offset int) ([]fleetstatus.FleetStatusServiceLog, int64, error) {
|
||||
rows := make([]fleetstatus.FleetStatusServiceLog, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&fleetstatus.FleetStatusServiceLog{}).
|
||||
Where("helicopter_id = ?", helicopterID)
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
query := base.Order("serviced_at DESC, created_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Preload("Helicopter").Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) HelicopterIDsWithFleetStatus(ctx context.Context) ([][]byte, error) {
|
||||
type rowT struct {
|
||||
HelicopterID []byte `gorm:"column:helicopter_id"`
|
||||
}
|
||||
rows := make([]rowT, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("fleet_statuses").
|
||||
Select("DISTINCT helicopter_id").
|
||||
Where("deleted_at IS NULL").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([][]byte, 0, len(rows))
|
||||
for i := range rows {
|
||||
if len(rows[i].HelicopterID) == 16 {
|
||||
out = append(out, rows[i].HelicopterID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *FleetStatusRepository) HelicopterIDByMissionID(ctx context.Context, missionID []byte) ([]byte, error) {
|
||||
if len(missionID) != 16 {
|
||||
return nil, nil
|
||||
}
|
||||
var out struct {
|
||||
HelicopterID []byte `gorm:"column:helicopter_id"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("missions m").
|
||||
Select("ra.helicopter_id AS helicopter_id").
|
||||
Joins("JOIN flights f ON f.id = m.flight_id AND f.deleted_at IS NULL").
|
||||
Joins("JOIN takeover_acs ta ON ta.id = f.takeover_ac_id AND ta.deleted_at IS NULL").
|
||||
Joins("JOIN reserve_acs ra ON ra.id = ta.reserve_ac_id AND ra.deleted_at IS NULL").
|
||||
Where("m.id = ? AND m.deleted_at IS NULL", missionID).
|
||||
Limit(1).
|
||||
Scan(&out).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.HelicopterID, nil
|
||||
}
|
||||
|
||||
91
internal/repository/mysql/fleet_status_repo_test.go
Normal file
91
internal/repository/mysql/fleet_status_repo_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
fleetstatus "wucher/internal/domain/fleet_status"
|
||||
"wucher/internal/domain/helicopter"
|
||||
)
|
||||
|
||||
func openFleetStatusTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:fleet_status_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&filemanager.Folder{}, &filemanager.File{}, &filemanager.Attachment{},
|
||||
&helicopter.Helicopter{},
|
||||
&fleetstatus.FleetStatus{}, &fleetstatus.MaintenanceSchedule{}, &fleetstatus.FleetStatusFile{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestFleetStatusRepositoryLatestByHelicopterIDs(t *testing.T) {
|
||||
db := openFleetStatusTestDB(t)
|
||||
repo := NewFleetStatusRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
heliRepo := NewHelicopterRepository(db)
|
||||
h1 := &helicopter.Helicopter{Designation: "H1", Identifier: "PK-H1", Type: "Twin"}
|
||||
h2 := &helicopter.Helicopter{Designation: "H2", Identifier: "PK-H2", Type: "Twin"}
|
||||
h3 := &helicopter.Helicopter{Designation: "H3", Identifier: "PK-H3", Type: "Twin"} // no fleet status
|
||||
for _, h := range []*helicopter.Helicopter{h1, h2, h3} {
|
||||
if err := heliRepo.Create(ctx, h); err != nil {
|
||||
t.Fatalf("create helicopter: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// h1: older then newer (newer = created last = latest). Plus a deleted row
|
||||
// created last, which must be ignored.
|
||||
if err := repo.Create(ctx, &fleetstatus.FleetStatus{HelicopterID: h1.ID, Status: fleetstatus.StatusActive}); err != nil {
|
||||
t.Fatalf("create fs1a: %v", err)
|
||||
}
|
||||
fs1bNewer := &fleetstatus.FleetStatus{HelicopterID: h1.ID, Status: fleetstatus.StatusServiced}
|
||||
if err := repo.Create(ctx, fs1bNewer); err != nil {
|
||||
t.Fatalf("create fs1b: %v", err)
|
||||
}
|
||||
deleted := &fleetstatus.FleetStatus{HelicopterID: h1.ID, Status: fleetstatus.StatusActive}
|
||||
if err := repo.Create(ctx, deleted); err != nil {
|
||||
t.Fatalf("create deleted: %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, deleted.ID, nil); err != nil {
|
||||
t.Fatalf("delete fs: %v", err)
|
||||
}
|
||||
|
||||
// h2: single record.
|
||||
fs2 := &fleetstatus.FleetStatus{HelicopterID: h2.ID, Status: fleetstatus.StatusActive}
|
||||
if err := repo.Create(ctx, fs2); err != nil {
|
||||
t.Fatalf("create fs2: %v", err)
|
||||
}
|
||||
|
||||
out, err := repo.LatestByHelicopterIDs(ctx, [][]byte{h1.ID, h2.ID, h3.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("LatestByHelicopterIDs: %v", err)
|
||||
}
|
||||
|
||||
if got := out[string(h1.ID)]; got == nil || string(got.ID) != string(fs1bNewer.ID) {
|
||||
t.Fatalf("h1 latest mismatch: got %+v, want fs1b (status served, not deleted)", got)
|
||||
}
|
||||
if got := out[string(h1.ID)]; got != nil && got.Status != fleetstatus.StatusServiced {
|
||||
t.Fatalf("h1 latest should be the serviced row, got status %q", got.Status)
|
||||
}
|
||||
if got := out[string(h2.ID)]; got == nil || string(got.ID) != string(fs2.ID) {
|
||||
t.Fatalf("h2 latest mismatch: got %+v", got)
|
||||
}
|
||||
if _, ok := out[string(h3.ID)]; ok {
|
||||
t.Fatalf("h3 has no fleet status and must be absent from the map")
|
||||
}
|
||||
}
|
||||
594
internal/repository/mysql/flight_data_repo.go
Normal file
594
internal/repository/mysql/flight_data_repo.go
Normal file
@@ -0,0 +1,594 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightdata "wucher/internal/domain/flight_data"
|
||||
"wucher/internal/shared/pkg/txctx"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type FlightDataRepository struct {
|
||||
db *gorm.DB
|
||||
hasHesloFlightsTable bool
|
||||
hasHesloSlingsTable bool
|
||||
hasLoggingSlingsTable bool
|
||||
hasHecFlightsTable bool
|
||||
hasHecSlingsTable bool
|
||||
hasHecLoadsTable bool
|
||||
}
|
||||
|
||||
func NewFlightDataRepository(db *gorm.DB) *FlightDataRepository {
|
||||
schema := newSchemaCache(db)
|
||||
return &FlightDataRepository{
|
||||
db: db,
|
||||
hasHesloFlightsTable: schema.HasTable("heslo_flights"),
|
||||
hasHesloSlingsTable: schema.HasTable("heslo_slings"),
|
||||
hasLoggingSlingsTable: schema.HasTable("logging_slings"),
|
||||
hasHecFlightsTable: schema.HasTable("hec_flights"),
|
||||
hasHecSlingsTable: schema.HasTable("hec_slings"),
|
||||
hasHecLoadsTable: schema.HasTable("hec_loads"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FlightDataRepository) Create(ctx context.Context, row *flightdata.FlightData) error {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if len(row.ID) != 16 {
|
||||
row.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if row.CreatedAt.IsZero() {
|
||||
row.CreatedAt = now
|
||||
}
|
||||
if row.UpdatedAt.IsZero() {
|
||||
row.UpdatedAt = row.CreatedAt
|
||||
}
|
||||
return txctx.DB(ctx, r.db).Table("flight_data").Create(flightDataPersistenceValues(row)).Error
|
||||
}
|
||||
|
||||
func (r *FlightDataRepository) Update(ctx context.Context, row *flightdata.FlightData) error {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
row.UpdatedAt = time.Now().UTC()
|
||||
updates := flightDataPersistenceValues(row)
|
||||
delete(updates, "id")
|
||||
delete(updates, "created_at")
|
||||
delete(updates, "created_by")
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&flightdata.FlightData{}).
|
||||
Where("id = ?", row.ID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *FlightDataRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&flightdata.FlightData{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *FlightDataRepository) GetByID(ctx context.Context, id []byte) (*flightdata.FlightData, error) {
|
||||
var row flightdata.FlightData
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Mission").
|
||||
Preload("Mission.Flight").
|
||||
Preload("Mission.Flight.Takeover").
|
||||
Preload("Mission.Flight.Takeover.RosterCrews").
|
||||
Preload("Mission.Flight.Takeover.OtherPeople").
|
||||
Preload("CoPilot").
|
||||
Preload("FromICAO").
|
||||
Preload("FromHospital").
|
||||
Preload("ToICAO").
|
||||
Preload("ToHospital").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FlightDataRepository) GetByMissionID(ctx context.Context, missionID []byte) (*flightdata.FlightData, error) {
|
||||
var row flightdata.FlightData
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Mission").
|
||||
Preload("Mission.Flight").
|
||||
Preload("Mission.Flight.Takeover").
|
||||
Preload("Mission.Flight.Takeover.RosterCrews").
|
||||
Preload("Mission.Flight.Takeover.OtherPeople").
|
||||
Preload("CoPilot").
|
||||
Preload("FromICAO").
|
||||
Preload("FromHospital").
|
||||
Preload("ToICAO").
|
||||
Preload("ToHospital").
|
||||
Where("mission_id = ? AND deleted_at IS NULL", missionID).
|
||||
Order("created_at DESC").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FlightDataRepository) ListByMissionID(ctx context.Context, missionID []byte) ([]flightdata.FlightData, error) {
|
||||
rows := make([]flightdata.FlightData, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Mission").
|
||||
Preload("Mission.Flight").
|
||||
Preload("Mission.Flight.Takeover").
|
||||
Preload("Mission.Flight.Takeover.RosterCrews").
|
||||
Preload("Mission.Flight.Takeover.OtherPeople").
|
||||
Preload("CoPilot").
|
||||
Preload("FromICAO").
|
||||
Preload("FromHospital").
|
||||
Preload("ToICAO").
|
||||
Preload("ToHospital").
|
||||
Where("mission_id = ? AND deleted_at IS NULL", missionID).
|
||||
Order("created_at ASC, id ASC").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *FlightDataRepository) GetByFlightID(ctx context.Context, flightID []byte) (*flightdata.FlightData, error) {
|
||||
var row flightdata.FlightData
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Mission").
|
||||
Preload("CoPilot").
|
||||
Preload("FromICAO").
|
||||
Preload("FromHospital").
|
||||
Preload("ToICAO").
|
||||
Preload("ToHospital").
|
||||
Where("mission_id IN (SELECT id FROM missions WHERE flight_id = ? AND deleted_at IS NULL) AND deleted_at IS NULL", flightID).
|
||||
Order("created_at DESC").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FlightDataRepository) List(ctx context.Context, date string, missionID, flightDataID []byte, limit, offset int) ([]flightdata.FlightData, int64, error) {
|
||||
rows := make([]flightdata.FlightData, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&flightdata.FlightData{}).
|
||||
Preload("Mission").
|
||||
Preload("Mission.Flight").
|
||||
Preload("Mission.Flight.Takeover").
|
||||
Preload("Mission.Flight.Takeover.RosterCrews").
|
||||
Preload("Mission.Flight.Takeover.OtherPeople").
|
||||
Preload("CoPilot").
|
||||
Preload("FromICAO").
|
||||
Preload("FromHospital").
|
||||
Preload("ToICAO").
|
||||
Preload("ToHospital").
|
||||
Where("deleted_at IS NULL")
|
||||
if date != "" {
|
||||
base = base.Where("DATE(take_off) = ?", date)
|
||||
}
|
||||
if len(missionID) == 16 {
|
||||
base = base.Where("mission_id = ?", missionID)
|
||||
}
|
||||
if len(flightDataID) == 16 {
|
||||
base = base.Where("id = ?", flightDataID)
|
||||
}
|
||||
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := base.Order("take_off DESC, created_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FlightDataRepository) GetSPODetailsByFlightDataID(ctx context.Context, flightDataID []byte) (*flightdata.SPODetails, error) {
|
||||
details := &flightdata.SPODetails{
|
||||
HESLO: flightdata.SPOHESLODetails{
|
||||
Flights: make([]flightdata.SPOHESLOFlight, 0),
|
||||
Slings: make([]flightdata.SPOHESLOSling, 0),
|
||||
},
|
||||
Logging: flightdata.SPOLoggingDetails{
|
||||
Slings: make([]flightdata.SPOLoggingSling, 0),
|
||||
},
|
||||
HEC: flightdata.SPOHECDetails{
|
||||
Flights: make([]flightdata.SPOHECFlight, 0),
|
||||
Slings: make([]flightdata.SPOHECSling, 0),
|
||||
Loads: make([]flightdata.SPOHECLoad, 0),
|
||||
},
|
||||
}
|
||||
|
||||
if len(flightDataID) != 16 {
|
||||
return details, nil
|
||||
}
|
||||
|
||||
db := r.db.WithContext(ctx)
|
||||
|
||||
if r.hasHesloFlightsTable {
|
||||
type hesloFlightRow struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
ROT int `gorm:"column:rot"`
|
||||
FuelTruckID []byte `gorm:"column:fuel_truck_id"`
|
||||
FuelTruckName string `gorm:"column:fuel_truck_name"`
|
||||
LoggingSlingID []byte `gorm:"column:logging_sling_id"`
|
||||
LoggingSlingName string `gorm:"column:logging_sling_name"`
|
||||
}
|
||||
rows := make([]hesloFlightRow, 0)
|
||||
if err := db.Table("heslo_flights hf").
|
||||
Select("hf.id, hf.rot, hf.fuel_truck_id, fuel.name AS fuel_truck_name, hf.logging_sling_id, log.name AS logging_sling_name").
|
||||
Joins("LEFT JOIN facilities fuel ON fuel.id = hf.fuel_truck_id").
|
||||
Joins("LEFT JOIN facilities log ON log.id = hf.logging_sling_id").
|
||||
Where("hf.flight_data_id = ?", flightDataID).
|
||||
Order("hf.created_at ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
details.HESLO.Flights = append(details.HESLO.Flights, flightdata.SPOHESLOFlight{
|
||||
ID: rows[i].ID,
|
||||
ROT: rows[i].ROT,
|
||||
FuelTruckID: rows[i].FuelTruckID,
|
||||
FuelTruckName: rows[i].FuelTruckName,
|
||||
LoggingSlingID: rows[i].LoggingSlingID,
|
||||
LoggingSlingName: rows[i].LoggingSlingName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if r.hasHesloSlingsTable && r.hasHesloFlightsTable {
|
||||
type hesloSlingRow struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
HESLOFlightID []byte `gorm:"column:heslo_flight_id"`
|
||||
SlingID []byte `gorm:"column:facility_sling_id"`
|
||||
SlingName string `gorm:"column:sling_name"`
|
||||
}
|
||||
rows := make([]hesloSlingRow, 0)
|
||||
if err := db.Table("heslo_slings hs").
|
||||
Select("hs.id, hs.heslo_flight_id, hs.facility_sling_id, sling.name AS sling_name").
|
||||
Joins("JOIN heslo_flights hf ON hf.id = hs.heslo_flight_id").
|
||||
Joins("LEFT JOIN facilities sling ON sling.id = hs.facility_sling_id").
|
||||
Where("hf.flight_data_id = ?", flightDataID).
|
||||
Order("hs.created_at ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
details.HESLO.Slings = append(details.HESLO.Slings, flightdata.SPOHESLOSling{
|
||||
ID: rows[i].ID,
|
||||
HESLOFlightID: rows[i].HESLOFlightID,
|
||||
SlingID: rows[i].SlingID,
|
||||
SlingName: rows[i].SlingName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if r.hasLoggingSlingsTable {
|
||||
type loggingSlingRow struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
SlingID []byte `gorm:"column:facility_sling_id"`
|
||||
SlingName string `gorm:"column:sling_name"`
|
||||
}
|
||||
rows := make([]loggingSlingRow, 0)
|
||||
if err := db.Table("logging_slings ls").
|
||||
Select("ls.id, ls.facility_sling_id, sling.name AS sling_name").
|
||||
Joins("LEFT JOIN facilities sling ON sling.id = ls.facility_sling_id").
|
||||
Where("ls.flight_data_id = ?", flightDataID).
|
||||
Order("ls.created_at ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
details.Logging.Slings = append(details.Logging.Slings, flightdata.SPOLoggingSling{
|
||||
ID: rows[i].ID,
|
||||
SlingID: rows[i].SlingID,
|
||||
SlingName: rows[i].SlingName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if r.hasHecFlightsTable {
|
||||
type hecFlightRow struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
ROT int `gorm:"column:rot"`
|
||||
HECCycle int `gorm:"column:hec_cycle"`
|
||||
EquipmentID []byte `gorm:"column:equipment_id"`
|
||||
EquipmentName string `gorm:"column:equipment_name"`
|
||||
}
|
||||
rows := make([]hecFlightRow, 0)
|
||||
if err := db.Table("hec_flights hf").
|
||||
Select("hf.id, hf.rot, hf.hec_cycle, hf.equipment_id, eq.name AS equipment_name").
|
||||
Joins("LEFT JOIN facilities eq ON eq.id = hf.equipment_id").
|
||||
Where("hf.flight_data_id = ?", flightDataID).
|
||||
Order("hf.created_at ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
details.HEC.Flights = append(details.HEC.Flights, flightdata.SPOHECFlight{
|
||||
ID: rows[i].ID,
|
||||
ROT: rows[i].ROT,
|
||||
HECCycle: rows[i].HECCycle,
|
||||
EquipmentID: rows[i].EquipmentID,
|
||||
EquipmentName: rows[i].EquipmentName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if r.hasHecSlingsTable {
|
||||
type hecSlingRow struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
SlingID []byte `gorm:"column:hec_sling_id"`
|
||||
SlingName string `gorm:"column:sling_name"`
|
||||
}
|
||||
rows := make([]hecSlingRow, 0)
|
||||
if err := db.Table("hec_slings hs").
|
||||
Select("hs.id, hs.hec_sling_id, sling.name AS sling_name").
|
||||
Joins("LEFT JOIN facilities sling ON sling.id = hs.hec_sling_id").
|
||||
Where("hs.flight_data_id = ?", flightDataID).
|
||||
Order("hs.created_at ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
details.HEC.Slings = append(details.HEC.Slings, flightdata.SPOHECSling{
|
||||
ID: rows[i].ID,
|
||||
SlingID: rows[i].SlingID,
|
||||
SlingName: rows[i].SlingName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if r.hasHecLoadsTable {
|
||||
type hecLoadRow struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
LoadCategory string `gorm:"column:load_category"`
|
||||
Quantity int `gorm:"column:quantity"`
|
||||
}
|
||||
rows := make([]hecLoadRow, 0)
|
||||
if err := db.Table("hec_loads").
|
||||
Select("id, load_category, quantity").
|
||||
Where("flight_data_id = ?", flightDataID).
|
||||
Order("created_at ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
details.HEC.Loads = append(details.HEC.Loads, flightdata.SPOHECLoad{
|
||||
ID: rows[i].ID,
|
||||
LoadCategory: rows[i].LoadCategory,
|
||||
Quantity: rows[i].Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return details, nil
|
||||
}
|
||||
|
||||
func (r *FlightDataRepository) UpsertSPODetails(ctx context.Context, flightDataID []byte, data *flightdata.SPOUpsertData) error {
|
||||
if len(flightDataID) != 16 || data == nil {
|
||||
return nil
|
||||
}
|
||||
log.Printf("[UpsertSPODetails] start: heslo=%v logging=%v hec=%v hasHesloFlights=%v hasLogging=%v hasHecFlights=%v",
|
||||
data.HESLO != nil, data.Logging != nil, data.HEC != nil,
|
||||
r.hasHesloFlightsTable, r.hasLoggingSlingsTable, r.hasHecFlightsTable)
|
||||
|
||||
txErr := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
if data.HESLO != nil && r.hasHesloFlightsTable {
|
||||
if r.hasHesloSlingsTable {
|
||||
if err := tx.Exec(
|
||||
"DELETE hs FROM heslo_slings hs JOIN heslo_flights hf ON hf.id = hs.heslo_flight_id WHERE hf.flight_data_id = ?",
|
||||
flightDataID,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM heslo_flights WHERE flight_data_id = ?", flightDataID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range data.HESLO.Flights {
|
||||
hf := &flightdata.HESLOFlight{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FlightDataID: flightDataID,
|
||||
ROT: f.ROT,
|
||||
FacilityFuelTruckID: f.FuelTruckID,
|
||||
LoggingSlingID: f.LoggingSlingID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.Omit("FlightData", "FacilityFuelTruck", "LoggingSling").Create(hf).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if r.hasHesloSlingsTable {
|
||||
for _, s := range f.Slings {
|
||||
if len(s.SlingID) != 16 {
|
||||
continue
|
||||
}
|
||||
hs := &flightdata.HESLOSling{
|
||||
ID: uuidv7.MustBytes(),
|
||||
HESLOFlightID: hf.ID,
|
||||
FacilitySlingID: s.SlingID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.Omit("HESLOFlight", "FacilitySling").Create(hs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.Logging != nil && r.hasLoggingSlingsTable {
|
||||
if err := tx.Exec("DELETE FROM logging_slings WHERE flight_data_id = ?", flightDataID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range data.Logging.Slings {
|
||||
if len(s.SlingID) != 16 {
|
||||
continue
|
||||
}
|
||||
ls := &flightdata.LoggingSling{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FlightDataID: flightDataID,
|
||||
FacilitySlingID: s.SlingID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.Omit("FlightData", "FacilitySling").Create(ls).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.HEC != nil {
|
||||
if r.hasHecSlingsTable {
|
||||
if err := tx.Exec("DELETE FROM hec_slings WHERE flight_data_id = ?", flightDataID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if r.hasHecLoadsTable {
|
||||
if err := tx.Exec("DELETE FROM hec_loads WHERE flight_data_id = ?", flightDataID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if r.hasHecFlightsTable {
|
||||
if err := tx.Exec("DELETE FROM hec_flights WHERE flight_data_id = ?", flightDataID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range data.HEC.Flights {
|
||||
hf := &flightdata.HECFlight{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FlightDataID: flightDataID,
|
||||
ROT: f.ROT,
|
||||
HECCycle: f.HECCycle,
|
||||
EquipmentID: f.EquipmentID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.Omit("FlightData", "Equipment").Create(hf).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.hasHecSlingsTable {
|
||||
for _, s := range data.HEC.Slings {
|
||||
if len(s.SlingID) != 16 {
|
||||
continue
|
||||
}
|
||||
hs := &flightdata.HECSling{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FlightDataID: flightDataID,
|
||||
HECSlingID: s.SlingID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.Omit("FlightData", "HECSling").Create(hs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.hasHecLoadsTable {
|
||||
for _, l := range data.HEC.Loads {
|
||||
hl := &flightdata.HECLoads{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FlightDataID: flightDataID,
|
||||
LoadCategory: l.LoadCategory,
|
||||
Quantity: l.Quantity,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.Omit("FlightData").Create(hl).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
log.Printf("[UpsertSPODetails] done: err=%v", txErr)
|
||||
return txErr
|
||||
}
|
||||
|
||||
func flightDataPersistenceValues(row *flightdata.FlightData) map[string]any {
|
||||
if row == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"id": nullableBytesValue(row.ID),
|
||||
"mission_id": nullableBytesValue(row.MissionID),
|
||||
"co_pilot_id": nullableBytesValue(row.CoPilotID),
|
||||
"from_icao_id": nullableBytesValue(row.FromICAOID),
|
||||
"from_hospital_id": nullableBytesValue(row.FromHospitalID),
|
||||
"to_icao_id": nullableBytesValue(row.ToICAOID),
|
||||
"to_hospital_id": nullableBytesValue(row.ToHospitalID),
|
||||
"take_off": nullableTimeValue(row.FlightTakeOff),
|
||||
"landing": nullableTimeValue(row.FlightLanding),
|
||||
"duration": row.FlightDuration,
|
||||
"flight_type": row.FlightType,
|
||||
"red": row.FlightRED,
|
||||
"max_n1": row.MaxN1,
|
||||
"max_n2": row.MaxN2,
|
||||
"pax_count": row.PaxCount,
|
||||
"ticket_no": row.TicketNo,
|
||||
"engine": row.Engine,
|
||||
"landing_count": row.LandingCount,
|
||||
"rotor_brake_cycle": row.RotorBrakeCycle,
|
||||
"hook_releases": row.HookReleases,
|
||||
"delivery_note_number": row.DeliveryNoteNumber,
|
||||
"customer_name": row.CustomerName,
|
||||
"flight_plan_distance": row.FlightPlanDistance,
|
||||
"flight_plan_time": row.FlightPlanTime,
|
||||
"flight_plan_true_course": row.FlightPlanTrueCourse,
|
||||
"fuel_before_flight": row.FuelBeforeFlight,
|
||||
"fuel_upload": row.FuelUpload,
|
||||
"fuel_after_flight": row.FuelAfterFlight,
|
||||
"fuel_planning": row.FuelPlanning,
|
||||
"flight_position": row.FlightPositioning,
|
||||
"other_information": row.OtherInformation,
|
||||
"status": row.Status,
|
||||
"created_at": row.CreatedAt,
|
||||
"created_by": nullableBytesValue(row.CreatedBy),
|
||||
"updated_at": row.UpdatedAt,
|
||||
"updated_by": nullableBytesValue(row.UpdatedBy),
|
||||
"deleted_at": nullableTimePtrValue(row.DeletedAt),
|
||||
"deleted_by": nullableBytesValue(row.DeletedBy),
|
||||
}
|
||||
}
|
||||
|
||||
func nullableBytesValue(v []byte) any {
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func nullableTimeValue(v time.Time) any {
|
||||
if v.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func nullableTimePtrValue(v *time.Time) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
}
|
||||
110
internal/repository/mysql/flight_data_repo_test.go
Normal file
110
internal/repository/mysql/flight_data_repo_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightdata "wucher/internal/domain/flight_data"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openFlightDataTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:flight_data_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestFlightDataPersistenceValues_AllowsNullableLocationFields(t *testing.T) {
|
||||
row := &flightdata.FlightData{
|
||||
ID: uuidv7.MustBytes(),
|
||||
MissionID: uuidv7.MustBytes(),
|
||||
CoPilotID: uuidv7.MustBytes(),
|
||||
FromHospitalID: uuidv7.MustBytes(),
|
||||
ToICAOID: uuidv7.MustBytes(),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
CreatedBy: uuidv7.MustBytes(),
|
||||
UpdatedBy: uuidv7.MustBytes(),
|
||||
Status: flightdata.StatusInProgress,
|
||||
FlightTakeOff: time.Now().UTC(),
|
||||
FlightLanding: time.Now().UTC(),
|
||||
FlightDuration: 4 * time.Second,
|
||||
FlightRED: 10 * time.Second,
|
||||
FlightPlanTime: 10 * time.Second,
|
||||
FlightPlanDistance: 10,
|
||||
FlightPlanTrueCourse: 10,
|
||||
FuelBeforeFlight: 10,
|
||||
FuelUpload: 10,
|
||||
FuelAfterFlight: 10,
|
||||
FuelPlanning: 10,
|
||||
}
|
||||
|
||||
values := flightDataPersistenceValues(row)
|
||||
if values["from_icao_id"] != nil {
|
||||
t.Fatalf("expected from_icao_id to be nil, got %#v", values["from_icao_id"])
|
||||
}
|
||||
if values["to_hospital_id"] != nil {
|
||||
t.Fatalf("expected to_hospital_id to be nil, got %#v", values["to_hospital_id"])
|
||||
}
|
||||
if values["from_hospital_id"] == nil {
|
||||
t.Fatal("expected from_hospital_id to be populated")
|
||||
}
|
||||
if values["to_icao_id"] == nil {
|
||||
t.Fatal("expected to_icao_id to be populated")
|
||||
}
|
||||
if values["status"] != flightdata.StatusInProgress {
|
||||
t.Fatalf("expected status to be on_progress, got %#v", values["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlightDataPersistenceValues_ZeroTimesBecomeNull(t *testing.T) {
|
||||
row := &flightdata.FlightData{
|
||||
ID: uuidv7.MustBytes(),
|
||||
MissionID: uuidv7.MustBytes(),
|
||||
CoPilotID: uuidv7.MustBytes(),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
CreatedBy: uuidv7.MustBytes(),
|
||||
UpdatedBy: uuidv7.MustBytes(),
|
||||
}
|
||||
|
||||
values := flightDataPersistenceValues(row)
|
||||
if values["take_off"] != nil {
|
||||
t.Fatalf("expected take_off to be nil, got %#v", values["take_off"])
|
||||
}
|
||||
if values["landing"] != nil {
|
||||
t.Fatalf("expected landing to be nil, got %#v", values["landing"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFlightDataRepositoryCachesOptionalTables(t *testing.T) {
|
||||
db := openFlightDataTestDB(t)
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE heslo_flights (id BLOB PRIMARY KEY, flight_data_id BLOB, created_at DATETIME)`,
|
||||
`CREATE TABLE heslo_slings (id BLOB PRIMARY KEY, heslo_flight_id BLOB, created_at DATETIME)`,
|
||||
`CREATE TABLE logging_slings (id BLOB PRIMARY KEY, flight_data_id BLOB, created_at DATETIME)`,
|
||||
`CREATE TABLE hec_flights (id BLOB PRIMARY KEY, flight_data_id BLOB, created_at DATETIME)`,
|
||||
`CREATE TABLE hec_slings (id BLOB PRIMARY KEY, flight_data_id BLOB, created_at DATETIME)`,
|
||||
`CREATE TABLE hec_loads (id BLOB PRIMARY KEY, flight_data_id BLOB, created_at DATETIME)`,
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
repo := NewFlightDataRepository(db)
|
||||
if !repo.hasHesloFlightsTable || !repo.hasHesloSlingsTable || !repo.hasLoggingSlingsTable ||
|
||||
!repo.hasHecFlightsTable || !repo.hasHecSlingsTable || !repo.hasHecLoadsTable {
|
||||
t.Fatalf("expected all optional table flags to be cached as true")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightinspection "wucher/internal/domain/flight_inspection"
|
||||
flightinspectionfilechecklist "wucher/internal/domain/flight_inspection_file_checklist"
|
||||
helicopterfile "wucher/internal/domain/helicopter_file"
|
||||
)
|
||||
|
||||
type FlightInspectionFileChecklistRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFlightInspectionFileChecklistRepository(db *gorm.DB) *FlightInspectionFileChecklistRepository {
|
||||
return &FlightInspectionFileChecklistRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FlightInspectionFileChecklistRepository) Create(ctx context.Context, row *flightinspectionfilechecklist.FlightInspectionFileChecklist) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FlightInspectionFileChecklistRepository) Update(ctx context.Context, row *flightinspectionfilechecklist.FlightInspectionFileChecklist) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *FlightInspectionFileChecklistRepository) Delete(ctx context.Context, id []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "flight_inspection_file_checklists", id); err != nil {
|
||||
return err
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).Delete(&flightinspectionfilechecklist.FlightInspectionFileChecklist{}, "id = ?", id).Error)
|
||||
}
|
||||
|
||||
func (r *FlightInspectionFileChecklistRepository) GetByID(ctx context.Context, id []byte) (*flightinspectionfilechecklist.FlightInspectionFileChecklist, error) {
|
||||
var row flightinspectionfilechecklist.FlightInspectionFileChecklist
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FlightInspectionFileChecklistRepository) List(ctx context.Context, filter string, sort string, limit, offset int) ([]flightinspectionfilechecklist.FlightInspectionFileChecklist, int64, error) {
|
||||
rows := make([]flightinspectionfilechecklist.FlightInspectionFileChecklist, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&flightinspectionfilechecklist.FlightInspectionFileChecklist{})
|
||||
if strings.TrimSpace(filter) != "" {
|
||||
like := "%" + strings.ToLower(strings.TrimSpace(filter)) + "%"
|
||||
base = base.Where(
|
||||
"LOWER(HEX(id)) LIKE ? OR LOWER(HEX(flight_inspection_id)) LIKE ? OR LOWER(HEX(helicopter_file_id)) LIKE ? OR CAST(is_done AS CHAR) LIKE ?",
|
||||
like, like, like, like,
|
||||
)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FlightInspectionFileChecklistRepository) ListByInspectionID(ctx context.Context, inspectionID []byte) ([]flightinspectionfilechecklist.FlightInspectionFileChecklist, error) {
|
||||
rows := make([]flightinspectionfilechecklist.FlightInspectionFileChecklist, 0)
|
||||
if len(inspectionID) != 16 {
|
||||
return rows, nil
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("flight_inspection_id = ?", inspectionID).
|
||||
Order("is_done ASC, created_at ASC").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *FlightInspectionFileChecklistRepository) FlightInspectionExists(ctx context.Context, inspectionID []byte) (bool, error) {
|
||||
if len(inspectionID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flightinspection.FlightInspection{}).
|
||||
Where("id = ?", inspectionID).
|
||||
Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func (r *FlightInspectionFileChecklistRepository) HelicopterFileExists(ctx context.Context, helicopterFileID []byte) (bool, error) {
|
||||
if len(helicopterFileID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&helicopterfile.HelicopterFile{}).
|
||||
Where("id = ?", helicopterFileID).
|
||||
Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
60
internal/repository/mysql/flight_inspection_repo.go
Normal file
60
internal/repository/mysql/flight_inspection_repo.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightinspection "wucher/internal/domain/flight_inspection"
|
||||
)
|
||||
|
||||
type FlightInspectionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFlightInspectionRepository(db *gorm.DB) *FlightInspectionRepository {
|
||||
return &FlightInspectionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FlightInspectionRepository) Create(ctx context.Context, row *flightinspection.FlightInspection) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FlightInspectionRepository) Update(ctx context.Context, row *flightinspection.FlightInspection) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *FlightInspectionRepository) GetByID(ctx context.Context, id []byte) (*flightinspection.FlightInspection, error) {
|
||||
var row flightinspection.FlightInspection
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *FlightInspectionRepository) List(ctx context.Context, sort string, limit, offset int) ([]flightinspection.FlightInspection, int64, error) {
|
||||
rows := make([]flightinspection.FlightInspection, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&flightinspection.FlightInspection{})
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
44
internal/repository/mysql/flight_prep_check_repo.go
Normal file
44
internal/repository/mysql/flight_prep_check_repo.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightprepcheck "wucher/internal/domain/flight_prep_check"
|
||||
)
|
||||
|
||||
type FlightPrepCheckRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFlightPrepCheckRepository(db *gorm.DB) *FlightPrepCheckRepository {
|
||||
return &FlightPrepCheckRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FlightPrepCheckRepository) Create(ctx context.Context, check *flightprepcheck.FlightPrepCheck) error {
|
||||
return r.db.WithContext(ctx).Create(check).Error
|
||||
}
|
||||
|
||||
func (r *FlightPrepCheckRepository) Update(ctx context.Context, check *flightprepcheck.FlightPrepCheck) error {
|
||||
return r.db.WithContext(ctx).Save(check).Error
|
||||
}
|
||||
|
||||
func (r *FlightPrepCheckRepository) GetByID(ctx context.Context, id []byte) (*flightprepcheck.FlightPrepCheck, error) {
|
||||
var check flightprepcheck.FlightPrepCheck
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&check).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &check, err
|
||||
}
|
||||
|
||||
func (r *FlightPrepCheckRepository) GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*flightprepcheck.FlightPrepCheck, error) {
|
||||
var check flightprepcheck.FlightPrepCheck
|
||||
err := r.db.WithContext(ctx).Where("flight_inspection_id = ?", flightInspectionID).First(&check).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &check, err
|
||||
}
|
||||
385
internal/repository/mysql/flight_repo.go
Normal file
385
internal/repository/mysql/flight_repo.go
Normal file
@@ -0,0 +1,385 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/flight"
|
||||
)
|
||||
|
||||
type FlightRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func preloadTakeoverRelations(q *gorm.DB) *gorm.DB {
|
||||
return q.
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.Base").
|
||||
Preload("Takeover.Base.OperationalShiftTimes").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Preload("Takeover.RosterCrews").
|
||||
Preload("Takeover.OtherPeople")
|
||||
}
|
||||
|
||||
func NewFlightRepository(db *gorm.DB) *FlightRepository {
|
||||
return &FlightRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FlightRepository) Create(ctx context.Context, row *flight.Flight) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FlightRepository) GetLatestMissionCodeByPrefix(ctx context.Context, prefix string) (string, error) {
|
||||
var out struct {
|
||||
MissionCode string `gorm:"column:mission_code"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("flights").
|
||||
Select("mission_code").
|
||||
Where("mission_code LIKE ? AND deleted_at IS NULL", prefix+"%").
|
||||
Order("mission_code DESC").
|
||||
Limit(1).
|
||||
Scan(&out).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.MissionCode, nil
|
||||
}
|
||||
|
||||
func (r *FlightRepository) Update(ctx context.Context, row *flight.Flight) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *FlightRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *FlightRepository) GetByID(ctx context.Context, id []byte) (*flight.Flight, error) {
|
||||
var row flight.Flight
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Select(flightReadSelectSQL()).
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.Base").
|
||||
Preload("Takeover.Base.OperationalShiftTimes").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Preload("Takeover.RosterCrews").
|
||||
Preload("Takeover.OtherPeople").
|
||||
Joins("LEFT JOIN users cu ON cu.id = flights.created_by").
|
||||
Joins("LEFT JOIN users uu ON uu.id = flights.updated_by").
|
||||
Where("flights.id = ? AND flights.deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FlightRepository) ListByUserID(ctx context.Context, userID []byte, limit, offset int) ([]flight.Flight, int64, error) {
|
||||
rows := make([]flight.Flight, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Select(flightReadSelectSQL()).
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.Base").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Preload("Takeover.RosterCrews").
|
||||
Preload("Takeover.OtherPeople").
|
||||
Joins("LEFT JOIN users cu ON cu.id = flights.created_by").
|
||||
Joins("LEFT JOIN users uu ON uu.id = flights.updated_by").
|
||||
Where("flights.deleted_at IS NULL").
|
||||
Where(
|
||||
`EXISTS (
|
||||
SELECT 1
|
||||
FROM duty_roster_crews c
|
||||
JOIN duty_rosters r ON r.id = c.roster_id
|
||||
WHERE c.user_id = ?
|
||||
AND c.deleted_at IS NULL
|
||||
AND r.deleted_at IS NULL
|
||||
AND r.flight_id = flights.id
|
||||
)`,
|
||||
userID,
|
||||
)
|
||||
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := base.Order("flights.date DESC, flights.created_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FlightRepository) ListByCreatedBy(ctx context.Context, createdBy []byte, filter string, date string, sort string, limit, offset int) ([]flight.Flight, int64, error) {
|
||||
rows := make([]flight.Flight, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Select(flightReadSelectSQL()).
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.Base").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Preload("Takeover.RosterCrews").
|
||||
Preload("Takeover.OtherPeople").
|
||||
Joins("LEFT JOIN users cu ON cu.id = flights.created_by").
|
||||
Joins("LEFT JOIN users uu ON uu.id = flights.updated_by").
|
||||
Where("flights.deleted_at IS NULL").
|
||||
Where("flights.created_by = ?", createdBy)
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("flights.mission_code LIKE ?", like)
|
||||
}
|
||||
if date != "" {
|
||||
base = base.Where("flights.date = ?", date)
|
||||
}
|
||||
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
query = query.Order("flights.date DESC, flights.created_at DESC")
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *FlightRepository) GetByReserveAcID(ctx context.Context, reserveAcID []byte) (*flight.Flight, error) {
|
||||
var row flight.Flight
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Select(flightReadSelectSQL()).
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.Base").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Preload("Takeover.RosterCrews").
|
||||
Preload("Takeover.OtherPeople").
|
||||
Joins("LEFT JOIN users cu ON cu.id = flights.created_by").
|
||||
Joins("LEFT JOIN users uu ON uu.id = flights.updated_by").
|
||||
Joins("JOIN takeover_acs ta ON ta.id = flights.takeover_ac_id AND ta.deleted_at IS NULL").
|
||||
Where("ta.reserve_ac_id = ? AND flights.deleted_at IS NULL", reserveAcID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FlightRepository) ListByReserveAcID(ctx context.Context, reserveAcID []byte) ([]flight.Flight, error) {
|
||||
rows := make([]flight.Flight, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Select(flightReadSelectSQL()).
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.Base").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Preload("Takeover.RosterCrews").
|
||||
Preload("Takeover.OtherPeople").
|
||||
Joins("LEFT JOIN users cu ON cu.id = flights.created_by").
|
||||
Joins("LEFT JOIN users uu ON uu.id = flights.updated_by").
|
||||
Joins("JOIN takeover_acs ta ON ta.id = flights.takeover_ac_id AND ta.deleted_at IS NULL").
|
||||
Where("ta.reserve_ac_id = ? AND flights.deleted_at IS NULL", reserveAcID).
|
||||
Order("flights.created_at ASC, flights.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *FlightRepository) GetByTakeoverAcID(ctx context.Context, takeoverAcID []byte) (*flight.Flight, error) {
|
||||
var row flight.Flight
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Select(flightReadSelectSQL()).
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.Base").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Preload("Takeover.RosterCrews").
|
||||
Preload("Takeover.OtherPeople").
|
||||
Joins("LEFT JOIN users cu ON cu.id = flights.created_by").
|
||||
Joins("LEFT JOIN users uu ON uu.id = flights.updated_by").
|
||||
Where("flights.takeover_ac_id = ? AND flights.deleted_at IS NULL", takeoverAcID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FlightRepository) ListByTakeoverAcIDs(ctx context.Context, takeoverAcIDs [][]byte) ([]flight.Flight, error) {
|
||||
if len(takeoverAcIDs) == 0 {
|
||||
return []flight.Flight{}, nil
|
||||
}
|
||||
rows := make([]flight.Flight, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Select(flightListSelectSQL()).
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.Base").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Preload("Takeover.RosterCrews").
|
||||
Preload("Takeover.OtherPeople").
|
||||
Where("flights.takeover_ac_id IN ? AND flights.deleted_at IS NULL", takeoverAcIDs).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *FlightRepository) GetByDutyRosterID(ctx context.Context, dutyRosterID []byte) (*flight.Flight, error) {
|
||||
var row flight.Flight
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Select(flightListSelectSQL()).
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.Base").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Preload("Takeover.RosterCrews").
|
||||
Preload("Takeover.OtherPeople").
|
||||
Joins("JOIN duty_rosters dr ON dr.flight_id = flights.id AND dr.deleted_at IS NULL").
|
||||
Where("dr.id = ? AND flights.deleted_at IS NULL", dutyRosterID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FlightRepository) ListByDutyRosterIDs(ctx context.Context, dutyRosterIDs [][]byte) ([]flight.Flight, error) {
|
||||
if len(dutyRosterIDs) == 0 {
|
||||
return []flight.Flight{}, nil
|
||||
}
|
||||
rows := make([]flight.Flight, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Select(flightListSelectSQL()).
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Joins("JOIN duty_rosters dr ON dr.flight_id = flights.id AND dr.deleted_at IS NULL").
|
||||
Where("dr.id IN ? AND flights.deleted_at IS NULL", dutyRosterIDs).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *FlightRepository) List(ctx context.Context, filter string, date string, sort string, limit, offset int) ([]flight.Flight, int64, error) {
|
||||
rows := make([]flight.Flight, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&flight.Flight{}).
|
||||
Select(flightReadSelectSQL()).
|
||||
Preload("Takeover").
|
||||
Preload("Takeover.Base").
|
||||
Preload("Takeover.ReserveAc").
|
||||
Preload("Takeover.ReserveAc.Aircraft").
|
||||
Preload("Takeover.ReserveAc.Inspection").
|
||||
Preload("Takeover.RosterCrews").
|
||||
Preload("Takeover.OtherPeople").
|
||||
Joins("LEFT JOIN users cu ON cu.id = flights.created_by").
|
||||
Joins("LEFT JOIN users uu ON uu.id = flights.updated_by").
|
||||
Where("flights.deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("flights.mission_code LIKE ?", like)
|
||||
}
|
||||
if date != "" {
|
||||
base = base.Where("flights.date = ?", date)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func flightListSelectSQL() string {
|
||||
// created_by_name / updated_by_name are resolved via correlated subqueries so the list
|
||||
// queries carry the auditor display names without needing extra user JOINs (the read
|
||||
// query joins users; the list variants don't). Keeps get-all in parity with get-by-id.
|
||||
return `flights.*,
|
||||
(SELECT dr.id FROM duty_rosters dr WHERE dr.flight_id = flights.id AND dr.deleted_at IS NULL LIMIT 1) AS duty_roster_id,
|
||||
(SELECT ta.reserve_ac_id FROM takeover_acs ta WHERE ta.id = flights.takeover_ac_id AND ta.deleted_at IS NULL LIMIT 1) AS reserve_ac_id,
|
||||
COALESCE((SELECT NULLIF(TRIM(CONCAT(COALESCE(cu.first_name, ''), ' ', COALESCE(cu.last_name, ''))), '')
|
||||
FROM users cu WHERE cu.id = flights.created_by), '') AS created_by_name,
|
||||
COALESCE((SELECT NULLIF(TRIM(CONCAT(COALESCE(uu.first_name, ''), ' ', COALESCE(uu.last_name, ''))), '')
|
||||
FROM users uu WHERE uu.id = flights.updated_by), '') AS updated_by_name`
|
||||
}
|
||||
|
||||
func flightReadSelectSQL() string {
|
||||
return `flights.*,
|
||||
(SELECT dr.id FROM duty_rosters dr WHERE dr.flight_id = flights.id AND dr.deleted_at IS NULL LIMIT 1) AS duty_roster_id,
|
||||
(SELECT ta.reserve_ac_id FROM takeover_acs ta WHERE ta.id = flights.takeover_ac_id AND ta.deleted_at IS NULL LIMIT 1) AS reserve_ac_id,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(cu.first_name, ''), ' ', COALESCE(cu.last_name, ''))), ''),
|
||||
''
|
||||
) AS created_by_name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(uu.first_name, ''), ' ', COALESCE(uu.last_name, ''))), ''),
|
||||
''
|
||||
) AS updated_by_name`
|
||||
}
|
||||
126
internal/repository/mysql/flight_repo_test.go
Normal file
126
internal/repository/mysql/flight_repo_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"wucher/internal/domain/base"
|
||||
"wucher/internal/domain/flight"
|
||||
flightinspection "wucher/internal/domain/flight_inspection"
|
||||
"wucher/internal/domain/helicopter"
|
||||
reserveac "wucher/internal/domain/reserve_ac"
|
||||
takeoverdomain "wucher/internal/domain/takeover"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func TestFlightRepository_ListByCreatedBy_PreloadsTakeoverCompletionRelations(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := db.AutoMigrate(
|
||||
&base.Base{},
|
||||
&helicopter.Helicopter{},
|
||||
&flightinspection.FlightInspection{},
|
||||
&reserveac.ReserveAc{},
|
||||
&takeoverdomain.TakeoverAc{},
|
||||
&takeoverdomain.TakeoverRosterCrew{},
|
||||
&takeoverdomain.TakeoverOtherPerson{},
|
||||
&flight.Flight{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE users (
|
||||
id BLOB PRIMARY KEY,
|
||||
first_name TEXT,
|
||||
last_name TEXT
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create users table: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE duty_rosters (
|
||||
id BLOB PRIMARY KEY,
|
||||
flight_id BLOB,
|
||||
deleted_at DATETIME NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create duty_rosters table: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
baseID := uuidv7.MustBytes()
|
||||
heliID := uuidv7.MustBytes()
|
||||
inspectionID := uuidv7.MustBytes()
|
||||
reserveID := uuidv7.MustBytes()
|
||||
takeoverID := uuidv7.MustBytes()
|
||||
flightID := uuidv7.MustBytes()
|
||||
creatorID := uuidv7.MustBytes()
|
||||
|
||||
if err := db.Create(&base.Base{ID: baseID, BaseName: "Base A", CreatedAt: now, UpdatedAt: now}).Error; err != nil {
|
||||
t.Fatalf("create base: %v", err)
|
||||
}
|
||||
if err := db.Create(&helicopter.Helicopter{ID: heliID, Designation: "H145", Identifier: "D-TEST", Type: "H145", CreatedAt: now, UpdatedAt: now}).Error; err != nil {
|
||||
t.Fatalf("create helicopter: %v", err)
|
||||
}
|
||||
if err := db.Create(&flightinspection.FlightInspection{ID: inspectionID, InspectionDate: now, Status: flightinspection.StatusDraft, CreatedAt: now, UpdatedAt: now}).Error; err != nil {
|
||||
t.Fatalf("create flight inspection: %v", err)
|
||||
}
|
||||
if err := db.Create(&reserveac.ReserveAc{
|
||||
ID: reserveID,
|
||||
BaseID: baseID,
|
||||
AircraftID: heliID,
|
||||
InspectionID: inspectionID,
|
||||
Status: reserveac.StatusApproved,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create reserve ac: %v", err)
|
||||
}
|
||||
if err := db.Create(&takeoverdomain.TakeoverAc{
|
||||
ID: takeoverID,
|
||||
BaseID: baseID,
|
||||
ReserveAcID: reserveID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CreatedBy: creatorID,
|
||||
UpdatedBy: creatorID,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create takeover: %v", err)
|
||||
}
|
||||
if err := db.Create(&takeoverdomain.TakeoverRosterCrew{
|
||||
ID: uuidv7.MustBytes(),
|
||||
TakeoverID: takeoverID,
|
||||
RoleCode: "pilot",
|
||||
CrewType: "main",
|
||||
UserID: uuidv7.MustBytes(),
|
||||
NameLabel: "Pilot A",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create takeover crew: %v", err)
|
||||
}
|
||||
if err := db.Create(&flight.Flight{
|
||||
ID: flightID,
|
||||
TakeoverAcID: takeoverID,
|
||||
Status: "draft",
|
||||
Date: now,
|
||||
CreatedAt: now,
|
||||
CreatedBy: creatorID,
|
||||
UpdatedAt: now,
|
||||
UpdatedBy: creatorID,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create flight: %v", err)
|
||||
}
|
||||
|
||||
repo := NewFlightRepository(db)
|
||||
rows, total, err := repo.ListByCreatedBy(context.Background(), creatorID, "", "", "", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list by created by: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("unexpected result size total=%d len=%d", total, len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if row.Takeover == nil || row.Takeover.Base == nil || row.Takeover.ReserveAc == nil || row.Takeover.ReserveAc.Aircraft == nil || row.Takeover.ReserveAc.Inspection == nil {
|
||||
t.Fatalf("expected takeover relations to be preloaded: %#v", row.Takeover)
|
||||
}
|
||||
if len(row.Takeover.RosterCrews) == 0 {
|
||||
t.Fatalf("expected takeover roster crews to be preloaded")
|
||||
}
|
||||
}
|
||||
283
internal/repository/mysql/fm_report_repo.go
Normal file
283
internal/repository/mysql/fm_report_repo.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
fmreport "wucher/internal/domain/fm_report"
|
||||
)
|
||||
|
||||
type FMReportRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFMReportRepository(db *gorm.DB) *FMReportRepository {
|
||||
return &FMReportRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FMReportRepository) MaxReportSeqByHelicopter(ctx context.Context, helicopterID []byte) (int, error) {
|
||||
if len(helicopterID) != 16 {
|
||||
return 0, nil
|
||||
}
|
||||
var maxSeq *int
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&fmreport.Report{}).
|
||||
Where("helicopter_id = ? AND report_code IS NOT NULL AND report_code <> '' AND deleted_at IS NULL", helicopterID).
|
||||
Select("MAX(CAST(SUBSTRING_INDEX(report_code, '-', -1) AS UNSIGNED))").
|
||||
Scan(&maxSeq).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if maxSeq == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return *maxSeq, nil
|
||||
}
|
||||
|
||||
func (r *FMReportRepository) SetReportCode(ctx context.Context, id []byte, code string) error {
|
||||
if len(id) != 16 {
|
||||
return nil
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&fmreport.Report{}).
|
||||
Where("id = ?", id).
|
||||
Update("report_code", code).Error
|
||||
if isDuplicateKeyError(err) {
|
||||
return fmreport.ErrDuplicateReportCode
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isDuplicateKeyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return true
|
||||
}
|
||||
var mysqlErr *mysqldriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||
}
|
||||
|
||||
func (r *FMReportRepository) Upsert(ctx context.Context, row *fmreport.Report) error {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "flight_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"takeover_id",
|
||||
"flight_inspection_id",
|
||||
"engine1_gpc_n1",
|
||||
"engine1_ptc_n2",
|
||||
"engine2_gpc_n1",
|
||||
"engine2_ptc_n2",
|
||||
"completed_at",
|
||||
"completed_by",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
}),
|
||||
}).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FMReportRepository) GetByFlightID(ctx context.Context, flightID []byte) (*fmreport.Report, error) {
|
||||
var row fmreport.Report
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("flight_id = ? AND deleted_at IS NULL", flightID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FMReportRepository) List(ctx context.Context, filter fmreport.ListFilter, limit, offset int) ([]fmreport.Report, int64, error) {
|
||||
rows := make([]fmreport.Report, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&fmreport.Report{}).
|
||||
Joins("JOIN flights f ON f.id = fm_reports.flight_id AND f.deleted_at IS NULL").
|
||||
Where("fm_reports.deleted_at IS NULL")
|
||||
|
||||
if len(filter.FlightID) == 16 {
|
||||
base = base.Where("fm_reports.flight_id = ?", filter.FlightID)
|
||||
}
|
||||
if len(filter.TakeoverID) == 16 {
|
||||
base = base.Where("fm_reports.takeover_id = ?", filter.TakeoverID)
|
||||
}
|
||||
if len(filter.FlightInspectionID) == 16 {
|
||||
base = base.Where("fm_reports.flight_inspection_id = ?", filter.FlightInspectionID)
|
||||
}
|
||||
if strings.TrimSpace(filter.FlightDate) != "" {
|
||||
base = base.Where("f.date = ?", strings.TrimSpace(filter.FlightDate))
|
||||
}
|
||||
if strings.TrimSpace(filter.FromDate) != "" {
|
||||
base = base.Where("f.date >= ?", strings.TrimSpace(filter.FromDate))
|
||||
}
|
||||
if strings.TrimSpace(filter.ToDate) != "" {
|
||||
base = base.Where("f.date <= ?", strings.TrimSpace(filter.ToDate))
|
||||
}
|
||||
if strings.TrimSpace(filter.HelicopterIdentifier) != "" {
|
||||
base = base.
|
||||
Joins("LEFT JOIN takeover_acs ta ON ta.id = fm_reports.takeover_id AND ta.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN reserve_acs ra ON ra.id = ta.reserve_ac_id AND ra.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN helicopters heli ON heli.id = ra.helicopter_id AND heli.deleted_at IS NULL").
|
||||
Where("heli.identifier LIKE ?", "%"+strings.TrimSpace(filter.HelicopterIdentifier)+"%")
|
||||
}
|
||||
if strings.TrimSpace(filter.PilotName) != "" {
|
||||
like := "%" + strings.TrimSpace(filter.PilotName) + "%"
|
||||
base = base.Where(
|
||||
"EXISTS (SELECT 1 FROM takeover_roster_crews trc LEFT JOIN users trc_u ON trc_u.id = trc.user_id WHERE trc.takeover_id = fm_reports.takeover_id AND trc.role_code = 'PILOT' AND trc.deleted_at IS NULL AND (trc.name_label LIKE ? OR CONCAT(trc_u.first_name, ' ', trc_u.last_name) LIKE ?))",
|
||||
like, like,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(filter.Search) != "" {
|
||||
like := "%" + strings.TrimSpace(filter.Search) + "%"
|
||||
base = base.Where("(f.mission_code LIKE ? OR COALESCE(fm_reports.engine1_gpc_n1, '') LIKE ? OR COALESCE(fm_reports.engine1_ptc_n2, '') LIKE ? OR COALESCE(fm_reports.engine2_gpc_n1, '') LIKE ? OR COALESCE(fm_reports.engine2_ptc_n2, '') LIKE ?)", like, like, like, like, like)
|
||||
}
|
||||
if filter.HasEngine1GpcN1 != nil {
|
||||
if *filter.HasEngine1GpcN1 {
|
||||
base = base.Where("fm_reports.engine1_gpc_n1 IS NOT NULL AND fm_reports.engine1_gpc_n1 <> ''")
|
||||
} else {
|
||||
base = base.Where("(fm_reports.engine1_gpc_n1 IS NULL OR fm_reports.engine1_gpc_n1 = '')")
|
||||
}
|
||||
}
|
||||
if filter.HasEngine1PtcN2 != nil {
|
||||
if *filter.HasEngine1PtcN2 {
|
||||
base = base.Where("fm_reports.engine1_ptc_n2 IS NOT NULL AND fm_reports.engine1_ptc_n2 <> ''")
|
||||
} else {
|
||||
base = base.Where("(fm_reports.engine1_ptc_n2 IS NULL OR fm_reports.engine1_ptc_n2 = '')")
|
||||
}
|
||||
}
|
||||
if filter.HasEngine2GpcN1 != nil {
|
||||
if *filter.HasEngine2GpcN1 {
|
||||
base = base.Where("fm_reports.engine2_gpc_n1 IS NOT NULL AND fm_reports.engine2_gpc_n1 <> ''")
|
||||
} else {
|
||||
base = base.Where("(fm_reports.engine2_gpc_n1 IS NULL OR fm_reports.engine2_gpc_n1 = '')")
|
||||
}
|
||||
}
|
||||
if filter.HasEngine2PtcN2 != nil {
|
||||
if *filter.HasEngine2PtcN2 {
|
||||
base = base.Where("fm_reports.engine2_ptc_n2 IS NOT NULL AND fm_reports.engine2_ptc_n2 <> ''")
|
||||
} else {
|
||||
base = base.Where("(fm_reports.engine2_ptc_n2 IS NULL OR fm_reports.engine2_ptc_n2 = '')")
|
||||
}
|
||||
}
|
||||
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := base.Order(fmReportListSortClause(filter.Sort))
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func fmReportListSortClause(sort string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(sort)) {
|
||||
case "flight_id":
|
||||
return "fm_reports.flight_id ASC"
|
||||
case "-flight_id":
|
||||
return "fm_reports.flight_id DESC"
|
||||
case "takeover_id":
|
||||
return "fm_reports.takeover_id ASC"
|
||||
case "-takeover_id":
|
||||
return "fm_reports.takeover_id DESC"
|
||||
case "flight_inspection_id":
|
||||
return "fm_reports.flight_inspection_id ASC"
|
||||
case "-flight_inspection_id":
|
||||
return "fm_reports.flight_inspection_id DESC"
|
||||
case "flight_date":
|
||||
return "f.date ASC"
|
||||
case "-flight_date":
|
||||
return "f.date DESC"
|
||||
case "created_at":
|
||||
return "fm_reports.created_at ASC"
|
||||
case "-created_at":
|
||||
return "fm_reports.created_at DESC"
|
||||
case "updated_at":
|
||||
return "fm_reports.updated_at ASC"
|
||||
case "-updated_at":
|
||||
return "fm_reports.updated_at DESC"
|
||||
case "engine1_gpc_n1":
|
||||
return "fm_reports.engine1_gpc_n1 ASC"
|
||||
case "-engine1_gpc_n1":
|
||||
return "fm_reports.engine1_gpc_n1 DESC"
|
||||
case "engine1_ptc_n2":
|
||||
return "fm_reports.engine1_ptc_n2 ASC"
|
||||
case "-engine1_ptc_n2":
|
||||
return "fm_reports.engine1_ptc_n2 DESC"
|
||||
case "engine2_gpc_n1":
|
||||
return "fm_reports.engine2_gpc_n1 ASC"
|
||||
case "-engine2_gpc_n1":
|
||||
return "fm_reports.engine2_gpc_n1 DESC"
|
||||
case "engine2_ptc_n2":
|
||||
return "fm_reports.engine2_ptc_n2 ASC"
|
||||
case "-engine2_ptc_n2":
|
||||
return "fm_reports.engine2_ptc_n2 DESC"
|
||||
default:
|
||||
return "f.date DESC, fm_reports.created_at DESC"
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FMReportRepository) GetByID(ctx context.Context, id []byte) (*fmreport.Report, error) {
|
||||
if len(id) != 16 {
|
||||
return nil, nil
|
||||
}
|
||||
var row fmreport.Report
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *FMReportRepository) Delete(ctx context.Context, id, deletedBy []byte) error {
|
||||
if len(id) != 16 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&fmreport.Report{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{
|
||||
"deleted_at": gorm.Expr("NOW(3)"),
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *FMReportRepository) CreateFleetHistory(ctx context.Context, row *fmreport.FleetHistory) error {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "fm_report_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *FMReportRepository) GetFleetHistoryByReportID(ctx context.Context, reportID []byte) (*fmreport.FleetHistory, error) {
|
||||
var row fmreport.FleetHistory
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("fm_report_id = ?", reportID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
68
internal/repository/mysql/fm_report_update_test.go
Normal file
68
internal/repository/mysql/fm_report_update_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
fmreport "wucher/internal/domain/fm_report"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
// TestFMReportUpdatePersistsEngineFields drives the repo Upsert path used by
|
||||
// PATCH /fm-reports/update with the exact (correct) engine field names and asserts they
|
||||
// persist through a read-back.
|
||||
func TestFMReportUpdatePersistsEngineFields(t *testing.T) {
|
||||
dsn := fmt.Sprintf("file:fmreport_update_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{NowFunc: func() time.Time { return time.Now().UTC() }})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&fmreport.Report{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repo := NewFMReportRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
flightID := uuidv7.MustBytes()
|
||||
// Seed an in-progress report with no engine values (like the user's).
|
||||
if err := repo.Upsert(ctx, &fmreport.Report{
|
||||
FlightID: flightID,
|
||||
TakeoverID: uuidv7.MustBytes(),
|
||||
FlightInspectionID: uuidv7.MustBytes(),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// Apply the user's exact payload values.
|
||||
row, err := repo.GetByFlightID(ctx, flightID)
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("get seeded: %v row=%v", err, row)
|
||||
}
|
||||
e1g, e1p := "GPC/N1 63.2", "PTC/N2 61.8"
|
||||
e2g, e2p := "GPC/N1 63.2", "PTC/N2 61.8"
|
||||
row.Engine1GpcN1, row.Engine1PtcN2 = &e1g, &e1p
|
||||
row.Engine2GpcN1, row.Engine2PtcN2 = &e2g, &e2p
|
||||
if err := repo.Upsert(ctx, row); err != nil {
|
||||
t.Fatalf("update upsert: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByFlightID(ctx, flightID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("get after update: %v row=%v", err, got)
|
||||
}
|
||||
if got.Engine1GpcN1 == nil || *got.Engine1GpcN1 != "GPC/N1 63.2" {
|
||||
t.Fatalf("engine1_gpc_n1 = %v, want 'GPC/N1 63.2' (not persisted)", got.Engine1GpcN1)
|
||||
}
|
||||
if got.Engine1PtcN2 == nil || *got.Engine1PtcN2 != "PTC/N2 61.8" {
|
||||
t.Fatalf("engine1_ptc_n2 = %v, want 'PTC/N2 61.8'", got.Engine1PtcN2)
|
||||
}
|
||||
if got.Engine2GpcN1 == nil || *got.Engine2GpcN1 != "GPC/N1 63.2" {
|
||||
t.Fatalf("engine2_gpc_n1 = %v, want 'GPC/N1 63.2'", got.Engine2GpcN1)
|
||||
}
|
||||
t.Logf("engine fields persisted: e1_gpc=%q e1_ptc=%q e2_gpc=%q", *got.Engine1GpcN1, *got.Engine1PtcN2, *got.Engine2GpcN1)
|
||||
}
|
||||
88
internal/repository/mysql/forces_present_repo.go
Normal file
88
internal/repository/mysql/forces_present_repo.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/forces_present"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
)
|
||||
|
||||
type ForcesPresentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewForcesPresentRepository(db *gorm.DB) *ForcesPresentRepository {
|
||||
return &ForcesPresentRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ForcesPresentRepository) Create(ctx context.Context, row *forces_present.ForcesPresent) error {
|
||||
requestedIsActive := row.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&forces_present.ForcesPresent{}).Where("id = ?", row.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *ForcesPresentRepository) Update(ctx context.Context, row *forces_present.ForcesPresent) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *ForcesPresentRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "forces_present", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&forces_present.ForcesPresent{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *ForcesPresentRepository) GetByID(ctx context.Context, id []byte) (*forces_present.ForcesPresent, error) {
|
||||
var row forces_present.ForcesPresent
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *ForcesPresentRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]forces_present.ForcesPresent, int64, error) {
|
||||
var rows []forces_present.ForcesPresent
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&forces_present.ForcesPresent{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("name LIKE ? OR note LIKE ?", like, like)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("forces_present", "is_active", "sortkey", "name", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
238
internal/repository/mysql/forces_present_repo_test.go
Normal file
238
internal/repository/mysql/forces_present_repo_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/forces_present"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openForcesPresentTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:forces_present_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&forces_present.ForcesPresent{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewForcesPresentRepository(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcesPresentRepositoryCreate(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
row := &forces_present.ForcesPresent{Name: "Team A"}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
inactive := &forces_present.ForcesPresent{Name: "Team Inactive", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
gotInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get inactive: %v", err)
|
||||
}
|
||||
if gotInactive == nil || gotInactive.IsActive {
|
||||
t.Fatalf("expected inactive forces present persisted as false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "AfterClose"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcesPresentRepositoryUpdate(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
row := &forces_present.ForcesPresent{Name: "Old"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Name = "New"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.Name != "New" {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcesPresentRepositoryDelete(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
row := &forces_present.ForcesPresent{Name: "DeleteMe"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft deleted row hidden from GetByID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcesPresentRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
row := &forces_present.ForcesPresent{Name: "Found"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "Found" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestForcesPresentRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Charlie"})
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Alpha"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Main Team"})
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Backup"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "Main", "name DESC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Name != "Main Team" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
if err := db.Migrator().DropTable(&forces_present.ForcesPresent{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Main"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active sortkey first and inactive last", func(t *testing.T) {
|
||||
db := openForcesPresentTestDB(t)
|
||||
repo := NewForcesPresentRepository(db)
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Gamma", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Beta", SortKey: intPtrForcesPresentRepo(0), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Charlie", SortKey: intPtrForcesPresentRepo(2), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Alpha", SortKey: intPtrForcesPresentRepo(1), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Zulu", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &forces_present.ForcesPresent{Name: "Bravo", SortKey: intPtrForcesPresentRepo(9), IsActive: false})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{rows[0].Name, rows[1].Name, rows[2].Name, rows[3].Name, rows[4].Name, rows[5].Name}
|
||||
wantOrder := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func intPtrForcesPresentRepo(v int) *int { return &v }
|
||||
206
internal/repository/mysql/health_insurance_companies_repo.go
Normal file
206
internal/repository/mysql/health_insurance_companies_repo.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/health_insurance_companies"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
)
|
||||
|
||||
type HealthInsuranceCompaniesRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHealthInsuranceCompaniesRepository(db *gorm.DB) *HealthInsuranceCompaniesRepository {
|
||||
return &HealthInsuranceCompaniesRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *HealthInsuranceCompaniesRepository) Create(ctx context.Context, row *health_insurance_companies.HealthInsuranceCompany) error {
|
||||
requestedIsActive := row.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&health_insurance_companies.HealthInsuranceCompany{}).Where("id = ?", row.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *HealthInsuranceCompaniesRepository) Update(ctx context.Context, row *health_insurance_companies.HealthInsuranceCompany) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *HealthInsuranceCompaniesRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "health_insurance_companies", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&health_insurance_companies.HealthInsuranceCompany{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *HealthInsuranceCompaniesRepository) GetByID(ctx context.Context, id []byte) (*health_insurance_companies.HealthInsuranceCompany, error) {
|
||||
var row health_insurance_companies.HealthInsuranceCompany
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *HealthInsuranceCompaniesRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]health_insurance_companies.HealthInsuranceCompany, int64, error) {
|
||||
var rows []health_insurance_companies.HealthInsuranceCompany
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&health_insurance_companies.HealthInsuranceCompany{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("name LIKE ? OR state LIKE ? OR address LIKE ? OR mobile_number LIKE ? OR email LIKE ? OR note LIKE ?", like, like, like, like, like, like)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("health_insurance_companies", "is_active", "sortkey", "name", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *HealthInsuranceCompaniesRepository) ResolveStateDetails(ctx context.Context, stateNames []string) (map[string]health_insurance_companies.StateDetail, error) {
|
||||
normalized := make([]string, 0, len(stateNames))
|
||||
seen := map[string]struct{}{}
|
||||
for _, stateName := range stateNames {
|
||||
key := strings.ToLower(strings.TrimSpace(stateName))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, key)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return map[string]health_insurance_companies.StateDetail{}, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
LandID string `gorm:"column:land_id"`
|
||||
FederalStateID string `gorm:"column:federal_state_id"`
|
||||
FederalState string `gorm:"column:federal_state"`
|
||||
LandName string `gorm:"column:land_name"`
|
||||
LandISOCode string `gorm:"column:land_iso_code"`
|
||||
StateKey string `gorm:"column:state_key"`
|
||||
}
|
||||
|
||||
var rows []row
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("federal_states").
|
||||
Select("LOWER(TRIM(federal_states.name)) AS state_key, LOWER(HEX(lands.id)) AS land_id, LOWER(HEX(federal_states.id)) AS federal_state_id, federal_states.name AS federal_state, lands.name AS land_name, lands.land_iso_code AS land_iso_code").
|
||||
Joins("JOIN lands ON lands.id = federal_states.land_id AND lands.deleted_at IS NULL").
|
||||
Where("federal_states.deleted_at IS NULL").
|
||||
Where("LOWER(TRIM(federal_states.name)) IN ?", normalized).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]health_insurance_companies.StateDetail, len(rows))
|
||||
for i := range rows {
|
||||
if rows[i].StateKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := out[rows[i].StateKey]; exists {
|
||||
continue
|
||||
}
|
||||
out[rows[i].StateKey] = health_insurance_companies.StateDetail{
|
||||
LandID: rows[i].LandID,
|
||||
FederalStateID: rows[i].FederalStateID,
|
||||
FederalState: rows[i].FederalState,
|
||||
LandName: rows[i].LandName,
|
||||
LandISOCode: rows[i].LandISOCode,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *HealthInsuranceCompaniesRepository) ResolveLandDetails(ctx context.Context, landIDs []string) (map[string]health_insurance_companies.LandDetail, error) {
|
||||
normalized := make([]string, 0, len(landIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, landID := range landIDs {
|
||||
key := normalizeLandKey(landID)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, key)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return map[string]health_insurance_companies.LandDetail{}, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
LandID string `gorm:"column:land_id"`
|
||||
LandName string `gorm:"column:land_name"`
|
||||
LandISOCode string `gorm:"column:land_iso_code"`
|
||||
}
|
||||
|
||||
var rows []row
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("lands").
|
||||
Select("LOWER(HEX(lands.id)) AS land_id, lands.name AS land_name, lands.land_iso_code AS land_iso_code").
|
||||
Where("lands.deleted_at IS NULL").
|
||||
Where("LOWER(HEX(lands.id)) IN ?", normalized).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]health_insurance_companies.LandDetail, len(rows))
|
||||
for i := range rows {
|
||||
if rows[i].LandID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := out[rows[i].LandID]; exists {
|
||||
continue
|
||||
}
|
||||
out[rows[i].LandID] = health_insurance_companies.LandDetail{
|
||||
LandID: rows[i].LandID,
|
||||
LandName: rows[i].LandName,
|
||||
LandISOCode: rows[i].LandISOCode,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeLandKey(raw string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(raw))
|
||||
key = strings.ReplaceAll(key, "-", "")
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/health_insurance_companies"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openHealthInsuranceCompaniesTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:health_insurance_companies_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&health_insurance_companies.HealthInsuranceCompany{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewHealthInsuranceCompaniesRepository(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthInsuranceCompaniesRepositoryCreate(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
row := &health_insurance_companies.HealthInsuranceCompany{Name: "Main", State: "Berlin"}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
inactive := &health_insurance_companies.HealthInsuranceCompany{Name: "Inactive", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
gotInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get inactive: %v", err)
|
||||
}
|
||||
if gotInactive == nil || gotInactive.IsActive {
|
||||
t.Fatalf("expected inactive hic persisted as false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "AfterClose"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthInsuranceCompaniesRepositoryUpdate(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
row := &health_insurance_companies.HealthInsuranceCompany{Name: "Old", State: "Old"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Name = "New"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.Name != "New" {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthInsuranceCompaniesRepositoryDelete(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
row := &health_insurance_companies.HealthInsuranceCompany{Name: "DeleteMe"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft deleted row hidden from GetByID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthInsuranceCompaniesRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
row := &health_insurance_companies.HealthInsuranceCompany{Name: "Found"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "Found" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHealthInsuranceCompaniesRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "C", State: "Gamma"})
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "A", State: "Alpha"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "Main Base", State: "Center"})
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "Backup", State: "Secondary"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "Main", "name DESC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Name != "Main Base" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
if err := db.Migrator().DropTable(&health_insurance_companies.HealthInsuranceCompany{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "Main"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active sortkey first and inactive last", func(t *testing.T) {
|
||||
db := openHealthInsuranceCompaniesTestDB(t)
|
||||
repo := NewHealthInsuranceCompaniesRepository(db)
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "Gamma", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "Beta", SortKey: intPtrHICRepo(0), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "Charlie", SortKey: intPtrHICRepo(2), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "Alpha", SortKey: intPtrHICRepo(1), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "Zulu", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &health_insurance_companies.HealthInsuranceCompany{Name: "Bravo", SortKey: intPtrHICRepo(9), IsActive: false})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{rows[0].Name, rows[1].Name, rows[2].Name, rows[3].Name, rows[4].Name, rows[5].Name}
|
||||
wantOrder := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func intPtrHICRepo(v int) *int { return &v }
|
||||
176
internal/repository/mysql/helicopter_file_repo.go
Normal file
176
internal/repository/mysql/helicopter_file_repo.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/domain/helicopter"
|
||||
helicopterfile "wucher/internal/domain/helicopter_file"
|
||||
)
|
||||
|
||||
type HelicopterFileRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHelicopterFileRepository(db *gorm.DB) *HelicopterFileRepository {
|
||||
return &HelicopterFileRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) Create(ctx context.Context, row *helicopterfile.HelicopterFile) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) Update(ctx context.Context, row *helicopterfile.HelicopterFile) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) Delete(ctx context.Context, id []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "helicopter_files", id); err != nil {
|
||||
return err
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).Delete(&helicopterfile.HelicopterFile{}, "id = ?", id).Error)
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) GetByID(ctx context.Context, id []byte) (*helicopterfile.HelicopterFile, error) {
|
||||
var row helicopterfile.HelicopterFile
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Helicopter").
|
||||
Preload("FileAttachment").
|
||||
Preload("FileAttachment.File").
|
||||
Preload("SourceFile").
|
||||
Where("id = ?", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) List(ctx context.Context, filter string, sort string, limit, offset int) ([]helicopterfile.HelicopterFile, int64, error) {
|
||||
rows := make([]helicopterfile.HelicopterFile, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&helicopterfile.HelicopterFile{})
|
||||
if strings.TrimSpace(filter) != "" {
|
||||
like := "%" + strings.ToLower(strings.TrimSpace(filter)) + "%"
|
||||
base = base.Where(
|
||||
"LOWER(section) LIKE ? OR LOWER(HEX(id)) LIKE ? OR LOWER(HEX(helicopter_id)) LIKE ? OR LOWER(HEX(file_attachment_id)) LIKE ? OR LOWER(HEX(source_file_id)) LIKE ? OR CAST(is_mandatory AS CHAR) LIKE ?",
|
||||
like, like, like, like, like, like,
|
||||
)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.
|
||||
Preload("Helicopter").
|
||||
Preload("FileAttachment").
|
||||
Preload("FileAttachment.File").
|
||||
Preload("SourceFile").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) ListPendingAttachments(ctx context.Context, limit int) ([]helicopterfile.HelicopterFile, error) {
|
||||
rows := make([]helicopterfile.HelicopterFile, 0)
|
||||
query := r.db.WithContext(ctx).
|
||||
Preload("Helicopter").
|
||||
Preload("SourceFile").
|
||||
Where("source_file_id IS NOT NULL AND file_attachment_id IS NULL").
|
||||
Order("created_at ASC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) ListPendingAttachmentsBySourceFileID(ctx context.Context, sourceFileID []byte, limit int) ([]helicopterfile.HelicopterFile, error) {
|
||||
rows := make([]helicopterfile.HelicopterFile, 0)
|
||||
if len(sourceFileID) != 16 {
|
||||
return rows, nil
|
||||
}
|
||||
query := r.db.WithContext(ctx).
|
||||
Preload("Helicopter").
|
||||
Preload("SourceFile").
|
||||
Where("source_file_id = ? AND file_attachment_id IS NULL", sourceFileID).
|
||||
Order("created_at ASC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) ListByHelicopter(ctx context.Context, helicopterID []byte) ([]helicopterfile.HelicopterFile, error) {
|
||||
rows := make([]helicopterfile.HelicopterFile, 0)
|
||||
if len(helicopterID) != 16 {
|
||||
return rows, nil
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Helicopter").
|
||||
Preload("FileAttachment").
|
||||
Preload("FileAttachment.File").
|
||||
Preload("SourceFile").
|
||||
Where("helicopter_id = ?", helicopterID).
|
||||
Order("section ASC, position ASC, created_at ASC").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) ListByHelicopterAndSection(ctx context.Context, helicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) {
|
||||
rows := make([]helicopterfile.HelicopterFile, 0)
|
||||
if len(helicopterID) != 16 || strings.TrimSpace(section) == "" {
|
||||
return rows, nil
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Helicopter").
|
||||
Preload("FileAttachment").
|
||||
Preload("FileAttachment.File").
|
||||
Preload("SourceFile").
|
||||
Where("helicopter_id = ? AND section = ?", helicopterID, strings.TrimSpace(section)).
|
||||
Order("position ASC, created_at ASC").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) HelicopterExists(ctx context.Context, helicopterID []byte) (bool, error) {
|
||||
if len(helicopterID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&helicopter.Helicopter{}).
|
||||
Where("id = ?", helicopterID).
|
||||
Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func (r *HelicopterFileRepository) AttachmentExists(ctx context.Context, attachmentID []byte) (bool, error) {
|
||||
if len(attachmentID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&filemanager.Attachment{}).
|
||||
Where("id = ?", attachmentID).
|
||||
Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
373
internal/repository/mysql/helicopter_repo.go
Normal file
373
internal/repository/mysql/helicopter_repo.go
Normal file
@@ -0,0 +1,373 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"wucher/internal/domain/helicopter"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
)
|
||||
|
||||
type HelicopterRepository struct {
|
||||
db *gorm.DB
|
||||
hasUsersTable bool
|
||||
}
|
||||
|
||||
func NewHelicopterRepository(db *gorm.DB) *HelicopterRepository {
|
||||
schema := newSchemaCache(db)
|
||||
return &HelicopterRepository{
|
||||
db: db,
|
||||
hasUsersTable: schema.HasTable("users"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) Create(ctx context.Context, h *helicopter.Helicopter) error {
|
||||
requestedIsActive := h.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(h).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&helicopter.Helicopter{}).Where("id = ?", h.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) CreateWithDB(ctx context.Context, db *gorm.DB, h *helicopter.Helicopter) error {
|
||||
requestedIsActive := h.IsActive
|
||||
if err := db.WithContext(ctx).Create(h).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.WithContext(ctx).Model(&helicopter.Helicopter{}).Where("id = ?", h.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
||||
return r.db.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) Update(ctx context.Context, h *helicopter.Helicopter) error {
|
||||
// Persist only helicopter columns; avoid association auto-save side effects
|
||||
// when handler payload carries preloaded FotoAttachment relation.
|
||||
return r.db.WithContext(ctx).Omit(clause.Associations).Save(h).Error
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) ExistsByIdentifier(ctx context.Context, identifier string, excludeID []byte) (bool, error) {
|
||||
db := r.db.WithContext(ctx).Model(&helicopter.Helicopter{}).
|
||||
Where("UPPER(TRIM(identifier)) = ?", strings.ToUpper(strings.TrimSpace(identifier)))
|
||||
if len(excludeID) == 16 {
|
||||
db = db.Where("id <> ?", excludeID)
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return total > 0, nil
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) Delete(ctx context.Context, id []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "helicopters", id); err != nil {
|
||||
return err
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).Delete(&helicopter.Helicopter{}, "id = ?", id).Error)
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) GetByID(ctx context.Context, id []byte) (*helicopter.Helicopter, error) {
|
||||
var h helicopter.Helicopter
|
||||
err := r.withAuditUsers(r.db.WithContext(ctx)).
|
||||
Preload("FotoAttachment").
|
||||
Preload("FotoAttachment.File").
|
||||
Where("helicopters.id = ?", id).
|
||||
First(&h).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &h, err
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) List(ctx context.Context, filter string, statuses []string, sort string, limit, offset int, groundedIDs [][]byte) ([]helicopter.Helicopter, int64, error) {
|
||||
var helicopters []helicopter.Helicopter
|
||||
var total int64
|
||||
|
||||
base := r.withAuditUsers(r.db.WithContext(ctx))
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("helicopters.designation LIKE ? OR helicopters.identifier LIKE ? OR helicopters.type LIKE ?", like, like, like)
|
||||
}
|
||||
base = r.applyStatusFilter(base, statuses, groundedIDs)
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("helicopters", "is_active", "sortkey", "designation", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
query = query.Preload("FotoAttachment").Preload("FotoAttachment.File")
|
||||
if err := query.Find(&helicopters).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return helicopters, total, nil
|
||||
}
|
||||
|
||||
// applyStatusFilter restricts query to helicopters whose derived operational
|
||||
// status is in statuses. The conditions mirror helicopter.DeriveStatus: AOG
|
||||
// (air_on_ground) outranks an active flight assignment ("booked"), and
|
||||
// "available" means neither; "mcf" has no data source yet, so it matches
|
||||
// nothing. Multiple statuses are OR-ed together. Returns query unchanged when
|
||||
// no status filter applies.
|
||||
func (r *HelicopterRepository) applyStatusFilter(query *gorm.DB, statuses []string, groundedIDs [][]byte) *gorm.DB {
|
||||
if len(statuses) == 0 {
|
||||
return query
|
||||
}
|
||||
|
||||
// inUse mirrors IsInUse: the helicopter has a non-deleted flight assignment
|
||||
// reachable through takeover_acs.reserve_ac_id. A fresh subquery is built
|
||||
// per use to avoid sharing builder state.
|
||||
inUse := func() *gorm.DB {
|
||||
return r.db.
|
||||
Table("reserve_acs ra").
|
||||
Select("1").
|
||||
Joins("JOIN takeover_acs ta ON ta.reserve_ac_id = ra.id AND ta.deleted_at IS NULL").
|
||||
Joins("JOIN flights f ON f.takeover_ac_id = ta.id").
|
||||
Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = ra.inspection_id").
|
||||
Where("ra.helicopter_id = helicopters.id").
|
||||
Where("ra.deleted_at IS NULL AND f.deleted_at IS NULL AND afi.id IS NULL")
|
||||
}
|
||||
|
||||
// A helicopter is AOG when its persisted air_on_ground flag is set OR it is
|
||||
// grounded by an open complaint (computed in Go from IsGrounding and passed in via
|
||||
// groundedIDs, since the read-time grounding is not persisted in air_on_ground).
|
||||
hasGrounded := len(groundedIDs) > 0
|
||||
|
||||
group := r.db.Session(&gorm.Session{NewDB: true})
|
||||
seen := make(map[string]struct{}, len(statuses))
|
||||
for _, status := range statuses {
|
||||
if _, ok := seen[status]; ok {
|
||||
continue
|
||||
}
|
||||
seen[status] = struct{}{}
|
||||
switch status {
|
||||
case helicopter.StatusAOG:
|
||||
aog := r.db.Where("helicopters.air_on_ground = ?", true)
|
||||
if hasGrounded {
|
||||
aog = aog.Or("helicopters.id IN ?", groundedIDs)
|
||||
}
|
||||
group = group.Or(aog)
|
||||
case helicopter.StatusBooked:
|
||||
booked := r.db.Where("helicopters.air_on_ground = ?", false).Where("EXISTS (?)", inUse())
|
||||
if hasGrounded {
|
||||
booked = booked.Where("helicopters.id NOT IN ?", groundedIDs)
|
||||
}
|
||||
group = group.Or(booked)
|
||||
case helicopter.StatusAvailable:
|
||||
avail := r.db.Where("helicopters.air_on_ground = ?", false).Where("NOT EXISTS (?)", inUse())
|
||||
if hasGrounded {
|
||||
avail = avail.Where("helicopters.id NOT IN ?", groundedIDs)
|
||||
}
|
||||
group = group.Or(avail)
|
||||
case helicopter.StatusMCF:
|
||||
group = group.Or("1 = 0") // no data source yet
|
||||
}
|
||||
}
|
||||
return query.Where(group)
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) NextReportNumber(ctx context.Context, id []byte) (string, int, error) {
|
||||
var reportNumber string
|
||||
var sequence int
|
||||
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var h helicopter.Helicopter
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&h).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
identifier := strings.TrimSpace(h.Identifier)
|
||||
if identifier == "" {
|
||||
return errors.New("helicopter identifier is empty")
|
||||
}
|
||||
if base, _, ok := splitIdentifierBase(identifier); ok {
|
||||
identifier = base
|
||||
}
|
||||
|
||||
next := h.ReportSequence + 1
|
||||
if err := tx.Model(&helicopter.Helicopter{}).Where("id = ?", h.ID).Update("report_sequence", next).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sequence = next
|
||||
reportNumber = fmt.Sprintf("%s-%04d", identifier, next)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
return reportNumber, sequence, nil
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) IsInUse(ctx context.Context, id []byte) (bool, error) {
|
||||
if len(id) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("reserve_acs ra").
|
||||
Joins("JOIN takeover_acs ta ON ta.reserve_ac_id = ra.id AND ta.deleted_at IS NULL").
|
||||
Joins("JOIN flights f ON f.takeover_ac_id = ta.id").
|
||||
Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = ra.inspection_id").
|
||||
Where("ra.helicopter_id = ? AND ra.deleted_at IS NULL AND f.deleted_at IS NULL AND afi.id IS NULL", id).
|
||||
Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) InUseByAircraftIDs(ctx context.Context, ids [][]byte) (map[string]bool, error) {
|
||||
out := make(map[string]bool)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
filtered := make([][]byte, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if len(id) != 16 {
|
||||
continue
|
||||
}
|
||||
key := string(id)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
filtered = append(filtered, append([]byte(nil), id...))
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
HelicopterID []byte `gorm:"column:helicopter_id"`
|
||||
}
|
||||
rows := make([]row, 0)
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("reserve_acs ra").
|
||||
Select("DISTINCT ra.helicopter_id").
|
||||
Joins("JOIN takeover_acs ta ON ta.reserve_ac_id = ra.id AND ta.deleted_at IS NULL").
|
||||
Joins("JOIN flights f ON f.takeover_ac_id = ta.id").
|
||||
Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = ra.inspection_id").
|
||||
Where("ra.deleted_at IS NULL AND f.deleted_at IS NULL AND afi.id IS NULL").
|
||||
Where("ra.helicopter_id IN ?", filtered).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
out[string(rows[i].HelicopterID)] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ActiveFlightIDByAircraftIDs returns, per helicopter, the id of its current active flight
|
||||
// (a takeover flight with no after-flight inspection yet). Mirrors the InUseByAircraftIDs
|
||||
// join so "in use" and "which flight" stay consistent. Only in-use helicopters appear in
|
||||
// the map; the newest flight wins if several are open. Used by the fleet status page to
|
||||
// create an EASA release scoped to the flight.
|
||||
func (r *HelicopterRepository) ActiveFlightIDByAircraftIDs(ctx context.Context, ids [][]byte) (map[string][]byte, error) {
|
||||
out := make(map[string][]byte)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
filtered := make([][]byte, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if len(id) != 16 {
|
||||
continue
|
||||
}
|
||||
key := string(id)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
filtered = append(filtered, append([]byte(nil), id...))
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
HelicopterID []byte `gorm:"column:helicopter_id"`
|
||||
FlightID []byte `gorm:"column:flight_id"`
|
||||
}
|
||||
rows := make([]row, 0)
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("reserve_acs ra").
|
||||
Select("ra.helicopter_id AS helicopter_id, f.id AS flight_id").
|
||||
Joins("JOIN takeover_acs ta ON ta.reserve_ac_id = ra.id AND ta.deleted_at IS NULL").
|
||||
Joins("JOIN flights f ON f.takeover_ac_id = ta.id").
|
||||
Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = ra.inspection_id").
|
||||
Where("ra.deleted_at IS NULL AND f.deleted_at IS NULL AND afi.id IS NULL").
|
||||
Where("ra.helicopter_id IN ?", filtered).
|
||||
Order("f.created_at DESC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
k := string(rows[i].HelicopterID)
|
||||
if _, ok := out[k]; !ok && len(rows[i].FlightID) == 16 {
|
||||
out[k] = rows[i].FlightID
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitIdentifierBase(raw string) (string, int, bool) {
|
||||
s := strings.TrimSpace(raw)
|
||||
idx := strings.LastIndex(s, "-")
|
||||
if idx <= 0 || idx+5 != len(s) {
|
||||
return "", 0, false
|
||||
}
|
||||
base := strings.TrimSpace(s[:idx])
|
||||
if base == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
seq, err := strconv.Atoi(s[idx+1:])
|
||||
if err != nil || seq <= 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
return base, seq, true
|
||||
}
|
||||
|
||||
func (r *HelicopterRepository) withAuditUsers(db *gorm.DB) *gorm.DB {
|
||||
base := db.Model(&helicopter.Helicopter{})
|
||||
if !r.hasUsersTable {
|
||||
return base.Select("helicopters.*, '' AS created_by_name, '' AS updated_by_name")
|
||||
}
|
||||
return base.
|
||||
Select(`
|
||||
helicopters.*,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(cu.first_name, ''), ' ', COALESCE(cu.last_name, ''))), ''),
|
||||
NULLIF(TRIM(cu.username), ''),
|
||||
NULLIF(TRIM(cu.email), ''),
|
||||
''
|
||||
) AS created_by_name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(uu.first_name, ''), ' ', COALESCE(uu.last_name, ''))), ''),
|
||||
NULLIF(TRIM(uu.username), ''),
|
||||
NULLIF(TRIM(uu.email), ''),
|
||||
''
|
||||
) AS updated_by_name
|
||||
`).
|
||||
Joins("LEFT JOIN users cu ON cu.id = helicopters.created_by").
|
||||
Joins("LEFT JOIN users uu ON uu.id = helicopters.updated_by")
|
||||
}
|
||||
587
internal/repository/mysql/helicopter_repo_test.go
Normal file
587
internal/repository/mysql/helicopter_repo_test.go
Normal file
@@ -0,0 +1,587 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
afterflightinspection "wucher/internal/domain/after_flight_inspection"
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/domain/helicopter"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
func openHelicopterTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:helicopter_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&filemanager.Folder{}, &filemanager.File{}, &filemanager.Attachment{}, &helicopter.Helicopter{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewHelicopterRepository(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
if repo.hasUsersTable {
|
||||
t.Fatalf("expected users table cache to be false in base helicopter test db")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelicopterRepositoryCachesUsersTablePresence(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
if err := db.Exec(`CREATE TABLE users (
|
||||
id BLOB PRIMARY KEY,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
username TEXT,
|
||||
email TEXT
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create users table: %v", err)
|
||||
}
|
||||
|
||||
repo := NewHelicopterRepository(db)
|
||||
if !repo.hasUsersTable {
|
||||
t.Fatalf("expected users table cache to be true")
|
||||
}
|
||||
|
||||
creatorID := uuidv7.MustBytes()
|
||||
updaterID := uuidv7.MustBytes()
|
||||
if err := db.Table("users").Create([]map[string]any{
|
||||
{
|
||||
"id": creatorID,
|
||||
"first_name": "Ada",
|
||||
"last_name": "Lovelace",
|
||||
"username": "ada",
|
||||
"email": "ada@example.com",
|
||||
},
|
||||
{
|
||||
"id": updaterID,
|
||||
"first_name": "Grace",
|
||||
"last_name": "Hopper",
|
||||
"username": "grace",
|
||||
"email": "grace@example.com",
|
||||
},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed users: %v", err)
|
||||
}
|
||||
|
||||
row := &helicopter.Helicopter{
|
||||
Designation: "H145",
|
||||
Identifier: "PK-AUDIT",
|
||||
Type: "Twin",
|
||||
CreatedBy: creatorID,
|
||||
UpdatedBy: updaterID,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed helicopter: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get helicopter: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected helicopter row")
|
||||
}
|
||||
if got.CreatedByName != "Ada Lovelace" {
|
||||
t.Fatalf("unexpected created_by_name: %q", got.CreatedByName)
|
||||
}
|
||||
if got.UpdatedByName != "Grace Hopper" {
|
||||
t.Fatalf("unexpected updated_by_name: %q", got.UpdatedByName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelicopterRepositoryCreate(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
row := &helicopter.Helicopter{Designation: "H145", Identifier: "PK-ABC", Type: "Twin"}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
inactive := &helicopter.Helicopter{Designation: "Inactive", Identifier: "PK-INACTIVE", Type: "Single", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
gotInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get inactive: %v", err)
|
||||
}
|
||||
if gotInactive == nil || gotInactive.IsActive {
|
||||
t.Fatalf("expected inactive helicopter persisted as false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &helicopter.Helicopter{Designation: "AfterClose", Identifier: "PK-CLOSE", Type: "Twin"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelicopterRepositoryUpdate(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
row := &helicopter.Helicopter{Designation: "Old", Identifier: "PK-OLD", Type: "Single"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Designation = "New"
|
||||
row.Type = "Twin"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.Designation != "New" || loaded.Type != "Twin" {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelicopterRepositoryUpdate_ReplacesFotoAttachmentID(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
folder := &filemanager.Folder{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Name: "photos",
|
||||
NameNormalized: "photos",
|
||||
Depth: 0,
|
||||
PathCache: "/photos",
|
||||
NameSlot: "live",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(folder).Error; err != nil {
|
||||
t.Fatalf("create folder: %v", err)
|
||||
}
|
||||
|
||||
makeFile := func(name, key string) *filemanager.File {
|
||||
return &filemanager.File{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FolderID: folder.ID,
|
||||
Name: name,
|
||||
NameNormalized: strings.ToLower(name),
|
||||
Extension: "webp",
|
||||
SizeBytes: 1234,
|
||||
MimeType: "image/webp",
|
||||
Bucket: "bucket",
|
||||
ObjectKey: key,
|
||||
Status: filemanager.FileStatusReady,
|
||||
NameSlot: "live",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
oldFile := makeFile("old.webp", "objects/old.webp")
|
||||
newFile := makeFile("new.webp", "objects/new.webp")
|
||||
if err := db.Create(oldFile).Error; err != nil {
|
||||
t.Fatalf("create old file: %v", err)
|
||||
}
|
||||
if err := db.Create(newFile).Error; err != nil {
|
||||
t.Fatalf("create new file: %v", err)
|
||||
}
|
||||
|
||||
oldAttachment := &filemanager.Attachment{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FileID: oldFile.ID,
|
||||
RefType: "helicopter_photo",
|
||||
RefID: "helicopter-1",
|
||||
IsPrimary: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
newAttachment := &filemanager.Attachment{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FileID: newFile.ID,
|
||||
RefType: "helicopter_photo",
|
||||
RefID: "helicopter-1",
|
||||
IsPrimary: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(oldAttachment).Error; err != nil {
|
||||
t.Fatalf("create old attachment: %v", err)
|
||||
}
|
||||
if err := db.Create(newAttachment).Error; err != nil {
|
||||
t.Fatalf("create new attachment: %v", err)
|
||||
}
|
||||
|
||||
row := &helicopter.Helicopter{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Designation: "Old",
|
||||
Identifier: "PK-OLD-ATT",
|
||||
Type: "Single",
|
||||
FotoAttachmentID: oldAttachment.ID,
|
||||
FotoAttachment: oldAttachment,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create helicopter: %v", err)
|
||||
}
|
||||
|
||||
row.FotoAttachmentID = newAttachment.ID
|
||||
row.FotoAttachment = oldAttachment
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("update helicopter: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after update: %v", err)
|
||||
}
|
||||
if loaded == nil {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
if string(loaded.FotoAttachmentID) != string(newAttachment.ID) {
|
||||
t.Fatalf("expected foto_attachment_id replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelicopterRepositoryDelete(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
row := &helicopter.Helicopter{Designation: "DeleteMe", Identifier: "PK-DEL", Type: "Twin"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected row deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelicopterRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
row := &helicopter.Helicopter{Designation: "Found", Identifier: "PK-FND", Type: "Twin"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Identifier != "PK-FND" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHelicopterRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "H145", Identifier: "PK-A", Type: "Twin"})
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "H125", Identifier: "PK-B", Type: "Single"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", nil, "", 0, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "Main", Identifier: "PK-MAIN", Type: "Twin"})
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "Backup", Identifier: "PK-BACK", Type: "Single"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "MAIN", nil, "identifier DESC", 1, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Identifier != "PK-MAIN" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active sortkey first and inactive last", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "Gamma", Identifier: "PK-G", Type: "Twin", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "Beta", Identifier: "PK-B0", Type: "Twin", SortKey: intPtr(0), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "Charlie", Identifier: "PK-C", Type: "Twin", SortKey: intPtr(2), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "Alpha", Identifier: "PK-A1", Type: "Twin", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "Alpha", Identifier: "PK-A2", Type: "Twin", SortKey: intPtr(1), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "Zulu", Identifier: "PK-Z", Type: "Twin", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "Bravo", Identifier: "PK-B", Type: "Twin", IsActive: false, SortKey: intPtr(9)})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", nil, "", 0, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 7 || len(rows) != 7 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{
|
||||
rows[0].Designation + "|" + rows[0].Identifier,
|
||||
rows[1].Designation + "|" + rows[1].Identifier,
|
||||
rows[2].Designation + "|" + rows[2].Identifier,
|
||||
rows[3].Designation + "|" + rows[3].Identifier,
|
||||
rows[4].Designation + "|" + rows[4].Identifier,
|
||||
rows[5].Designation + "|" + rows[5].Identifier,
|
||||
rows[6].Designation + "|" + rows[6].Identifier,
|
||||
}
|
||||
wantOrder := []string{
|
||||
"Beta|PK-B0",
|
||||
"Alpha|PK-A2",
|
||||
"Charlie|PK-C",
|
||||
"Alpha|PK-A1",
|
||||
"Gamma|PK-G",
|
||||
"Bravo|PK-B",
|
||||
"Zulu|PK-Z",
|
||||
}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status filter", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
|
||||
// Minimal schema for the booked/available EXISTS subquery.
|
||||
if err := db.Exec(`CREATE TABLE reserve_acs (id BLOB PRIMARY KEY, helicopter_id BLOB, inspection_id BLOB, deleted_at DATETIME)`).Error; err != nil {
|
||||
t.Fatalf("create reserve_acs: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE takeover_acs (id BLOB PRIMARY KEY, reserve_ac_id BLOB, deleted_at DATETIME)`).Error; err != nil {
|
||||
t.Fatalf("create takeover_acs: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE flights (id BLOB PRIMARY KEY, reserve_ac_id BLOB, takeover_ac_id BLOB, deleted_at DATETIME)`).Error; err != nil {
|
||||
t.Fatalf("create flights: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&afterflightinspection.AfterFlightInspection{}); err != nil {
|
||||
t.Fatalf("auto migrate after flight inspection: %v", err)
|
||||
}
|
||||
|
||||
aog := &helicopter.Helicopter{Designation: "AOG", Identifier: "PK-AOG", Type: "Twin", AirOnGround: true}
|
||||
booked := &helicopter.Helicopter{Designation: "Booked", Identifier: "PK-BKD", Type: "Twin"}
|
||||
closed := &helicopter.Helicopter{Designation: "Closed", Identifier: "PK-CLS", Type: "Twin"}
|
||||
available := &helicopter.Helicopter{Designation: "Avail", Identifier: "PK-AVL", Type: "Twin"}
|
||||
for _, h := range []*helicopter.Helicopter{aog, booked, closed, available} {
|
||||
if err := repo.Create(context.Background(), h); err != nil {
|
||||
t.Fatalf("create helicopter: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Give "booked" an active (non-deleted) flight assignment.
|
||||
raID := uuidv7.MustBytes()
|
||||
takeoverID := uuidv7.MustBytes()
|
||||
if err := db.Table("reserve_acs").Create(map[string]any{"id": raID, "helicopter_id": booked.ID}).Error; err != nil {
|
||||
t.Fatalf("insert reserve_ac: %v", err)
|
||||
}
|
||||
if err := db.Table("takeover_acs").Create(map[string]any{"id": takeoverID, "reserve_ac_id": raID}).Error; err != nil {
|
||||
t.Fatalf("insert takeover_ac: %v", err)
|
||||
}
|
||||
if err := db.Table("flights").Create(map[string]any{"id": uuidv7.MustBytes(), "takeover_ac_id": takeoverID}).Error; err != nil {
|
||||
t.Fatalf("insert flight: %v", err)
|
||||
}
|
||||
closedRAID := uuidv7.MustBytes()
|
||||
closedTakeoverID := uuidv7.MustBytes()
|
||||
closedInspectionID := uuidv7.MustBytes()
|
||||
if err := db.Table("reserve_acs").Create(map[string]any{"id": closedRAID, "helicopter_id": closed.ID, "inspection_id": closedInspectionID}).Error; err != nil {
|
||||
t.Fatalf("insert closed reserve_ac: %v", err)
|
||||
}
|
||||
if err := db.Table("takeover_acs").Create(map[string]any{"id": closedTakeoverID, "reserve_ac_id": closedRAID}).Error; err != nil {
|
||||
t.Fatalf("insert closed takeover_ac: %v", err)
|
||||
}
|
||||
if err := db.Table("flights").Create(map[string]any{"id": uuidv7.MustBytes(), "takeover_ac_id": closedTakeoverID}).Error; err != nil {
|
||||
t.Fatalf("insert closed flight: %v", err)
|
||||
}
|
||||
if err := db.Create(&afterflightinspection.AfterFlightInspection{FlightInspectionID: closedInspectionID, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}).Error; err != nil {
|
||||
t.Fatalf("insert after flight inspection: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
statuses []string
|
||||
groundedIDs [][]byte
|
||||
want []string
|
||||
}{
|
||||
{name: "aog only", statuses: []string{"aog"}, want: []string{"PK-AOG"}},
|
||||
{name: "booked only", statuses: []string{"booked"}, want: []string{"PK-BKD"}},
|
||||
{name: "available only", statuses: []string{"available"}, want: []string{"PK-AVL", "PK-CLS"}},
|
||||
{name: "available and booked", statuses: []string{"available", "booked"}, want: []string{"PK-AVL", "PK-BKD", "PK-CLS"}},
|
||||
{name: "mcf matches nothing", statuses: []string{"mcf"}, want: []string{}},
|
||||
{name: "no filter returns all", statuses: nil, want: []string{"PK-AOG", "PK-AVL", "PK-BKD", "PK-CLS"}},
|
||||
{name: "complaint-grounded excluded from available", statuses: []string{"available"}, groundedIDs: [][]byte{available.ID}, want: []string{"PK-CLS"}},
|
||||
{name: "complaint-grounded appears as aog", statuses: []string{"aog"}, groundedIDs: [][]byte{available.ID}, want: []string{"PK-AOG", "PK-AVL"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rows, total, err := repo.List(context.Background(), "", tc.statuses, "identifier ASC", 0, 0, tc.groundedIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if int(total) != len(tc.want) || len(rows) != len(tc.want) {
|
||||
t.Fatalf("count mismatch: total=%d len=%d want=%d", total, len(rows), len(tc.want))
|
||||
}
|
||||
got := make([]string, len(rows))
|
||||
for i := range rows {
|
||||
got[i] = rows[i].Identifier
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("unexpected rows: got=%v want=%v", got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
if err := db.Migrator().DropTable(&helicopter.Helicopter{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", nil, "", 10, 0, nil); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
_ = repo.Create(context.Background(), &helicopter.Helicopter{Designation: "Main", Identifier: "PK-ERR", Type: "Twin"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", nil, "designation ASC, )", 10, 0, nil); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHelicopterRepositoryNextReportNumber(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
row := &helicopter.Helicopter{
|
||||
Designation: "H145",
|
||||
Identifier: " PK-ABC ",
|
||||
Type: "Twin",
|
||||
ReportSequence: 7,
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
reportNumber, sequence, err := repo.NextReportNumber(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reportNumber != "PK-ABC-0008" || sequence != 8 {
|
||||
t.Fatalf("unexpected report number/sequence: %q %d", reportNumber, sequence)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.ReportSequence != 8 {
|
||||
t.Fatalf("expected report_sequence updated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("identifier empty", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
row := &helicopter.Helicopter{
|
||||
Designation: "H145",
|
||||
Identifier: " ",
|
||||
Type: "Twin",
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.NextReportNumber(context.Background(), row.ID); err == nil {
|
||||
t.Fatalf("expected identifier empty error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
if _, _, err := repo.NextReportNumber(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected not found error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openHelicopterTestDB(t)
|
||||
repo := NewHelicopterRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, _, err := repo.NextReportNumber(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
399
internal/repository/mysql/helicopter_usage_repo.go
Normal file
399
internal/repository/mysql/helicopter_usage_repo.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
flightdata "wucher/internal/domain/flight_data"
|
||||
helicopterusage "wucher/internal/domain/helicopter_usage"
|
||||
"wucher/internal/shared/pkg/metricparse"
|
||||
)
|
||||
|
||||
type HelicopterUsageRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHelicopterUsageRepository(db *gorm.DB) *HelicopterUsageRepository {
|
||||
return &HelicopterUsageRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) Create(ctx context.Context, row *helicopterusage.HelicopterUsage) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) Update(ctx context.Context, row *helicopterusage.HelicopterUsage) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "helicopter_usage", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&helicopterusage.HelicopterUsage{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) RefreshAll(ctx context.Context) error {
|
||||
now := time.Now().UTC()
|
||||
existing := map[string]helicopterusage.HelicopterUsage{}
|
||||
{
|
||||
var rows []helicopterusage.HelicopterUsage
|
||||
if err := r.db.WithContext(ctx).
|
||||
Select("helicopter_usage.*").
|
||||
Where("helicopter_usage.deleted_at IS NULL").
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range rows {
|
||||
key, err := helicopterUsageKey(rows[i].HelicopterID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
existing[key] = rows[i]
|
||||
}
|
||||
}
|
||||
|
||||
helicopterIDs, err := r.listActiveHelicopterIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(helicopterIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
flightStats, err := r.flightStatsByHelicopter(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reportStats, err := r.reportStatsByHelicopter(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, helicopterID := range helicopterIDs {
|
||||
key, _ := helicopterUsageKey(helicopterID)
|
||||
row := &helicopterusage.HelicopterUsage{
|
||||
HelicopterID: append([]byte(nil), helicopterID...),
|
||||
TotalLanding: flightStats[key].TotalLanding,
|
||||
TotalAirframeHours: flightStats[key].TotalAirframeHours,
|
||||
TotalAirframeCycles: flightStats[key].TotalAirframeCycles,
|
||||
TotalEngine1Hours: flightStats[key].TotalAirframeHours,
|
||||
TotalEngine2Hours: flightStats[key].TotalAirframeHours,
|
||||
TotalEngine1GpcNgN1: reportStats[key].TotalEngine1GpcNgN1,
|
||||
TotalEngine1PtcNfN2: reportStats[key].TotalEngine1PtcNfN2,
|
||||
TotalEngine2GpcNgN1: reportStats[key].TotalEngine2GpcNgN1,
|
||||
TotalEngine2PtcNfN2: reportStats[key].TotalEngine2PtcNfN2,
|
||||
TotalFlightReport: reportStats[key].TotalFlightReport,
|
||||
TotalHookRelease: flightStats[key].TotalHookRelease,
|
||||
TotalRotorBrakeCycle: flightStats[key].TotalRotorBrakeCycle,
|
||||
}
|
||||
if ex, ok := existing[key]; ok {
|
||||
row.ID = ex.ID
|
||||
row.CreatedAt = ex.CreatedAt
|
||||
row.CreatedBy = ex.CreatedBy
|
||||
row.UpdatedBy = ex.UpdatedBy
|
||||
row.TotalEngine1Ccc = ex.TotalEngine1Ccc
|
||||
row.TotalEngine2Ccc = ex.TotalEngine2Ccc
|
||||
|
||||
row.ManualAirframeHours = ex.ManualAirframeHours
|
||||
row.ManualAirframeCycles = ex.ManualAirframeCycles
|
||||
row.ManualEngine1Hours = ex.ManualEngine1Hours
|
||||
row.ManualEngine1GpcNgN1 = ex.ManualEngine1GpcNgN1
|
||||
row.ManualEngine1PtcNfN2 = ex.ManualEngine1PtcNfN2
|
||||
row.ManualEngine2Hours = ex.ManualEngine2Hours
|
||||
row.ManualEngine2GpcNgN1 = ex.ManualEngine2GpcNgN1
|
||||
row.ManualEngine2PtcNfN2 = ex.ManualEngine2PtcNfN2
|
||||
row.ManualLanding = ex.ManualLanding
|
||||
row.ManualFlightReport = ex.ManualFlightReport
|
||||
row.ManualHookRelease = ex.ManualHookRelease
|
||||
row.ManualRotorBrakeCycle = ex.ManualRotorBrakeCycle
|
||||
|
||||
row.TotalAirframeHours += ex.ManualAirframeHours
|
||||
row.TotalAirframeCycles += ex.ManualAirframeCycles
|
||||
row.TotalEngine1Hours += ex.ManualEngine1Hours
|
||||
row.TotalEngine1GpcNgN1 += ex.ManualEngine1GpcNgN1
|
||||
row.TotalEngine1PtcNfN2 += ex.ManualEngine1PtcNfN2
|
||||
row.TotalEngine2Hours += ex.ManualEngine2Hours
|
||||
row.TotalEngine2GpcNgN1 += ex.ManualEngine2GpcNgN1
|
||||
row.TotalEngine2PtcNfN2 += ex.ManualEngine2PtcNfN2
|
||||
row.TotalLanding += ex.ManualLanding
|
||||
row.TotalFlightReport += ex.ManualFlightReport
|
||||
row.TotalHookRelease += ex.ManualHookRelease
|
||||
row.TotalRotorBrakeCycle += ex.ManualRotorBrakeCycle
|
||||
} else {
|
||||
row.CreatedAt = now
|
||||
}
|
||||
row.UpdatedAt = now
|
||||
if err := r.upsertByHelicopterID(ctx, row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) GetByID(ctx context.Context, id []byte) (*helicopterusage.HelicopterUsage, error) {
|
||||
var row helicopterusage.HelicopterUsage
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Helicopter").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) GetByHelicopterID(ctx context.Context, helicopterID []byte) (*helicopterusage.HelicopterUsage, error) {
|
||||
var row helicopterusage.HelicopterUsage
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Helicopter").
|
||||
Where("helicopter_id = ? AND deleted_at IS NULL", helicopterID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
type helicopterUsageFlightStats struct {
|
||||
TotalLanding float64
|
||||
TotalAirframeHours float64
|
||||
TotalAirframeCycles float64
|
||||
TotalHookRelease float64
|
||||
TotalRotorBrakeCycle float64
|
||||
}
|
||||
|
||||
type helicopterUsageReportStats struct {
|
||||
TotalFlightReport float64
|
||||
TotalEngine1GpcNgN1 float64
|
||||
TotalEngine1PtcNfN2 float64
|
||||
TotalEngine2GpcNgN1 float64
|
||||
TotalEngine2PtcNfN2 float64
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) listActiveHelicopterIDs(ctx context.Context) ([][]byte, error) {
|
||||
type row struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
}
|
||||
rows := make([]row, 0)
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("helicopters").
|
||||
Select("id").
|
||||
Where("is_active = ?", true).
|
||||
Order("created_at ASC, id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([][]byte, 0, len(rows))
|
||||
for i := range rows {
|
||||
if len(rows[i].ID) != 16 {
|
||||
continue
|
||||
}
|
||||
out = append(out, append([]byte(nil), rows[i].ID...))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) flightStatsByHelicopter(ctx context.Context) (map[string]helicopterUsageFlightStats, error) {
|
||||
type row struct {
|
||||
HelicopterID []byte `gorm:"column:helicopter_id"`
|
||||
TotalLanding int64 `gorm:"column:total_landing"`
|
||||
TotalAirframeNS int64 `gorm:"column:total_airframe_ns"`
|
||||
TotalAirframeCycles int64 `gorm:"column:total_airframe_cycles"`
|
||||
TotalHookRelease int64 `gorm:"column:total_hook_release"`
|
||||
TotalRotorBrakeCycle int64 `gorm:"column:total_rotor_brake_cycle"`
|
||||
}
|
||||
rows := make([]row, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("flight_data fd").
|
||||
Select(`
|
||||
ra.helicopter_id,
|
||||
COALESCE(SUM(fd.landing_count), 0) AS total_landing,
|
||||
COALESCE(SUM(fd.duration), 0) AS total_airframe_ns,
|
||||
COUNT(fd.id) AS total_airframe_cycles,
|
||||
COALESCE(SUM(fd.hook_releases), 0) AS total_hook_release,
|
||||
COALESCE(SUM(fd.rotor_brake_cycle), 0) AS total_rotor_brake_cycle
|
||||
`).
|
||||
Joins("JOIN missions m ON m.id = fd.mission_id AND m.deleted_at IS NULL").
|
||||
Joins("JOIN flights f ON f.id = m.flight_id AND f.deleted_at IS NULL").
|
||||
Joins("JOIN takeover_acs ta ON ta.id = f.takeover_ac_id AND ta.deleted_at IS NULL").
|
||||
Joins("JOIN reserve_acs ra ON ra.id = ta.reserve_ac_id AND ra.deleted_at IS NULL").
|
||||
Where("fd.deleted_at IS NULL AND fd.status = ?", flightdata.StatusCompleted).
|
||||
Group("ra.helicopter_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]helicopterUsageFlightStats, len(rows))
|
||||
for i := range rows {
|
||||
key, err := helicopterUsageKey(rows[i].HelicopterID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[key] = helicopterUsageFlightStats{
|
||||
TotalLanding: float64(rows[i].TotalLanding),
|
||||
TotalAirframeHours: float64(rows[i].TotalAirframeNS) / float64(time.Hour),
|
||||
TotalAirframeCycles: float64(rows[i].TotalAirframeCycles),
|
||||
TotalHookRelease: float64(rows[i].TotalHookRelease),
|
||||
TotalRotorBrakeCycle: float64(rows[i].TotalRotorBrakeCycle),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) reportStatsByHelicopter(ctx context.Context) (map[string]helicopterUsageReportStats, error) {
|
||||
type row struct {
|
||||
HelicopterID []byte `gorm:"column:helicopter_id"`
|
||||
Engine1GpcN1 *string `gorm:"column:engine1_gpc_n1"`
|
||||
Engine1PtcN2 *string `gorm:"column:engine1_ptc_n2"`
|
||||
Engine2GpcN1 *string `gorm:"column:engine2_gpc_n1"`
|
||||
Engine2PtcN2 *string `gorm:"column:engine2_ptc_n2"`
|
||||
}
|
||||
rows := make([]row, 0)
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("fm_reports").
|
||||
Select("helicopter_id, engine1_gpc_n1, engine1_ptc_n2, engine2_gpc_n1, engine2_ptc_n2").
|
||||
Where("deleted_at IS NULL AND helicopter_id IS NOT NULL").
|
||||
Order("helicopter_id ASC, created_at ASC, id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]helicopterUsageReportStats)
|
||||
for i := range rows {
|
||||
key, err := helicopterUsageKey(rows[i].HelicopterID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
acc := out[key]
|
||||
acc.TotalFlightReport++
|
||||
acc.TotalEngine1GpcNgN1 += parseNumericMetric(rows[i].Engine1GpcN1)
|
||||
acc.TotalEngine1PtcNfN2 += parseNumericMetric(rows[i].Engine1PtcN2)
|
||||
acc.TotalEngine2GpcNgN1 += parseNumericMetric(rows[i].Engine2GpcN1)
|
||||
acc.TotalEngine2PtcNfN2 += parseNumericMetric(rows[i].Engine2PtcN2)
|
||||
out[key] = acc
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) upsertByHelicopterID(ctx context.Context, row *helicopterusage.HelicopterUsage) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "helicopter_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"total_landing",
|
||||
"total_airframe_hours",
|
||||
"total_airframe_cycles",
|
||||
"total_engine_1_hours",
|
||||
"total_engine_1_gpc_ng_n1",
|
||||
"total_engine_1_ptc_nf_n2",
|
||||
"total_engine_2_hours",
|
||||
"total_engine_2_gpc_ng_n1",
|
||||
"total_engine_2_ptc_nf_n2",
|
||||
"total_flight_report",
|
||||
"total_hook_release",
|
||||
"total_rotor_brake_cycle",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
}),
|
||||
}).
|
||||
Create(row).Error
|
||||
}
|
||||
|
||||
func helicopterUsageKey(id []byte) (string, error) {
|
||||
if len(id) != 16 {
|
||||
return "", fmt.Errorf("invalid helicopter id")
|
||||
}
|
||||
return string(id), nil
|
||||
}
|
||||
|
||||
func parseNumericMetric(v *string) float64 {
|
||||
return metricparse.Parse(v)
|
||||
}
|
||||
|
||||
// FlightStatsByFlightID aggregates one flight's completed flight_data — the "today"
|
||||
// contribution of an FM report. Uses the same SUM/COUNT definitions as the cumulative
|
||||
// totals so prev = total - today reconciles exactly.
|
||||
func (r *HelicopterUsageRepository) FlightStatsByFlightID(ctx context.Context, flightID []byte) (helicopterusage.FlightDayStats, error) {
|
||||
if len(flightID) != 16 {
|
||||
return helicopterusage.FlightDayStats{}, nil
|
||||
}
|
||||
var row struct {
|
||||
TotalLanding int64 `gorm:"column:total_landing"`
|
||||
TotalAirframeNS int64 `gorm:"column:total_airframe_ns"`
|
||||
TotalAirframeCycles int64 `gorm:"column:total_airframe_cycles"`
|
||||
TotalHookRelease int64 `gorm:"column:total_hook_release"`
|
||||
TotalRotorBrakeCycle int64 `gorm:"column:total_rotor_brake_cycle"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("flight_data fd").
|
||||
Select(`
|
||||
COALESCE(SUM(fd.landing_count), 0) AS total_landing,
|
||||
COALESCE(SUM(fd.duration), 0) AS total_airframe_ns,
|
||||
COUNT(fd.id) AS total_airframe_cycles,
|
||||
COALESCE(SUM(fd.hook_releases), 0) AS total_hook_release,
|
||||
COALESCE(SUM(fd.rotor_brake_cycle), 0) AS total_rotor_brake_cycle
|
||||
`).
|
||||
Joins("JOIN missions m ON m.id = fd.mission_id AND m.deleted_at IS NULL").
|
||||
Where("fd.deleted_at IS NULL AND fd.status = ? AND m.flight_id = ?", flightdata.StatusCompleted, flightID).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return helicopterusage.FlightDayStats{}, err
|
||||
}
|
||||
return helicopterusage.FlightDayStats{
|
||||
AirframeHours: float64(row.TotalAirframeNS) / float64(time.Hour),
|
||||
AirframeCycles: float64(row.TotalAirframeCycles),
|
||||
Landing: float64(row.TotalLanding),
|
||||
HookRelease: float64(row.TotalHookRelease),
|
||||
RotorBrakeCycle: float64(row.TotalRotorBrakeCycle),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *HelicopterUsageRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]helicopterusage.HelicopterUsage, int64, error) {
|
||||
rows := make([]helicopterusage.HelicopterUsage, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&helicopterusage.HelicopterUsage{}).
|
||||
Joins("LEFT JOIN helicopters h ON h.id = helicopter_usage.helicopter_id AND h.deleted_at IS NULL").
|
||||
Where("helicopter_usage.deleted_at IS NULL")
|
||||
|
||||
if strings.TrimSpace(filter) != "" {
|
||||
like := "%" + strings.ToLower(strings.TrimSpace(filter)) + "%"
|
||||
base = base.Where(
|
||||
"LOWER(HEX(helicopter_usage.id)) LIKE ? OR LOWER(HEX(helicopter_usage.helicopter_id)) LIKE ? OR LOWER(h.designation) LIKE ? OR LOWER(h.identifier) LIKE ?",
|
||||
like, like, like, like,
|
||||
)
|
||||
}
|
||||
|
||||
query := base
|
||||
if strings.TrimSpace(sort) != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
query = query.Order("helicopter_usage.created_at DESC")
|
||||
}
|
||||
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Preload("Helicopter").Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
81
internal/repository/mysql/helicopter_usage_repo_test.go
Normal file
81
internal/repository/mysql/helicopter_usage_repo_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
helicopterusage "wucher/internal/domain/helicopter_usage"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openHelicopterUsageTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:helicopter_usage_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&helicopterusage.HelicopterUsage{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// TestHelicopterUsageRepositoryDecimalRoundTrip guards that the formerly-int counters
|
||||
// (landing, cycles, ccc, flight report, hook release, rotor brake) keep 2 decimals through
|
||||
// a persist -> read cycle.
|
||||
func TestHelicopterUsageRepositoryDecimalRoundTrip(t *testing.T) {
|
||||
db := openHelicopterUsageTestDB(t)
|
||||
repo := NewHelicopterUsageRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
helID := uuidv7.MustBytes()
|
||||
row := &helicopterusage.HelicopterUsage{HelicopterID: helID}
|
||||
row.ApplyManualSummary(helicopterusage.ManualSummaryInput{
|
||||
Landing: usageF64(123.45),
|
||||
AirframeCycles: usageF64(12.30),
|
||||
Engine1Ccc: usageF64(44.25),
|
||||
FlightReport: usageF64(80.10),
|
||||
HookRelease: usageF64(10.75),
|
||||
RotorBrakeCycle: usageF64(8.99),
|
||||
})
|
||||
if err := repo.Create(ctx, row); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByHelicopterID(ctx, helID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected row")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
got float64
|
||||
want float64
|
||||
}{
|
||||
{"total_landing", got.TotalLanding, 123.45},
|
||||
{"total_airframe_cycles", got.TotalAirframeCycles, 12.30},
|
||||
{"total_engine_1_ccc", got.TotalEngine1Ccc, 44.25},
|
||||
{"total_flight_report", got.TotalFlightReport, 80.10},
|
||||
{"total_hook_release", got.TotalHookRelease, 10.75},
|
||||
{"total_rotor_brake_cycle", got.TotalRotorBrakeCycle, 8.99},
|
||||
{"manual_landing", got.ManualLanding, 123.45},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if c.got != c.want {
|
||||
t.Fatalf("%s = %v, want %v (decimal truncated?)", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func usageF64(v float64) *float64 { return &v }
|
||||
84
internal/repository/mysql/hems_operation_category_repo.go
Normal file
84
internal/repository/mysql/hems_operation_category_repo.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/operation"
|
||||
)
|
||||
|
||||
type HEMSOperationCategoryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHEMSOperationCategoryRepository(db *gorm.DB) *HEMSOperationCategoryRepository {
|
||||
return &HEMSOperationCategoryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *HEMSOperationCategoryRepository) Create(ctx context.Context, row *operation.HEMSOperationCategory) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *HEMSOperationCategoryRepository) Update(ctx context.Context, row *operation.HEMSOperationCategory) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&operation.HEMSOperationCategory{}).
|
||||
Where("id = ? AND deleted_at IS NULL", row.ID).
|
||||
Updates(map[string]any{
|
||||
"name": row.Name,
|
||||
"type": row.Type,
|
||||
"updated_by": row.UpdatedBy,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *HEMSOperationCategoryRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
"updated_at": now,
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&operation.HEMSOperationCategory{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *HEMSOperationCategoryRepository) GetByID(ctx context.Context, id []byte) (*operation.HEMSOperationCategory, error) {
|
||||
var row operation.HEMSOperationCategory
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *HEMSOperationCategoryRepository) List(ctx context.Context, filter string, sort string, limit, offset int) ([]operation.HEMSOperationCategory, int64, error) {
|
||||
var rows []operation.HEMSOperationCategory
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&operation.HEMSOperationCategory{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("name LIKE ? OR type LIKE ?", like, like)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
91
internal/repository/mysql/hems_operation_repo.go
Normal file
91
internal/repository/mysql/hems_operation_repo.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/operation"
|
||||
)
|
||||
|
||||
type HEMSOperationRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHEMSOperationRepository(db *gorm.DB) *HEMSOperationRepository {
|
||||
return &HEMSOperationRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *HEMSOperationRepository) Create(ctx context.Context, row *operation.HEMSOperation) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *HEMSOperationRepository) Update(ctx context.Context, row *operation.HEMSOperation) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&operation.HEMSOperation{}).
|
||||
Where("id = ? AND deleted_at IS NULL", row.ID).
|
||||
Updates(map[string]any{
|
||||
"date": row.Date,
|
||||
"operational_data_id": nullableBytes(row.OperationalDataID),
|
||||
"mission_id": nullableBytes(row.MissionID),
|
||||
"hems_operation_category_id": nullableBytes(row.HEMSOperationCategoryID),
|
||||
"updated_by": row.UpdatedBy,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *HEMSOperationRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
"updated_at": now,
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&operation.HEMSOperation{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *HEMSOperationRepository) GetByID(ctx context.Context, id []byte) (*operation.HEMSOperation, error) {
|
||||
var row operation.HEMSOperation
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("OperationalData").
|
||||
Preload("Mission").
|
||||
Preload("HEMSOperationCategory").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *HEMSOperationRepository) List(ctx context.Context, filter string, sort string, limit, offset int) ([]operation.HEMSOperation, int64, error) {
|
||||
var rows []operation.HEMSOperation
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&operation.HEMSOperation{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("DATE_FORMAT(date, '%Y-%m-%dT%H:%i:%sZ') LIKE ?", like)
|
||||
}
|
||||
|
||||
query := base.Preload("OperationalData").Preload("Mission").Preload("HEMSOperationCategory")
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
170
internal/repository/mysql/hems_operational_data_repo.go
Normal file
170
internal/repository/mysql/hems_operational_data_repo.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/operation"
|
||||
)
|
||||
|
||||
type HEMSOperationalDataRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHEMSOperationalDataRepository(db *gorm.DB) *HEMSOperationalDataRepository {
|
||||
return &HEMSOperationalDataRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *HEMSOperationalDataRepository) Create(ctx context.Context, row *operation.HEMSOperationalData) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
forcePresents := append([]operation.OperationalDataForcePresent(nil), row.ForcePresents...)
|
||||
files := append([]operation.OperationalFile(nil), row.Files...)
|
||||
row.ForcePresents = nil
|
||||
row.Files = nil
|
||||
|
||||
if err := tx.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range forcePresents {
|
||||
forcePresents[i].OperationalDataID = row.ID
|
||||
if err := tx.Create(&forcePresents[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i := range files {
|
||||
files[i].OperationalDataID = row.ID
|
||||
if err := tx.Create(&files[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
row.ForcePresents = forcePresents
|
||||
row.Files = files
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *HEMSOperationalDataRepository) GetByID(ctx context.Context, id []byte) (*operation.HEMSOperationalData, error) {
|
||||
var row operation.HEMSOperationalData
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("ForcePresents").
|
||||
Preload("Files").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *HEMSOperationalDataRepository) Update(ctx context.Context, row *operation.HEMSOperationalData) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
forcePresents := append([]operation.OperationalDataForcePresent(nil), row.ForcePresents...)
|
||||
files := append([]operation.OperationalFile(nil), row.Files...)
|
||||
row.ForcePresents = nil
|
||||
row.Files = nil
|
||||
|
||||
if err := tx.Model(&operation.HEMSOperationalData{}).
|
||||
Where("id = ? AND deleted_at IS NULL", row.ID).
|
||||
Updates(map[string]any{
|
||||
"time": row.Time,
|
||||
"land_id": nullableBytes(row.LandID),
|
||||
"vocation_id": nullableBytes(row.VocationID),
|
||||
"state_id": nullableBytes(row.StateID),
|
||||
"location": row.Location,
|
||||
"postcode": row.Postcode,
|
||||
"notes": row.Notes,
|
||||
"darkness": row.Darkness,
|
||||
"false_information": row.FalseInformation,
|
||||
"fog": row.Fog,
|
||||
"precipitation": row.Precipitation,
|
||||
"mountain_use": row.MountainUse,
|
||||
"wind": row.Wind,
|
||||
"nfo_search": row.NFOSearch,
|
||||
"landing_site_search": row.LandingSiteSearch,
|
||||
"temperature": row.Temperature,
|
||||
"altitude": row.Altitude,
|
||||
"terrain_index": row.TerrainIndex,
|
||||
"rope_recovery": row.RopeRecovery,
|
||||
"night_flight": row.NightFlight,
|
||||
"instrument_flight": row.InstrumentFlight,
|
||||
"additional_info": row.AdditionalInfo,
|
||||
"updated_by": row.UpdatedBy,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Where("operational_data_id = ?", row.ID).Delete(&operation.OperationalDataForcePresent{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range forcePresents {
|
||||
forcePresents[i].OperationalDataID = row.ID
|
||||
if err := tx.Create(&forcePresents[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Where("operational_data_id = ?", row.ID).Delete(&operation.OperationalFile{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range files {
|
||||
files[i].OperationalDataID = row.ID
|
||||
if err := tx.Create(&files[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
row.ForcePresents = forcePresents
|
||||
row.Files = files
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *HEMSOperationalDataRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
"updated_at": now,
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&operation.HEMSOperationalData{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *HEMSOperationalDataRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]operation.HEMSOperationalData, int64, error) {
|
||||
var rows []operation.HEMSOperationalData
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&operation.HEMSOperationalData{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("location LIKE ? OR notes LIKE ? OR additional_info LIKE ? OR terrain_index LIKE ?", like, like, like, like)
|
||||
}
|
||||
|
||||
query := base.Preload("ForcePresents").Preload("Files")
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func nullableBytes(raw []byte) any {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
97
internal/repository/mysql/hospital_repo.go
Normal file
97
internal/repository/mysql/hospital_repo.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/hospital"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
)
|
||||
|
||||
type HospitalRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHospitalRepository(db *gorm.DB) *HospitalRepository {
|
||||
return &HospitalRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *HospitalRepository) Create(ctx context.Context, row *hospital.Hospital) error {
|
||||
requestedIsActive := row.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&hospital.Hospital{}).Where("id = ?", row.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *HospitalRepository) Update(ctx context.Context, row *hospital.Hospital) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *HospitalRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "no_icao_codes", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&hospital.Hospital{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *HospitalRepository) GetByID(ctx context.Context, id []byte) (*hospital.Hospital, error) {
|
||||
var row hospital.Hospital
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *HospitalRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]hospital.Hospital, int64, error) {
|
||||
var rows []hospital.Hospital
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&hospital.Hospital{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where(
|
||||
"hospital_name LIKE ? OR address LIKE ? OR landline_number LIKE ? OR mobile_number LIKE ? OR email LIKE ? OR note LIKE ?",
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("no_icao_codes", "is_active", "sortkey", "hospital_name", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
243
internal/repository/mysql/hospital_repo_test.go
Normal file
243
internal/repository/mysql/hospital_repo_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/hospital"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openHospitalTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:hospital_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&hospital.Hospital{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewHospitalRepository(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHospitalRepositoryCreate(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
row := &hospital.Hospital{Name: "RSUD Kota", Address: "Jl. Merdeka", LandlineNumber: "021123", MobileNumber: "08123", Email: "a@b.c"}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
|
||||
inactive := &hospital.Hospital{Name: "RS Inactive", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
loadedInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil || loadedInactive == nil {
|
||||
t.Fatalf("expected inactive row loadable")
|
||||
}
|
||||
if loadedInactive.IsActive {
|
||||
t.Fatalf("expected inactive row persisted with is_active=false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &hospital.Hospital{Name: "AfterClose"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHospitalRepositoryUpdate(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
row := &hospital.Hospital{Name: "Old", Address: "Old Addr"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Name = "New"
|
||||
row.Address = "New Addr"
|
||||
row.LandlineNumber = "021999"
|
||||
row.MobileNumber = "08999"
|
||||
row.Email = "new@hospital.local"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.Name != "New" || loaded.Address != "New Addr" || loaded.LandlineNumber != "021999" || loaded.MobileNumber != "08999" || loaded.Email != "new@hospital.local" {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHospitalRepositoryDelete(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
row := &hospital.Hospital{Name: "DeleteMe", Address: "A"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft deleted row hidden from GetByID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHospitalRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
row := &hospital.Hospital{Name: "Found", Address: "Address", Email: "found@hospital.local"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "Found" || got.Address != "Address" || got.Email != "found@hospital.local" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHospitalRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Charlie Hospital", Address: "Gamma"})
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Alpha Hospital", Address: "Beta"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Main Hospital", Address: "Center"})
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Backup", Address: "Secondary"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "Main", "hospital_name DESC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Name != "Main Hospital" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
if err := db.Migrator().DropTable(&hospital.Hospital{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Main"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "hospital_name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active sortkey first and inactive last", func(t *testing.T) {
|
||||
db := openHospitalTestDB(t)
|
||||
repo := NewHospitalRepository(db)
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Gamma", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Beta", SortKey: intPtrHospitalRepo(0), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Charlie", SortKey: intPtrHospitalRepo(2), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Alpha", SortKey: intPtrHospitalRepo(1), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Zulu", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &hospital.Hospital{Name: "Bravo", SortKey: intPtrHospitalRepo(9), IsActive: false})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{rows[0].Name, rows[1].Name, rows[2].Name, rows[3].Name, rows[4].Name, rows[5].Name}
|
||||
wantOrder := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func intPtrHospitalRepo(v int) *int { return &v }
|
||||
104
internal/repository/mysql/icao_repo.go
Normal file
104
internal/repository/mysql/icao_repo.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/icao"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
)
|
||||
|
||||
type ICAORepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewICAORepository(db *gorm.DB) *ICAORepository {
|
||||
return &ICAORepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ICAORepository) Create(ctx context.Context, row *icao.ICAO) error {
|
||||
requestedIsActive := row.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&icao.ICAO{}).
|
||||
Where("id = ?", row.ID).
|
||||
UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *ICAORepository) Update(ctx context.Context, row *icao.ICAO) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Omit("FederalState", "Land").
|
||||
Save(row).Error
|
||||
}
|
||||
|
||||
func (r *ICAORepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "icaos", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&icao.ICAO{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *ICAORepository) GetByID(ctx context.Context, id []byte) (*icao.ICAO, error) {
|
||||
var row icao.ICAO
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Land").
|
||||
Preload("FederalState").
|
||||
Preload("FederalState.Land").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *ICAORepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]icao.ICAO, int64, error) {
|
||||
var rows []icao.ICAO
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&icao.ICAO{}).
|
||||
Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("icao_code LIKE ? OR name LIKE ? OR address LIKE ? OR landline_number LIKE ? OR mobile_number LIKE ? OR email LIKE ? OR note LIKE ?", like, like, like, like, like, like, like)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("icaos", "is_active", "sortkey", "icao_code", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.
|
||||
Preload("Land").
|
||||
Preload("FederalState").
|
||||
Preload("FederalState.Land").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
272
internal/repository/mysql/icao_repo_test.go
Normal file
272
internal/repository/mysql/icao_repo_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/federal_state"
|
||||
"wucher/internal/domain/icao"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openICAOTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:icao_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&icao.ICAO{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewICAORepository(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestICAORepositoryCreate(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
row := &icao.ICAO{ICAOCode: "EDDB", Address: "Berlin"}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
inactive := &icao.ICAO{ICAOCode: "EDIN", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
gotInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get inactive: %v", err)
|
||||
}
|
||||
if gotInactive == nil || gotInactive.IsActive {
|
||||
t.Fatalf("expected inactive icao persisted as false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &icao.ICAO{ICAOCode: "EDDM"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestICAORepositoryUpdate(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
row := &icao.ICAO{ICAOCode: "EDDF", Address: "Old"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.ICAOCode = "EDDH"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.ICAOCode != "EDDH" {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestICAORepositoryUpdateOmitsFederalStateAssociation(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
row := &icao.ICAO{
|
||||
ICAOCode: "EDDF",
|
||||
FederalStateID: uuidv7.MustBytes(),
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
newFederalStateID := uuidv7.MustBytes()
|
||||
row.FederalStateID = newFederalStateID
|
||||
row.FederalState = &federal_state.FederalState{
|
||||
ID: uuidv7.MustBytes(),
|
||||
Name: "stale preload relation",
|
||||
}
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected update error with stale relation: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if loaded == nil {
|
||||
t.Fatalf("expected row")
|
||||
}
|
||||
if string(loaded.FederalStateID) != string(newFederalStateID) {
|
||||
t.Fatalf("expected federal_state_id updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestICAORepositoryDelete(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
row := &icao.ICAO{ICAOCode: "EDDK"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft deleted row hidden from GetByID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestICAORepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
row := &icao.ICAO{ICAOCode: "EDDL"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.ICAOCode != "EDDL" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestICAORepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "C", Address: "Gamma"})
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "A", Address: "Alpha"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "Main Base", Address: "Center"})
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "Backup", Address: "Secondary"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "Main", "icao_code DESC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].ICAOCode != "Main Base" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
if err := db.Migrator().DropTable(&icao.ICAO{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "Main"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "icao_code ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active sortkey first and inactive last", func(t *testing.T) {
|
||||
db := openICAOTestDB(t)
|
||||
repo := NewICAORepository(db)
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "Gamma", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "Beta", SortKey: intPtrICAORepo(0), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "Charlie", SortKey: intPtrICAORepo(2), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "Alpha", SortKey: intPtrICAORepo(1), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "Zulu", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &icao.ICAO{ICAOCode: "Bravo", SortKey: intPtrICAORepo(9), IsActive: false})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{rows[0].ICAOCode, rows[1].ICAOCode, rows[2].ICAOCode, rows[3].ICAOCode, rows[4].ICAOCode, rows[5].ICAOCode}
|
||||
wantOrder := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func intPtrICAORepo(v int) *int { return &v }
|
||||
159
internal/repository/mysql/idempotency_store.go
Normal file
159
internal/repository/mysql/idempotency_store.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"wucher/internal/domain/transient"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
const (
|
||||
idempotencyStateProcessing = "processing"
|
||||
idempotencyStateDone = "done"
|
||||
)
|
||||
|
||||
type QueueIdempotencyStore struct {
|
||||
db *gorm.DB
|
||||
prefix string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewQueueIdempotencyStore(db *gorm.DB, prefix string) *QueueIdempotencyStore {
|
||||
return &QueueIdempotencyStore{
|
||||
db: db,
|
||||
prefix: strings.TrimSpace(prefix),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
func (s *QueueIdempotencyStore) Acquire(ctx context.Context, key string, processingTTL time.Duration) (queue.IdempotencyState, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return queue.IdempotencyStateAcquired, errors.New("queue idempotency db is required")
|
||||
}
|
||||
storeKey := s.key(key)
|
||||
if storeKey == "" {
|
||||
return "", errors.New("queue idempotency key is required")
|
||||
}
|
||||
if processingTTL <= 0 {
|
||||
processingTTL = 15 * time.Minute
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
expiresAt := now.Add(processingTTL)
|
||||
record := transient.QueueIdempotencyRecord{
|
||||
IdempotencyKey: storeKey,
|
||||
State: idempotencyStateProcessing,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
create := s.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&record)
|
||||
if create.Error != nil {
|
||||
return "", create.Error
|
||||
}
|
||||
if create.RowsAffected == 1 {
|
||||
return queue.IdempotencyStateAcquired, nil
|
||||
}
|
||||
|
||||
existing, err := s.load(ctx, storeKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
retry := s.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&record)
|
||||
if retry.Error != nil {
|
||||
return "", retry.Error
|
||||
}
|
||||
if retry.RowsAffected == 1 {
|
||||
return queue.IdempotencyStateAcquired, nil
|
||||
}
|
||||
return queue.IdempotencyStateInProgress, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !existing.ExpiresAt.After(now) {
|
||||
update := s.db.WithContext(ctx).
|
||||
Model(&transient.QueueIdempotencyRecord{}).
|
||||
Where("idempotency_key = ? AND expires_at <= ?", storeKey, now).
|
||||
Updates(map[string]any{
|
||||
"state": idempotencyStateProcessing,
|
||||
"expires_at": expiresAt,
|
||||
"updated_at": now,
|
||||
})
|
||||
if update.Error != nil {
|
||||
return "", update.Error
|
||||
}
|
||||
if update.RowsAffected == 1 {
|
||||
return queue.IdempotencyStateAcquired, nil
|
||||
}
|
||||
existing, err = s.load(ctx, storeKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return queue.IdempotencyStateAcquired, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if existing.State == idempotencyStateDone {
|
||||
return queue.IdempotencyStateDuplicate, nil
|
||||
}
|
||||
return queue.IdempotencyStateInProgress, nil
|
||||
}
|
||||
|
||||
func (s *QueueIdempotencyStore) MarkCompleted(ctx context.Context, key string, ttl time.Duration) error {
|
||||
if s == nil || s.db == nil {
|
||||
return errors.New("queue idempotency db is required")
|
||||
}
|
||||
storeKey := s.key(key)
|
||||
if storeKey == "" {
|
||||
return errors.New("queue idempotency key is required")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 24 * time.Hour
|
||||
}
|
||||
now := s.now().UTC()
|
||||
record := transient.QueueIdempotencyRecord{
|
||||
IdempotencyKey: storeKey,
|
||||
State: idempotencyStateDone,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
}
|
||||
return s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "idempotency_key"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"state": record.State,
|
||||
"expires_at": record.ExpiresAt,
|
||||
"updated_at": now,
|
||||
}),
|
||||
}).Create(&record).Error
|
||||
}
|
||||
|
||||
func (s *QueueIdempotencyStore) Release(ctx context.Context, key string) error {
|
||||
if s == nil || s.db == nil {
|
||||
return errors.New("queue idempotency db is required")
|
||||
}
|
||||
storeKey := s.key(key)
|
||||
if storeKey == "" {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).Delete(&transient.QueueIdempotencyRecord{}, "idempotency_key = ?", storeKey).Error
|
||||
}
|
||||
|
||||
func (s *QueueIdempotencyStore) load(ctx context.Context, key string) (*transient.QueueIdempotencyRecord, error) {
|
||||
var record transient.QueueIdempotencyRecord
|
||||
if err := s.db.WithContext(ctx).Where("idempotency_key = ?", key).First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (s *QueueIdempotencyStore) key(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
return s.prefix + key
|
||||
}
|
||||
88
internal/repository/mysql/idempotency_store_test.go
Normal file
88
internal/repository/mysql/idempotency_store_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
func TestQueueIdempotencyStore_AcquireMarkCompletedRelease(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
baseTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := NewQueueIdempotencyStore(db, "queue:idempotency:")
|
||||
store.now = func() time.Time { return baseTime }
|
||||
|
||||
ctx := context.Background()
|
||||
state, err := store.Acquire(ctx, "msg-1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire first: %v", err)
|
||||
}
|
||||
if state != queue.IdempotencyStateAcquired {
|
||||
t.Fatalf("expected acquired, got %s", state)
|
||||
}
|
||||
|
||||
state, err = store.Acquire(ctx, "msg-1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire in-progress: %v", err)
|
||||
}
|
||||
if state != queue.IdempotencyStateInProgress {
|
||||
t.Fatalf("expected in progress, got %s", state)
|
||||
}
|
||||
|
||||
if err := store.MarkCompleted(ctx, "msg-1", time.Hour); err != nil {
|
||||
t.Fatalf("mark completed: %v", err)
|
||||
}
|
||||
state, err = store.Acquire(ctx, "msg-1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire duplicate: %v", err)
|
||||
}
|
||||
if state != queue.IdempotencyStateDuplicate {
|
||||
t.Fatalf("expected duplicate, got %s", state)
|
||||
}
|
||||
|
||||
if err := store.Release(ctx, "msg-1"); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
state, err = store.Acquire(ctx, "msg-1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire after release: %v", err)
|
||||
}
|
||||
if state != queue.IdempotencyStateAcquired {
|
||||
t.Fatalf("expected acquired after release, got %s", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueIdempotencyStore_ReacquiresExpiredProcessingRecord(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
baseTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := NewQueueIdempotencyStore(db, "queue:idempotency:")
|
||||
store.now = func() time.Time { return baseTime }
|
||||
|
||||
ctx := context.Background()
|
||||
state, err := store.Acquire(ctx, "msg-expired", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire first: %v", err)
|
||||
}
|
||||
if state != queue.IdempotencyStateAcquired {
|
||||
t.Fatalf("expected acquired, got %s", state)
|
||||
}
|
||||
|
||||
store.now = func() time.Time { return baseTime.Add(2 * time.Minute) }
|
||||
state, err = store.Acquire(ctx, "msg-expired", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("reacquire expired: %v", err)
|
||||
}
|
||||
if state != queue.IdempotencyStateAcquired {
|
||||
t.Fatalf("expected reacquired state, got %s", state)
|
||||
}
|
||||
}
|
||||
84
internal/repository/mysql/insurance_patient_data_repo.go
Normal file
84
internal/repository/mysql/insurance_patient_data_repo.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
insurancepatientdata "wucher/internal/domain/insurance_patient_data"
|
||||
)
|
||||
|
||||
type InsurancePatientDataRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewInsurancePatientDataRepository(db *gorm.DB) *InsurancePatientDataRepository {
|
||||
return &InsurancePatientDataRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *InsurancePatientDataRepository) Create(ctx context.Context, row *insurancepatientdata.InsurancePatientData) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *InsurancePatientDataRepository) Update(ctx context.Context, row *insurancepatientdata.InsurancePatientData) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *InsurancePatientDataRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "insurance_patient_data", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&insurancepatientdata.InsurancePatientData{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *InsurancePatientDataRepository) GetByID(ctx context.Context, id []byte) (*insurancepatientdata.InsurancePatientData, error) {
|
||||
var row insurancepatientdata.InsurancePatientData
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("HealthInsuranceCompanies", "deleted_at IS NULL").
|
||||
Preload("FederalState", "deleted_at IS NULL").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *InsurancePatientDataRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]insurancepatientdata.InsurancePatientData, int64, error) {
|
||||
var rows []insurancepatientdata.InsurancePatientData
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&insurancepatientdata.InsurancePatientData{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("HEX(health_insurance_companies_id) LIKE ? OR HEX(federal_state_id) LIKE ?", like, like)
|
||||
}
|
||||
|
||||
query := base.
|
||||
Preload("HealthInsuranceCompanies", "deleted_at IS NULL").
|
||||
Preload("FederalState", "deleted_at IS NULL")
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
183
internal/repository/mysql/land_repo.go
Normal file
183
internal/repository/mysql/land_repo.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/land"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
"wucher/internal/transport/http/dto"
|
||||
)
|
||||
|
||||
type LandRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLandRepository(db *gorm.DB) *LandRepository {
|
||||
return &LandRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *LandRepository) Create(ctx context.Context, row *land.Land) error {
|
||||
requestedIsActive := row.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&land.Land{}).Where("id = ?", row.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *LandRepository) Update(ctx context.Context, row *land.Land) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *LandRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "lands", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&land.Land{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *LandRepository) GetByID(ctx context.Context, id []byte) (*land.Land, error) {
|
||||
var row land.Land
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *LandRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]land.Land, int64, error) {
|
||||
var rows []land.Land
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&land.Land{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("(name LIKE ? OR land_iso_code LIKE ?)", like, like)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("lands", "is_active", "sortkey", "name", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *LandRepository) GetByIDView(ctx context.Context, id []byte) (*dto.LandView, error) {
|
||||
row, err := r.GetByID(ctx, id)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
views, err := r.buildViews(ctx, []land.Land{*row})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(views) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &views[0], nil
|
||||
}
|
||||
|
||||
func (r *LandRepository) ListView(ctx context.Context, filter, sort string, limit, offset int) ([]dto.LandView, int64, error) {
|
||||
rows, total, err := r.List(ctx, filter, sort, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views, err := r.buildViews(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return views, total, nil
|
||||
}
|
||||
|
||||
type landFederalStateRow struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
Name string `gorm:"column:name"`
|
||||
LandID []byte `gorm:"column:land_id"`
|
||||
}
|
||||
|
||||
func (r *LandRepository) attachFederalStates(ctx context.Context, rows []land.Land) (map[string][]dto.LandFederalStateView, error) {
|
||||
if len(rows) == 0 {
|
||||
return map[string][]dto.LandFederalStateView{}, nil
|
||||
}
|
||||
|
||||
landIDs := make([][]byte, 0, len(rows))
|
||||
for i := range rows {
|
||||
if len(rows[i].ID) == 0 {
|
||||
continue
|
||||
}
|
||||
landIDs = append(landIDs, rows[i].ID)
|
||||
}
|
||||
if len(landIDs) == 0 {
|
||||
return map[string][]dto.LandFederalStateView{}, nil
|
||||
}
|
||||
|
||||
var states []landFederalStateRow
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("federal_states").
|
||||
Select("id, name, land_id").
|
||||
Where("deleted_at IS NULL AND land_id IN ?", landIDs).
|
||||
Order("name ASC").
|
||||
Find(&states).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grouped := make(map[string][]dto.LandFederalStateView, len(landIDs))
|
||||
for i := range states {
|
||||
s := states[i]
|
||||
key := string(s.LandID)
|
||||
grouped[key] = append(grouped[key], dto.LandFederalStateView{
|
||||
ID: append([]byte(nil), s.ID...),
|
||||
Name: s.Name,
|
||||
})
|
||||
}
|
||||
return grouped, nil
|
||||
}
|
||||
|
||||
func (r *LandRepository) buildViews(ctx context.Context, rows []land.Land) ([]dto.LandView, error) {
|
||||
if len(rows) == 0 {
|
||||
return []dto.LandView{}, nil
|
||||
}
|
||||
federalStatesByLandID, err := r.attachFederalStates(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
views := make([]dto.LandView, 0, len(rows))
|
||||
for i := range rows {
|
||||
key := string(rows[i].ID)
|
||||
list := federalStatesByLandID[key]
|
||||
views = append(views, dto.LandView{
|
||||
Row: rows[i],
|
||||
FederalStateTotal: len(list),
|
||||
FederalStateList: list,
|
||||
})
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
304
internal/repository/mysql/land_repo_test.go
Normal file
304
internal/repository/mysql/land_repo_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/land"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openLandTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:land_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&land.Land{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE IF NOT EXISTS federal_states (
|
||||
id BLOB PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
land_id BLOB NOT NULL,
|
||||
deleted_at DATETIME
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create federal_states table: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewLandRepository(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandRepositoryCreate(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
row := &land.Land{Name: "Germany", LandISOCode: "DE"}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
exportID := "DE001"
|
||||
withExport := &land.Land{Name: "WithExport", LandISOCode: "DX", BMDExportID: &exportID}
|
||||
if err := repo.Create(context.Background(), withExport); err != nil {
|
||||
t.Fatalf("create with export id: %v", err)
|
||||
}
|
||||
gotWithExport, err := repo.GetByID(context.Background(), withExport.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get with export id: %v", err)
|
||||
}
|
||||
if gotWithExport == nil || gotWithExport.BMDExportID == nil || *gotWithExport.BMDExportID != "DE001" {
|
||||
t.Fatalf("expected bmd_export_id persisted")
|
||||
}
|
||||
inactive := &land.Land{Name: "Inactive", LandISOCode: "IN", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
gotInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get inactive: %v", err)
|
||||
}
|
||||
if gotInactive == nil || gotInactive.IsActive {
|
||||
t.Fatalf("expected inactive land persisted as false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &land.Land{Name: "AfterClose", LandISOCode: "AF"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandRepositoryUpdate(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
row := &land.Land{Name: "Old", LandISOCode: "OL"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Name = "New"
|
||||
updatedExportID := "DE999"
|
||||
row.BMDExportID = &updatedExportID
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.Name != "New" {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
if loaded.BMDExportID == nil || *loaded.BMDExportID != "DE999" {
|
||||
t.Fatalf("expected updated bmd_export_id")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandRepositoryDelete(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
row := &land.Land{Name: "DeleteMe", LandISOCode: "DM"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft deleted row hidden from GetByID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
row := &land.Land{Name: "Found", LandISOCode: "FO"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "Found" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLandRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "C", LandISOCode: "CC"})
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "A", LandISOCode: "AA"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Main Base", LandISOCode: "MB"})
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Backup", LandISOCode: "BK"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "Main", "name DESC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Name != "Main Base" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success filter by land iso code", func(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Germany", LandISOCode: "DE"})
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Indonesia", LandISOCode: "ID"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "DE", "name ASC", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].LandISOCode != "DE" {
|
||||
t.Fatalf("expected filter to match land_iso_code, got total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("includes federal state aggregate fields in view", func(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
row := &land.Land{Name: "Germany", LandISOCode: "DE"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
if err := db.Table("federal_states").Create([]map[string]any{
|
||||
{"id": uuidv7.MustBytes(), "name": "Bayern", "land_id": row.ID, "deleted_at": nil},
|
||||
{"id": uuidv7.MustBytes(), "name": "Berlin", "land_id": row.ID, "deleted_at": nil},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed federal_states: %v", err)
|
||||
}
|
||||
|
||||
rows, total, err := repo.ListView(context.Background(), "Germany", "", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("unexpected rows: total=%d len=%d", total, len(rows))
|
||||
}
|
||||
if rows[0].FederalStateTotal != 2 || len(rows[0].FederalStateList) != 2 {
|
||||
t.Fatalf("expected federal states attached in list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
if err := db.Migrator().DropTable(&land.Land{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Main", LandISOCode: "MN"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active sortkey first and inactive last", func(t *testing.T) {
|
||||
db := openLandTestDB(t)
|
||||
repo := NewLandRepository(db)
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Gamma", LandISOCode: "GA", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Beta", LandISOCode: "BT", SortKey: intPtrLandRepo(0), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Charlie", LandISOCode: "CH", SortKey: intPtrLandRepo(2), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Alpha", LandISOCode: "AL", SortKey: intPtrLandRepo(1), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Zulu", LandISOCode: "ZU", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &land.Land{Name: "Bravo", LandISOCode: "BR", SortKey: intPtrLandRepo(9), IsActive: false})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{rows[0].Name, rows[1].Name, rows[2].Name, rows[3].Name, rows[4].Name, rows[5].Name}
|
||||
wantOrder := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func intPtrLandRepo(v int) *int { return &v }
|
||||
135
internal/repository/mysql/master_settings_repo.go
Normal file
135
internal/repository/mysql/master_settings_repo.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
mastersettings "wucher/internal/domain/master_settings"
|
||||
)
|
||||
|
||||
type MasterSettingsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMasterSettingsRepository(db *gorm.DB) *MasterSettingsRepository {
|
||||
return &MasterSettingsRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *MasterSettingsRepository) Create(ctx context.Context, row *mastersettings.MasterSettings) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *MasterSettingsRepository) Update(ctx context.Context, row *mastersettings.MasterSettings) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *MasterSettingsRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "master_settings", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&mastersettings.MasterSettings{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *MasterSettingsRepository) GetByID(ctx context.Context, id []byte) (*mastersettings.MasterSettings, error) {
|
||||
var row mastersettings.MasterSettings
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *MasterSettingsRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]mastersettings.MasterSettings, int64, error) {
|
||||
var rows []mastersettings.MasterSettings
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&mastersettings.MasterSettings{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("company_name LIKE ? OR logo_url LIKE ? OR title LIKE ? OR subtitle LIKE ?", like, like, like, like)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *MasterSettingsRepository) UpsertSettingValues(ctx context.Context, rows []mastersettings.MasterSettingValue) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
items := make([]mastersettings.MasterSettingValue, 0, len(rows))
|
||||
for i := range rows {
|
||||
key := strings.TrimSpace(rows[i].SettingKey)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
item := rows[i]
|
||||
item.SettingKey = key
|
||||
item.UpdatedAt = now
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "setting_key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"value",
|
||||
"is_encrypted",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
}),
|
||||
}).Create(&items).Error
|
||||
}
|
||||
|
||||
func (r *MasterSettingsRepository) GetSettingValuesByKeys(ctx context.Context, keys []string) ([]mastersettings.MasterSettingValue, error) {
|
||||
trimmed := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
trimmed = append(trimmed, key)
|
||||
}
|
||||
if len(trimmed) == 0 {
|
||||
return []mastersettings.MasterSettingValue{}, nil
|
||||
}
|
||||
var rows []mastersettings.MasterSettingValue
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("setting_key IN ?", trimmed).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
274
internal/repository/mysql/master_settings_repo_test.go
Normal file
274
internal/repository/mysql/master_settings_repo_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
mastersettings "wucher/internal/domain/master_settings"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openMasterSettingsTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:master_settings_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&mastersettings.MasterSettings{}, &mastersettings.MasterSettingValue{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewMasterSettingsRepository(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterSettingsRepositoryCreate(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
row := &mastersettings.MasterSettings{
|
||||
CompanyName: "Wucher",
|
||||
Title: "Welcome",
|
||||
Subtitle: "Flight ops",
|
||||
}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &mastersettings.MasterSettings{CompanyName: "AfterClose"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterSettingsRepositoryUpdate(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
row := &mastersettings.MasterSettings{
|
||||
CompanyName: "Wucher",
|
||||
Title: "Old",
|
||||
Subtitle: "Old subtitle",
|
||||
}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Title = "New"
|
||||
row.Subtitle = "New subtitle"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected update error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after update: %v", err)
|
||||
}
|
||||
if got == nil || got.Title != "New" || got.Subtitle != "New subtitle" {
|
||||
t.Fatalf("expected updated values persisted")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterSettingsRepositoryDelete(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
row := &mastersettings.MasterSettings{CompanyName: "Delete Me"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err != nil {
|
||||
t.Fatalf("unexpected delete error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft deleted row hidden from GetByID")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterSettingsRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
row := &mastersettings.MasterSettings{CompanyName: "Wucher"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.CompanyName != "Wucher" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMasterSettingsRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
_ = repo.Create(context.Background(), &mastersettings.MasterSettings{CompanyName: "Beta", Title: "T1"})
|
||||
_ = repo.Create(context.Background(), &mastersettings.MasterSettings{CompanyName: "Alpha", Title: "T2"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
_ = repo.Create(context.Background(), &mastersettings.MasterSettings{CompanyName: "Wucher", Title: "Primary"})
|
||||
_ = repo.Create(context.Background(), &mastersettings.MasterSettings{CompanyName: "Other", Title: "Secondary"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "Wuc", "company_name DESC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].CompanyName != "Wucher" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
if err := db.Migrator().DropTable(&mastersettings.MasterSettings{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
_ = repo.Create(context.Background(), &mastersettings.MasterSettings{CompanyName: "Wucher"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "company_name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMasterSettingsRepositorySettingValues(t *testing.T) {
|
||||
db := openMasterSettingsTestDB(t)
|
||||
repo := NewMasterSettingsRepository(db)
|
||||
actor := []byte("abcdefghijklmnop")
|
||||
|
||||
rows := []mastersettings.MasterSettingValue{
|
||||
{
|
||||
SettingKey: mastersettings.SettingMicrosoftEntraTenantID,
|
||||
Value: "tenant-a",
|
||||
CreatedBy: actor,
|
||||
UpdatedBy: actor,
|
||||
IsEncrypted: false,
|
||||
},
|
||||
{
|
||||
SettingKey: mastersettings.SettingMicrosoftEntraClientSecret,
|
||||
Value: "ciphertext",
|
||||
CreatedBy: actor,
|
||||
UpdatedBy: actor,
|
||||
IsEncrypted: true,
|
||||
},
|
||||
}
|
||||
if err := repo.UpsertSettingValues(context.Background(), rows); err != nil {
|
||||
t.Fatalf("upsert setting values: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetSettingValuesByKeys(context.Background(), []string{
|
||||
mastersettings.SettingMicrosoftEntraTenantID,
|
||||
mastersettings.SettingMicrosoftEntraClientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get setting values: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(got))
|
||||
}
|
||||
|
||||
if err := repo.UpsertSettingValues(context.Background(), []mastersettings.MasterSettingValue{
|
||||
{
|
||||
SettingKey: mastersettings.SettingMicrosoftEntraTenantID,
|
||||
Value: "tenant-b",
|
||||
UpdatedBy: actor,
|
||||
IsEncrypted: false,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert update row: %v", err)
|
||||
}
|
||||
got, err = repo.GetSettingValuesByKeys(context.Background(), []string{
|
||||
mastersettings.SettingMicrosoftEntraTenantID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get updated value: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Value != "tenant-b" {
|
||||
t.Fatalf("expected updated value tenant-b, got %#v", got)
|
||||
}
|
||||
}
|
||||
116
internal/repository/mysql/mcf_repo.go
Normal file
116
internal/repository/mysql/mcf_repo.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/mcf"
|
||||
)
|
||||
|
||||
type MCFRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewMCFRepository(db *gorm.DB) *MCFRepository { return &MCFRepository{db: db} }
|
||||
|
||||
func (r *MCFRepository) Create(ctx context.Context, row *mcf.MaintenanceCheckFlight) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *MCFRepository) Update(ctx context.Context, row *mcf.MaintenanceCheckFlight) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *MCFRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
return r.db.WithContext(ctx).Model(&mcf.MaintenanceCheckFlight{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{"deleted_at": gorm.Expr("NOW(3)"), "deleted_by": deletedBy, "updated_by": deletedBy}).Error
|
||||
}
|
||||
|
||||
func (r *MCFRepository) GetByID(ctx context.Context, id []byte) (*mcf.MaintenanceCheckFlight, error) {
|
||||
var row mcf.MaintenanceCheckFlight
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *MCFRepository) GetLatestByHelicopter(ctx context.Context, helicopterID []byte) (*mcf.MaintenanceCheckFlight, error) {
|
||||
var row mcf.MaintenanceCheckFlight
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("helicopter_id = ? AND deleted_at IS NULL", helicopterID).
|
||||
Order("created_at DESC").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *MCFRepository) ListByHelicopter(ctx context.Context, helicopterID []byte) ([]mcf.MaintenanceCheckFlight, error) {
|
||||
rows := make([]mcf.MaintenanceCheckFlight, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("helicopter_id = ? AND deleted_at IS NULL", helicopterID).
|
||||
Order("created_at DESC").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// LatestByHelicopterIDs returns the latest (non-deleted) MCF per helicopter, keyed by
|
||||
// helicopter id (raw bytes as string). Helicopters without an MCF are simply absent.
|
||||
func (r *MCFRepository) LatestByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]*mcf.MaintenanceCheckFlight, error) {
|
||||
out := make(map[string]*mcf.MaintenanceCheckFlight, len(helicopterIDs))
|
||||
if len(helicopterIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows := make([]mcf.MaintenanceCheckFlight, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("helicopter_id IN ? AND deleted_at IS NULL AND id = (SELECT m2.id FROM maintenance_check_flights m2 WHERE m2.helicopter_id = maintenance_check_flights.helicopter_id AND m2.deleted_at IS NULL ORDER BY m2.created_at DESC, m2.id DESC LIMIT 1)", helicopterIDs).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for i := range rows {
|
||||
out[string(rows[i].HelicopterID)] = &rows[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HelicopterHasOpenMCF reports whether the helicopter has a draft MCF (created but not
|
||||
// yet completed/signed). Enforces one open MCF per helicopter.
|
||||
func (r *MCFRepository) HelicopterHasOpenMCF(ctx context.Context, helicopterID []byte) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&mcf.MaintenanceCheckFlight{}).
|
||||
Where("helicopter_id = ? AND deleted_at IS NULL AND completed_at IS NULL AND cancelled_at IS NULL", helicopterID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *MCFRepository) PendingHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]bool, error) {
|
||||
out := make(map[string]bool)
|
||||
if len(helicopterIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
type rowT struct {
|
||||
HelicopterID []byte `gorm:"column:helicopter_id"`
|
||||
}
|
||||
rows := make([]rowT, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("maintenance_check_flights m").
|
||||
Select("m.helicopter_id").
|
||||
Where("m.deleted_at IS NULL AND m.cancelled_at IS NULL AND m.helicopter_id IN ? AND m.id = (SELECT m2.id FROM maintenance_check_flights m2 WHERE m2.helicopter_id = m.helicopter_id AND m2.deleted_at IS NULL AND m2.cancelled_at IS NULL ORDER BY m2.created_at DESC, m2.id DESC LIMIT 1) AND m.completed_at IS NULL", helicopterIDs).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for i := range rows {
|
||||
out[string(rows[i].HelicopterID)] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
235
internal/repository/mysql/medicine_repo.go
Normal file
235
internal/repository/mysql/medicine_repo.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/medicine"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
)
|
||||
|
||||
type MedicineRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMedicineRepository(db *gorm.DB) *MedicineRepository {
|
||||
return &MedicineRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) Create(ctx context.Context, row *medicine.Medicine) error {
|
||||
requestedIsActive := row.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Model(row).Association("MotorReactions").Replace(row.MotorReactions); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&medicine.Medicine{}).Where("id = ?", row.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) Update(ctx context.Context, row *medicine.Medicine) error {
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Save(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.MotorReactions != nil {
|
||||
if err := db.Model(row).Association("MotorReactions").Replace(row.MotorReactions); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Allow medicine delete even when it still has reaction links by detaching M:N rows first.
|
||||
if err := tx.Where("medicine_id = ?", id).Delete(&medicine.MedicineMotorReaction{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, tx, "medicines", id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(tx.
|
||||
Model(&medicine.Medicine{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) GetByID(ctx context.Context, id []byte) (*medicine.Medicine, error) {
|
||||
var row medicine.Medicine
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("MedicineGroup", "deleted_at IS NULL").
|
||||
Preload("MotorReaction", "deleted_at IS NULL").
|
||||
Preload("MotorReactions", "deleted_at IS NULL").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]medicine.Medicine, int64, error) {
|
||||
var rows []medicine.Medicine
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&medicine.Medicine{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("name LIKE ? OR note LIKE ? OR pack LIKE ? OR unit LIKE ? OR CAST(`column` AS CHAR) LIKE ?", like, like, like, like, like)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("medicines", "is_active", "sortkey", "name", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
query = query.
|
||||
Preload("MedicineGroup", "deleted_at IS NULL").
|
||||
Preload("MotorReaction", "deleted_at IS NULL").
|
||||
Preload("MotorReactions", "deleted_at IS NULL")
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) CreateGroup(ctx context.Context, row *medicine.MedicineGroup) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) UpdateGroup(ctx context.Context, row *medicine.MedicineGroup) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) DeleteGroup(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "medicine_groups", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&medicine.MedicineGroup{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}).Error)
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) GetGroupByID(ctx context.Context, id []byte) (*medicine.MedicineGroup, error) {
|
||||
var row medicine.MedicineGroup
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) ListGroups(ctx context.Context, filter, sort string, limit, offset int) ([]medicine.MedicineGroup, int64, error) {
|
||||
var rows []medicine.MedicineGroup
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&medicine.MedicineGroup{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("name LIKE ?", like)
|
||||
}
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
query = query.Order("name ASC")
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) CreateReaction(ctx context.Context, row *medicine.MotorReaction) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) UpdateReaction(ctx context.Context, row *medicine.MotorReaction) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) DeleteReaction(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "motor_reactions", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&medicine.MotorReaction{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}).Error)
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) GetReactionByID(ctx context.Context, id []byte) (*medicine.MotorReaction, error) {
|
||||
var row medicine.MotorReaction
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *MedicineRepository) ListReactions(ctx context.Context, filter, sort string, limit, offset int) ([]medicine.MotorReaction, int64, error) {
|
||||
var rows []medicine.MotorReaction
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&medicine.MotorReaction{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("name LIKE ? OR CAST(score AS CHAR) LIKE ?", like, like)
|
||||
}
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
query = query.Order("name ASC")
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
263
internal/repository/mysql/medicine_repo_test.go
Normal file
263
internal/repository/mysql/medicine_repo_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/medicine"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openMedicineTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:medicine_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&medicine.Medicine{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewMedicineRepository(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMedicineRepositoryCreate(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
row := &medicine.Medicine{Name: "Paracetamol", Pack:"500mg", Unit: "tablet", Column: 1}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
inactive := &medicine.Medicine{Name: "Ibuprofen", Pack: "200mg", Unit: "capsule", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
gotInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get inactive: %v", err)
|
||||
}
|
||||
if gotInactive == nil || gotInactive.IsActive || gotInactive.Unit != "capsule" {
|
||||
t.Fatalf("expected inactive medicine persisted as false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &medicine.Medicine{Name: "AfterClose", Pack: "10mg", Column: 2}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMedicineRepositoryUpdate(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
row := &medicine.Medicine{Name: "Old", Pack: "100mg", Column: 1}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Name = "New"
|
||||
row.Pack = "250mg"
|
||||
row.Unit = "solution"
|
||||
row.Column = 3
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.Name != "New" || loaded.Pack != "250mg" || loaded.Column != 3 {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMedicineRepositoryDelete(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
row := &medicine.Medicine{Name: "DeleteMe", Pack: "500mg", Unit: "tablet", Column: 2}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft deleted row hidden from GetByID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMedicineRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
row := &medicine.Medicine{Name: "Found", Pack: "10mg", Unit: "tablet", Column: 8}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "Found" || got.Pack != "10mg" || got.Unit != "tablet" || got.Column != 8 {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMedicineRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "C", Pack:"1mg", Unit: "tablet", Column: 11})
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "A", Pack:"2mg", Unit: "capsule", Column: 12})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Main", Pack:"500mg", Unit: "tablet", Column: 77})
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Backup", Pack:"200mg", Unit: "capsule", Column: 10})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "77", "`column` DESC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Column != 77 {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success filtering by unit field", func(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Tablet A", Pack: "100mg", Unit: "tablet", Column: 1})
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Capsule B", Pack: "200mg", Unit: "capsule", Column: 2})
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Spray C", Pack: "50mg", Unit: "spray", Column: 3})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "spray", "", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Unit != "spray" {
|
||||
t.Fatalf("unexpected filtered result by unit")
|
||||
}
|
||||
|
||||
rows, total, err = repo.List(context.Background(), "capsule", "", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Unit != "capsule" {
|
||||
t.Fatalf("unexpected filtered result by unit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
if err := db.Migrator().DropTable(&medicine.Medicine{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Main", Pack:"100mg", Unit: "tablet", Column: 2})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active then name ascending", func(t *testing.T) {
|
||||
db := openMedicineTestDB(t)
|
||||
repo := NewMedicineRepository(db)
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Gamma", Unit: "tablet", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Beta", Unit: "tablet", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Charlie", Unit: "tablet", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Alpha", Unit: "tablet", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Zulu", Unit: "tablet", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &medicine.Medicine{Name: "Bravo", Unit: "tablet", IsActive: false})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{rows[0].Name, rows[1].Name, rows[2].Name, rows[3].Name, rows[4].Name, rows[5].Name}
|
||||
wantOrder := []string{"Alpha", "Beta", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
528
internal/repository/mysql/mission_repo.go
Normal file
528
internal/repository/mysql/mission_repo.go
Normal file
@@ -0,0 +1,528 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightdata "wucher/internal/domain/flight_data"
|
||||
"wucher/internal/domain/mission"
|
||||
"wucher/internal/shared/pkg/txctx"
|
||||
)
|
||||
|
||||
type MissionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMissionRepository(db *gorm.DB) *MissionRepository {
|
||||
return &MissionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *MissionRepository) Create(ctx context.Context, row *mission.Mission) error {
|
||||
normalizeMissionTimeForDB(row)
|
||||
if err := txctx.DB(ctx, r.db).Create(row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return mission.ErrDuplicateFlight
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return fn(txctx.With(ctx, tx))
|
||||
})
|
||||
}
|
||||
|
||||
// MaxCodeSeqByPrefix returns the highest numeric suffix among mission codes that
|
||||
// start with prefix (e.g. "HEMS-26-"), or 0 when none exist. Used to compute the
|
||||
// next per-type, per-year sequence number.
|
||||
func (r *MissionRepository) MaxCodeSeqByPrefix(ctx context.Context, prefix string) (int, error) {
|
||||
var maxSeq *int
|
||||
err := txctx.DB(ctx, r.db).
|
||||
Model(&mission.Mission{}).
|
||||
Where("code LIKE ? AND deleted_at IS NULL", prefix+"%").
|
||||
Select("MAX(CAST(SUBSTRING_INDEX(code, '-', -1) AS UNSIGNED))").
|
||||
Scan(&maxSeq).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if maxSeq == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return *maxSeq, nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) AttachFile(ctx context.Context, missionID []byte, fileAttachmentID []byte) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var missionRow mission.Mission
|
||||
if err := tx.Where("id = ? AND deleted_at IS NULL", missionID).
|
||||
Take(&missionRow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var existing mission.MissionFile
|
||||
err := tx.Where("mission_id = ? AND file_attachment_id = ?", missionID, fileAttachmentID).
|
||||
Take(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&mission.MissionFile{
|
||||
MissionID: append([]byte(nil), missionID...),
|
||||
FileAttachmentID: append([]byte(nil), fileAttachmentID...),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *MissionRepository) DetachFile(ctx context.Context, missionID []byte, fileAttachmentID []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("mission_id = ? AND file_attachment_id = ?", missionID, fileAttachmentID).
|
||||
Delete(&mission.MissionFile{}).Error
|
||||
}
|
||||
|
||||
func (r *MissionRepository) CreateCategory(ctx context.Context, row *mission.MissionCategory) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *MissionRepository) UpdateFlightDataIDByID(ctx context.Context, missionID, flightDataID, updatedBy []byte) error {
|
||||
patch := map[string]any{
|
||||
"flight_data_id": flightDataID,
|
||||
}
|
||||
if len(updatedBy) == 16 {
|
||||
patch["updated_by"] = updatedBy
|
||||
}
|
||||
res := txctx.DB(ctx, r.db).
|
||||
Model(&mission.Mission{}).
|
||||
Where("id = ? AND deleted_at IS NULL", missionID).
|
||||
Updates(patch)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) UpdateByID(ctx context.Context, missionID []byte, missionType string, missionCategoryID []byte, subtypeID []byte, note string, updatedBy []byte) error {
|
||||
patch := map[string]any{
|
||||
"type": missionType,
|
||||
"mission_category_id": missionCategoryID,
|
||||
"subtype_id": nil,
|
||||
"note": strings.TrimSpace(note),
|
||||
}
|
||||
if len(subtypeID) == 16 {
|
||||
patch["subtype_id"] = subtypeID
|
||||
}
|
||||
if len(updatedBy) == 16 {
|
||||
patch["updated_by"] = updatedBy
|
||||
}
|
||||
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&mission.Mission{}).
|
||||
Where("id = ? AND deleted_at IS NULL", missionID).
|
||||
Updates(patch)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) DeleteByID(ctx context.Context, missionID []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
patch := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
}
|
||||
if len(deletedBy) == 16 {
|
||||
patch["updated_by"] = deletedBy
|
||||
}
|
||||
|
||||
res := tx.Model(&mission.Mission{}).
|
||||
Where("id = ? AND deleted_at IS NULL", missionID).
|
||||
Updates(patch)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
fdPatch := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
}
|
||||
if len(deletedBy) == 16 {
|
||||
fdPatch["updated_by"] = deletedBy
|
||||
}
|
||||
|
||||
if err := tx.Model(&flightdata.FlightData{}).
|
||||
Where("mission_id = ? AND deleted_at IS NULL", missionID).
|
||||
Updates(fdPatch).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *MissionRepository) DeleteByFlightID(ctx context.Context, flightID []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
patch := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
}
|
||||
if len(deletedBy) == 16 {
|
||||
patch["updated_by"] = deletedBy
|
||||
}
|
||||
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&mission.Mission{}).
|
||||
Where("flight_id = ? AND deleted_at IS NULL", flightID).
|
||||
Updates(patch)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) ListByFlightID(ctx context.Context, flightID []byte) ([]mission.Mission, error) {
|
||||
rows := make([]mission.Mission, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Select(r.statusSelectSQL()).
|
||||
Joins("LEFT JOIN mission_subcategories msc ON msc.id = missions.subtype_id").
|
||||
Preload("MissionSubCategory").
|
||||
Preload("Flight").
|
||||
Preload("Files").
|
||||
Preload("Files.FileAttachment").
|
||||
Preload("Files.FileAttachment.File").
|
||||
Where("missions.flight_id = ? AND missions.deleted_at IS NULL", flightID).
|
||||
Order("missions.created_at ASC, missions.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) ListByFlightIDs(ctx context.Context, flightIDs [][]byte) ([]mission.Mission, error) {
|
||||
if len(flightIDs) == 0 {
|
||||
return []mission.Mission{}, nil
|
||||
}
|
||||
rows := make([]mission.Mission, 0)
|
||||
err := r.db.WithContext(ctx).
|
||||
Select(r.statusSelectSQL()).
|
||||
Joins("LEFT JOIN mission_subcategories msc ON msc.id = missions.subtype_id").
|
||||
Preload("MissionSubCategory").
|
||||
Preload("Flight").
|
||||
Preload("Files").
|
||||
Preload("Files.FileAttachment").
|
||||
Preload("Files.FileAttachment.File").
|
||||
Where("missions.flight_id IN ? AND missions.deleted_at IS NULL", flightIDs).
|
||||
Order("missions.flight_id ASC, missions.created_at ASC, missions.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) GetByID(ctx context.Context, missionID []byte) (*mission.Mission, error) {
|
||||
var row mission.Mission
|
||||
err := r.db.WithContext(ctx).
|
||||
Select(r.statusSelectSQL()).
|
||||
Joins("LEFT JOIN mission_subcategories msc ON msc.id = missions.subtype_id").
|
||||
Preload("MissionSubCategory").
|
||||
Preload("Flight").
|
||||
Preload("Files").
|
||||
Preload("Files.FileAttachment").
|
||||
Preload("Files.FileAttachment.File").
|
||||
Where("missions.id = ? AND missions.deleted_at IS NULL", missionID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) GetByFlightID(ctx context.Context, flightID []byte) (*mission.Mission, error) {
|
||||
var row mission.Mission
|
||||
err := r.db.WithContext(ctx).
|
||||
Select(r.statusSelectSQL()).
|
||||
Joins("LEFT JOIN mission_subcategories msc ON msc.id = missions.subtype_id").
|
||||
Preload("MissionSubCategory").
|
||||
Preload("Flight").
|
||||
Preload("Files").
|
||||
Preload("Files.FileAttachment").
|
||||
Preload("Files.FileAttachment.File").
|
||||
Where("missions.flight_id = ? AND missions.deleted_at IS NULL", flightID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) List(ctx context.Context, filter, sort string, flightID []byte, flightDataStatus string, limit, offset int) ([]mission.Mission, int64, error) {
|
||||
rows, total, _, err := r.listWithFilter(ctx, mission.ListFilter{
|
||||
Search: filter,
|
||||
Sort: sort,
|
||||
FlightID: flightID,
|
||||
FlightDataStatus: flightDataStatus,
|
||||
}, limit, offset, false)
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func (r *MissionRepository) ListDatatable(ctx context.Context, filter mission.ListFilter, limit, offset int) ([]mission.Mission, int64, int64, error) {
|
||||
return r.listWithFilter(ctx, filter, limit, offset, true)
|
||||
}
|
||||
|
||||
func (r *MissionRepository) statusSelectSQL() string {
|
||||
return `missions.*,
|
||||
COALESCE((
|
||||
SELECT flight_data.id
|
||||
FROM flight_data
|
||||
WHERE flight_data.mission_id = missions.id
|
||||
AND flight_data.deleted_at IS NULL
|
||||
ORDER BY flight_data.created_at DESC, flight_data.id DESC
|
||||
LIMIT 1
|
||||
), missions.flight_data_id) AS flight_data_id,
|
||||
` + r.flightDataStatusExprSQL() + ` AS flight_data_status`
|
||||
}
|
||||
|
||||
func (r *MissionRepository) flightDataStatusExprSQL() string {
|
||||
completeCondition := `(
|
||||
flight_data.take_off IS NOT NULL
|
||||
AND flight_data.landing IS NOT NULL
|
||||
AND flight_data.duration > 0
|
||||
AND flight_data.red > 0
|
||||
AND flight_data.max_n1 > 0
|
||||
AND flight_data.max_n2 > 0
|
||||
AND flight_data.pax_count > 0
|
||||
AND flight_data.landing_count > 0
|
||||
AND flight_data.rotor_brake_cycle > 0
|
||||
AND COALESCE(flight_data.ticket_no, '') <> ''
|
||||
AND COALESCE(flight_data.engine, '') <> ''
|
||||
AND COALESCE(flight_data.delivery_note_number, '') <> ''
|
||||
AND COALESCE(flight_data.customer_name, '') <> ''
|
||||
AND flight_data.flight_plan_distance > 0
|
||||
AND flight_data.flight_plan_time > 0
|
||||
AND flight_data.flight_plan_true_course > 0
|
||||
AND flight_data.fuel_before_flight > 0
|
||||
AND flight_data.fuel_upload > 0
|
||||
AND flight_data.fuel_after_flight > 0
|
||||
AND flight_data.fuel_planning > 0
|
||||
AND COALESCE(flight_data.other_information, '') <> ''
|
||||
)`
|
||||
return `COALESCE((
|
||||
SELECT CASE
|
||||
WHEN COUNT(flight_data.id) = 0 THEN 'draft'
|
||||
WHEN SUM(CASE WHEN ` + completeCondition + ` THEN 1 ELSE 0 END) = 0 THEN 'draft'
|
||||
WHEN SUM(CASE WHEN ` + completeCondition + ` THEN 1 ELSE 0 END) = COUNT(flight_data.id) THEN 'completed'
|
||||
ELSE 'in_progress'
|
||||
END
|
||||
FROM flight_data
|
||||
WHERE flight_data.mission_id = missions.id
|
||||
AND flight_data.deleted_at IS NULL
|
||||
), 'draft')`
|
||||
}
|
||||
|
||||
func (r *MissionRepository) listWithFilter(ctx context.Context, filter mission.ListFilter, limit, offset int, includeUnfilteredTotal bool) ([]mission.Mission, int64, int64, error) {
|
||||
rows := make([]mission.Mission, 0)
|
||||
var total int64
|
||||
var filteredTotal int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&mission.Mission{}).
|
||||
Joins("LEFT JOIN flights ON flights.id = missions.flight_id").
|
||||
Joins("LEFT JOIN mission_subcategories msc ON msc.id = missions.subtype_id").
|
||||
Where("missions.deleted_at IS NULL")
|
||||
|
||||
if includeUnfilteredTotal {
|
||||
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
} else {
|
||||
total = 0
|
||||
}
|
||||
|
||||
base = r.applyMissionListFilter(base, filter)
|
||||
|
||||
countQuery := base.Session(&gorm.Session{})
|
||||
if err := countQuery.Count(&filteredTotal).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
query := base.Session(&gorm.Session{}).
|
||||
Select(r.statusSelectSQL()).
|
||||
Preload("MissionSubCategory").
|
||||
Preload("Flight").
|
||||
Preload("Files").
|
||||
Preload("Files.FileAttachment").
|
||||
Preload("Files.FileAttachment.File")
|
||||
if filter.Sort != "" {
|
||||
query = query.Order(filter.Sort)
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
if !includeUnfilteredTotal {
|
||||
total = filteredTotal
|
||||
}
|
||||
return rows, total, filteredTotal, nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) applyMissionListFilter(query *gorm.DB, filter mission.ListFilter) *gorm.DB {
|
||||
if filter.Search != "" {
|
||||
like := "%" + filter.Search + "%"
|
||||
query = query.Where(
|
||||
"missions.type LIKE ? OR flights.mission_code LIKE ? OR DATE_FORMAT(flights.date, '%Y-%m-%d') LIKE ?",
|
||||
like, like, like,
|
||||
)
|
||||
}
|
||||
if len(filter.FlightID) == 16 {
|
||||
query = query.Where("missions.flight_id = ?", filter.FlightID)
|
||||
}
|
||||
if filter.FlightDataStatus != "" {
|
||||
query = query.Where(r.flightDataStatusExprSQL()+" = ?", filter.FlightDataStatus)
|
||||
}
|
||||
if filter.MissionType != "" {
|
||||
query = query.Where("missions.type = ?", strings.ToUpper(strings.TrimSpace(filter.MissionType)))
|
||||
}
|
||||
if filter.StartDate != nil {
|
||||
query = query.Where("flights.date >= ?", filter.StartDate.UTC().Format("2006-01-02"))
|
||||
}
|
||||
if filter.EndDate != nil {
|
||||
query = query.Where("flights.date <= ?", filter.EndDate.UTC().Format("2006-01-02"))
|
||||
}
|
||||
if len(filter.PilotID) == 16 {
|
||||
query = query.Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM duty_rosters dr
|
||||
JOIN duty_roster_crews c ON c.roster_id = dr.id
|
||||
WHERE dr.flight_id = missions.flight_id
|
||||
AND dr.deleted_at IS NULL
|
||||
AND c.deleted_at IS NULL
|
||||
AND c.role_code = 'pilot'
|
||||
AND c.user_id = ?
|
||||
)`, filter.PilotID)
|
||||
}
|
||||
if len(filter.HelicopterID) == 16 {
|
||||
query = query.Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM takeover_acs ta
|
||||
JOIN reserve_acs ra ON ra.id = ta.reserve_ac_id
|
||||
WHERE ta.id = flights.takeover_ac_id
|
||||
AND ta.deleted_at IS NULL
|
||||
AND ra.deleted_at IS NULL
|
||||
AND ra.helicopter_id = ?
|
||||
)`, filter.HelicopterID)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func normalizeMissionTimeForDB(row *mission.Mission) {
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
row.StartTime = normalizeMissionDBClock(row.StartTime)
|
||||
row.EndTime = normalizeMissionDBClock(row.EndTime)
|
||||
}
|
||||
|
||||
func normalizeMissionDBClock(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if len(trimmed) == 5 {
|
||||
return trimmed + ":00"
|
||||
}
|
||||
if len(trimmed) == 8 {
|
||||
return trimmed
|
||||
}
|
||||
if parsed, err := time.Parse("15:04", trimmed); err == nil {
|
||||
return parsed.Format("15:04:05")
|
||||
}
|
||||
if parsed, err := time.Parse("15:04:05", trimmed); err == nil {
|
||||
return parsed.Format("15:04:05")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func (r *MissionRepository) ListCategoriesWithSubCategories(ctx context.Context) ([]mission.MissionCategory, error) {
|
||||
var rows []mission.MissionCategory
|
||||
if err := r.db.WithContext(ctx).
|
||||
Preload("SubCategory", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("sub_type ASC")
|
||||
}).
|
||||
Order("code_type ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
rows[i].CodeType = strings.TrimSpace(rows[i].CodeType)
|
||||
rows[i].TypeName = strings.TrimSpace(rows[i].TypeName)
|
||||
for j := range rows[i].SubCategory {
|
||||
rows[i].SubCategory[j].SubCodeType = strings.TrimSpace(rows[i].SubCategory[j].SubCodeType)
|
||||
rows[i].SubCategory[j].SubTypeName = strings.TrimSpace(rows[i].SubCategory[j].SubTypeName)
|
||||
rows[i].SubCategory[j].TaskName = strings.TrimSpace(rows[i].SubCategory[j].TaskName)
|
||||
}
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) FindCategoryByCode(ctx context.Context, code string) (*mission.MissionCategory, error) {
|
||||
var row mission.MissionCategory
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("code_type = ?", code).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *MissionRepository) SubCategoryBelongsToCategory(ctx context.Context, subCategoryID, categoryID []byte) (bool, error) {
|
||||
if len(subCategoryID) != 16 || len(categoryID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var cnt int64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("mission_subcategories").
|
||||
Where("id = ? AND mission_category_id = ?", subCategoryID, categoryID).
|
||||
Count(&cnt).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return cnt > 0, nil
|
||||
}
|
||||
88
internal/repository/mysql/opc_repo.go
Normal file
88
internal/repository/mysql/opc_repo.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/opc"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
)
|
||||
|
||||
type OpcRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewOpcRepository(db *gorm.DB) *OpcRepository {
|
||||
return &OpcRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *OpcRepository) Create(ctx context.Context, o *opc.Opc) error {
|
||||
requestedIsActive := o.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(o).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&opc.Opc{}).Where("id = ?", o.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *OpcRepository) Update(ctx context.Context, o *opc.Opc) error {
|
||||
return r.db.WithContext(ctx).Save(o).Error
|
||||
}
|
||||
|
||||
func (r *OpcRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "hems_opcs", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&opc.Opc{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *OpcRepository) GetByID(ctx context.Context, id []byte) (*opc.Opc, error) {
|
||||
var row opc.Opc
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *OpcRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]opc.Opc, int64, error) {
|
||||
var rows []opc.Opc
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&opc.Opc{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("name LIKE ? OR note LIKE ?", like, like)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("hems_opcs", "is_active", "sortkey", "name", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
238
internal/repository/mysql/opc_repo_test.go
Normal file
238
internal/repository/mysql/opc_repo_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/opc"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openOpcTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:opc_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&opc.Opc{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewOpcRepository(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpcRepositoryCreate(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
row := &opc.Opc{Title: "Main"}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
inactive := &opc.Opc{Title: "Inactive", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
gotInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get inactive: %v", err)
|
||||
}
|
||||
if gotInactive == nil || gotInactive.IsActive {
|
||||
t.Fatalf("expected inactive opc persisted as false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &opc.Opc{Title: "AfterClose"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpcRepositoryUpdate(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
row := &opc.Opc{Title: "Old"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Title = "New"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.Title != "New" {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpcRepositoryDelete(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
row := &opc.Opc{Title: "DeleteMe"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft deleted row hidden from GetByID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpcRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
row := &opc.Opc{Title: "Found"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Title != "Found" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpcRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "C"})
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "A"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "Main Base"})
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "Backup"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "Main", "name DESC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Title != "Main Base" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
if err := db.Migrator().DropTable(&opc.Opc{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "Main"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active sortkey first and inactive last", func(t *testing.T) {
|
||||
db := openOpcTestDB(t)
|
||||
repo := NewOpcRepository(db)
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "Gamma", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "Beta", SortKey: intPtrOpcRepo(0), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "Charlie", SortKey: intPtrOpcRepo(2), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "Alpha", SortKey: intPtrOpcRepo(1), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "Zulu", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &opc.Opc{Title: "Bravo", SortKey: intPtrOpcRepo(9), IsActive: false})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{rows[0].Title, rows[1].Title, rows[2].Title, rows[3].Title, rows[4].Title, rows[5].Title}
|
||||
wantOrder := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func intPtrOpcRepo(v int) *int { return &v }
|
||||
114
internal/repository/mysql/other_person_repo.go
Normal file
114
internal/repository/mysql/other_person_repo.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
otherperson "wucher/internal/domain/other_person"
|
||||
)
|
||||
|
||||
type OtherPersonRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewOtherPersonRepository(db *gorm.DB) *OtherPersonRepository {
|
||||
return &OtherPersonRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *OtherPersonRepository) Create(ctx context.Context, p *otherperson.OtherPerson) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
p.MobilePhone = strings.TrimSpace(p.MobilePhone)
|
||||
p.Email = strings.TrimSpace(p.Email)
|
||||
return r.db.WithContext(ctx).Create(p).Error
|
||||
}
|
||||
|
||||
func (r *OtherPersonRepository) Update(ctx context.Context, p *otherperson.OtherPerson) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&otherperson.OtherPerson{}).
|
||||
Where("id = ? AND deleted_at IS NULL", p.ID).
|
||||
Updates(map[string]any{
|
||||
"name": strings.TrimSpace(p.Name),
|
||||
"mobile_phone": strings.TrimSpace(p.MobilePhone),
|
||||
"email": strings.TrimSpace(p.Email),
|
||||
"updated_by": p.UpdatedBy,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *OtherPersonRepository) Delete(ctx context.Context, id, deletedBy []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&otherperson.OtherPerson{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{
|
||||
"deleted_at": time.Now().UTC(),
|
||||
"deleted_by": deletedBy,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *OtherPersonRepository) FindByIdentity(ctx context.Context, name, phone, email string) (*otherperson.OtherPerson, error) {
|
||||
var row otherperson.OtherPerson
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("name = ? AND mobile_phone = ? AND email = ? AND deleted_at IS NULL",
|
||||
strings.TrimSpace(name), strings.TrimSpace(phone), strings.TrimSpace(email)).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *OtherPersonRepository) Upsert(ctx context.Context, p *otherperson.OtherPerson) error {
|
||||
name := strings.TrimSpace(p.Name)
|
||||
phone := strings.TrimSpace(p.MobilePhone)
|
||||
email := strings.TrimSpace(p.Email)
|
||||
|
||||
var existing otherperson.OtherPerson
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("name = ? AND mobile_phone = ? AND email = ? AND deleted_at IS NULL", name, phone, email).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
p.ID = existing.ID
|
||||
p.Name, p.MobilePhone, p.Email = existing.Name, existing.MobilePhone, existing.Email
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
p.Name, p.MobilePhone, p.Email = name, phone, email
|
||||
return r.db.WithContext(ctx).Create(p).Error
|
||||
}
|
||||
|
||||
func (r *OtherPersonRepository) GetByID(ctx context.Context, id []byte) (*otherperson.OtherPerson, error) {
|
||||
var row otherperson.OtherPerson
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *OtherPersonRepository) Search(ctx context.Context, q string, limit, offset int) ([]otherperson.OtherPerson, int64, error) {
|
||||
rows := make([]otherperson.OtherPerson, 0)
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&otherperson.OtherPerson{}).
|
||||
Where("deleted_at IS NULL")
|
||||
if s := strings.TrimSpace(q); s != "" {
|
||||
like := "%" + s + "%"
|
||||
base = base.Where("name LIKE ? OR mobile_phone LIKE ? OR email LIKE ?", like, like, like)
|
||||
}
|
||||
var total int64
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
err := base.Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
176
internal/repository/mysql/other_person_repo_test.go
Normal file
176
internal/repository/mysql/other_person_repo_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
otherperson "wucher/internal/domain/other_person"
|
||||
)
|
||||
|
||||
func openOtherPersonTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:other_person_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&otherperson.OtherPerson{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestOtherPersonRepositoryUpsertDedup(t *testing.T) {
|
||||
db := openOtherPersonTestDB(t)
|
||||
repo := NewOtherPersonRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
first := &otherperson.OtherPerson{Name: " Guest One ", MobilePhone: " +43-1 ", Email: " a@x.io "}
|
||||
if err := repo.Upsert(ctx, first); err != nil {
|
||||
t.Fatalf("first upsert: %v", err)
|
||||
}
|
||||
if len(first.ID) == 0 {
|
||||
t.Fatalf("expected id set on create")
|
||||
}
|
||||
// values should be trimmed on create.
|
||||
if first.Name != "Guest One" || first.MobilePhone != "+43-1" || first.Email != "a@x.io" {
|
||||
t.Fatalf("expected trimmed values, got %q/%q/%q", first.Name, first.MobilePhone, first.Email)
|
||||
}
|
||||
|
||||
// Same identity (even with different whitespace) must reuse the existing row, not create a new one.
|
||||
second := &otherperson.OtherPerson{Name: "Guest One", MobilePhone: "+43-1", Email: "a@x.io"}
|
||||
if err := repo.Upsert(ctx, second); err != nil {
|
||||
t.Fatalf("second upsert: %v", err)
|
||||
}
|
||||
if string(second.ID) != string(first.ID) {
|
||||
t.Fatalf("expected reuse of existing id")
|
||||
}
|
||||
|
||||
// A different identity creates a new row.
|
||||
third := &otherperson.OtherPerson{Name: "Guest Two", MobilePhone: "+43-2", Email: "b@x.io"}
|
||||
if err := repo.Upsert(ctx, third); err != nil {
|
||||
t.Fatalf("third upsert: %v", err)
|
||||
}
|
||||
if string(third.ID) == string(first.ID) {
|
||||
t.Fatalf("expected new id for distinct identity")
|
||||
}
|
||||
|
||||
_, total, err := repo.Search(ctx, "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("expected 2 master rows, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherPersonRepositorySearch(t *testing.T) {
|
||||
db := openOtherPersonTestDB(t)
|
||||
repo := NewOtherPersonRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, p := range []*otherperson.OtherPerson{
|
||||
{Name: "Alice Anderson", MobilePhone: "111", Email: "alice@x.io"},
|
||||
{Name: "Bob Brown", MobilePhone: "222", Email: "bob@x.io"},
|
||||
} {
|
||||
if err := repo.Upsert(ctx, p); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, total, err := repo.Search(ctx, "alice", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("search by name: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Name != "Alice Anderson" {
|
||||
t.Fatalf("expected only Alice, got total=%d rows=%d", total, len(rows))
|
||||
}
|
||||
|
||||
// Match on email fragment too.
|
||||
rows, total, err = repo.Search(ctx, "bob@", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("search by email: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Name != "Bob Brown" {
|
||||
t.Fatalf("expected only Bob, got total=%d rows=%d", total, len(rows))
|
||||
}
|
||||
|
||||
// Empty query returns everything.
|
||||
_, total, err = repo.Search(ctx, " ", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("search empty: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("expected 2 rows for empty query, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherPersonRepositoryCreateUpdateDelete(t *testing.T) {
|
||||
db := openOtherPersonTestDB(t)
|
||||
repo := NewOtherPersonRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
row := &otherperson.OtherPerson{Name: " Carol ", MobilePhone: " 333 ", Email: " carol@x.io "}
|
||||
if err := repo.Create(ctx, row); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 || row.Name != "Carol" {
|
||||
t.Fatalf("expected trimmed create with id, got %q id=%d", row.Name, len(row.ID))
|
||||
}
|
||||
|
||||
// FindByIdentity trims and matches.
|
||||
found, err := repo.FindByIdentity(ctx, "Carol", "333", "carol@x.io")
|
||||
if err != nil {
|
||||
t.Fatalf("find identity: %v", err)
|
||||
}
|
||||
if found == nil || string(found.ID) != string(row.ID) {
|
||||
t.Fatalf("expected to find created row")
|
||||
}
|
||||
miss, err := repo.FindByIdentity(ctx, "Nobody", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("find miss: %v", err)
|
||||
}
|
||||
if miss != nil {
|
||||
t.Fatalf("expected nil for missing identity")
|
||||
}
|
||||
|
||||
// Update replaces identity fields.
|
||||
row.Name = "Caroline"
|
||||
row.Email = "caroline@x.io"
|
||||
if err := repo.Update(ctx, row); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, err := repo.GetByID(ctx, row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after update: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "Caroline" || got.Email != "caroline@x.io" {
|
||||
t.Fatalf("update not persisted: %+v", got)
|
||||
}
|
||||
|
||||
// Delete soft-deletes: GetByID/Search no longer return it.
|
||||
if err := repo.Delete(ctx, row.ID, nil); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
got, err = repo.GetByID(ctx, row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft-deleted row to be hidden")
|
||||
}
|
||||
_, total, err := repo.Search(ctx, "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("search after delete: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("expected 0 live rows after delete, got %d", total)
|
||||
}
|
||||
}
|
||||
89
internal/repository/mysql/patient_data_repo.go
Normal file
89
internal/repository/mysql/patient_data_repo.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
patientdata "wucher/internal/domain/patient_data"
|
||||
)
|
||||
|
||||
type PatientDataRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPatientDataRepository(db *gorm.DB) *PatientDataRepository {
|
||||
return &PatientDataRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *PatientDataRepository) Create(ctx context.Context, row *patientdata.PatientData) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *PatientDataRepository) Update(ctx context.Context, row *patientdata.PatientData) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *PatientDataRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&patientdata.PatientData{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *PatientDataRepository) GetByID(ctx context.Context, id []byte) (*patientdata.PatientData, error) {
|
||||
var row patientdata.PatientData
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("OPC", "deleted_at IS NULL").
|
||||
Preload("InsurancePatientData", "deleted_at IS NULL").
|
||||
Preload("InsurancePatientData.HealthInsuranceCompanies", "deleted_at IS NULL").
|
||||
Preload("InsurancePatientData.FederalState", "deleted_at IS NULL").
|
||||
Where("patient_data.id = ? AND patient_data.deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *PatientDataRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]patientdata.PatientData, int64, error) {
|
||||
var rows []patientdata.PatientData
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&patientdata.PatientData{}).Where("patient_data.deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where(
|
||||
"family_name LIKE ? OR first_name LIKE ? OR svnr LIKE ? OR email LIKE ? OR phone LIKE ? OR street LIKE ?",
|
||||
like, like, like, like, like, like,
|
||||
)
|
||||
}
|
||||
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := base.
|
||||
Preload("OPC", "deleted_at IS NULL").
|
||||
Preload("InsurancePatientData", "deleted_at IS NULL").
|
||||
Preload("InsurancePatientData.HealthInsuranceCompanies", "deleted_at IS NULL").
|
||||
Preload("InsurancePatientData.FederalState", "deleted_at IS NULL")
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
300
internal/repository/mysql/reserve_ac_repo.go
Normal file
300
internal/repository/mysql/reserve_ac_repo.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightinspection "wucher/internal/domain/flight_inspection"
|
||||
"wucher/internal/domain/helicopter"
|
||||
reserveac "wucher/internal/domain/reserve_ac"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
shareddto "wucher/internal/transport/http/dto/shared"
|
||||
)
|
||||
|
||||
type ReserveAcRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewReserveAcRepository(db *gorm.DB) *ReserveAcRepository {
|
||||
return &ReserveAcRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) Create(ctx context.Context, row *reserveac.ReserveAc) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) Update(ctx context.Context, row *reserveac.ReserveAc) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&reserveac.ReserveAc{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) GetByID(ctx context.Context, id []byte) (*reserveac.ReserveAc, error) {
|
||||
var row reserveac.ReserveAc
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Aircraft").
|
||||
Preload("Inspection").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) GetActiveByInspectionID(ctx context.Context, inspectionID []byte) (*reserveac.ReserveAc, error) {
|
||||
if len(inspectionID) != 16 {
|
||||
return nil, nil
|
||||
}
|
||||
var row reserveac.ReserveAc
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Aircraft").
|
||||
Preload("Inspection").
|
||||
Where("inspection_id = ? AND deleted_at IS NULL", inspectionID).
|
||||
Limit(1).
|
||||
Find(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) List(ctx context.Context, filter string, sort string, limit, offset int) ([]reserveac.ReserveAc, int64, error) {
|
||||
rows := make([]reserveac.ReserveAc, 0)
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&reserveac.ReserveAc{}).Where("deleted_at IS NULL")
|
||||
if strings.TrimSpace(filter) != "" {
|
||||
like := "%" + strings.ToLower(strings.TrimSpace(filter)) + "%"
|
||||
base = base.Where(
|
||||
"LOWER(status) LIKE ? OR LOWER(HEX(id)) LIKE ? OR LOWER(HEX(helicopter_id)) LIKE ? OR LOWER(HEX(inspection_id)) LIKE ?",
|
||||
like, like, like, like,
|
||||
)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) AircraftExists(ctx context.Context, helicopterID []byte) (bool, error) {
|
||||
if len(helicopterID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&helicopter.Helicopter{}).
|
||||
Where("id = ?", helicopterID).
|
||||
Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) FlightInspectionExists(ctx context.Context, inspectionID []byte) (bool, error) {
|
||||
if len(inspectionID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&flightinspection.FlightInspection{}).
|
||||
Where("id = ?", inspectionID).
|
||||
Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) ExistsActiveByInspectionID(ctx context.Context, inspectionID []byte, excludeID []byte) (bool, error) {
|
||||
if len(inspectionID) != 16 {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
q := r.db.WithContext(ctx).
|
||||
Model(&reserveac.ReserveAc{}).
|
||||
Where("inspection_id = ? AND deleted_at IS NULL", inspectionID)
|
||||
if len(excludeID) == 16 {
|
||||
q = q.Where("id <> ?", excludeID)
|
||||
}
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return total > 0, nil
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) GetLastDailyInspectionByHelicopterID(ctx context.Context, helicopterID []byte) (*shareddto.LastDailyInspection, error) {
|
||||
if len(helicopterID) != 16 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
Name string `gorm:"column:name"`
|
||||
ShortName string `gorm:"column:short_name"`
|
||||
At time.Time `gorm:"column:inspected_at"`
|
||||
}
|
||||
var out row
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("reserve_acs ra").
|
||||
Select(`
|
||||
fi.id AS id,
|
||||
TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))) AS name,
|
||||
COALESCE(NULLIF(pp.short_name, ''), NULLIF(tp.short_name, ''), '') AS short_name,
|
||||
COALESCE(afi.updated_at, fpc.updated_at, bfi.updated_at) AS inspected_at`).
|
||||
Joins("JOIN flight_inspections fi ON fi.id = ra.inspection_id").
|
||||
Joins("LEFT JOIN before_flight_inspections bfi ON bfi.flight_inspection_id = fi.id").
|
||||
Joins("LEFT JOIN flight_prep_checks fpc ON fpc.flight_inspection_id = fi.id").
|
||||
Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = fi.id").
|
||||
Joins(`JOIN users u ON u.id = CASE
|
||||
WHEN afi.updated_at IS NOT NULL THEN afi.updated_by
|
||||
WHEN fpc.updated_at IS NOT NULL THEN fpc.updated_by
|
||||
WHEN bfi.updated_at IS NOT NULL THEN bfi.updated_by
|
||||
ELSE NULL
|
||||
END`).
|
||||
Joins("LEFT JOIN pilot_profiles pp ON pp.user_id = u.id").
|
||||
Joins("LEFT JOIN technician_profiles tp ON tp.user_id = u.id").
|
||||
Where("ra.helicopter_id = ? AND ra.deleted_at IS NULL", helicopterID).
|
||||
Where("COALESCE(afi.updated_at, fpc.updated_at, bfi.updated_at) IS NOT NULL").
|
||||
Order("fi.inspection_date DESC").
|
||||
Order("COALESCE(afi.updated_at, fpc.updated_at, bfi.updated_at) DESC").
|
||||
Limit(1).
|
||||
Take(&out).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &shareddto.LastDailyInspection{
|
||||
ID: out.ID,
|
||||
Name: strings.TrimSpace(out.Name),
|
||||
ShortName: strings.TrimSpace(out.ShortName),
|
||||
At: out.At.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *ReserveAcRepository) GetLastDailyInspectionSummaryByHelicopterID(ctx context.Context, helicopterID []byte) (*shareddto.LastDailyInspectionSummary, error) {
|
||||
if len(helicopterID) != 16 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
InspectionID []byte `gorm:"column:inspection_id"`
|
||||
InspectorID []byte `gorm:"column:inspector_id"`
|
||||
InspectorName string `gorm:"column:inspector_name"`
|
||||
InspectorLicenseNo string `gorm:"column:inspector_license_no"`
|
||||
BaseID []byte `gorm:"column:base_id"`
|
||||
BaseName string `gorm:"column:base_name"`
|
||||
BaseAbbreviation string `gorm:"column:base_abbreviation"`
|
||||
BaseType string `gorm:"column:base_type"`
|
||||
InspectedAt time.Time `gorm:"column:inspected_at"`
|
||||
CompletedCount int `gorm:"column:completed_count"`
|
||||
TotalCount int `gorm:"column:total_count"`
|
||||
}
|
||||
var out row
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("reserve_acs ra").
|
||||
Select(`
|
||||
ra.inspection_id AS inspection_id,
|
||||
COALESCE(u.id, ra.updated_by) AS inspector_id,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))), ''),
|
||||
NULLIF(TRIM(u.username), ''),
|
||||
NULLIF(TRIM(u.email), ''),
|
||||
''
|
||||
) AS inspector_name,
|
||||
COALESCE(NULLIF(pp.license_no, ''), NULLIF(tp.license_no, ''), '') AS inspector_license_no,
|
||||
ra.base_id AS base_id,
|
||||
COALESCE(TRIM(b.base), '') AS base_name,
|
||||
COALESCE(TRIM(b.base_abbreviation), '') AS base_abbreviation,
|
||||
COALESCE(NULLIF(LOWER(bc.key), ''), 'base') AS base_type,
|
||||
COALESCE(afi.updated_at, fpc.updated_at, bfi.updated_at, ra.updated_at) AS inspected_at,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM flight_inspection_file_checklists fic
|
||||
WHERE fic.flight_inspection_id = fi.id
|
||||
) AS total_count,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM flight_inspection_file_checklists fic
|
||||
WHERE fic.flight_inspection_id = fi.id AND fic.is_done = 1
|
||||
) AS completed_count
|
||||
`).
|
||||
Joins("JOIN flight_inspections fi ON fi.id = ra.inspection_id").
|
||||
Joins("LEFT JOIN before_flight_inspections bfi ON bfi.flight_inspection_id = fi.id").
|
||||
Joins("LEFT JOIN flight_prep_checks fpc ON fpc.flight_inspection_id = fi.id").
|
||||
Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = fi.id").
|
||||
Joins(`LEFT JOIN users u ON u.id = CASE
|
||||
WHEN afi.updated_at IS NOT NULL THEN afi.updated_by
|
||||
WHEN fpc.updated_at IS NOT NULL THEN fpc.updated_by
|
||||
WHEN bfi.updated_at IS NOT NULL THEN bfi.updated_by
|
||||
ELSE ra.updated_by
|
||||
END`).
|
||||
Joins("LEFT JOIN pilot_profiles pp ON pp.user_id = u.id").
|
||||
Joins("LEFT JOIN technician_profiles tp ON tp.user_id = u.id").
|
||||
Joins("LEFT JOIN bases b ON b.id = ra.base_id AND b.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN base_categories bc ON bc.id = b.base_category_id").
|
||||
Where("ra.helicopter_id = ? AND ra.deleted_at IS NULL", helicopterID).
|
||||
Where("COALESCE(afi.updated_at, fpc.updated_at, bfi.updated_at, ra.updated_at) IS NOT NULL").
|
||||
Order("fi.inspection_date DESC, COALESCE(afi.updated_at, fpc.updated_at, bfi.updated_at, ra.updated_at) DESC").
|
||||
Limit(1).
|
||||
Take(&out).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inspectionID, _ := uuidv7.BytesToString(out.InspectionID)
|
||||
inspectorID, _ := uuidv7.BytesToString(out.InspectorID)
|
||||
baseID, _ := uuidv7.BytesToString(out.BaseID)
|
||||
return &shareddto.LastDailyInspectionSummary{
|
||||
InspectionID: inspectionID,
|
||||
Status: "inspected_cleared",
|
||||
InspectorID: inspectorID,
|
||||
InspectorName: strings.TrimSpace(out.InspectorName),
|
||||
InspectorLicenseNo: strings.TrimSpace(out.InspectorLicenseNo),
|
||||
BaseID: baseID,
|
||||
BaseName: strings.TrimSpace(out.BaseName),
|
||||
BaseAbbreviation: strings.TrimSpace(out.BaseAbbreviation),
|
||||
BaseType: strings.TrimSpace(out.BaseType),
|
||||
InspectedAt: out.InspectedAt.UTC(),
|
||||
InspectionFilesChecklistCompleted: out.CompletedCount,
|
||||
InspectionFilesChecklistTotal: out.TotalCount,
|
||||
}, nil
|
||||
}
|
||||
202
internal/repository/mysql/role_repo.go
Normal file
202
internal/repository/mysql/role_repo.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"wucher/internal/domain/auth"
|
||||
)
|
||||
|
||||
type RoleRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRoleRepository(db *gorm.DB) *RoleRepository {
|
||||
return &RoleRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *RoleRepository) CreateRole(ctx context.Context, role *auth.Role) error {
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
role.CreatedBy = actor
|
||||
role.UpdatedBy = actor
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(role).Error
|
||||
}
|
||||
|
||||
func (r *RoleRepository) UpdateRole(ctx context.Context, role *auth.Role) error {
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
role.UpdatedBy = actor
|
||||
}
|
||||
return r.db.WithContext(ctx).Save(role).Error
|
||||
}
|
||||
|
||||
func (r *RoleRepository) DeleteRole(ctx context.Context, id []byte) error {
|
||||
return r.db.WithContext(ctx).Delete(&auth.Role{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *RoleRepository) GetRoleByID(ctx context.Context, id []byte) (*auth.Role, error) {
|
||||
var role auth.Role
|
||||
err := r.db.WithContext(ctx).First(&role, "id = ?", id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &role, err
|
||||
}
|
||||
|
||||
func (r *RoleRepository) GetRoleByName(ctx context.Context, name string) (*auth.Role, error) {
|
||||
var role auth.Role
|
||||
err := r.db.WithContext(ctx).Where("name = ?", name).First(&role).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &role, err
|
||||
}
|
||||
|
||||
func (r *RoleRepository) HasRolePermission(ctx context.Context, roleID []byte, permissionKey string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("role_permissions rp").
|
||||
Joins("JOIN permissions p ON p.id = rp.permission_id").
|
||||
Where("rp.role_id = ? AND p.`key` = ?", roleID, permissionKey).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) GetPermissionByID(ctx context.Context, id []byte) (*auth.Permission, error) {
|
||||
var perm auth.Permission
|
||||
err := r.db.WithContext(ctx).First(&perm, "id = ?", id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &perm, err
|
||||
}
|
||||
|
||||
func (r *RoleRepository) GetPermissionByKey(ctx context.Context, key string) (*auth.Permission, error) {
|
||||
var perm auth.Permission
|
||||
err := r.db.WithContext(ctx).Where("`key` = ?", strings.ToLower(strings.TrimSpace(key))).First(&perm).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &perm, err
|
||||
}
|
||||
|
||||
func (r *RoleRepository) UpdatePermission(ctx context.Context, permission *auth.Permission) error {
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
permission.UpdatedBy = actor
|
||||
}
|
||||
return r.db.WithContext(ctx).Save(permission).Error
|
||||
}
|
||||
|
||||
func (r *RoleRepository) ListPermissionsByRoleID(ctx context.Context, roleID []byte) ([]auth.Permission, error) {
|
||||
var perms []auth.Permission
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("permissions p").
|
||||
Select("p.*").
|
||||
Joins("JOIN role_permissions rp ON rp.permission_id = p.id").
|
||||
Where("rp.role_id = ?", roleID).
|
||||
Order("p.name ASC").
|
||||
Find(&perms).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) AssignPermission(ctx context.Context, roleID, permissionID []byte) (*auth.RolePermission, error) {
|
||||
rp := &auth.RolePermission{RoleID: roleID, PermissionID: permissionID}
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
rp.CreatedBy = actor
|
||||
rp.UpdatedBy = actor
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "role_id"}, {Name: "permission_id"}},
|
||||
DoNothing: true,
|
||||
}).
|
||||
Create(rp).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Already exists, return current row.
|
||||
if len(rp.ID) == 0 {
|
||||
var existing auth.RolePermission
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("role_id = ? AND permission_id = ?", roleID, permissionID).
|
||||
First(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &existing, nil
|
||||
}
|
||||
return rp, nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) RemovePermission(ctx context.Context, roleID, permissionID []byte) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Delete(&auth.RolePermission{}, "role_id = ? AND permission_id = ?", roleID, permissionID).
|
||||
Error
|
||||
}
|
||||
|
||||
func (r *RoleRepository) ListPermissions(ctx context.Context, filter string, sort string, limit, offset int) ([]auth.Permission, int64, error) {
|
||||
var perms []auth.Permission
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&auth.Permission{})
|
||||
if filter != "" {
|
||||
like := "%" + strings.ToLower(filter) + "%"
|
||||
query = query.Where("LOWER(name) LIKE ? OR LOWER(`key`) LIKE ?", like, like)
|
||||
}
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
query = query.Order("created_at DESC")
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&perms).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return perms, total, nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) ListRoles(ctx context.Context, filterName, sort string, limit, offset int) ([]auth.Role, int64, error) {
|
||||
var roles []auth.Role
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&auth.Role{})
|
||||
|
||||
if filterName != "" {
|
||||
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(filterName)+"%")
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
query = query.Order("created_at DESC")
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
|
||||
if err := query.Find(&roles).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return roles, total, nil
|
||||
}
|
||||
58
internal/repository/mysql/schema_cache.go
Normal file
58
internal/repository/mysql/schema_cache.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// schemaCache memoizes table/column existence checks for a single DB handle.
|
||||
// It is intended for startup-time feature detection, not per-request probing.
|
||||
type schemaCache struct {
|
||||
db *gorm.DB
|
||||
tableExists map[string]bool
|
||||
columnExists map[string]bool
|
||||
}
|
||||
|
||||
func newSchemaCache(db *gorm.DB) *schemaCache {
|
||||
return &schemaCache{
|
||||
db: db,
|
||||
tableExists: make(map[string]bool),
|
||||
columnExists: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *schemaCache) HasTable(dst any) bool {
|
||||
key := schemaKey(dst)
|
||||
if ok, found := s.tableExists[key]; found {
|
||||
return ok
|
||||
}
|
||||
ok := s.db.Migrator().HasTable(dst)
|
||||
s.tableExists[key] = ok
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *schemaCache) HasColumn(dst any, column string) bool {
|
||||
key := schemaKey(dst) + "\x00" + column
|
||||
if ok, found := s.columnExists[key]; found {
|
||||
return ok
|
||||
}
|
||||
ok := s.db.Migrator().HasColumn(dst, column)
|
||||
s.columnExists[key] = ok
|
||||
return ok
|
||||
}
|
||||
|
||||
func schemaKey(dst any) string {
|
||||
if dst == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
if s, ok := dst.(string); ok {
|
||||
return "table:" + s
|
||||
}
|
||||
t := reflect.TypeOf(dst)
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
return fmt.Sprintf("type:%s.%s", t.PkgPath(), t.Name())
|
||||
}
|
||||
154
internal/repository/mysql/takeover_repo.go
Normal file
154
internal/repository/mysql/takeover_repo.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
helicopterfile "wucher/internal/domain/helicopter_file"
|
||||
takeover "wucher/internal/domain/takeover"
|
||||
)
|
||||
|
||||
type TakeoverRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTakeoverRepository(db *gorm.DB) *TakeoverRepository {
|
||||
return &TakeoverRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *TakeoverRepository) Create(ctx context.Context, row *takeover.TakeoverAc) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *TakeoverRepository) Update(ctx context.Context, row *takeover.TakeoverAc) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *TakeoverRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
now := time.Now().UTC()
|
||||
return r.db.WithContext(ctx).Model(&takeover.TakeoverAc{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy}).Error
|
||||
}
|
||||
|
||||
// ListActiveEditedTemplateUUIDs returns template UUIDs for takeover-linked files
|
||||
// that are still active. Trashed or lifecycle-deleted files are ignored.
|
||||
func (r *TakeoverRepository) ListActiveEditedTemplateUUIDs(tx *gorm.DB, takeoverID []byte) ([][]byte, error) {
|
||||
if len(takeoverID) != 16 {
|
||||
return nil, nil
|
||||
}
|
||||
db := tx
|
||||
if db == nil {
|
||||
db = r.db
|
||||
}
|
||||
var editedTemplateUUIDs [][]byte
|
||||
if err := db.Table("file_files").
|
||||
Where(
|
||||
"takeover_id = ? AND template_uuid IS NOT NULL AND deleted_at IS NULL AND lifecycle_deleted_at IS NULL AND status <> ?",
|
||||
takeoverID,
|
||||
filemanager.FileStatusTrashed,
|
||||
).
|
||||
Distinct().
|
||||
Pluck("template_uuid", &editedTemplateUUIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return editedTemplateUUIDs, nil
|
||||
}
|
||||
|
||||
// ListChecklistTemplateFiles returns the helicopter files that make up a section's
|
||||
// inspection checklist. Documents created from a template (via create-from-template)
|
||||
// are stored as helicopter_files rows for takeover/WOPI editing but are not checklist
|
||||
// items, so they are excluded here. Pass the active transaction handle to run within
|
||||
// an ongoing transaction; when tx is nil the repository's own connection is used.
|
||||
func (r *TakeoverRepository) ListChecklistTemplateFiles(tx *gorm.DB, helicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) {
|
||||
db := tx
|
||||
if db == nil {
|
||||
db = r.db
|
||||
}
|
||||
var rows []helicopterfile.HelicopterFile
|
||||
if err := db.
|
||||
Model(&helicopterfile.HelicopterFile{}).
|
||||
Preload("SourceFile").
|
||||
Preload("FileAttachment").
|
||||
Preload("FileAttachment.File").
|
||||
Where("helicopter_id = ? AND section = ?", helicopterID, section).
|
||||
Order("position ASC, id ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := rows[:0]
|
||||
for i := range rows {
|
||||
if helicopterFileRowCreatedFromTemplate(&rows[i]) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, rows[i])
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// helicopterFileRowCreatedFromTemplate reports whether a helicopter file row points to a
|
||||
// document instantiated from a template (non-empty template_uuid on the underlying file).
|
||||
func helicopterFileRowCreatedFromTemplate(row *helicopterfile.HelicopterFile) bool {
|
||||
if row == nil {
|
||||
return false
|
||||
}
|
||||
if row.SourceFile != nil {
|
||||
return len(row.SourceFile.TemplateUUID) == 16
|
||||
}
|
||||
if row.FileAttachment != nil && row.FileAttachment.File != nil {
|
||||
return len(row.FileAttachment.File.TemplateUUID) == 16
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *TakeoverRepository) GetByID(ctx context.Context, id []byte) (*takeover.TakeoverAc, error) {
|
||||
var row takeover.TakeoverAc
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Base").
|
||||
Preload("ReserveAc").
|
||||
Preload("ReserveAc.Aircraft").
|
||||
Preload("ReserveAc.Inspection").
|
||||
Preload("RosterCrews").
|
||||
Preload("OtherPeople").
|
||||
Preload("Files").
|
||||
Preload("Files.FileAttachment").
|
||||
Preload("Files.FileAttachment.File").
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *TakeoverRepository) List(ctx context.Context, limit, offset int) ([]takeover.TakeoverAc, int64, error) {
|
||||
rows := make([]takeover.TakeoverAc, 0)
|
||||
var total int64
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&takeover.TakeoverAc{}).
|
||||
Preload("Base").
|
||||
Preload("ReserveAc").
|
||||
Preload("ReserveAc.Aircraft").
|
||||
Preload("ReserveAc.Inspection").
|
||||
Preload("RosterCrews").
|
||||
Preload("OtherPeople").
|
||||
Preload("Files").
|
||||
Preload("Files.FileAttachment").
|
||||
Preload("Files.FileAttachment.File").
|
||||
Where("deleted_at IS NULL")
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := base.Order("created_at DESC")
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
126
internal/repository/mysql/token_store.go
Normal file
126
internal/repository/mysql/token_store.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"wucher/internal/domain/transient"
|
||||
)
|
||||
|
||||
type TokenStore struct {
|
||||
db *gorm.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewTokenStore(db *gorm.DB) *TokenStore {
|
||||
return &TokenStore{
|
||||
db: db,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TokenStore) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
|
||||
if s == nil || s.db == nil {
|
||||
return errors.New("token store db is required")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return errors.New("token store key is required")
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
var expiresAt *time.Time
|
||||
if ttl > 0 {
|
||||
ts := now.Add(ttl)
|
||||
expiresAt = &ts
|
||||
}
|
||||
|
||||
entry := transient.TokenEntry{
|
||||
StoreKey: key,
|
||||
Value: append([]byte(nil), value...),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
return s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "store_key"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"value": entry.Value,
|
||||
"expires_at": entry.ExpiresAt,
|
||||
"updated_at": now,
|
||||
}),
|
||||
}).Create(&entry).Error
|
||||
}
|
||||
|
||||
func (s *TokenStore) SetIfNotExists(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, errors.New("token store db is required")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return false, errors.New("token store key is required")
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
var expiresAt *time.Time
|
||||
if ttl > 0 {
|
||||
ts := now.Add(ttl)
|
||||
expiresAt = &ts
|
||||
if err := s.db.WithContext(ctx).
|
||||
Delete(&transient.TokenEntry{}, "store_key = ? AND expires_at IS NOT NULL AND expires_at <= ?", key, now).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
entry := transient.TokenEntry{
|
||||
StoreKey: key,
|
||||
Value: append([]byte(nil), value...),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
tx := s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "store_key"}},
|
||||
DoNothing: true,
|
||||
}).Create(&entry)
|
||||
if tx.Error != nil {
|
||||
return false, tx.Error
|
||||
}
|
||||
return tx.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
func (s *TokenStore) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("token store db is required")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var entry transient.TokenEntry
|
||||
tx := s.db.WithContext(ctx).Where("store_key = ?", key).Limit(1).Find(&entry)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if entry.ExpiresAt != nil && !entry.ExpiresAt.After(s.now().UTC()) {
|
||||
_ = s.Delete(ctx, key)
|
||||
return nil, nil
|
||||
}
|
||||
return append([]byte(nil), entry.Value...), nil
|
||||
}
|
||||
|
||||
func (s *TokenStore) Delete(ctx context.Context, key string) error {
|
||||
if s == nil || s.db == nil {
|
||||
return errors.New("token store db is required")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).Delete(&transient.TokenEntry{}, "store_key = ?", key).Error
|
||||
}
|
||||
82
internal/repository/mysql/token_store_test.go
Normal file
82
internal/repository/mysql/token_store_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"wucher/internal/domain/transient"
|
||||
)
|
||||
|
||||
func TestTokenStore_SetGetDelete(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
baseTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := NewTokenStore(db)
|
||||
store.now = func() time.Time { return baseTime }
|
||||
|
||||
ctx := context.Background()
|
||||
if err := store.Set(ctx, "auth:test", []byte("value"), time.Minute); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Get(ctx, "auth:test")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if string(got) != "value" {
|
||||
t.Fatalf("unexpected value %q", got)
|
||||
}
|
||||
|
||||
store.now = func() time.Time { return baseTime.Add(2 * time.Minute) }
|
||||
got, err = store.Get(ctx, "auth:test")
|
||||
if err != nil {
|
||||
t.Fatalf("get expired: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected expired value to be cleared, got %q", got)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&transient.TokenEntry{}).Where("store_key = ?", "auth:test").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count expired token: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected expired token deleted, got %d rows", count)
|
||||
}
|
||||
|
||||
store.now = func() time.Time { return baseTime }
|
||||
if err := store.Set(ctx, "auth:persistent", []byte("keep"), 0); err != nil {
|
||||
t.Fatalf("set persistent: %v", err)
|
||||
}
|
||||
store.now = func() time.Time { return baseTime.Add(24 * time.Hour) }
|
||||
got, err = store.Get(ctx, "auth:persistent")
|
||||
if err != nil {
|
||||
t.Fatalf("get persistent: %v", err)
|
||||
}
|
||||
if string(got) != "keep" {
|
||||
t.Fatalf("unexpected persistent value %q", got)
|
||||
}
|
||||
|
||||
if err := store.Delete(ctx, "auth:persistent"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
got, err = store.Get(ctx, "auth:persistent")
|
||||
if err != nil {
|
||||
t.Fatalf("get after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected deleted value to be nil, got %q", got)
|
||||
}
|
||||
|
||||
got, err = store.Get(ctx, "auth:missing")
|
||||
if err != nil {
|
||||
t.Fatalf("get missing: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected missing value to be nil, got %q", got)
|
||||
}
|
||||
}
|
||||
226
internal/repository/mysql/user_repo.go
Normal file
226
internal/repository/mysql/user_repo.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/domain/user"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
u.CreatedBy = actor
|
||||
u.UpdatedBy = actor
|
||||
}
|
||||
if err := tx.Create(u).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceUserRolesTx(ctx, tx, u.ID, u.RoleID, u.RoleIDs)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(ctx context.Context, u *user.User) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
|
||||
u.UpdatedBy = actor
|
||||
}
|
||||
// Persist only user columns and avoid relation side-effects from preloaded structs.
|
||||
if err := tx.Omit(clause.Associations).Save(u).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep legacy and current image columns in sync when image patch is requested.
|
||||
if u.ProfileAttachmentPatchSet {
|
||||
updates := map[string]any{
|
||||
"profile_attachment_id": nil,
|
||||
"image_attachment_id": nil,
|
||||
}
|
||||
if len(u.ProfileAttachmentID) == 16 {
|
||||
updates["profile_attachment_id"] = append([]byte(nil), u.ProfileAttachmentID...)
|
||||
updates["image_attachment_id"] = append([]byte(nil), u.ProfileAttachmentID...)
|
||||
}
|
||||
if err := tx.Model(&user.User{}).Where("id = ?", u.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if u.RoleIDs == nil {
|
||||
return nil
|
||||
}
|
||||
return replaceUserRolesTx(ctx, tx, u.ID, u.RoleID, u.RoleIDs)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(ctx context.Context, id []byte) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&user.User{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"image_attachment_id": nil,
|
||||
"profile_attachment_id": nil,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if idStr, err := uuidv7.BytesToString(id); err == nil && idStr != "" {
|
||||
if err := tx.Exec("DELETE FROM attachments WHERE ref_type = ? AND ref_id = ?", "user_profile", idStr).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Where("user_id = ?", id).Delete(&auth.UserRole{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&user.User{}, "id = ?", id).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(ctx context.Context, id []byte) (*user.User, error) {
|
||||
var u user.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&user.User{}).
|
||||
Select("users.*, roles.name AS role_name, roles.description AS role_description").
|
||||
Joins("LEFT JOIN roles ON roles.id = users.role_id").
|
||||
Preload("ProfileAttachment").
|
||||
Preload("ProfileAttachment.File").
|
||||
Where("users.id = ?", id).
|
||||
First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := attachUserDomainRoles(ctx, r.db, &u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) {
|
||||
var u user.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&user.User{}).
|
||||
Select("users.*, roles.name AS role_name, roles.description AS role_description").
|
||||
Joins("LEFT JOIN roles ON roles.id = users.role_id").
|
||||
Preload("ProfileAttachment").
|
||||
Preload("ProfileAttachment.File").
|
||||
Where("users.email = ?", email).
|
||||
First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := attachUserDomainRoles(ctx, r.db, &u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*user.User, error) {
|
||||
var u user.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&user.User{}).
|
||||
Select("users.*, roles.name AS role_name, roles.description AS role_description").
|
||||
Joins("LEFT JOIN roles ON roles.id = users.role_id").
|
||||
Preload("ProfileAttachment").
|
||||
Preload("ProfileAttachment.File").
|
||||
Where("users.username = ?", username).
|
||||
First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := attachUserDomainRoles(ctx, r.db, &u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetLastNameByID(ctx context.Context, id []byte) (string, error) {
|
||||
var u user.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("users").
|
||||
Select("last_name").
|
||||
Where("id = ?", id).
|
||||
First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.LastName, nil
|
||||
}
|
||||
|
||||
// GetShortNameByID returns the user's profile short name (pilot or technician
|
||||
// 3-letter code, e.g. "GAN"), or an empty string when the user has no profile
|
||||
// short name set. Callers apply their own fallback (e.g. initials).
|
||||
func (r *UserRepository) GetShortNameByID(ctx context.Context, id []byte) (string, error) {
|
||||
var out struct {
|
||||
ShortName string `gorm:"column:short_name"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("users u").
|
||||
Select("COALESCE(NULLIF(pp.short_name, ''), NULLIF(tp.short_name, ''), '') AS short_name").
|
||||
Joins("LEFT JOIN pilot_profiles pp ON pp.user_id = u.id").
|
||||
Joins("LEFT JOIN technician_profiles tp ON tp.user_id = u.id").
|
||||
Where("u.id = ?", id).
|
||||
Take(&out).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.ShortName, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) List(ctx context.Context, filter string, sort string, limit, offset int) ([]user.User, int64, error) {
|
||||
var users []user.User
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).
|
||||
Model(&user.User{}).
|
||||
Joins("LEFT JOIN roles ON roles.id = users.role_id")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("users.email LIKE ? OR users.username LIKE ? OR users.first_name LIKE ? OR users.last_name LIKE ?", like, like, like, like)
|
||||
}
|
||||
query := base.Select("users.*, roles.name AS role_name, roles.description AS role_description")
|
||||
query = query.Preload("ProfileAttachment").Preload("ProfileAttachment.File")
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&users).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ptrs := make([]*user.User, 0, len(users))
|
||||
for i := range users {
|
||||
ptrs = append(ptrs, &users[i])
|
||||
}
|
||||
if err := attachUserDomainRoles(ctx, r.db, ptrs...); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return users, total, nil
|
||||
}
|
||||
205
internal/repository/mysql/user_repo_test.go
Normal file
205
internal/repository/mysql/user_repo_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/domain/user"
|
||||
"wucher/internal/shared/pkg/appctx"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func setupUserRepoFixture(t *testing.T) (*UserRepository, context.Context, []byte) {
|
||||
t.Helper()
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
repo := NewUserRepository(db)
|
||||
roleID := uuidv7.MustBytes()
|
||||
if err := db.Create(&auth.Role{
|
||||
ID: roleID,
|
||||
Code: "admin",
|
||||
Name: "admin",
|
||||
Description: "admin role",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed role: %v", err)
|
||||
}
|
||||
return repo, context.Background(), roleID
|
||||
}
|
||||
|
||||
func TestUserRepository_CRUDAndLookupBranches(t *testing.T) {
|
||||
repo, baseCtx, roleID := setupUserRepoFixture(t)
|
||||
actorID := uuidv7.MustBytes()
|
||||
ctx := appctx.WithUserID(baseCtx, actorID)
|
||||
|
||||
u := &user.User{
|
||||
Email: "john@example.com",
|
||||
Username: "john",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
Timezone: "UTC",
|
||||
RoleID: roleID,
|
||||
}
|
||||
if err := repo.Create(ctx, u); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if len(u.CreatedBy) != 16 || len(u.UpdatedBy) != 16 {
|
||||
t.Fatalf("expected created_by and updated_by set by actor")
|
||||
}
|
||||
|
||||
byEmail, err := repo.GetByEmail(ctx, u.Email)
|
||||
if err != nil || byEmail == nil {
|
||||
t.Fatalf("get by email failed: user=%v err=%v", byEmail, err)
|
||||
}
|
||||
byUsername, err := repo.GetByUsername(ctx, u.Username)
|
||||
if err != nil || byUsername == nil {
|
||||
t.Fatalf("get by username failed: user=%v err=%v", byUsername, err)
|
||||
}
|
||||
lastName, err := repo.GetLastNameByID(ctx, u.ID)
|
||||
if err != nil || lastName != "Doe" {
|
||||
t.Fatalf("get last name failed: lastName=%q err=%v", lastName, err)
|
||||
}
|
||||
|
||||
// Update branch: RoleIDs nil should skip role-replacement path.
|
||||
byEmail.FirstName = "Johnny"
|
||||
byEmail.RoleIDs = nil
|
||||
if err := repo.Update(ctx, byEmail); err != nil {
|
||||
t.Fatalf("update (roleIDs nil): %v", err)
|
||||
}
|
||||
|
||||
// Update branch: RoleIDs provided should execute role-replacement path.
|
||||
byEmail.LastName = "Doe-Updated"
|
||||
byEmail.RoleIDs = [][]byte{roleID}
|
||||
if err := repo.Update(ctx, byEmail); err != nil {
|
||||
t.Fatalf("update (roleIDs set): %v", err)
|
||||
}
|
||||
|
||||
// List branches: with/without filter, sort, and limit.
|
||||
list, total, err := repo.List(ctx, "john", "users.email ASC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list with filter: %v", err)
|
||||
}
|
||||
if total < 1 || len(list) != 1 {
|
||||
t.Fatalf("expected one filtered result, total=%d len=%d", total, len(list))
|
||||
}
|
||||
list, total, err = repo.List(ctx, "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list without limit: %v", err)
|
||||
}
|
||||
if total < 1 || len(list) < 1 {
|
||||
t.Fatalf("expected at least one user, total=%d len=%d", total, len(list))
|
||||
}
|
||||
|
||||
// Not found branches.
|
||||
if got, err := repo.GetByID(ctx, uuidv7.MustBytes()); err != nil || got != nil {
|
||||
t.Fatalf("expected not found by id => nil,nil; got=%v err=%v", got, err)
|
||||
}
|
||||
if got, err := repo.GetByEmail(ctx, "missing@example.com"); err != nil || got != nil {
|
||||
t.Fatalf("expected not found by email => nil,nil; got=%v err=%v", got, err)
|
||||
}
|
||||
if got, err := repo.GetByUsername(ctx, "missing-user"); err != nil || got != nil {
|
||||
t.Fatalf("expected not found by username => nil,nil; got=%v err=%v", got, err)
|
||||
}
|
||||
if got, err := repo.GetLastNameByID(ctx, uuidv7.MustBytes()); err != nil || got != "" {
|
||||
t.Fatalf("expected not found last_name => empty,nil; got=%q err=%v", got, err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, u.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
// Delete branch with invalid uuid bytes (skip attachment cleanup query branch).
|
||||
if err := repo.Delete(ctx, []byte("short")); err != nil {
|
||||
t.Fatalf("delete short id should be noop-success, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepository_ErrorBranchesWhenDatabaseClosed(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
repo := NewUserRepository(db)
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB: %v", err)
|
||||
}
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Fatalf("close db: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
roleID := uuidv7.MustBytes()
|
||||
userID := uuidv7.MustBytes()
|
||||
u := &user.User{
|
||||
ID: userID,
|
||||
Email: "closed@example.com",
|
||||
Username: "closed",
|
||||
FirstName: "Closed",
|
||||
LastName: "DB",
|
||||
Timezone: "UTC",
|
||||
RoleID: roleID,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, u); err == nil {
|
||||
t.Fatalf("expected create error on closed db")
|
||||
}
|
||||
if err := repo.Update(ctx, u); err == nil {
|
||||
t.Fatalf("expected update error on closed db")
|
||||
}
|
||||
if err := repo.Delete(ctx, userID); err == nil {
|
||||
t.Fatalf("expected delete error on closed db")
|
||||
}
|
||||
if _, err := repo.GetByID(ctx, userID); err == nil {
|
||||
t.Fatalf("expected get by id error on closed db")
|
||||
}
|
||||
if _, err := repo.GetByEmail(ctx, u.Email); err == nil {
|
||||
t.Fatalf("expected get by email error on closed db")
|
||||
}
|
||||
if _, err := repo.GetByUsername(ctx, u.Username); err == nil {
|
||||
t.Fatalf("expected get by username error on closed db")
|
||||
}
|
||||
if _, err := repo.GetLastNameByID(ctx, userID); err == nil {
|
||||
t.Fatalf("expected get last name error on closed db")
|
||||
}
|
||||
if _, _, err := repo.List(ctx, "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected list error on closed db")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepository_CreateDuplicateAndDeleteAttachmentCleanupError(t *testing.T) {
|
||||
repo, ctx, roleID := setupUserRepoFixture(t)
|
||||
|
||||
u := &user.User{
|
||||
Email: "dup@example.com",
|
||||
Username: "dup-user",
|
||||
FirstName: "Dup",
|
||||
LastName: "User",
|
||||
Timezone: "UTC",
|
||||
RoleID: roleID,
|
||||
}
|
||||
if err := repo.Create(ctx, u); err != nil {
|
||||
t.Fatalf("create first user: %v", err)
|
||||
}
|
||||
|
||||
dup := &user.User{
|
||||
Email: "dup@example.com",
|
||||
Username: "dup-user-2",
|
||||
FirstName: "Dup2",
|
||||
LastName: "User2",
|
||||
Timezone: "UTC",
|
||||
RoleID: roleID,
|
||||
}
|
||||
if err := repo.Create(ctx, dup); err == nil {
|
||||
t.Fatalf("expected duplicate-email create error")
|
||||
}
|
||||
|
||||
// Force attachment cleanup statement to fail so delete returns error branch.
|
||||
if err := repo.db.Exec("DROP TABLE attachments").Error; err != nil {
|
||||
t.Fatalf("drop attachments table: %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, u.ID); err == nil {
|
||||
t.Fatalf("expected delete error when attachments table missing")
|
||||
}
|
||||
}
|
||||
286
internal/repository/mysql/user_roles.go
Normal file
286
internal/repository/mysql/user_roles.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/domain/user"
|
||||
)
|
||||
|
||||
type userRoleJoinRow struct {
|
||||
UserID []byte `gorm:"column:user_id"`
|
||||
RoleID []byte `gorm:"column:role_id"`
|
||||
RoleName string `gorm:"column:role_name"`
|
||||
RoleDescription string `gorm:"column:role_description"`
|
||||
}
|
||||
|
||||
func normalizeAssignedRoleIDs(primaryRoleID []byte, roleIDs [][]byte) [][]byte {
|
||||
out := make([][]byte, 0, len(roleIDs)+1)
|
||||
seen := make(map[string]struct{}, len(roleIDs)+1)
|
||||
|
||||
appendRole := func(roleID []byte) {
|
||||
if len(roleID) != 16 {
|
||||
return
|
||||
}
|
||||
key := string(roleID)
|
||||
if _, exists := seen[key]; exists {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, append([]byte(nil), roleID...))
|
||||
}
|
||||
|
||||
appendRole(primaryRoleID)
|
||||
for i := range roleIDs {
|
||||
appendRole(roleIDs[i])
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func replaceUserRolesTx(ctx context.Context, tx *gorm.DB, userID, primaryRoleID []byte, roleIDs [][]byte) error {
|
||||
normalized := normalizeAssignedRoleIDs(primaryRoleID, roleIDs)
|
||||
if len(userID) != 16 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&auth.UserRole{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
actor := actorUserIDFromContext(ctx)
|
||||
items := make([]auth.UserRole, 0, len(normalized))
|
||||
for i := range normalized {
|
||||
item := auth.UserRole{
|
||||
UserID: userID,
|
||||
RoleID: normalized[i],
|
||||
}
|
||||
if len(actor) > 0 {
|
||||
item.CreatedBy = actor
|
||||
item.UpdatedBy = actor
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return tx.Create(&items).Error
|
||||
}
|
||||
|
||||
func listUserRoleIDsTx(tx *gorm.DB, userID []byte) ([][]byte, error) {
|
||||
var rows []struct {
|
||||
RoleID []byte `gorm:"column:role_id"`
|
||||
}
|
||||
if err := tx.Table("user_roles").
|
||||
Select("role_id").
|
||||
Where("user_id = ?", userID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleIDs := make([][]byte, 0, len(rows))
|
||||
for i := range rows {
|
||||
if len(rows[i].RoleID) != 16 {
|
||||
continue
|
||||
}
|
||||
roleIDs = append(roleIDs, append([]byte(nil), rows[i].RoleID...))
|
||||
}
|
||||
return roleIDs, nil
|
||||
}
|
||||
|
||||
func replacePrimaryUserRoleTx(ctx context.Context, tx *gorm.DB, userID, currentPrimaryRoleID, newPrimaryRoleID []byte) error {
|
||||
roleIDs, err := listUserRoleIDsTx(tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filtered := make([][]byte, 0, len(roleIDs))
|
||||
currentPrimaryKey := string(currentPrimaryRoleID)
|
||||
newPrimaryKey := string(newPrimaryRoleID)
|
||||
for i := range roleIDs {
|
||||
key := string(roleIDs[i])
|
||||
if key == currentPrimaryKey || key == newPrimaryKey {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, roleIDs[i])
|
||||
}
|
||||
|
||||
return replaceUserRolesTx(ctx, tx, userID, newPrimaryRoleID, filtered)
|
||||
}
|
||||
|
||||
func loadUserRoleRows(ctx context.Context, db *gorm.DB, userIDs [][]byte) (map[string][]userRoleJoinRow, error) {
|
||||
rowsByUserID := make(map[string][]userRoleJoinRow, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return rowsByUserID, nil
|
||||
}
|
||||
|
||||
var rows []userRoleJoinRow
|
||||
if err := db.WithContext(ctx).
|
||||
Table("user_roles ur").
|
||||
Select("ur.user_id, roles.id AS role_id, roles.name AS role_name, roles.description AS role_description").
|
||||
Joins("JOIN roles ON roles.id = ur.role_id").
|
||||
Where("ur.user_id IN ?", userIDs).
|
||||
Order("roles.name ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
key := string(rows[i].UserID)
|
||||
rowsByUserID[key] = append(rowsByUserID[key], userRoleJoinRow{
|
||||
UserID: append([]byte(nil), rows[i].UserID...),
|
||||
RoleID: append([]byte(nil), rows[i].RoleID...),
|
||||
RoleName: rows[i].RoleName,
|
||||
RoleDescription: rows[i].RoleDescription,
|
||||
})
|
||||
}
|
||||
|
||||
return rowsByUserID, nil
|
||||
}
|
||||
|
||||
func orderUserRoleRows(primaryRoleID []byte, rows []userRoleJoinRow) []userRoleJoinRow {
|
||||
if len(rows) < 2 || len(primaryRoleID) != 16 {
|
||||
return rows
|
||||
}
|
||||
|
||||
primaryKey := string(primaryRoleID)
|
||||
ordered := make([]userRoleJoinRow, 0, len(rows))
|
||||
for i := range rows {
|
||||
if string(rows[i].RoleID) == primaryKey {
|
||||
ordered = append(ordered, rows[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
for i := range rows {
|
||||
if string(rows[i].RoleID) == primaryKey {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, rows[i])
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func loadRoleRowsByID(ctx context.Context, db *gorm.DB, roleIDs [][]byte) (map[string]userRoleJoinRow, error) {
|
||||
out := make(map[string]userRoleJoinRow, len(roleIDs))
|
||||
if len(roleIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
Name string `gorm:"column:name"`
|
||||
Description string `gorm:"column:description"`
|
||||
}
|
||||
if err := db.WithContext(ctx).
|
||||
Table("roles").
|
||||
Select("id, name, description").
|
||||
Where("id IN ?", roleIDs).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
out[string(rows[i].ID)] = userRoleJoinRow{
|
||||
RoleID: append([]byte(nil), rows[i].ID...),
|
||||
RoleName: rows[i].Name,
|
||||
RoleDescription: rows[i].Description,
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func attachAuthUserRoles(ctx context.Context, db *gorm.DB, users ...*auth.User) error {
|
||||
userIDs := make([][]byte, 0, len(users))
|
||||
missingPrimaryRoleIDs := make([][]byte, 0, len(users))
|
||||
for i := range users {
|
||||
if users[i] == nil || len(users[i].ID) != 16 {
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, users[i].ID)
|
||||
if len(users[i].RoleID) == 16 {
|
||||
missingPrimaryRoleIDs = append(missingPrimaryRoleIDs, users[i].RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
rowsByUserID, err := loadUserRoleRows(ctx, db, userIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roleRowsByID, err := loadRoleRowsByID(ctx, db, missingPrimaryRoleIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range users {
|
||||
if users[i] == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
rows := orderUserRoleRows(users[i].RoleID, rowsByUserID[string(users[i].ID)])
|
||||
if len(rows) == 0 && len(users[i].RoleID) == 16 {
|
||||
if roleRow, ok := roleRowsByID[string(users[i].RoleID)]; ok {
|
||||
rows = []userRoleJoinRow{roleRow}
|
||||
}
|
||||
}
|
||||
|
||||
users[i].RoleIDs = make([][]byte, 0, len(rows))
|
||||
users[i].Roles = make([]auth.Role, 0, len(rows))
|
||||
for j := range rows {
|
||||
users[i].RoleIDs = append(users[i].RoleIDs, append([]byte(nil), rows[j].RoleID...))
|
||||
users[i].Roles = append(users[i].Roles, auth.Role{
|
||||
ID: append([]byte(nil), rows[j].RoleID...),
|
||||
Name: rows[j].RoleName,
|
||||
Description: rows[j].RoleDescription,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func attachUserDomainRoles(ctx context.Context, db *gorm.DB, users ...*user.User) error {
|
||||
userIDs := make([][]byte, 0, len(users))
|
||||
for i := range users {
|
||||
if users[i] == nil || len(users[i].ID) != 16 {
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, users[i].ID)
|
||||
}
|
||||
|
||||
rowsByUserID, err := loadUserRoleRows(ctx, db, userIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range users {
|
||||
if users[i] == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
rows := orderUserRoleRows(users[i].RoleID, rowsByUserID[string(users[i].ID)])
|
||||
if len(rows) == 0 && len(users[i].RoleID) == 16 {
|
||||
rows = []userRoleJoinRow{{
|
||||
RoleID: append([]byte(nil), users[i].RoleID...),
|
||||
RoleName: users[i].RoleName,
|
||||
RoleDescription: users[i].RoleDescription,
|
||||
}}
|
||||
}
|
||||
|
||||
users[i].RoleIDs = make([][]byte, 0, len(rows))
|
||||
users[i].Roles = make([]user.RoleSummary, 0, len(rows))
|
||||
for j := range rows {
|
||||
users[i].RoleIDs = append(users[i].RoleIDs, append([]byte(nil), rows[j].RoleID...))
|
||||
users[i].Roles = append(users[i].Roles, user.RoleSummary{
|
||||
ID: append([]byte(nil), rows[j].RoleID...),
|
||||
Name: rows[j].RoleName,
|
||||
Description: rows[j].RoleDescription,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
101
internal/repository/mysql/user_roles_test.go
Normal file
101
internal/repository/mysql/user_roles_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/domain/user"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func TestUserRepository_SyncsAndLoadsUserRoles(t *testing.T) {
|
||||
db := openDBTestSQLite(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
pilotRoleID := uuidv7.MustBytes()
|
||||
chiefPilotRoleID := uuidv7.MustBytes()
|
||||
staffRoleID := uuidv7.MustBytes()
|
||||
roles := []auth.Role{
|
||||
{ID: pilotRoleID, Code: "pilot", Name: "pilot", Description: "Pilot"},
|
||||
{ID: chiefPilotRoleID, Code: "chief_pilot", Name: "chief_pilot", Description: "Chief Pilot"},
|
||||
{ID: staffRoleID, Code: "staff", Name: "staff", Description: "Staff"},
|
||||
}
|
||||
if err := db.Create(&roles).Error; err != nil {
|
||||
t.Fatalf("create roles: %v", err)
|
||||
}
|
||||
|
||||
repo := NewUserRepository(db)
|
||||
ctx := context.Background()
|
||||
u := &user.User{
|
||||
Email: "multi-role@example.com",
|
||||
Username: "multi.role",
|
||||
FirstName: "Multi",
|
||||
LastName: "Role",
|
||||
Timezone: "UTC",
|
||||
RoleID: pilotRoleID,
|
||||
RoleIDs: [][]byte{pilotRoleID, chiefPilotRoleID},
|
||||
}
|
||||
if err := repo.Create(ctx, u); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&auth.UserRole{}).Where("user_id = ?", u.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count user_roles: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 user_roles rows, got %d", count)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected user")
|
||||
}
|
||||
if len(got.RoleIDs) != 2 || len(got.Roles) != 2 {
|
||||
t.Fatalf("expected two roles loaded, got roleIDs=%d roles=%d", len(got.RoleIDs), len(got.Roles))
|
||||
}
|
||||
if got.Roles[0].Name != "pilot" {
|
||||
t.Fatalf("expected primary role first, got %q", got.Roles[0].Name)
|
||||
}
|
||||
|
||||
list, total, err := repo.List(ctx, "", "", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list users: %v", err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 || len(list[0].Roles) != 2 {
|
||||
t.Fatalf("expected list to include two roles, total=%d len=%d roles=%d", total, len(list), len(list[0].Roles))
|
||||
}
|
||||
|
||||
got.RoleID = staffRoleID
|
||||
got.RoleIDs = [][]byte{staffRoleID}
|
||||
if err := repo.Update(ctx, got); err != nil {
|
||||
t.Fatalf("update user roles: %v", err)
|
||||
}
|
||||
|
||||
got, err = repo.GetByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after update: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected user after update")
|
||||
}
|
||||
if len(got.Roles) != 1 || got.Roles[0].Name != "staff" {
|
||||
t.Fatalf("expected roles replaced with staff, got %+v", got.Roles)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, u.ID); err != nil {
|
||||
t.Fatalf("delete user: %v", err)
|
||||
}
|
||||
if err := db.Model(&auth.UserRole{}).Where("user_id = ?", u.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count user_roles after delete: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected user_roles deleted, got %d", count)
|
||||
}
|
||||
}
|
||||
88
internal/repository/mysql/vocation_repo.go
Normal file
88
internal/repository/mysql/vocation_repo.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/vocation"
|
||||
"wucher/internal/shared/pkg/sortkey"
|
||||
)
|
||||
|
||||
type VocationRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewVocationRepository(db *gorm.DB) *VocationRepository {
|
||||
return &VocationRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *VocationRepository) Create(ctx context.Context, v *vocation.Vocation) error {
|
||||
requestedIsActive := v.IsActive
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.Create(v).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&vocation.Vocation{}).Where("id = ?", v.ID).UpdateColumn("is_active", requestedIsActive).Error
|
||||
}
|
||||
|
||||
func (r *VocationRepository) Update(ctx context.Context, v *vocation.Vocation) error {
|
||||
return r.db.WithContext(ctx).Save(v).Error
|
||||
}
|
||||
|
||||
func (r *VocationRepository) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
|
||||
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "vocations", id); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"deleted_at": now,
|
||||
"deleted_by": deletedBy,
|
||||
"updated_by": deletedBy,
|
||||
}
|
||||
return mapDeleteConstraintError(r.db.WithContext(ctx).
|
||||
Model(&vocation.Vocation{}).
|
||||
Where("id = ? AND deleted_at IS NULL", id).
|
||||
Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (r *VocationRepository) GetByID(ctx context.Context, id []byte) (*vocation.Vocation, error) {
|
||||
var row vocation.Vocation
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (r *VocationRepository) List(ctx context.Context, filter, sort string, limit, offset int) ([]vocation.Vocation, int64, error) {
|
||||
var rows []vocation.Vocation
|
||||
var total int64
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&vocation.Vocation{}).Where("deleted_at IS NULL")
|
||||
if filter != "" {
|
||||
like := "%" + filter + "%"
|
||||
base = base.Where("name LIKE ? OR note LIKE ?", like, like)
|
||||
}
|
||||
|
||||
query := base
|
||||
if sort != "" {
|
||||
query = query.Order(sort)
|
||||
} else {
|
||||
for _, clause := range sortkey.ActivePositiveSortClauses("vocations", "is_active", "sortkey", "name", false) {
|
||||
query = query.Order(clause)
|
||||
}
|
||||
}
|
||||
if err := base.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
238
internal/repository/mysql/vocation_repo_test.go
Normal file
238
internal/repository/mysql/vocation_repo_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/domain/vocation"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openVocationTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:vocation_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&vocation.Vocation{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewVocationRepository(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
if repo == nil || repo.db == nil {
|
||||
t.Fatalf("expected repository initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVocationRepositoryCreate(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
row := &vocation.Vocation{Name: "Main"}
|
||||
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(row.ID) == 0 {
|
||||
t.Fatalf("expected id set")
|
||||
}
|
||||
inactive := &vocation.Vocation{Name: "Inactive", IsActive: false}
|
||||
if err := repo.Create(context.Background(), inactive); err != nil {
|
||||
t.Fatalf("create inactive: %v", err)
|
||||
}
|
||||
gotInactive, err := repo.GetByID(context.Background(), inactive.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get inactive: %v", err)
|
||||
}
|
||||
if gotInactive == nil || gotInactive.IsActive {
|
||||
t.Fatalf("expected inactive vocation persisted as false")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Create(context.Background(), &vocation.Vocation{Name: "AfterClose"}); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVocationRepositoryUpdate(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
row := &vocation.Vocation{Name: "Old"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
row.Name = "New"
|
||||
if err := repo.Update(context.Background(), row); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
loaded, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil || loaded == nil || loaded.Name != "New" {
|
||||
t.Fatalf("expected updated row")
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
if err := repo.Update(context.Background(), row); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVocationRepositoryDelete(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
row := &vocation.Vocation{Name: "DeleteMe"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
deletedBy := uuidv7.MustBytes()
|
||||
|
||||
if err := repo.Delete(context.Background(), row.ID, deletedBy); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected soft deleted row hidden from GetByID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVocationRepositoryGetByID(t *testing.T) {
|
||||
t.Run("found", func(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
row := &vocation.Vocation{Name: "Found"}
|
||||
if err := repo.Create(context.Background(), row); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "Found" {
|
||||
t.Fatalf("expected row found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
got, err := repo.GetByID(context.Background(), uuidv7.MustBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db error", func(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
|
||||
if _, err := repo.GetByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
||||
t.Fatalf("expected error on closed DB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVocationRepositoryList(t *testing.T) {
|
||||
t.Run("success without limit", func(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "C"})
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "A"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 2 || len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, total=%d len=%d", total, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with filter sort and limit", func(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "Main Base"})
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "Backup"})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "Main", "name DESC", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 || rows[0].Name != "Main Base" {
|
||||
t.Fatalf("unexpected list result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("count error", func(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
if err := db.Migrator().DropTable(&vocation.Vocation{}); err != nil {
|
||||
t.Fatalf("drop table: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "", 10, 0); err == nil {
|
||||
t.Fatalf("expected count error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find error", func(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "Main"})
|
||||
|
||||
if _, _, err := repo.List(context.Background(), "", "name ASC, )", 10, 0); err == nil {
|
||||
t.Fatalf("expected find error from invalid sort")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default order active sortkey first and inactive last", func(t *testing.T) {
|
||||
db := openVocationTestDB(t)
|
||||
repo := NewVocationRepository(db)
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "Gamma", IsActive: true})
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "Beta", SortKey: intPtrVocationRepo(0), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "Charlie", SortKey: intPtrVocationRepo(2), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "Alpha", SortKey: intPtrVocationRepo(1), IsActive: true})
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "Zulu", IsActive: false})
|
||||
_ = repo.Create(context.Background(), &vocation.Vocation{Name: "Bravo", SortKey: intPtrVocationRepo(9), IsActive: false})
|
||||
|
||||
rows, total, err := repo.List(context.Background(), "", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 6 || len(rows) != 6 {
|
||||
t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
gotOrder := []string{rows[0].Name, rows[1].Name, rows[2].Name, rows[3].Name, rows[4].Name, rows[5].Name}
|
||||
wantOrder := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"}
|
||||
for i := range wantOrder {
|
||||
if gotOrder[i] != wantOrder[i] {
|
||||
t.Fatalf("unexpected default order: got=%v want=%v", gotOrder, wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func intPtrVocationRepo(v int) *int { return &v }
|
||||
430
internal/repository/s3/file_object_storage.go
Normal file
430
internal/repository/s3/file_object_storage.go
Normal file
@@ -0,0 +1,430 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
|
||||
"wucher/internal/config"
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/shared/pkg/metrics"
|
||||
)
|
||||
|
||||
type FileObjectStorage struct {
|
||||
cfg config.FileStorageConfig
|
||||
client *awss3.Client
|
||||
presigner *awss3.PresignClient
|
||||
component string
|
||||
}
|
||||
|
||||
func NewFileObjectStorage(ctx context.Context, cfg config.FileStorageConfig) (*FileObjectStorage, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loadOptions := make([]func(*awsconfig.LoadOptions) error, 0, 3)
|
||||
if cfg.Region != "" {
|
||||
loadOptions = append(loadOptions, awsconfig.WithRegion(cfg.Region))
|
||||
}
|
||||
if strings.TrimSpace(cfg.Endpoint) != "" &&
|
||||
strings.TrimSpace(cfg.AccessKeyID) != "" &&
|
||||
strings.TrimSpace(cfg.SecretAccessKey) != "" {
|
||||
loadOptions = append(loadOptions, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||
strings.TrimSpace(cfg.AccessKeyID),
|
||||
strings.TrimSpace(cfg.SecretAccessKey),
|
||||
"",
|
||||
)))
|
||||
}
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := awss3.NewFromConfig(awsCfg, func(options *awss3.Options) {
|
||||
options.UsePathStyle = cfg.ForcePathStyle
|
||||
if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
|
||||
options.BaseEndpoint = awsv2.String(endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
storage := &FileObjectStorage{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
presigner: awss3.NewPresignClient(client),
|
||||
component: "api",
|
||||
}
|
||||
|
||||
if cfg.AutoCreateBucket {
|
||||
if err := storage.EnsureBucket(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
func (s *FileObjectStorage) EnsureBucket(ctx context.Context) error {
|
||||
headStarted := time.Now()
|
||||
if _, err := s.client.HeadBucket(ctx, &awss3.HeadBucketInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
}); err == nil {
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "HeadBucket", nil, headStarted)
|
||||
return nil
|
||||
} else {
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "HeadBucket", err, headStarted)
|
||||
}
|
||||
|
||||
input := &awss3.CreateBucketInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
}
|
||||
if s.cfg.Region != "" && s.cfg.Region != "us-east-1" {
|
||||
input.CreateBucketConfiguration = &types.CreateBucketConfiguration{
|
||||
LocationConstraint: types.BucketLocationConstraint(s.cfg.Region),
|
||||
}
|
||||
}
|
||||
|
||||
createStarted := time.Now()
|
||||
_, err := s.client.CreateBucket(ctx, input)
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "CreateBucket", err, createStarted)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if isBucketAlreadyOwnedByYou(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileObjectStorage) PutObject(ctx context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
|
||||
objectKey := buildObjectKey(s.cfg.ObjectKeyPrefix, input.ObjectKey)
|
||||
|
||||
started := time.Now()
|
||||
output, err := s.client.PutObject(ctx, &awss3.PutObjectInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
Key: awsv2.String(objectKey),
|
||||
Body: input.Body,
|
||||
ContentType: optionalString(input.ContentType),
|
||||
Metadata: input.Metadata,
|
||||
})
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "PutObject", err, started)
|
||||
metrics.ObserveFileUpload(s.component, err)
|
||||
if err == nil {
|
||||
metrics.ObserveAWSTransferredBytes(s.component, "s3", "PutObject", "upload", input.SizeBytes)
|
||||
}
|
||||
if err != nil {
|
||||
return filemanager.ObjectMetadata{}, err
|
||||
}
|
||||
|
||||
return filemanager.ObjectMetadata{
|
||||
Bucket: s.cfg.Bucket,
|
||||
ObjectKey: objectKey,
|
||||
ETag: awsv2.ToString(output.ETag),
|
||||
VersionID: awsv2.ToString(output.VersionId),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *FileObjectStorage) CopyObject(ctx context.Context, sourceObjectKey, destinationObjectKey string) (filemanager.ObjectMetadata, error) {
|
||||
sourceKey := buildObjectKey(s.cfg.ObjectKeyPrefix, sourceObjectKey)
|
||||
destinationKey := buildObjectKey(s.cfg.ObjectKeyPrefix, destinationObjectKey)
|
||||
|
||||
started := time.Now()
|
||||
_, err := s.client.CopyObject(ctx, &awss3.CopyObjectInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
CopySource: awsv2.String(url.PathEscape(s.cfg.Bucket + "/" + sourceKey)),
|
||||
Key: awsv2.String(destinationKey),
|
||||
MetadataDirective: types.MetadataDirectiveCopy,
|
||||
})
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "CopyObject", err, started)
|
||||
if err != nil {
|
||||
return filemanager.ObjectMetadata{}, err
|
||||
}
|
||||
return s.HeadObject(ctx, destinationObjectKey)
|
||||
}
|
||||
|
||||
func (s *FileObjectStorage) DeleteObject(ctx context.Context, objectKey string) error {
|
||||
started := time.Now()
|
||||
_, err := s.client.DeleteObject(ctx, &awss3.DeleteObjectInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
Key: awsv2.String(buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)),
|
||||
})
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "DeleteObject", err, started)
|
||||
metrics.ObserveFileDelete(s.component, err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileObjectStorage) PutObjectTagging(ctx context.Context, objectKey string, tags map[string]string) error {
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
|
||||
tagSet := make([]types.Tag, 0, len(tags))
|
||||
for k, v := range tags {
|
||||
keyTag := strings.TrimSpace(k)
|
||||
valueTag := strings.TrimSpace(v)
|
||||
if keyTag == "" || valueTag == "" {
|
||||
continue
|
||||
}
|
||||
tagSet = append(tagSet, types.Tag{
|
||||
Key: awsv2.String(keyTag),
|
||||
Value: awsv2.String(valueTag),
|
||||
})
|
||||
}
|
||||
if len(tagSet) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
_, err := s.client.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
Key: awsv2.String(key),
|
||||
Tagging: &types.Tagging{TagSet: tagSet},
|
||||
})
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "PutObjectTagging", err, started)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileObjectStorage) DeleteObjectTaggingKeys(ctx context.Context, objectKey string, keys []string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
|
||||
|
||||
started := time.Now()
|
||||
existing, err := s.client.GetObjectTagging(ctx, &awss3.GetObjectTaggingInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
Key: awsv2.String(key),
|
||||
})
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "GetObjectTagging", err, started)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed := make(map[string]struct{}, len(keys))
|
||||
for i := range keys {
|
||||
k := strings.TrimSpace(keys[i])
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
removed[k] = struct{}{}
|
||||
}
|
||||
tagSet := make([]types.Tag, 0, len(existing.TagSet))
|
||||
for i := range existing.TagSet {
|
||||
tagKey := strings.TrimSpace(awsv2.ToString(existing.TagSet[i].Key))
|
||||
if _, drop := removed[tagKey]; drop {
|
||||
continue
|
||||
}
|
||||
tagSet = append(tagSet, existing.TagSet[i])
|
||||
}
|
||||
|
||||
started = time.Now()
|
||||
_, err = s.client.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
Key: awsv2.String(key),
|
||||
Tagging: &types.Tagging{TagSet: tagSet},
|
||||
})
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "PutObjectTagging", err, started)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FileObjectStorage) HeadObject(ctx context.Context, objectKey string) (filemanager.ObjectMetadata, error) {
|
||||
key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
|
||||
started := time.Now()
|
||||
output, err := s.client.HeadObject(ctx, &awss3.HeadObjectInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
Key: awsv2.String(key),
|
||||
})
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "HeadObject", err, started)
|
||||
metrics.ObserveFileHead(s.component, err)
|
||||
if err != nil {
|
||||
if isObjectNotFoundError(err) {
|
||||
return filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound
|
||||
}
|
||||
return filemanager.ObjectMetadata{}, err
|
||||
}
|
||||
|
||||
return filemanager.ObjectMetadata{
|
||||
Bucket: s.cfg.Bucket,
|
||||
ObjectKey: key,
|
||||
ETag: awsv2.ToString(output.ETag),
|
||||
VersionID: awsv2.ToString(output.VersionId),
|
||||
SizeBytes: awsv2.ToInt64(output.ContentLength),
|
||||
LastModified: output.LastModified,
|
||||
ContentType: awsv2.ToString(output.ContentType),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *FileObjectStorage) GetObject(ctx context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
|
||||
key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
|
||||
started := time.Now()
|
||||
output, err := s.client.GetObject(ctx, &awss3.GetObjectInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
Key: awsv2.String(key),
|
||||
})
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "GetObject", err, started)
|
||||
if err != nil {
|
||||
if isObjectNotFoundError(err) {
|
||||
return nil, filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound
|
||||
}
|
||||
return nil, filemanager.ObjectMetadata{}, err
|
||||
}
|
||||
|
||||
metrics.ObserveAWSTransferredBytes(s.component, "s3", "GetObject", "download", awsv2.ToInt64(output.ContentLength))
|
||||
return output.Body, filemanager.ObjectMetadata{
|
||||
Bucket: s.cfg.Bucket,
|
||||
ObjectKey: key,
|
||||
ETag: awsv2.ToString(output.ETag),
|
||||
VersionID: awsv2.ToString(output.VersionId),
|
||||
SizeBytes: awsv2.ToInt64(output.ContentLength),
|
||||
LastModified: output.LastModified,
|
||||
ContentType: awsv2.ToString(output.ContentType),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *FileObjectStorage) PresignPutObject(ctx context.Context, input filemanager.PresignPutObjectInput) (filemanager.PresignedPutObject, error) {
|
||||
key := buildObjectKey(s.cfg.ObjectKeyPrefix, input.ObjectKey)
|
||||
ttl := input.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = s.cfg.PresignPutTTL
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 15 * time.Minute
|
||||
}
|
||||
|
||||
putInput := &awss3.PutObjectInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
Key: awsv2.String(key),
|
||||
ContentType: optionalString(input.ContentType),
|
||||
Metadata: input.Metadata,
|
||||
}
|
||||
|
||||
output, err := s.presigner.PresignPutObject(ctx, putInput, func(options *awss3.PresignOptions) {
|
||||
options.Expires = ttl
|
||||
})
|
||||
if err != nil {
|
||||
return filemanager.PresignedPutObject{}, err
|
||||
}
|
||||
|
||||
return filemanager.PresignedPutObject{
|
||||
Bucket: s.cfg.Bucket,
|
||||
ObjectKey: key,
|
||||
Method: output.Method,
|
||||
URL: output.URL,
|
||||
Headers: canonicalSignedHeaders(output.SignedHeader),
|
||||
ExpiresAt: time.Now().UTC().Add(ttl),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *FileObjectStorage) PresignGetObject(ctx context.Context, objectKey string, ttl time.Duration) (string, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = s.cfg.PresignGetTTL
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
output, err := s.presigner.PresignGetObject(ctx, &awss3.GetObjectInput{
|
||||
Bucket: awsv2.String(s.cfg.Bucket),
|
||||
Key: awsv2.String(buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)),
|
||||
}, func(options *awss3.PresignOptions) {
|
||||
options.Expires = ttl
|
||||
})
|
||||
metrics.ObserveAWSRequest(s.component, "s3", "GetObject", err, started)
|
||||
metrics.ObserveFilePresignDownload(s.component, err)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return output.URL, nil
|
||||
}
|
||||
|
||||
func validateConfig(cfg config.FileStorageConfig) error {
|
||||
if strings.ToLower(strings.TrimSpace(cfg.Provider)) != "s3" {
|
||||
return errors.New("unsupported file storage provider")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Bucket) == "" {
|
||||
return errors.New("file storage bucket is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Region) == "" {
|
||||
return errors.New("file storage region is required")
|
||||
}
|
||||
if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parsed.Scheme != "https" && !cfg.AllowInsecureEndpoint {
|
||||
return errors.New("insecure file storage endpoint is disabled")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildObjectKey(prefix, objectKey string) string {
|
||||
objectKey = strings.TrimSpace(objectKey)
|
||||
objectKey = strings.TrimPrefix(objectKey, "/")
|
||||
objectKey = strings.TrimPrefix(objectKey, "./")
|
||||
if objectKey == "" {
|
||||
return objectKey
|
||||
}
|
||||
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
if prefix == "" {
|
||||
return objectKey
|
||||
}
|
||||
if objectKey == prefix || strings.HasPrefix(objectKey, prefix+"/") {
|
||||
return objectKey
|
||||
}
|
||||
return prefix + "/" + objectKey
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return awsv2.String(value)
|
||||
}
|
||||
|
||||
func isBucketAlreadyOwnedByYou(err error) bool {
|
||||
var apiErr smithy.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return false
|
||||
}
|
||||
return apiErr.ErrorCode() == "BucketAlreadyOwnedByYou" || apiErr.ErrorCode() == "BucketAlreadyExists"
|
||||
}
|
||||
|
||||
func isObjectNotFoundError(err error) bool {
|
||||
var apiErr smithy.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(apiErr.ErrorCode()) {
|
||||
case "NotFound", "NoSuchKey", "404":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalSignedHeaders(header http.Header) map[string]string {
|
||||
if len(header) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(header))
|
||||
for key, values := range header {
|
||||
k := strings.TrimSpace(strings.ToLower(key))
|
||||
if k == "" || len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
out[k] = strings.TrimSpace(values[0])
|
||||
}
|
||||
return out
|
||||
}
|
||||
76
internal/repository/s3/file_object_storage_test.go
Normal file
76
internal/repository/s3/file_object_storage_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"wucher/internal/config"
|
||||
)
|
||||
|
||||
func TestValidateConfig(t *testing.T) {
|
||||
valid := config.FileStorageConfig{
|
||||
Provider: "s3",
|
||||
Bucket: "wucher-file-dev",
|
||||
Region: "ap-southeast-1",
|
||||
Endpoint: "http://localhost:4566",
|
||||
AllowInsecureEndpoint: true,
|
||||
}
|
||||
|
||||
if err := validateConfig(valid); err != nil {
|
||||
t.Fatalf("expected valid config, got %v", err)
|
||||
}
|
||||
|
||||
invalidProvider := valid
|
||||
invalidProvider.Provider = "local"
|
||||
if err := validateConfig(invalidProvider); err == nil {
|
||||
t.Fatalf("expected invalid provider error")
|
||||
}
|
||||
|
||||
missingBucket := valid
|
||||
missingBucket.Bucket = ""
|
||||
if err := validateConfig(missingBucket); err == nil {
|
||||
t.Fatalf("expected missing bucket error")
|
||||
}
|
||||
|
||||
invalidEndpoint := valid
|
||||
invalidEndpoint.Endpoint = "://bad-url"
|
||||
if err := validateConfig(invalidEndpoint); err == nil {
|
||||
t.Fatalf("expected invalid endpoint error")
|
||||
}
|
||||
|
||||
httpEndpointBlocked := valid
|
||||
httpEndpointBlocked.AllowInsecureEndpoint = false
|
||||
if err := validateConfig(httpEndpointBlocked); err == nil {
|
||||
t.Fatalf("expected insecure endpoint error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildObjectKey(t *testing.T) {
|
||||
if got := buildObjectKey("fm/objects", "abc/123"); got != "fm/objects/abc/123" {
|
||||
t.Fatalf("unexpected key: %q", got)
|
||||
}
|
||||
if got := buildObjectKey("fm/objects/", "/abc/123"); got != "fm/objects/abc/123" {
|
||||
t.Fatalf("unexpected key with slashes: %q", got)
|
||||
}
|
||||
if got := buildObjectKey("fm/objects", "fm/objects/abc/123"); got != "fm/objects/abc/123" {
|
||||
t.Fatalf("unexpected key with existing prefix: %q", got)
|
||||
}
|
||||
if got := buildObjectKey("", "abc/123"); got != "abc/123" {
|
||||
t.Fatalf("unexpected key without prefix: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalSignedHeaders(t *testing.T) {
|
||||
headers := http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"X-Amz-Date": []string{"20260405T000000Z"},
|
||||
}
|
||||
|
||||
got := canonicalSignedHeaders(headers)
|
||||
if got["content-type"] != "application/pdf" {
|
||||
t.Fatalf("unexpected content-type header: %q", got["content-type"])
|
||||
}
|
||||
if got["x-amz-date"] != "20260405T000000Z" {
|
||||
t.Fatalf("unexpected x-amz-date header: %q", got["x-amz-date"])
|
||||
}
|
||||
}
|
||||
356
internal/repository/sqs/email_queue.go
Normal file
356
internal/repository/sqs/email_queue.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package sqs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
|
||||
|
||||
appconfig "wucher/internal/config"
|
||||
"wucher/internal/shared/pkg/metrics"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
type API interface {
|
||||
SendMessage(ctx context.Context, params *sqs.SendMessageInput, optFns ...func(*sqs.Options)) (*sqs.SendMessageOutput, error)
|
||||
ReceiveMessage(ctx context.Context, params *sqs.ReceiveMessageInput, optFns ...func(*sqs.Options)) (*sqs.ReceiveMessageOutput, error)
|
||||
DeleteMessageBatch(ctx context.Context, params *sqs.DeleteMessageBatchInput, optFns ...func(*sqs.Options)) (*sqs.DeleteMessageBatchOutput, error)
|
||||
ChangeMessageVisibility(ctx context.Context, params *sqs.ChangeMessageVisibilityInput, optFns ...func(*sqs.Options)) (*sqs.ChangeMessageVisibilityOutput, error)
|
||||
GetQueueAttributes(ctx context.Context, params *sqs.GetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error)
|
||||
}
|
||||
|
||||
type EmailQueue struct {
|
||||
api API
|
||||
queueURL string
|
||||
queueName string
|
||||
component string
|
||||
queueType string
|
||||
messageGroupID string
|
||||
maxMessages int
|
||||
waitTimeSeconds int32
|
||||
visibilityTimeout int32
|
||||
}
|
||||
|
||||
type QueueOption func(*EmailQueue)
|
||||
|
||||
func WithMetricsComponent(component string) QueueOption {
|
||||
return func(q *EmailQueue) {
|
||||
if q == nil {
|
||||
return
|
||||
}
|
||||
component = strings.TrimSpace(component)
|
||||
if component == "" {
|
||||
return
|
||||
}
|
||||
q.component = component
|
||||
}
|
||||
}
|
||||
|
||||
func NewClient(ctx context.Context, cfg appconfig.SQSConfig) (*sqs.Client, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loadOptions := make([]func(*awsconfig.LoadOptions) error, 0, 1)
|
||||
if strings.TrimSpace(cfg.Region) != "" {
|
||||
loadOptions = append(loadOptions, awsconfig.WithRegion(strings.TrimSpace(cfg.Region)))
|
||||
}
|
||||
if key := strings.TrimSpace(cfg.AccessKeyID); key != "" {
|
||||
secret := strings.TrimSpace(cfg.SecretAccessKey)
|
||||
loadOptions = append(loadOptions, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(key, secret, "")))
|
||||
}
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := sqs.NewFromConfig(awsCfg, func(options *sqs.Options) {
|
||||
if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
|
||||
options.EndpointResolver = sqs.EndpointResolverFromURL(endpoint)
|
||||
}
|
||||
})
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func NewEmailQueue(api API, cfg appconfig.SQSConfig, opts ...QueueOption) (*EmailQueue, error) {
|
||||
return NewQueue(api, cfg, opts...)
|
||||
}
|
||||
|
||||
func NewQueue(api API, cfg appconfig.SQSConfig, opts ...QueueOption) (*EmailQueue, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if api == nil {
|
||||
return nil, errors.New("sqs client is required")
|
||||
}
|
||||
waitTimeSeconds := cfg.WaitTimeSeconds
|
||||
if waitTimeSeconds <= 0 {
|
||||
waitTimeSeconds = 20
|
||||
}
|
||||
if waitTimeSeconds > 20 {
|
||||
waitTimeSeconds = 20
|
||||
}
|
||||
maxMessages := cfg.MaxMessages
|
||||
if maxMessages <= 0 {
|
||||
maxMessages = 10
|
||||
}
|
||||
if maxMessages > 10 {
|
||||
maxMessages = 10
|
||||
}
|
||||
visibilityTimeout := int32(cfg.VisibilityTimeout.Seconds())
|
||||
if visibilityTimeout <= 0 {
|
||||
visibilityTimeout = 60
|
||||
}
|
||||
if visibilityTimeout > 43200 {
|
||||
visibilityTimeout = 43200
|
||||
}
|
||||
queueType := strings.ToLower(strings.TrimSpace(cfg.QueueType))
|
||||
if queueType == "" {
|
||||
queueType = queue.QueueTypeStandard
|
||||
}
|
||||
queue := &EmailQueue{
|
||||
api: api,
|
||||
queueURL: strings.TrimSpace(cfg.QueueURL),
|
||||
queueName: metrics.QueueNameFromURL(cfg.QueueURL),
|
||||
component: "worker",
|
||||
queueType: queueType,
|
||||
messageGroupID: strings.TrimSpace(cfg.MessageGroupID),
|
||||
maxMessages: maxMessages,
|
||||
waitTimeSeconds: waitTimeSeconds,
|
||||
visibilityTimeout: visibilityTimeout,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(queue)
|
||||
}
|
||||
}
|
||||
metrics.InitializeQueueSeries(queue.component, queue.queueName)
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Backend() string {
|
||||
return queue.BackendSQS
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Publish(ctx context.Context, message queue.OutboundMessage) error {
|
||||
input := &sqs.SendMessageInput{
|
||||
QueueUrl: awsv2.String(q.queueURL),
|
||||
MessageBody: awsv2.String(string(message.Body)),
|
||||
}
|
||||
if len(message.Attributes) > 0 {
|
||||
input.MessageAttributes = make(map[string]sqstypes.MessageAttributeValue, len(message.Attributes))
|
||||
for key, value := range message.Attributes {
|
||||
v := value
|
||||
input.MessageAttributes[key] = sqstypes.MessageAttributeValue{
|
||||
DataType: awsv2.String("String"),
|
||||
StringValue: awsv2.String(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
if q.queueType == queue.QueueTypeFIFO {
|
||||
groupID := strings.TrimSpace(message.MessageGroupID)
|
||||
if groupID == "" {
|
||||
groupID = q.messageGroupID
|
||||
}
|
||||
if groupID == "" {
|
||||
groupID = "default"
|
||||
}
|
||||
deduplicationID := strings.TrimSpace(message.DeduplicationID)
|
||||
if deduplicationID == "" {
|
||||
deduplicationID = message.ID
|
||||
}
|
||||
input.MessageGroupId = awsv2.String(groupID)
|
||||
input.MessageDeduplicationId = awsv2.String(deduplicationID)
|
||||
}
|
||||
started := time.Now()
|
||||
_, err := q.api.SendMessage(ctx, input)
|
||||
metrics.ObserveQueueSend(q.component, q.queueName, int64(len(message.Body)), err, started)
|
||||
return err
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Receive(ctx context.Context, maxMessages int) ([]*queue.Delivery, error) {
|
||||
if maxMessages <= 0 {
|
||||
maxMessages = 1
|
||||
}
|
||||
if q.maxMessages > 0 && maxMessages > q.maxMessages {
|
||||
maxMessages = q.maxMessages
|
||||
}
|
||||
if maxMessages > 10 {
|
||||
maxMessages = 10
|
||||
}
|
||||
started := time.Now()
|
||||
output, err := q.api.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
|
||||
QueueUrl: awsv2.String(q.queueURL),
|
||||
MaxNumberOfMessages: int32(maxMessages),
|
||||
WaitTimeSeconds: q.waitTimeSeconds,
|
||||
VisibilityTimeout: q.visibilityTimeout,
|
||||
MessageSystemAttributeNames: []sqstypes.MessageSystemAttributeName{
|
||||
sqstypes.MessageSystemAttributeNameApproximateReceiveCount,
|
||||
sqstypes.MessageSystemAttributeNameSentTimestamp,
|
||||
},
|
||||
MessageAttributeNames: []string{"All"},
|
||||
})
|
||||
if err != nil {
|
||||
metrics.ObserveQueueReceive(q.component, q.queueName, 0, 0, err, started)
|
||||
return nil, err
|
||||
}
|
||||
var totalMessageBytes int64
|
||||
for i := range output.Messages {
|
||||
totalMessageBytes += int64(len(awsv2.ToString(output.Messages[i].Body)))
|
||||
}
|
||||
metrics.ObserveQueueReceive(q.component, q.queueName, len(output.Messages), totalMessageBytes, nil, started)
|
||||
|
||||
deliveries := make([]*queue.Delivery, 0, len(output.Messages))
|
||||
for _, message := range output.Messages {
|
||||
attributes := make(map[string]string, len(message.MessageAttributes))
|
||||
for key, value := range message.MessageAttributes {
|
||||
if value.StringValue != nil {
|
||||
attributes[key] = *value.StringValue
|
||||
}
|
||||
}
|
||||
receiveCount, _ := strconv.Atoi(message.Attributes[string(sqstypes.MessageSystemAttributeNameApproximateReceiveCount)])
|
||||
sentAt := time.Now().UTC()
|
||||
if raw := message.Attributes[string(sqstypes.MessageSystemAttributeNameSentTimestamp)]; raw != "" {
|
||||
if millis, convErr := strconv.ParseInt(raw, 10, 64); convErr == nil {
|
||||
sentAt = time.UnixMilli(millis).UTC()
|
||||
}
|
||||
}
|
||||
body := awsv2.ToString(message.Body)
|
||||
deliveries = append(deliveries, &queue.Delivery{
|
||||
ID: awsv2.ToString(message.MessageId),
|
||||
ReceiptHandle: awsv2.ToString(message.ReceiptHandle),
|
||||
Body: []byte(body),
|
||||
Attributes: attributes,
|
||||
ReceiveCount: receiveCount,
|
||||
EnqueuedAt: sentAt,
|
||||
})
|
||||
}
|
||||
return deliveries, nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Ack(ctx context.Context, deliveries []*queue.Delivery) error {
|
||||
if len(deliveries) == 0 {
|
||||
return nil
|
||||
}
|
||||
entries := make([]sqstypes.DeleteMessageBatchRequestEntry, 0, len(deliveries))
|
||||
for i, delivery := range deliveries {
|
||||
if delivery == nil || strings.TrimSpace(delivery.ReceiptHandle) == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, sqstypes.DeleteMessageBatchRequestEntry{
|
||||
Id: awsv2.String(strconv.Itoa(i)),
|
||||
ReceiptHandle: awsv2.String(delivery.ReceiptHandle),
|
||||
})
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
started := time.Now()
|
||||
output, err := q.api.DeleteMessageBatch(ctx, &sqs.DeleteMessageBatchInput{
|
||||
QueueUrl: awsv2.String(q.queueURL),
|
||||
Entries: entries,
|
||||
})
|
||||
if err != nil {
|
||||
metrics.ObserveQueueDeleteBatch(q.component, q.queueName, 0, err, started)
|
||||
return err
|
||||
}
|
||||
if len(output.Failed) > 0 {
|
||||
batchErr := errors.New("sqs batch delete reported failures")
|
||||
metrics.ObserveQueueDeleteBatch(q.component, q.queueName, len(output.Successful), batchErr, started)
|
||||
return batchErr
|
||||
}
|
||||
metrics.ObserveQueueDeleteBatch(q.component, q.queueName, len(output.Successful), nil, started)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Retry(ctx context.Context, delivery *queue.Delivery, delay time.Duration) error {
|
||||
if delivery == nil {
|
||||
return nil
|
||||
}
|
||||
seconds := int32(delay.Seconds())
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
if seconds > 43200 {
|
||||
seconds = 43200
|
||||
}
|
||||
started := time.Now()
|
||||
_, err := q.api.ChangeMessageVisibility(ctx, &sqs.ChangeMessageVisibilityInput{
|
||||
QueueUrl: awsv2.String(q.queueURL),
|
||||
ReceiptHandle: awsv2.String(delivery.ReceiptHandle),
|
||||
VisibilityTimeout: seconds,
|
||||
})
|
||||
metrics.ObserveQueueVisibilityChange(q.component, q.queueName, err, started)
|
||||
return err
|
||||
}
|
||||
|
||||
func (q *EmailQueue) DeadLetter(ctx context.Context, delivery *queue.Delivery, reason string, cause error) error {
|
||||
// Leave the message unacked so SQS redrive policy can move it to the queue DLQ.
|
||||
_ = ctx
|
||||
_ = delivery
|
||||
_ = reason
|
||||
_ = cause
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) ApproximateDepth(ctx context.Context) (int64, error) {
|
||||
started := time.Now()
|
||||
output, err := q.api.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
|
||||
QueueUrl: awsv2.String(q.queueURL),
|
||||
AttributeNames: []sqstypes.QueueAttributeName{
|
||||
sqstypes.QueueAttributeNameApproximateNumberOfMessages,
|
||||
sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
metrics.ObserveQueueDepth(q.component, q.queueName, 0, err, started)
|
||||
return 0, err
|
||||
}
|
||||
visible, _ := strconv.ParseInt(output.Attributes[string(sqstypes.QueueAttributeNameApproximateNumberOfMessages)], 10, 64)
|
||||
inFlight, _ := strconv.ParseInt(output.Attributes[string(sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible)], 10, 64)
|
||||
depth := visible + inFlight
|
||||
metrics.ObserveQueueDepth(q.component, q.queueName, depth, nil, started)
|
||||
return depth, nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConfig(cfg appconfig.SQSConfig) error {
|
||||
if strings.TrimSpace(cfg.QueueURL) == "" {
|
||||
return errors.New("sqs queue url is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Region) == "" {
|
||||
return errors.New("aws region is required")
|
||||
}
|
||||
if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parsed.Scheme != "https" && !cfg.AllowInsecureEndpoint {
|
||||
return errors.New("insecure sqs endpoint is disabled")
|
||||
}
|
||||
}
|
||||
queueType := strings.ToLower(strings.TrimSpace(cfg.QueueType))
|
||||
if queueType == "" {
|
||||
queueType = queue.QueueTypeStandard
|
||||
}
|
||||
switch queueType {
|
||||
case queue.QueueTypeStandard:
|
||||
case queue.QueueTypeFIFO:
|
||||
if strings.TrimSpace(cfg.MessageGroupID) == "" {
|
||||
return errors.New("sqs message group id is required for fifo queues")
|
||||
}
|
||||
default:
|
||||
return errors.New("unsupported sqs queue type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
236
internal/repository/sqs/email_queue_test.go
Normal file
236
internal/repository/sqs/email_queue_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package sqs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
|
||||
"github.com/aws/smithy-go"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/queue"
|
||||
"wucher/internal/resilience"
|
||||
)
|
||||
|
||||
type mockAPI struct {
|
||||
sendInput *sqs.SendMessageInput
|
||||
receiveInput *sqs.ReceiveMessageInput
|
||||
deleteBatchInput *sqs.DeleteMessageBatchInput
|
||||
changeVisibilityInput *sqs.ChangeMessageVisibilityInput
|
||||
getAttributesInput *sqs.GetQueueAttributesInput
|
||||
sendErr error
|
||||
receiveErr error
|
||||
deleteBatchErr error
|
||||
changeVisibilityErr error
|
||||
getAttributesErr error
|
||||
sendCalls int
|
||||
receiveCalls int
|
||||
}
|
||||
|
||||
func (m *mockAPI) SendMessage(_ context.Context, params *sqs.SendMessageInput, _ ...func(*sqs.Options)) (*sqs.SendMessageOutput, error) {
|
||||
m.sendCalls++
|
||||
m.sendInput = params
|
||||
if m.sendErr != nil {
|
||||
return nil, m.sendErr
|
||||
}
|
||||
return &sqs.SendMessageOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockAPI) ReceiveMessage(_ context.Context, params *sqs.ReceiveMessageInput, _ ...func(*sqs.Options)) (*sqs.ReceiveMessageOutput, error) {
|
||||
m.receiveCalls++
|
||||
m.receiveInput = params
|
||||
if m.receiveErr != nil {
|
||||
return nil, m.receiveErr
|
||||
}
|
||||
return &sqs.ReceiveMessageOutput{
|
||||
Messages: []sqstypes.Message{{
|
||||
MessageId: awsv2.String("msg-1"),
|
||||
ReceiptHandle: awsv2.String("receipt-1"),
|
||||
Body: awsv2.String(`{"version":"v1","message_id":"msg-1","kind":"email.dispatch","occurred_at":"2026-03-19T00:00:00Z","payload":{"to":"user@example.com","subject":"Hello","body":"World"}}`),
|
||||
MessageAttributes: map[string]sqstypes.MessageAttributeValue{
|
||||
"correlation_id": {DataType: awsv2.String("String"), StringValue: awsv2.String("req-1")},
|
||||
},
|
||||
Attributes: map[string]string{
|
||||
string(sqstypes.MessageSystemAttributeNameApproximateReceiveCount): "3",
|
||||
string(sqstypes.MessageSystemAttributeNameSentTimestamp): "1710806400000",
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockAPI) DeleteMessageBatch(_ context.Context, params *sqs.DeleteMessageBatchInput, _ ...func(*sqs.Options)) (*sqs.DeleteMessageBatchOutput, error) {
|
||||
m.deleteBatchInput = params
|
||||
if m.deleteBatchErr != nil {
|
||||
return nil, m.deleteBatchErr
|
||||
}
|
||||
return &sqs.DeleteMessageBatchOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockAPI) ChangeMessageVisibility(_ context.Context, params *sqs.ChangeMessageVisibilityInput, _ ...func(*sqs.Options)) (*sqs.ChangeMessageVisibilityOutput, error) {
|
||||
m.changeVisibilityInput = params
|
||||
if m.changeVisibilityErr != nil {
|
||||
return nil, m.changeVisibilityErr
|
||||
}
|
||||
return &sqs.ChangeMessageVisibilityOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockAPI) GetQueueAttributes(_ context.Context, params *sqs.GetQueueAttributesInput, _ ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) {
|
||||
m.getAttributesInput = params
|
||||
if m.getAttributesErr != nil {
|
||||
return nil, m.getAttributesErr
|
||||
}
|
||||
return &sqs.GetQueueAttributesOutput{
|
||||
Attributes: map[string]string{
|
||||
string(sqstypes.QueueAttributeNameApproximateNumberOfMessages): "5",
|
||||
string(sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible): "2",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestEmailQueuePublishReceiveAckRetryDeadLetter(t *testing.T) {
|
||||
api := &mockAPI{}
|
||||
cfg := config.SQSConfig{
|
||||
Region: "ap-southeast-1",
|
||||
QueueURL: "https://sqs.ap-southeast-1.amazonaws.com/123/test",
|
||||
QueueType: queue.QueueTypeFIFO,
|
||||
MessageGroupID: "email",
|
||||
VisibilityTimeout: 30 * time.Second,
|
||||
WaitTimeSeconds: 20,
|
||||
}
|
||||
q, err := NewEmailQueue(api, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new queue: %v", err)
|
||||
}
|
||||
|
||||
err = q.Publish(context.Background(), queue.OutboundMessage{
|
||||
ID: "msg-1",
|
||||
Body: []byte(`{"hello":"world"}`),
|
||||
Attributes: map[string]string{"correlation_id": "req-1"},
|
||||
MessageGroupID: "email",
|
||||
DeduplicationID: "dedup-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
if api.sendInput == nil || awsv2.ToString(api.sendInput.MessageGroupId) != "email" {
|
||||
t.Fatalf("expected fifo publish settings")
|
||||
}
|
||||
|
||||
deliveries, err := q.Receive(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("receive: %v", err)
|
||||
}
|
||||
if len(deliveries) != 1 || deliveries[0].ReceiveCount != 3 {
|
||||
t.Fatalf("unexpected deliveries: %+v", deliveries)
|
||||
}
|
||||
|
||||
if err := q.Ack(context.Background(), deliveries); err != nil {
|
||||
t.Fatalf("ack: %v", err)
|
||||
}
|
||||
if api.deleteBatchInput == nil || len(api.deleteBatchInput.Entries) != 1 {
|
||||
t.Fatalf("expected batch delete")
|
||||
}
|
||||
|
||||
if err := q.Retry(context.Background(), deliveries[0], 45*time.Second); err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
if api.changeVisibilityInput == nil || api.changeVisibilityInput.VisibilityTimeout != 45 {
|
||||
t.Fatalf("expected retry visibility timeout 45")
|
||||
}
|
||||
|
||||
if err := q.DeadLetter(context.Background(), deliveries[0], "permanent failure", nil); err != nil {
|
||||
t.Fatalf("dead-letter: %v", err)
|
||||
}
|
||||
if got := awsv2.ToString(api.sendInput.MessageBody); got != `{"hello":"world"}` {
|
||||
t.Fatalf("expected dead-letter path to leave message managed by SQS redrive, got send body %q", got)
|
||||
}
|
||||
|
||||
depth, err := q.ApproximateDepth(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("depth: %v", err)
|
||||
}
|
||||
if depth != 7 {
|
||||
t.Fatalf("expected depth 7, got %d", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigRejectsInsecureEndpoint(t *testing.T) {
|
||||
err := validateConfig(config.SQSConfig{
|
||||
Region: "ap-southeast-1",
|
||||
QueueURL: "https://sqs.ap-southeast-1.amazonaws.com/123/test",
|
||||
Endpoint: "http://localhost:4566",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected insecure endpoint validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResilientAPI_CircuitBreaker(t *testing.T) {
|
||||
newExecutor := func() *resilience.Executor {
|
||||
return resilience.NewExecutor("sqs", config.CircuitBreakerPolicy{
|
||||
Enabled: true,
|
||||
Timeout: time.Second,
|
||||
MinRequests: 2,
|
||||
FailureRatio: 0.5,
|
||||
ConsecutiveFailures: 2,
|
||||
}, slog.New(slog.NewJSONHandler(io.Discard, nil)), resilience.ClassifySQSError)
|
||||
}
|
||||
|
||||
t.Run("client faults are excluded", func(t *testing.T) {
|
||||
api := &mockAPI{
|
||||
sendErr: sqsAPIError{code: "AccessDenied", fault: smithy.FaultClient},
|
||||
}
|
||||
wrapped := NewResilientAPI(api, newExecutor())
|
||||
|
||||
for range 3 {
|
||||
_, err := wrapped.SendMessage(context.Background(), &sqs.SendMessageInput{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected access denied error")
|
||||
}
|
||||
var openErr *resilience.OpenError
|
||||
if errors.As(err, &openErr) {
|
||||
t.Fatalf("expected excluded error to keep breaker closed, got %v", err)
|
||||
}
|
||||
}
|
||||
if api.sendCalls != 3 {
|
||||
t.Fatalf("expected all excluded calls to reach API, got %d", api.sendCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("server faults open the breaker", func(t *testing.T) {
|
||||
api := &mockAPI{
|
||||
receiveErr: sqsAPIError{code: "InternalError", fault: smithy.FaultServer},
|
||||
}
|
||||
wrapped := NewResilientAPI(api, newExecutor())
|
||||
|
||||
for range 2 {
|
||||
if _, err := wrapped.ReceiveMessage(context.Background(), &sqs.ReceiveMessageInput{}); err == nil {
|
||||
t.Fatalf("expected receive error")
|
||||
}
|
||||
}
|
||||
_, err := wrapped.ReceiveMessage(context.Background(), &sqs.ReceiveMessageInput{})
|
||||
var openErr *resilience.OpenError
|
||||
if !errors.As(err, &openErr) {
|
||||
t.Fatalf("expected circuit open error, got %v", err)
|
||||
}
|
||||
if api.receiveCalls != 2 {
|
||||
t.Fatalf("expected breaker to fail fast after opening, got %d receive calls", api.receiveCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type sqsAPIError struct {
|
||||
code string
|
||||
fault smithy.ErrorFault
|
||||
}
|
||||
|
||||
func (e sqsAPIError) Error() string { return e.code }
|
||||
func (e sqsAPIError) ErrorCode() string { return e.code }
|
||||
func (e sqsAPIError) ErrorMessage() string { return e.code }
|
||||
func (e sqsAPIError) ErrorFault() smithy.ErrorFault { return e.fault }
|
||||
89
internal/repository/sqs/integration_test.go
Normal file
89
internal/repository/sqs/integration_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package sqs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
func TestEmailQueueIntegration(t *testing.T) {
|
||||
queueURL := os.Getenv("SQS_INTEGRATION_QUEUE_URL")
|
||||
if queueURL == "" {
|
||||
t.Skip("SQS_INTEGRATION_QUEUE_URL is not set")
|
||||
}
|
||||
|
||||
cfg := config.SQSConfig{
|
||||
Region: getenv("SQS_INTEGRATION_REGION", os.Getenv("AWS_REGION")),
|
||||
Endpoint: getenv("SQS_INTEGRATION_ENDPOINT", os.Getenv("SQS_ENDPOINT")),
|
||||
QueueURL: queueURL,
|
||||
QueueType: getenv("SQS_INTEGRATION_QUEUE_TYPE", queue.QueueTypeStandard),
|
||||
MessageGroupID: getenv("SQS_INTEGRATION_MESSAGE_GROUP_ID", "integration"),
|
||||
MaxMessages: 1,
|
||||
WaitTimeSeconds: 5,
|
||||
VisibilityTimeout: 30 * time.Second,
|
||||
AllowInsecureEndpoint: os.Getenv("SQS_INTEGRATION_ALLOW_INSECURE_ENDPOINT") == "true",
|
||||
}
|
||||
client, err := NewClient(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
}
|
||||
adapter, err := NewEmailQueue(client, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new queue: %v", err)
|
||||
}
|
||||
|
||||
serializer := queue.NewJSONSerializer("v1", true, cfg.QueueType, cfg.MessageGroupID)
|
||||
outbound, err := serializer.Serialize(context.Background(), queue.EmailJob{
|
||||
Type: "integration",
|
||||
To: "integration@example.com",
|
||||
Subject: "integration-" + time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Body: "hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("serialize: %v", err)
|
||||
}
|
||||
if err := adapter.Publish(context.Background(), *outbound); err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
deliveries, err := adapter.Receive(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("receive: %v", err)
|
||||
}
|
||||
if len(deliveries) == 0 {
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("timed out waiting for message")
|
||||
}
|
||||
continue
|
||||
}
|
||||
envelope, err := serializer.Deserialize(deliveries[0].Body, deliveries[0].Attributes)
|
||||
if err != nil {
|
||||
t.Fatalf("deserialize: %v", err)
|
||||
}
|
||||
if envelope.MessageID != outbound.ID {
|
||||
if err := adapter.Retry(context.Background(), deliveries[0], 0); err != nil {
|
||||
t.Fatalf("retry unrelated message: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := adapter.Ack(context.Background(), deliveries); err != nil {
|
||||
t.Fatalf("ack: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
54
internal/repository/sqs/resilient_api.go
Normal file
54
internal/repository/sqs/resilient_api.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package sqs
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||
|
||||
"wucher/internal/resilience"
|
||||
)
|
||||
|
||||
type resilientAPI struct {
|
||||
next API
|
||||
executor *resilience.Executor
|
||||
}
|
||||
|
||||
func NewResilientAPI(next API, executor *resilience.Executor) API {
|
||||
if next == nil || executor == nil || !executor.Enabled() {
|
||||
return next
|
||||
}
|
||||
return &resilientAPI{
|
||||
next: next,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *resilientAPI) SendMessage(ctx context.Context, params *sqs.SendMessageInput, optFns ...func(*sqs.Options)) (*sqs.SendMessageOutput, error) {
|
||||
return resilience.Do(ctx, r.executor, func(ctx context.Context) (*sqs.SendMessageOutput, error) {
|
||||
return r.next.SendMessage(ctx, params, optFns...)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *resilientAPI) ReceiveMessage(ctx context.Context, params *sqs.ReceiveMessageInput, optFns ...func(*sqs.Options)) (*sqs.ReceiveMessageOutput, error) {
|
||||
return resilience.Do(ctx, r.executor, func(ctx context.Context) (*sqs.ReceiveMessageOutput, error) {
|
||||
return r.next.ReceiveMessage(ctx, params, optFns...)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *resilientAPI) DeleteMessageBatch(ctx context.Context, params *sqs.DeleteMessageBatchInput, optFns ...func(*sqs.Options)) (*sqs.DeleteMessageBatchOutput, error) {
|
||||
return resilience.Do(ctx, r.executor, func(ctx context.Context) (*sqs.DeleteMessageBatchOutput, error) {
|
||||
return r.next.DeleteMessageBatch(ctx, params, optFns...)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *resilientAPI) ChangeMessageVisibility(ctx context.Context, params *sqs.ChangeMessageVisibilityInput, optFns ...func(*sqs.Options)) (*sqs.ChangeMessageVisibilityOutput, error) {
|
||||
return resilience.Do(ctx, r.executor, func(ctx context.Context) (*sqs.ChangeMessageVisibilityOutput, error) {
|
||||
return r.next.ChangeMessageVisibility(ctx, params, optFns...)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *resilientAPI) GetQueueAttributes(ctx context.Context, params *sqs.GetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) {
|
||||
return resilience.Do(ctx, r.executor, func(ctx context.Context) (*sqs.GetQueueAttributesOutput, error) {
|
||||
return r.next.GetQueueAttributes(ctx, params, optFns...)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user