init push

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

View File

@@ -0,0 +1,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
}

View File

@@ -0,0 +1,11 @@
package mysql
import (
"context"
"wucher/internal/shared/pkg/appctx"
)
func actorUserIDFromContext(ctx context.Context) []byte {
return appctx.GetUserID(ctx)
}

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

View 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(&regular).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
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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(&current).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(&current).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
}

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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