Files
OpsLog/internal/qslcard/repo.go
T
2026-06-11 21:54:35 +02:00

169 lines
4.9 KiB
Go

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
}