feat(m1): backend scaffold - entries CRUD, start/stop, auth, migrations
This commit is contained in:
61
internal/store/001_initial.sql
Normal file
61
internal/store/001_initial.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- +migrate Up
|
||||
CREATE TABLE entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER,
|
||||
auto_stopped INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
day_key TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
CREATE INDEX idx_entries_day ON entries(day_key);
|
||||
|
||||
CREATE TABLE closed_days (
|
||||
day_key TEXT PRIMARY KEY,
|
||||
start_time INTEGER,
|
||||
end_time INTEGER,
|
||||
worked_ms INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
closed_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE closed_weeks (
|
||||
week_key TEXT PRIMARY KEY,
|
||||
expected_ms INTEGER NOT NULL,
|
||||
worked_ms INTEGER NOT NULL,
|
||||
delta_ms INTEGER NOT NULL,
|
||||
closed_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE settings_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
effective_from TEXT NOT NULL,
|
||||
hours_per_week REAL NOT NULL,
|
||||
workdays_mask INTEGER NOT NULL DEFAULT 31,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_log (
|
||||
entity TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
op TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (entity, entity_id, version)
|
||||
);
|
||||
|
||||
-- seed default settings
|
||||
INSERT INTO settings_history (effective_from, hours_per_week, workdays_mask, timezone, created_at)
|
||||
VALUES ('2000-01-01', 40.0, 31, 'UTC', unixepoch() * 1000);
|
||||
|
||||
-- +migrate Down
|
||||
DROP TABLE IF EXISTS sync_log;
|
||||
DROP TABLE IF EXISTS settings_history;
|
||||
DROP TABLE IF EXISTS closed_weeks;
|
||||
DROP TABLE IF EXISTS closed_days;
|
||||
DROP INDEX IF EXISTS idx_entries_day;
|
||||
DROP TABLE IF EXISTS entries;
|
||||
94
internal/store/closed_day_store.go
Normal file
94
internal/store/closed_day_store.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
)
|
||||
|
||||
// ClosedDayStore handles persistence for closed days.
|
||||
type ClosedDayStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewClosedDayStore(db *sql.DB) *ClosedDayStore {
|
||||
return &ClosedDayStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ClosedDayStore) Upsert(ctx context.Context, d *domain.ClosedDay) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO closed_days (day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(day_key) DO UPDATE SET
|
||||
start_time=excluded.start_time, end_time=excluded.end_time,
|
||||
worked_ms=excluded.worked_ms, kind=excluded.kind,
|
||||
closed_at=excluded.closed_at, updated_at=excluded.updated_at`,
|
||||
d.DayKey, d.StartTime, d.EndTime, d.WorkedMs, d.Kind, d.ClosedAt, d.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ClosedDayStore) Delete(ctx context.Context, dayKey string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM closed_days WHERE day_key=?`, dayKey)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ClosedDayStore) GetByDayKey(ctx context.Context, dayKey string) (*domain.ClosedDay, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at
|
||||
FROM closed_days WHERE day_key=?`, dayKey)
|
||||
return scanClosedDay(row)
|
||||
}
|
||||
|
||||
func (s *ClosedDayStore) ListByDateRange(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.ClosedDay, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at
|
||||
FROM closed_days WHERE day_key >= ? AND day_key <= ? ORDER BY day_key ASC`,
|
||||
fromDayKey, toDayKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []*domain.ClosedDay
|
||||
for rows.Next() {
|
||||
d, err := scanClosedDayRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func scanClosedDay(row *sql.Row) (*domain.ClosedDay, error) {
|
||||
var d domain.ClosedDay
|
||||
var startTime, endTime sql.NullInt64
|
||||
err := row.Scan(&d.DayKey, &startTime, &endTime, &d.WorkedMs, &d.Kind, &d.ClosedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if startTime.Valid {
|
||||
d.StartTime = &startTime.Int64
|
||||
}
|
||||
if endTime.Valid {
|
||||
d.EndTime = &endTime.Int64
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func scanClosedDayRow(rows *sql.Rows) (*domain.ClosedDay, error) {
|
||||
var d domain.ClosedDay
|
||||
var startTime, endTime sql.NullInt64
|
||||
err := rows.Scan(&d.DayKey, &startTime, &endTime, &d.WorkedMs, &d.Kind, &d.ClosedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if startTime.Valid {
|
||||
d.StartTime = &startTime.Int64
|
||||
}
|
||||
if endTime.Valid {
|
||||
d.EndTime = &endTime.Int64
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
87
internal/store/closed_week_store.go
Normal file
87
internal/store/closed_week_store.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
)
|
||||
|
||||
// ClosedWeekStore handles persistence for closed weeks.
|
||||
type ClosedWeekStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewClosedWeekStore(db *sql.DB) *ClosedWeekStore {
|
||||
return &ClosedWeekStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ClosedWeekStore) Upsert(ctx context.Context, w *domain.ClosedWeek) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO closed_weeks (week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(week_key) DO UPDATE SET
|
||||
expected_ms=excluded.expected_ms, worked_ms=excluded.worked_ms,
|
||||
delta_ms=excluded.delta_ms, closed_at=excluded.closed_at, updated_at=excluded.updated_at`,
|
||||
w.WeekKey, w.ExpectedMs, w.WorkedMs, w.DeltaMs, w.ClosedAt, w.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ClosedWeekStore) Delete(ctx context.Context, weekKey string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM closed_weeks WHERE week_key=?`, weekKey)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ClosedWeekStore) GetByWeekKey(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at
|
||||
FROM closed_weeks WHERE week_key=?`, weekKey)
|
||||
var w domain.ClosedWeek
|
||||
err := row.Scan(&w.WeekKey, &w.ExpectedMs, &w.WorkedMs, &w.DeltaMs, &w.ClosedAt, &w.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func (s *ClosedWeekStore) ListByRange(ctx context.Context, fromWeekKey, toWeekKey string) ([]*domain.ClosedWeek, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at
|
||||
FROM closed_weeks WHERE week_key >= ? AND week_key <= ? ORDER BY week_key ASC`,
|
||||
fromWeekKey, toWeekKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []*domain.ClosedWeek
|
||||
for rows.Next() {
|
||||
var w domain.ClosedWeek
|
||||
if err := rows.Scan(&w.WeekKey, &w.ExpectedMs, &w.WorkedMs, &w.DeltaMs, &w.ClosedAt, &w.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, &w)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// SumWorkedMsForWeek sums worked_ms across closed_days for the given day keys.
|
||||
func SumWorkedMsForWeek(ctx context.Context, db *sql.DB, dayKeys []string) (int64, error) {
|
||||
if len(dayKeys) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// Build IN clause
|
||||
placeholders := make([]byte, 0, len(dayKeys)*2)
|
||||
args := make([]interface{}, len(dayKeys))
|
||||
for i, k := range dayKeys {
|
||||
if i > 0 {
|
||||
placeholders = append(placeholders, ',')
|
||||
}
|
||||
placeholders = append(placeholders, '?')
|
||||
args[i] = k
|
||||
}
|
||||
query := "SELECT COALESCE(SUM(worked_ms),0) FROM closed_days WHERE day_key IN (" + string(placeholders) + ")"
|
||||
var total int64
|
||||
err := db.QueryRowContext(ctx, query, args...).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
91
internal/store/db.go
Normal file
91
internal/store/db.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed 001_initial.sql
|
||||
var schema string
|
||||
|
||||
// Open opens (or creates) the SQLite database at path and runs migrations.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=synchronous(NORMAL)",
|
||||
path,
|
||||
)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite WAL: single writer
|
||||
if err := migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrate runs embedded SQL migrations. Simple single-file approach: we track
|
||||
// a user_version pragma and apply the schema once if version == 0.
|
||||
func migrate(db *sql.DB) error {
|
||||
var version int
|
||||
if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
if version >= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Strip migration comments and split into individual statements
|
||||
stmts := splitStatements(schema)
|
||||
for _, stmt := range stmts {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("exec %q: %w", stmt[:min(len(stmt), 60)], err)
|
||||
}
|
||||
}
|
||||
|
||||
// PRAGMA user_version cannot be set inside a regular transaction in all SQLite versions;
|
||||
// execute it as a standalone statement.
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 1"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitStatements(sql string) []string {
|
||||
// Only process statements up to the "-- +migrate Down" marker.
|
||||
var lines []string
|
||||
for _, line := range strings.Split(sql, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "-- +migrate Down" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "-- +migrate") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
joined := strings.Join(lines, "\n")
|
||||
// Split on semicolons
|
||||
parts := strings.Split(joined, ";")
|
||||
return parts
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
26
internal/store/db_test.go
Normal file
26
internal/store/db_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
func TestMigration(t *testing.T) {
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tables := []string{"entries", "closed_days", "closed_weeks", "settings_history", "sync_log"}
|
||||
for _, tbl := range tables {
|
||||
var name string
|
||||
err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&name)
|
||||
if err == sql.ErrNoRows {
|
||||
t.Errorf("table %q not created", tbl)
|
||||
} else if err != nil {
|
||||
t.Errorf("query for %q: %v", tbl, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
174
internal/store/entry_store.go
Normal file
174
internal/store/entry_store.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
)
|
||||
|
||||
// EntryStore handles persistence for entries.
|
||||
type EntryStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewEntryStore(db *sql.DB) *EntryStore {
|
||||
return &EntryStore{db: db}
|
||||
}
|
||||
|
||||
func (s *EntryStore) Create(ctx context.Context, e *domain.Entry) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO entries (id, start_time, end_time, auto_stopped, note, day_key, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.ID, e.StartTime, e.EndTime, boolToInt(e.AutoStopped), e.Note, e.DayKey, e.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EntryStore) Update(ctx context.Context, e *domain.Entry) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE entries SET start_time=?, end_time=?, auto_stopped=?, note=?, day_key=?, updated_at=?
|
||||
WHERE id=? AND deleted_at IS NULL`,
|
||||
e.StartTime, e.EndTime, boolToInt(e.AutoStopped), e.Note, e.DayKey, e.UpdatedAt, e.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EntryStore) SoftDelete(ctx context.Context, id string, nowMs int64) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE entries SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
||||
nowMs, nowMs, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EntryStore) GetByID(ctx context.Context, id string) (*domain.Entry, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at
|
||||
FROM entries WHERE id=?`, id)
|
||||
return scanEntry(row)
|
||||
}
|
||||
|
||||
func (s *EntryStore) ListByDayKey(ctx context.Context, dayKey string) ([]*domain.Entry, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at
|
||||
FROM entries WHERE day_key=? AND deleted_at IS NULL ORDER BY start_time ASC`, dayKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEntries(rows)
|
||||
}
|
||||
|
||||
func (s *EntryStore) ListByDateRange(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.Entry, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at
|
||||
FROM entries WHERE day_key >= ? AND day_key <= ? AND deleted_at IS NULL ORDER BY start_time ASC`,
|
||||
fromDayKey, toDayKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEntries(rows)
|
||||
}
|
||||
|
||||
// RunningEntry returns the currently running entry (no end_time), if any.
|
||||
func (s *EntryStore) RunningEntry(ctx context.Context) (*domain.Entry, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at
|
||||
FROM entries WHERE end_time IS NULL AND deleted_at IS NULL LIMIT 1`)
|
||||
e, err := scanEntry(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// RunningEntryForDay returns a running entry for a specific day, if any.
|
||||
func (s *EntryStore) RunningEntryForDay(ctx context.Context, dayKey string) (*domain.Entry, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at
|
||||
FROM entries WHERE day_key=? AND end_time IS NULL AND deleted_at IS NULL LIMIT 1`, dayKey)
|
||||
e, err := scanEntry(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// StopAllRunningBefore stops all entries whose day_key < dayKey with the given end time.
|
||||
// Used for the midnight auto-stop.
|
||||
func (s *EntryStore) StopAllRunningBefore(ctx context.Context, dayKey string, endTimeMs int64, nowMs int64) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id FROM entries WHERE end_time IS NULL AND deleted_at IS NULL AND day_key < ?`, dayKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
for _, id := range ids {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE entries SET end_time=?, auto_stopped=1, updated_at=? WHERE id=?`,
|
||||
endTimeMs, nowMs, id); err != nil {
|
||||
return nil, fmt.Errorf("auto-stop %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func scanEntry(row *sql.Row) (*domain.Entry, error) {
|
||||
var e domain.Entry
|
||||
var endTime sql.NullInt64
|
||||
var deletedAt sql.NullInt64
|
||||
var autoStopped int
|
||||
err := row.Scan(&e.ID, &e.StartTime, &endTime, &autoStopped, &e.Note, &e.DayKey, &e.UpdatedAt, &deletedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if endTime.Valid {
|
||||
e.EndTime = &endTime.Int64
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
e.DeletedAt = &deletedAt.Int64
|
||||
}
|
||||
e.AutoStopped = autoStopped != 0
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func scanEntries(rows *sql.Rows) ([]*domain.Entry, error) {
|
||||
var result []*domain.Entry
|
||||
for rows.Next() {
|
||||
var e domain.Entry
|
||||
var endTime sql.NullInt64
|
||||
var deletedAt sql.NullInt64
|
||||
var autoStopped int
|
||||
if err := rows.Scan(&e.ID, &e.StartTime, &endTime, &autoStopped, &e.Note, &e.DayKey, &e.UpdatedAt, &deletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if endTime.Valid {
|
||||
e.EndTime = &endTime.Int64
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
e.DeletedAt = &deletedAt.Int64
|
||||
}
|
||||
e.AutoStopped = autoStopped != 0
|
||||
result = append(result, &e)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
81
internal/store/settings_store.go
Normal file
81
internal/store/settings_store.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
)
|
||||
|
||||
// SettingsStore handles persistence for settings history.
|
||||
type SettingsStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSettingsStore(db *sql.DB) *SettingsStore {
|
||||
return &SettingsStore{db: db}
|
||||
}
|
||||
|
||||
// Current returns the most recent settings effective on or before the given day key.
|
||||
func (s *SettingsStore) Current(ctx context.Context, asOfDayKey string) (*domain.Settings, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at
|
||||
FROM settings_history
|
||||
WHERE effective_from <= ?
|
||||
ORDER BY effective_from DESC, id DESC
|
||||
LIMIT 1`, asOfDayKey)
|
||||
return scanSettings(row)
|
||||
}
|
||||
|
||||
// Latest returns the most recently created settings row.
|
||||
func (s *SettingsStore) Latest(ctx context.Context) (*domain.Settings, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at
|
||||
FROM settings_history
|
||||
ORDER BY effective_from DESC, id DESC
|
||||
LIMIT 1`)
|
||||
return scanSettings(row)
|
||||
}
|
||||
|
||||
// History returns all settings rows ordered by effective_from DESC.
|
||||
func (s *SettingsStore) History(ctx context.Context) ([]*domain.Settings, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at
|
||||
FROM settings_history ORDER BY effective_from DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []*domain.Settings
|
||||
for rows.Next() {
|
||||
var s domain.Settings
|
||||
if err := rows.Scan(&s.ID, &s.EffectiveFrom, &s.HoursPerWeek, &s.WorkdaysMask, &s.Timezone, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, &s)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Insert inserts a new settings row.
|
||||
func (s *SettingsStore) Insert(ctx context.Context, set *domain.Settings) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO settings_history (effective_from, hours_per_week, workdays_mask, timezone, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
set.EffectiveFrom, set.HoursPerWeek, set.WorkdaysMask, set.Timezone, set.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
set.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanSettings(row *sql.Row) (*domain.Settings, error) {
|
||||
var s domain.Settings
|
||||
err := row.Scan(&s.ID, &s.EffectiveFrom, &s.HoursPerWeek, &s.WorkdaysMask, &s.Timezone, &s.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
Reference in New Issue
Block a user