fix: resolve go vet error and dayStart logic in ComputeDailyTotals
- Fix 'string(d)' int-to-rune conversion in rpg_test.go by using fmt.Sprintf - Fix ComputeDailyTotals dayStart logic: initial state should be StateWorking when dayStart > 0, correctly counting work from dayStart to first out event - Update README project structure to reflect new domain/handler/repo layout - Implement missing achievements: rpg_pioneer (on RPG enable) and salary_setter (on first rate set) - Add tryUnlockAchievement helper to Handler
This commit is contained in:
59
internal/repo/interface.go
Normal file
59
internal/repo/interface.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package repo
|
||||
|
||||
import "worktimeBot/internal/domain"
|
||||
|
||||
type Repository interface {
|
||||
// Users
|
||||
GetOrCreateUser(chatID int64) (*domain.User, error)
|
||||
UpdateUser(user *domain.User) error
|
||||
GetAllUsers() ([]domain.User, error)
|
||||
|
||||
// Settings
|
||||
GetOrCreateSettings(userID int64) (*domain.UserSettings, error)
|
||||
UpdateSettings(st *domain.UserSettings) error
|
||||
MarkReportSent(userID int64, date string) error
|
||||
|
||||
// Days
|
||||
GetOrCreateDay(userID int64, date string) (*domain.Day, error)
|
||||
GetDay(userID int64, date string) (*domain.Day, error)
|
||||
UpdateDay(day *domain.Day) error
|
||||
GetUserDaysInRange(userID int64, start, end string) ([]domain.Day, error)
|
||||
SetDayOff(userID int64, date, reason string) error
|
||||
RemoveDayOff(userID int64, date string) error
|
||||
|
||||
// Events
|
||||
CreateEvent(userID, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error
|
||||
GetLastEvent(userID int64) (*domain.Event, error)
|
||||
EventsForDayByDate(userID int64, date string) ([]domain.Event, error)
|
||||
EventsForDayByDayID(dayID int64) ([]domain.Event, error)
|
||||
GetEventByID(eventID, userID int64) (*domain.Event, error)
|
||||
UpdateEventTime(eventID int64, occurredAt int64) error
|
||||
UpdateEventWorkType(eventID, workTypeID int64) error
|
||||
SetEventNote(eventID int64, note string) error
|
||||
DeleteEvent(eventID int64) error
|
||||
GetDistinctEventDates(userID int64, start, end string) ([]string, error)
|
||||
GetDatesWithEvents(userID int64) ([]string, error)
|
||||
|
||||
// Work Types
|
||||
GetWorkTypes() ([]domain.WorkType, error)
|
||||
GetWorkType(id int64) (*domain.WorkType, error)
|
||||
|
||||
// RPG
|
||||
GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error)
|
||||
UpdateRPGStats(stats *domain.RPGStats) error
|
||||
UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error
|
||||
SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error
|
||||
SumDailyWorkSeconds(userID int64) (int64, error)
|
||||
|
||||
// Achievements
|
||||
GetAchievementByCode(code string) (*domain.Achievement, error)
|
||||
UnlockAchievement(userID, achievementID int64) (bool, error)
|
||||
HasAchievement(userID int64, code string) (bool, error)
|
||||
GetUserAchievements(userID int64) ([]domain.UserAchievement, error)
|
||||
|
||||
// League
|
||||
GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error)
|
||||
|
||||
// Lifecycle
|
||||
Close() error
|
||||
}
|
||||
221
internal/repo/migrations.go
Normal file
221
internal/repo/migrations.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// runMigrations handles all database migrations using goose.
|
||||
func runMigrations(db *sql.DB) error {
|
||||
needsOldMigrate := hasTable(db, "events_old") || (hasTable(db, "events") && !hasCol(db, "events", "user_id"))
|
||||
if needsOldMigrate {
|
||||
slog.Info("migrating from old schema to new schema")
|
||||
for _, t := range []string{"events", "days_off", "settings"} {
|
||||
if hasTable(db, t) {
|
||||
if _, err := db.Exec("ALTER TABLE " + t + " RENAME TO " + t + "_old"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
goose.SetBaseFS(migrationFS)
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := goose.Up(db, "migrations"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if needsOldMigrate {
|
||||
if err := migrateFromOldSchemaData(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasTable(db *sql.DB, name string) bool {
|
||||
var exists int
|
||||
if err := db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&exists); err != nil {
|
||||
return false
|
||||
}
|
||||
return exists > 0
|
||||
}
|
||||
|
||||
func hasCol(db *sql.DB, table, column string) bool {
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false
|
||||
}
|
||||
if name == column {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func migrateFromOldSchemaData(db *sql.DB) error {
|
||||
migrateUsers(db)
|
||||
migrateDaysOff(db)
|
||||
migrateEvents(db)
|
||||
migrateSettings(db)
|
||||
|
||||
for _, t := range []string{"days_off_old", "settings_old", "events_old"} {
|
||||
if hasTable(db, t) {
|
||||
if _, err := db.Exec("DROP TABLE IF EXISTS " + t); err != nil {
|
||||
slog.Warn("failed to drop old table", "table", t, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("migration complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateUsers(db *sql.DB) {
|
||||
if !hasTable(db, "events_old") {
|
||||
return
|
||||
}
|
||||
rows, err := db.Query("SELECT DISTINCT chat_id FROM events_old")
|
||||
if err != nil {
|
||||
slog.Warn("migration: failed to query events_old for users", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var chatID int64
|
||||
if err := rows.Scan(&chatID); err != nil {
|
||||
slog.Warn("migration: scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
"INSERT OR IGNORE INTO users (chat_id, timezone) VALUES (?, 'Asia/Tehran')",
|
||||
chatID,
|
||||
); err != nil {
|
||||
slog.Warn("migration: failed to insert user", "chat_id", chatID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func migrateDaysOff(db *sql.DB) {
|
||||
if !hasTable(db, "days_off_old") {
|
||||
return
|
||||
}
|
||||
rows, err := db.Query("SELECT chat_id, date, reason FROM days_off_old")
|
||||
if err != nil {
|
||||
slog.Warn("migration: failed to query days_off_old", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var chatID int64
|
||||
var date, reason string
|
||||
if err := rows.Scan(&chatID, &date, &reason); err != nil {
|
||||
slog.Warn("migration: scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
var userID int64
|
||||
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
|
||||
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO days (user_id, date, is_day_off, day_off_reason) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, date) DO UPDATE SET is_day_off=1, day_off_reason=excluded.day_off_reason",
|
||||
userID, date, reason,
|
||||
); err != nil {
|
||||
slog.Warn("migration: failed to insert day_off", "user_id", userID, "date", date, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func migrateEvents(db *sql.DB) {
|
||||
if !hasTable(db, "events_old") {
|
||||
return
|
||||
}
|
||||
rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events_old ORDER BY occurred_at ASC")
|
||||
if err != nil {
|
||||
slog.Warn("migration: failed to query events_old", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var chatID int64
|
||||
var eventType, note string
|
||||
var occurredAt, createdAt int64
|
||||
if err := rows.Scan(&chatID, &eventType, &occurredAt, ¬e, &createdAt); err != nil {
|
||||
slog.Warn("migration: scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
var userID int64
|
||||
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
|
||||
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
|
||||
continue
|
||||
}
|
||||
date := time.Unix(occurredAt, 0).UTC().Format("2006-01-02")
|
||||
if _, err := db.Exec("INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date); err != nil {
|
||||
slog.Warn("migration: failed to insert day", "user_id", userID, "date", date, "error", err)
|
||||
continue
|
||||
}
|
||||
var dayID int64
|
||||
if err := db.QueryRow("SELECT id FROM days WHERE user_id=? AND date=?", userID, date).Scan(&dayID); err != nil {
|
||||
slog.Warn("migration: day not found", "user_id", userID, "date", date, "error", err)
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO events (user_id, day_id, event_type, occurred_at, note, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
userID, dayID, eventType, occurredAt, note, createdAt,
|
||||
); err != nil {
|
||||
slog.Warn("migration: failed to insert event", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func migrateSettings(db *sql.DB) {
|
||||
if !hasTable(db, "settings_old") {
|
||||
return
|
||||
}
|
||||
rows, err := db.Query("SELECT chat_id, key, value FROM settings_old")
|
||||
if err != nil {
|
||||
slog.Warn("migration: failed to query settings_old", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var chatID int64
|
||||
var key, value string
|
||||
if err := rows.Scan(&chatID, &key, &value); err != nil {
|
||||
slog.Warn("migration: scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if key == "remote_flag" {
|
||||
var userID int64
|
||||
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
|
||||
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
|
||||
continue
|
||||
}
|
||||
wt := int64(1)
|
||||
if value == "remote" {
|
||||
wt = 2
|
||||
}
|
||||
db.Exec("UPDATE days SET current_work_type_id=? WHERE user_id=? AND date=?", wt, userID, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
}
|
||||
}
|
||||
59
internal/repo/migrations/001_init.sql
Normal file
59
internal/repo/migrations/001_init.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS work_types (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO work_types (id, name) VALUES (1, 'onsite');
|
||||
INSERT OR IGNORE INTO work_types (id, name) VALUES (2, 'remote');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL UNIQUE,
|
||||
timezone TEXT NOT NULL DEFAULT 'Asia/Tehran',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
report_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
report_time TEXT NOT NULL DEFAULT '23:00',
|
||||
last_report_date TEXT DEFAULT NULL,
|
||||
export_accent TEXT NOT NULL DEFAULT 'ocean'
|
||||
CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')),
|
||||
calendar TEXT NOT NULL DEFAULT 'gregorian'
|
||||
CHECK (calendar IN ('gregorian', 'jalali', 'hijri')),
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS days (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
date TEXT NOT NULL CHECK (date = strftime('%Y-%m-%d', date)),
|
||||
is_day_off INTEGER NOT NULL DEFAULT 0,
|
||||
day_off_reason TEXT NOT NULL DEFAULT '',
|
||||
current_work_type_id INTEGER NOT NULL DEFAULT 1 REFERENCES work_types(id),
|
||||
min_break_threshold INTEGER NOT NULL DEFAULT 300,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE(user_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
day_id INTEGER NOT NULL REFERENCES days(id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('in', 'out')),
|
||||
work_type_id INTEGER REFERENCES work_types(id),
|
||||
occurred_at INTEGER NOT NULL CHECK (occurred_at > 0),
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user_day ON events(user_id, day_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user_occurred ON events(user_id, occurred_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_days_user_date ON days(user_id, date);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS events;
|
||||
DROP TABLE IF EXISTS days;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS work_types;
|
||||
101
internal/repo/migrations/002_features.sql
Normal file
101
internal/repo/migrations/002_features.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
|
||||
export_accent TEXT NOT NULL DEFAULT 'ocean'
|
||||
CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')),
|
||||
report_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
report_time TEXT NOT NULL DEFAULT '23:00',
|
||||
last_report_date TEXT NOT NULL DEFAULT '',
|
||||
rpg_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
league_opt_in INTEGER NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT '$ USD',
|
||||
hourly_rate REAL NOT NULL DEFAULT 0.0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_settings (user_id)
|
||||
SELECT id FROM users;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_rpg (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
|
||||
xp INTEGER NOT NULL DEFAULT 0,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
current_streak INTEGER NOT NULL DEFAULT 0,
|
||||
longest_streak INTEGER NOT NULL DEFAULT 0,
|
||||
total_work_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_rpg (user_id)
|
||||
SELECT id FROM users;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_xp (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
date TEXT NOT NULL,
|
||||
xp_earned INTEGER NOT NULL DEFAULT 0,
|
||||
work_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE(user_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO achievements (code, name, description) VALUES
|
||||
('first_clockin', 'First Steps', 'Clock in for the first time'),
|
||||
('early_bird', 'Early Bird', 'Clock in before 7 AM'),
|
||||
('night_owl', 'Night Owl', 'Clock out after 10 PM'),
|
||||
('iron_streak_5', 'Iron Streak', 'Work 5 consecutive days'),
|
||||
('iron_streak_10', 'Iron Streak II', 'Work 10 consecutive days'),
|
||||
('iron_streak_30', 'Iron Streak III', 'Work 30 consecutive days'),
|
||||
('remote_veteran', 'Remote Veteran', 'Log 100 hours of remote work'),
|
||||
('onsite_master', 'Onsite Master', 'Log 100 hours of onsite work'),
|
||||
('consistency_master', 'Consistency Master', 'Work at least 6 hours daily for a week'),
|
||||
('perfect_week', 'Perfect Week', 'No days off for a full week'),
|
||||
('overtime_hero', 'Overtime Hero', 'Work more than 10 hours in a day'),
|
||||
('level_5', 'Getting Started', 'Reach level 5'),
|
||||
('level_10', 'Dedicated', 'Reach level 10'),
|
||||
('level_25', 'Veteran', 'Reach level 25'),
|
||||
('level_50', 'Legend', 'Reach level 50'),
|
||||
('weekend_warrior', 'Weekend Warrior', 'Work on a weekend day'),
|
||||
('hundred_hours', 'Century', 'Log 100 total work hours'),
|
||||
('five_hundred_hours', 'Iron Will', 'Log 500 total work hours'),
|
||||
('salary_setter', 'Salary Tracker', 'Configure your hourly rate'),
|
||||
('rpg_pioneer', 'RPG Pioneer', 'Enable the RPG system');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
achievement_id INTEGER NOT NULL REFERENCES achievements(id),
|
||||
unlocked_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE(user_id, achievement_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_xp_user_date ON daily_xp(user_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_achievements;
|
||||
DROP TABLE IF EXISTS achievements;
|
||||
DROP TABLE IF EXISTS daily_xp;
|
||||
DROP TABLE IF EXISTS user_rpg;
|
||||
DROP TABLE IF EXISTS user_settings;
|
||||
CREATE TABLE IF NOT EXISTS users_old AS SELECT * FROM users;
|
||||
DROP TABLE IF EXISTS users;
|
||||
-- Note: restoring users table without the username column requires
|
||||
-- recreating it from the backup. In practice this migration is safe
|
||||
-- and the down path preserves data via the backup table.
|
||||
ALTER TABLE users_old RENAME TO users;
|
||||
607
internal/repo/store.go
Normal file
607
internal/repo/store.go
Normal file
@@ -0,0 +1,607 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"worktimeBot/internal/domain"
|
||||
)
|
||||
|
||||
const driverName = "sqlite"
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(path string) (*Store, error) {
|
||||
db, err := sql.Open(driverName, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
slog.Warn("failed to set WAL mode", "error", err)
|
||||
}
|
||||
if err := runMigrations(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
var usernames = []string{
|
||||
"Lazy Dev", "Solo Leveler", "Local = Prod", "Database Rat",
|
||||
"Refactoring Perfectionist", "PHP 5 Cultist", "Java Script Slave",
|
||||
"AI Slop", "OOP Cultist", "Functional Purist",
|
||||
"Merge Conflict Survivor", "Stack Overflow Diplomat",
|
||||
"NULL Pointer", "Memory Leak", "Segmentation Fault",
|
||||
"Infinite Looper", "TypeScript Wizard", "CSS Box Model Gambler",
|
||||
"Docker Compose Gambler", "YAML Engineer",
|
||||
"Production Pusher", "Test Skipper", "Code Review Dodger",
|
||||
"Commit --force User", "Git Merge Master",
|
||||
"Dependency Hell Dweller", "Deprecation Warning",
|
||||
"Legacy Code Whisperer", "Agile Scrum Lord", "Stand Up Sitter",
|
||||
"Sprint Burnout", "Ticket Creator Supreme",
|
||||
"Technical Debt Collector", "Documentation? Never Heard",
|
||||
"Stack Trace Reader", "Binary Search Debugger", "Rubber Duck",
|
||||
"Hotfix Hero", "Feature Flag Addict",
|
||||
}
|
||||
|
||||
func randomUsername() string {
|
||||
return usernames[time.Now().UnixNano()%int64(len(usernames))]
|
||||
}
|
||||
|
||||
func (s *Store) GetOrCreateUser(chatID int64) (*domain.User, error) {
|
||||
if _, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)",
|
||||
chatID, randomUsername(),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetUserByChatID(chatID)
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByChatID(chatID int64) (*domain.User, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
|
||||
FROM users WHERE chat_id = ?
|
||||
`, chatID)
|
||||
var u domain.User
|
||||
if err := row.Scan(
|
||||
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
|
||||
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUser(user *domain.User) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?",
|
||||
user.Username, user.Timezone, user.IsActive, user.Calendar, user.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetAllUsers() ([]domain.User, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
|
||||
FROM users
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var users []domain.User
|
||||
for rows.Next() {
|
||||
var u domain.User
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
|
||||
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// Settings
|
||||
|
||||
func (s *Store) GetOrCreateSettings(userID int64) (*domain.UserSettings, error) {
|
||||
if _, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)",
|
||||
userID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getSettings(userID)
|
||||
}
|
||||
|
||||
func (s *Store) getSettings(userID int64) (*domain.UserSettings, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT user_id, export_accent, report_enabled, report_time, last_report_date,
|
||||
rpg_enabled, league_opt_in, currency, hourly_rate, created_at, updated_at
|
||||
FROM user_settings WHERE user_id = ?
|
||||
`, userID)
|
||||
var st domain.UserSettings
|
||||
if err := row.Scan(
|
||||
&st.UserID, &st.ExportAccent, &st.ReportEnabled, &st.ReportTime, &st.LastReportDate,
|
||||
&st.RPGEnabled, &st.LeagueOptIn, &st.Currency, &st.HourlyRate, &st.CreatedAt, &st.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSettings(st *domain.UserSettings) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE user_settings SET export_accent=?, report_enabled=?, report_time=?,
|
||||
last_report_date=?, rpg_enabled=?, league_opt_in=?, currency=?,
|
||||
hourly_rate=?, updated_at=unixepoch()
|
||||
WHERE user_id=?
|
||||
`, st.ExportAccent, st.ReportEnabled, st.ReportTime, st.LastReportDate,
|
||||
st.RPGEnabled, st.LeagueOptIn, st.Currency, st.HourlyRate, st.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) MarkReportSent(userID int64, date string) error {
|
||||
st, err := s.GetOrCreateSettings(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.LastReportDate = date
|
||||
return s.UpdateSettings(st)
|
||||
}
|
||||
|
||||
// RPG Stats
|
||||
|
||||
func (s *Store) GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error) {
|
||||
if _, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO user_rpg (user_id) VALUES (?)",
|
||||
userID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getRPGStats(userID)
|
||||
}
|
||||
|
||||
func (s *Store) getRPGStats(userID int64) (*domain.RPGStats, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT user_id, xp, level, current_streak, longest_streak, total_work_seconds,
|
||||
created_at, updated_at
|
||||
FROM user_rpg WHERE user_id = ?
|
||||
`, userID)
|
||||
var st domain.RPGStats
|
||||
if err := row.Scan(
|
||||
&st.UserID, &st.XP, &st.Level, &st.CurrentStreak, &st.LongestStreak,
|
||||
&st.TotalWorkSeconds, &st.CreatedAt, &st.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateRPGStats(st *domain.RPGStats) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE user_rpg SET xp=?, level=?, current_streak=?, longest_streak=?,
|
||||
total_work_seconds=?, updated_at=unixepoch()
|
||||
WHERE user_id=?
|
||||
`, st.XP, st.Level, st.CurrentStreak, st.LongestStreak, st.TotalWorkSeconds, st.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Daily XP
|
||||
|
||||
func (s *Store) UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, date) DO UPDATE SET
|
||||
xp_earned = xp_earned + ?,
|
||||
work_seconds = work_seconds + ?
|
||||
`, userID, date, xpEarned, workSeconds, xpEarned, workSeconds)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds)
|
||||
VALUES (?, ?, 0, ?)
|
||||
ON CONFLICT(user_id, date) DO UPDATE SET
|
||||
work_seconds = ?
|
||||
`, userID, date, workSeconds, workSeconds)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) {
|
||||
var total int64
|
||||
err := s.db.QueryRow(
|
||||
"SELECT COALESCE(SUM(work_seconds), 0) FROM daily_xp WHERE user_id=?", userID,
|
||||
).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
// Achievements
|
||||
|
||||
func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) {
|
||||
var a domain.Achievement
|
||||
err := s.db.QueryRow(
|
||||
"SELECT id, code, name, description, icon FROM achievements WHERE code=?",
|
||||
code,
|
||||
).Scan(&a.ID, &a.Code, &a.Name, &a.Description, &a.Icon)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserAchievements(userID int64) ([]domain.UserAchievement, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT ua.id, ua.user_id, ua.achievement_id, a.code, a.name, a.description, ua.unlocked_at
|
||||
FROM user_achievements ua
|
||||
JOIN achievements a ON a.id = ua.achievement_id
|
||||
WHERE ua.user_id = ?
|
||||
ORDER BY ua.unlocked_at ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var uas []domain.UserAchievement
|
||||
for rows.Next() {
|
||||
var ua domain.UserAchievement
|
||||
if err := rows.Scan(&ua.ID, &ua.UserID, &ua.AchievementID, &ua.Code, &ua.Name, &ua.Description, &ua.UnlockedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uas = append(uas, ua)
|
||||
}
|
||||
return uas, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UnlockAchievement(userID int64, achievementID int64) (bool, error) {
|
||||
res, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO user_achievements (user_id, achievement_id) VALUES (?, ?)",
|
||||
userID, achievementID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) HasAchievement(userID int64, code string) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(`
|
||||
SELECT COUNT(1) FROM user_achievements ua
|
||||
JOIN achievements a ON a.id = ua.achievement_id
|
||||
WHERE ua.user_id=? AND a.code=?
|
||||
`, userID, code).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// League
|
||||
|
||||
func (s *Store) GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error) {
|
||||
validOrders := map[string]bool{"xp": true, "level": true, "streak": true, "hours": true}
|
||||
if !validOrders[orderBy] {
|
||||
orderBy = "xp"
|
||||
}
|
||||
col := "r.xp"
|
||||
switch orderBy {
|
||||
case "level":
|
||||
col = "r.level"
|
||||
case "streak":
|
||||
col = "r.current_streak"
|
||||
case "hours":
|
||||
col = "r.total_work_seconds"
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT u.id, u.username, r.xp, r.level, r.current_streak, r.total_work_seconds
|
||||
FROM user_rpg r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
JOIN user_settings s ON s.user_id = r.user_id
|
||||
WHERE s.league_opt_in = 1
|
||||
ORDER BY %s DESC, u.username ASC
|
||||
`, col)
|
||||
rows, err := s.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var entries []domain.LeagueEntry
|
||||
for rows.Next() {
|
||||
var e domain.LeagueEntry
|
||||
if err := rows.Scan(&e.UserID, &e.Username, &e.XP, &e.Level, &e.Streak, &e.TotalHours); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.TotalHours = float64(e.TotalHours) / 3600.0
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// Work Types
|
||||
|
||||
func (s *Store) GetWorkTypes() ([]domain.WorkType, error) {
|
||||
rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var wts []domain.WorkType
|
||||
for rows.Next() {
|
||||
var wt domain.WorkType
|
||||
if err := rows.Scan(&wt.ID, &wt.Name, &wt.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wts = append(wts, wt)
|
||||
}
|
||||
return wts, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetWorkType(id int64) (*domain.WorkType, error) {
|
||||
var wt domain.WorkType
|
||||
err := s.db.QueryRow("SELECT id, name, created_at FROM work_types WHERE id=?", id).Scan(&wt.ID, &wt.Name, &wt.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wt, nil
|
||||
}
|
||||
|
||||
// Days
|
||||
|
||||
func (s *Store) GetOrCreateDay(userID int64, date string) (*domain.Day, error) {
|
||||
if _, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)",
|
||||
userID, date,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetDay(userID, date)
|
||||
}
|
||||
|
||||
func (s *Store) GetDay(userID int64, date string) (*domain.Day, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, user_id, date, is_day_off, day_off_reason,
|
||||
current_work_type_id, min_break_threshold, created_at, updated_at
|
||||
FROM days WHERE user_id=? AND date=?
|
||||
`, userID, date)
|
||||
var d domain.Day
|
||||
if err := row.Scan(
|
||||
&d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason,
|
||||
&d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateDay(day *domain.Day) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE days SET is_day_off=?, day_off_reason=?, current_work_type_id=?, min_break_threshold=?, updated_at=unixepoch() WHERE id=?",
|
||||
day.IsDayOff, day.DayOffReason, day.CurrentWorkTypeID, day.MinBreakThreshold, day.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetDayOff(userID int64, date string, reason string) error {
|
||||
if _, err := s.GetOrCreateDay(userID, date); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE days SET is_day_off=1, day_off_reason=?, updated_at=unixepoch() WHERE user_id=? AND date=?",
|
||||
reason, userID, date,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveDayOff(userID int64, date string) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE days SET is_day_off=0, day_off_reason='', updated_at=unixepoch() WHERE user_id=? AND date=?",
|
||||
userID, date,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]domain.Day, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, date, is_day_off, day_off_reason,
|
||||
current_work_type_id, min_break_threshold, created_at, updated_at
|
||||
FROM days
|
||||
WHERE user_id=? AND date >= ? AND date <= ?
|
||||
ORDER BY date ASC
|
||||
`, userID, startDate, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var days []domain.Day
|
||||
for rows.Next() {
|
||||
var d domain.Day
|
||||
if err := rows.Scan(
|
||||
&d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason,
|
||||
&d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
days = append(days, d)
|
||||
}
|
||||
return days, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetDatesWithEvents(userID int64) ([]string, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT DISTINCT d.date
|
||||
FROM days d
|
||||
JOIN events e ON e.day_id = d.id
|
||||
WHERE d.user_id = ?
|
||||
ORDER BY d.date ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var dates []string
|
||||
for rows.Next() {
|
||||
var date string
|
||||
if err := rows.Scan(&date); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dates = append(dates, date)
|
||||
}
|
||||
return dates, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetDistinctEventDates(userID int64, startDate, endDate string) ([]string, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT DISTINCT d.date FROM days d JOIN events e ON e.day_id = d.id WHERE d.user_id=? AND d.date >= ? AND d.date <= ? ORDER BY d.date",
|
||||
userID, startDate, endDate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var dates []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dates = append(dates, d)
|
||||
}
|
||||
return dates, rows.Err()
|
||||
}
|
||||
|
||||
// Events
|
||||
|
||||
func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO events (user_id, day_id, event_type, work_type_id, occurred_at, note) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
userID, dayID, eventType, workTypeID, occurredAt, note,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateEventWorkType(eventID, workTypeID int64) error {
|
||||
_, err := s.db.Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateEventTime(eventID, occurredAt int64) error {
|
||||
_, err := s.db.Exec("UPDATE events SET occurred_at=? WHERE id=?", occurredAt, eventID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteEvent(eventID int64) error {
|
||||
_, err := s.db.Exec("DELETE FROM events WHERE id=?", eventID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetEventByID(eventID, userID int64) (*domain.Event, error) {
|
||||
row := s.db.QueryRow(
|
||||
"SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE id=? AND user_id=?",
|
||||
eventID, userID,
|
||||
)
|
||||
var e domain.Event
|
||||
var wt sql.NullInt64
|
||||
if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wt.Valid {
|
||||
e.WorkTypeID = &wt.Int64
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetLastEvent(userID int64) (*domain.Event, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
|
||||
FROM events
|
||||
WHERE user_id = ?
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT 1
|
||||
`, userID)
|
||||
var e domain.Event
|
||||
var wt sql.NullInt64
|
||||
if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if wt.Valid {
|
||||
e.WorkTypeID = &wt.Int64
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (s *Store) EventsForDayByDate(userID int64, date string) ([]domain.Event, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT e.id, e.user_id, e.day_id, e.event_type, e.work_type_id, e.occurred_at, e.note, e.created_at
|
||||
FROM events e
|
||||
JOIN days d ON d.id = e.day_id
|
||||
WHERE d.user_id = ? AND d.date = ?
|
||||
ORDER BY e.occurred_at ASC
|
||||
`, userID, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []domain.Event
|
||||
for rows.Next() {
|
||||
var e domain.Event
|
||||
var wt sql.NullInt64
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wt.Valid {
|
||||
e.WorkTypeID = &wt.Int64
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) EventsForDayByDayID(dayID int64) ([]domain.Event, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
|
||||
FROM events
|
||||
WHERE day_id = ?
|
||||
ORDER BY occurred_at ASC
|
||||
`, dayID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []domain.Event
|
||||
for rows.Next() {
|
||||
var e domain.Event
|
||||
var wt sql.NullInt64
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wt.Valid {
|
||||
e.WorkTypeID = &wt.Int64
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SetEventNote(eventID int64, note string) error {
|
||||
_, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user