package qslcard import ( "context" "database/sql" "fmt" "time" ) // Record is one stored template row. JSON holds the template document; // callers Parse/Validate it as needed (the list view only needs metadata). type Record struct { ID int64 `json:"id"` Name string `json:"name"` ProfileID *int64 `json:"profile_id,omitempty"` JSON string `json:"json"` IsDefault bool `json:"is_default"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Repo accesses the qsl_templates table. type Repo struct{ db *sql.DB } // NewRepo builds a template repo on the given connection. func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } const repoCols = `id, name, profile_id, json, is_default, created_at, updated_at` // List returns all templates, defaults first then newest first. func (r *Repo) List(ctx context.Context) ([]Record, error) { rows, err := r.db.QueryContext(ctx, `SELECT `+repoCols+` FROM qsl_templates ORDER BY is_default DESC, id DESC`) if err != nil { return nil, err } defer rows.Close() var out []Record for rows.Next() { rec, err := scanRecord(rows) if err != nil { return nil, err } out = append(out, rec) } return out, rows.Err() } // Get returns one template by ID, or sql.ErrNoRows if missing. func (r *Repo) Get(ctx context.Context, id int64) (Record, error) { row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+` FROM qsl_templates WHERE id = ?`, id) return scanRecord(row) } // Save upserts a template. rec.ID == 0 means "create"; the generated ID is // written back so the caller can place the template's asset folder. func (r *Repo) Save(ctx context.Context, rec *Record) error { if rec.Name == "" { return fmt.Errorf("template name required") } if rec.JSON == "" { return fmt.Errorf("template json required") } now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") if rec.ID == 0 { res, err := r.db.ExecContext(ctx, ` INSERT INTO qsl_templates (name, profile_id, json, is_default, created_at, updated_at) VALUES (?,?,?,?,?,?)`, rec.Name, nullableID(rec.ProfileID), rec.JSON, boolInt(rec.IsDefault), now, now) if err != nil { return fmt.Errorf("insert template: %w", err) } id, err := res.LastInsertId() if err != nil { return fmt.Errorf("insert template id: %w", err) } rec.ID = id return nil } _, err := r.db.ExecContext(ctx, ` UPDATE qsl_templates SET name = ?, profile_id = ?, json = ?, updated_at = ? WHERE id = ?`, rec.Name, nullableID(rec.ProfileID), rec.JSON, now, rec.ID) if err != nil { return fmt.Errorf("update template: %w", err) } return nil } // Delete removes a template row. The caller removes the asset folder. func (r *Repo) Delete(ctx context.Context, id int64) error { _, err := r.db.ExecContext(ctx, `DELETE FROM qsl_templates WHERE id = ?`, id) return err } // SetDefault marks one template as the default for its profile scope, // clearing the flag on every other template of the same scope. func (r *Repo) SetDefault(ctx context.Context, id int64) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() //nolint:errcheck var profileID sql.NullInt64 if err := tx.QueryRowContext(ctx, `SELECT profile_id FROM qsl_templates WHERE id = ?`, id).Scan(&profileID); err != nil { return err } if profileID.Valid { _, err = tx.ExecContext(ctx, `UPDATE qsl_templates SET is_default = 0 WHERE profile_id = ?`, profileID.Int64) } else { _, err = tx.ExecContext(ctx, `UPDATE qsl_templates SET is_default = 0 WHERE profile_id IS NULL`) } if err != nil { return err } if _, err := tx.ExecContext(ctx, `UPDATE qsl_templates SET is_default = 1 WHERE id = ?`, id); err != nil { return err } return tx.Commit() } // DefaultFor returns the best template for a profile: its own default, then // any profile-less default, then the newest template. sql.ErrNoRows if none. func (r *Repo) DefaultFor(ctx context.Context, profileID int64) (Record, error) { row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+` FROM qsl_templates ORDER BY (is_default = 1 AND profile_id = ?) DESC, (is_default = 1 AND profile_id IS NULL) DESC, id DESC LIMIT 1`, profileID) return scanRecord(row) } // ----- helpers ----- type scannable interface{ Scan(dest ...any) error } func scanRecord(row scannable) (Record, error) { var rec Record var profileID sql.NullInt64 var isDefault int var createdAt, updatedAt string if err := row.Scan(&rec.ID, &rec.Name, &profileID, &rec.JSON, &isDefault, &createdAt, &updatedAt); err != nil { return rec, err } if profileID.Valid { v := profileID.Int64 rec.ProfileID = &v } rec.IsDefault = isDefault == 1 rec.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) rec.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) return rec, nil } func nullableID(p *int64) any { if p == nil { return nil } return *p } func boolInt(b bool) int { if b { return 1 } return 0 }