package storage import ( "encoding/json" "strings" "time" "github.com/sandrodach/mindsift/internal/domain" ) // Notifications: per-user settings (JSON on the user), a queue that is sent bundled every few minutes, // task watchers and the bookkeeping for the daily summary. func (s *Store) migrateNotifications() error { if err := s.addColumnIfMissing("users", "notification_settings", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } if err := s.addColumnIfMissing("users", "last_daily_summary", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } return s.execInTx( stmt(`CREATE TABLE IF NOT EXISTS notification_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 )`), stmt(`CREATE INDEX IF NOT EXISTS idx_notification_queue_user ON notification_queue(user_id, created_at)`), stmt(`CREATE TABLE IF NOT EXISTS task_watchers ( task_id TEXT NOT NULL, user_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (task_id, user_id) )`), ) } // NotificationSettingsOf returns the user's settings, defaults filled in for users who never saved any. func (s *Store) NotificationSettingsOf(userID string) domain.NotificationSettings { var raw, channels string _ = s.db.QueryRow(`SELECT notification_settings, alarm_channels FROM users WHERE id = ?`, userID).Scan(&raw, &channels) out := domain.DefaultNotificationSettings(parseAlarmChannels(channels)) if raw != "" { _ = json.Unmarshal([]byte(raw), &out) } if !validClock(out.DailySummary.Time) { out.DailySummary.Time = "07:00" } return out } func validClock(v string) bool { t, err := time.Parse("15:04", v) return err == nil && t.Format("15:04") == v } func (s *Store) SetNotificationSettings(userID string, n domain.NotificationSettings) error { if !validClock(n.DailySummary.Time) { n.DailySummary.Time = "07:00" } raw, _ := json.Marshal(n) _, err := s.db.Exec(`UPDATE users SET notification_settings = ? WHERE id = ?`, string(raw), userID) return err } // QueuedNotification is one event waiting to be sent bundled. type QueuedNotification struct { ID int64 UserID string Kind string Payload map[string]string CreatedAt string Attempts int } func (s *Store) QueueNotification(userID, kind string, payload map[string]string) error { raw, _ := json.Marshal(payload) _, err := s.db.Exec(`INSERT INTO notification_queue (user_id, kind, payload, created_at) VALUES (?, ?, ?, ?)`, userID, kind, string(raw), time.Now().UTC().Format(time.RFC3339)) return err } // DueNotificationUsers lists users whose oldest waiting event is older than the bundling window. func (s *Store) DueNotificationUsers(olderThan time.Time) ([]string, error) { rows, err := s.db.Query(`SELECT user_id FROM notification_queue GROUP BY user_id HAVING MIN(created_at) <= ?`, olderThan.UTC().Format(time.RFC3339)) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var id string if rows.Scan(&id) == nil { out = append(out, id) } } return out, rows.Err() } func (s *Store) QueuedNotifications(userID string) ([]QueuedNotification, error) { rows, err := s.db.Query(`SELECT id, user_id, kind, payload, created_at, attempts FROM notification_queue WHERE user_id = ? ORDER BY id`, userID) if err != nil { return nil, err } defer rows.Close() var out []QueuedNotification for rows.Next() { var q QueuedNotification var raw string if rows.Scan(&q.ID, &q.UserID, &q.Kind, &raw, &q.CreatedAt, &q.Attempts) == nil { _ = json.Unmarshal([]byte(raw), &q.Payload) out = append(out, q) } } return out, rows.Err() } func (s *Store) DeleteQueuedNotifications(ids []int64) error { for _, id := range ids { if _, err := s.db.Exec(`DELETE FROM notification_queue WHERE id = ?`, id); err != nil { return err } } return nil } func (s *Store) BumpQueuedNotifications(ids []int64) error { for _, id := range ids { if _, err := s.db.Exec(`UPDATE notification_queue SET attempts = attempts + 1 WHERE id = ?`, id); err != nil { return err } } return nil } // --- Task watchers --- func (s *Store) WatchTask(taskID, userID string, on bool) error { if on { _, err := s.db.Exec(`INSERT OR IGNORE INTO task_watchers (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, userID, nowUTC()) return err } _, err := s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ? AND user_id = ?`, taskID, userID) return err } func (s *Store) TaskWatchers(taskID string) []string { rows, err := s.db.Query(`SELECT user_id FROM task_watchers WHERE task_id = ?`, taskID) if err != nil { return nil } defer rows.Close() var out []string for rows.Next() { var id string if rows.Scan(&id) == nil { out = append(out, id) } } return out } // WatchedTaskIDs is the set of tasks the user watches (to mark them in task lists). func (s *Store) WatchedTaskIDs(userID string) map[string]bool { out := map[string]bool{} rows, err := s.db.Query(`SELECT task_id FROM task_watchers WHERE user_id = ?`, userID) if err != nil { return out } defer rows.Close() for rows.Next() { var id string if rows.Scan(&id) == nil { out[id] = true } } return out } func (s *Store) ForgetTaskWatchers(taskID string) { _, _ = s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ?`, taskID) } // --- Daily summary --- // SummaryTask is an open task with a due date, for the daily summary. type SummaryTask struct { Title, DueDate, ListTitle string } // ActiveUserIDs lists all active accounts (the summary job checks their settings). func (s *Store) ActiveUserIDs() []string { rows, err := s.db.Query(`SELECT id FROM users WHERE is_active = 1`) if err != nil { return nil } defer rows.Close() var out []string for rows.Next() { var id string if rows.Scan(&id) == nil { out = append(out, id) } } return out } // OpenTasksDueBefore lists the user's open tasks (own ones, those of own lists and of lists shared with // the user directly or through a group) due before // the given day (YYYY-MM-DD, exclusive), oldest first. func (s *Store) OpenTasksDueBefore(userID, beforeDay string) ([]SummaryTask, error) { rows, err := s.db.Query(`SELECT t.content, t.due_date, COALESCE(l.title, '') FROM thoughts t LEFT JOIN task_lists l ON l.id = t.task_list_id WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task')) AND t.is_completed = 0 AND t.due_date != '' AND substr(t.due_date, 1, 10) < ? AND (t.user_id = ? OR (t.task_list_id != '' AND t.task_list_id IN ( SELECT id FROM task_lists WHERE user_id = ? UNION SELECT task_list_id FROM task_list_shares WHERE target_type = 'user' AND target_id = ? UNION SELECT task_list_id FROM task_list_shares WHERE target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)))) ORDER BY t.due_date`, beforeDay, userID, userID, userID, userID) if err != nil { return nil, err } defer rows.Close() var out []SummaryTask for rows.Next() { var t SummaryTask if rows.Scan(&t.Title, &t.DueDate, &t.ListTitle) == nil { t.Title = strings.TrimSpace(strings.SplitN(t.Title, "\n", 2)[0]) out = append(out, t) } } return out, rows.Err() } // ClaimDailySummary marks today's summary as sent; false when it was already sent today. func (s *Store) ClaimDailySummary(userID, day string) bool { res, err := s.db.Exec(`UPDATE users SET last_daily_summary = ? WHERE id = ? AND last_daily_summary != ?`, day, userID, day) if err != nil { return false } n, _ := res.RowsAffected() return n == 1 } // SharedFolderName is the display name of a shared workspace folder. func (s *Store) SharedFolderName(folderID string) string { var name string _ = s.db.QueryRow(`SELECT name FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&name) if name == "" { name = "Ordner" } return name }