All checks were successful
CI / build (push) Successful in 48s
- Add /setcalendar command for calendar type parity (Settings UI) - Add 17 store tests covering all repository methods - Add handler tests for formatDuration, fmtHHMM, fmtDDHHMM - Add domain tests for BurnoutLevel.String, NavMonth, FormatSalary - Fix silent error discarding in save* helpers (log write failures) - Fix computeAndAwardXP error handling instead of discarding - Wrap critical store.go SQL errors with fmt.Errorf context - Update README commands table with /setcalendar
697 lines
20 KiB
Go
697 lines
20 KiB
Go
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, fmt.Errorf("open: %w", err)
|
|
}
|
|
if err := db.Ping(); err != nil {
|
|
return nil, fmt.Errorf("ping: %w", 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, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
func (s *Store) DeleteUserData(userID int64) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("delete user data begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.Exec(`DELETE FROM events WHERE day_id IN (SELECT id FROM days WHERE user_id=?)`, userID); err != nil {
|
|
return fmt.Errorf("delete user data events: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM days WHERE user_id=?`, userID); err != nil {
|
|
return fmt.Errorf("delete user data days: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM user_achievements WHERE user_id=?`, userID); err != nil {
|
|
return fmt.Errorf("delete user data achievements: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM daily_xp WHERE user_id=?`, userID); err != nil {
|
|
return fmt.Errorf("delete user data daily_xp: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM user_rpg WHERE user_id=?`, userID); err != nil {
|
|
return fmt.Errorf("delete user data rpg: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM user_settings WHERE user_id=?`, userID); err != nil {
|
|
return fmt.Errorf("delete user data settings: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM users WHERE id=?`, userID); err != nil {
|
|
return fmt.Errorf("delete user data users: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("delete user data commit: %w", err)
|
|
}
|
|
return 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, fmt.Errorf("get or create user: %w", 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, fmt.Errorf("get user by chat id: %w", 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,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update user: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (s *Store) GetDailyXPInRange(userID int64, start, end string) ([]domain.DailyXP, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, user_id, date, xp_earned, work_seconds, created_at
|
|
FROM daily_xp WHERE user_id=? AND date >= ? AND date <= ?
|
|
ORDER BY date`, userID, start, end,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var result []domain.DailyXP
|
|
for rows.Next() {
|
|
var x domain.DailyXP
|
|
if err := rows.Scan(&x.ID, &x.UserID, &x.Date, &x.XPEarned, &x.WorkSeconds, &x.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, x)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *Store) SumWorkSecondsByWorkType(userID int64) (remoteSeconds, onsiteSeconds int64, _ error) {
|
|
// Each day has a current_work_type_id. Daily XP work_seconds are per day.
|
|
// Join with days to get the day's primary work type, then sum by type.
|
|
rows, err := s.db.Query(`
|
|
SELECT COALESCE(d.current_work_type_id, 1), COALESCE(SUM(dx.work_seconds), 0)
|
|
FROM daily_xp dx
|
|
JOIN days d ON d.user_id = dx.user_id AND d.date = dx.date
|
|
WHERE dx.user_id = ? AND dx.work_seconds > 0
|
|
GROUP BY d.current_work_type_id
|
|
`, userID)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var wtID int64
|
|
var secs int64
|
|
if err := rows.Scan(&wtID, &secs); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
switch wtID {
|
|
case 2:
|
|
remoteSeconds += secs
|
|
default:
|
|
onsiteSeconds += secs
|
|
}
|
|
}
|
|
return remoteSeconds, onsiteSeconds, rows.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
|
|
}
|