169 lines
4.9 KiB
Go
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
|
|
}
|