package store import ( "context" "fmt" "time" ) type UserShow struct { ItemID string `json:"itemId"` Title string `json:"title"` Year *int `json:"year,omitempty"` ImageTag string `json:"imageTag,omitempty"` AddedAt time.Time `json:"addedAt"` } type NotificationPreferences struct { Enabled bool `json:"enabled"` ShowReturnAlerts bool `json:"showReturnAlerts"` LeadDays int `json:"leadDays"` } type UserNotification struct { ID int64 `json:"id"` Kind string `json:"kind"` ItemID string `json:"itemId,omitempty"` Title string `json:"title"` Message string `json:"message"` EventAt *time.Time `json:"eventAt,omitempty"` CreatedAt time.Time `json:"createdAt"` ReadAt *time.Time `json:"readAt,omitempty"` } func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error { _, err := s.pool.Exec(ctx, ` INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (emby_user_id, item_id) DO UPDATE SET title = EXCLUDED.title, year = EXCLUDED.year, image_tag = EXCLUDED.image_tag`, userID, show.ItemID, show.Title, show.Year, show.ImageTag) return err } // SaveUserShowIfAbsent is the auto-follow path. Manual saves intentionally refresh // metadata, while playback must only announce a show when it actually added it. func (s *Store) SaveUserShowIfAbsent(ctx context.Context, userID string, show UserShow) (bool, error) { result, err := s.pool.Exec(ctx, ` INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (emby_user_id, item_id) DO NOTHING`, userID, show.ItemID, show.Title, show.Year, show.ImageTag) if err != nil { return false, err } return result.RowsAffected() == 1, nil } func (s *Store) DeleteUserShow(ctx context.Context, userID, itemID string) error { _, err := s.pool.Exec(ctx, ` WITH removed AS ( DELETE FROM user_shows WHERE emby_user_id = $1 AND item_id = $2 RETURNING item_id ) UPDATE user_notifications SET dismissed_at = now() WHERE emby_user_id = $1 AND item_id IN (SELECT item_id FROM removed) AND dismissed_at IS NULL`, userID, itemID) return err } func (s *Store) UserShows(ctx context.Context, userID string) ([]UserShow, error) { rows, err := s.pool.Query(ctx, ` SELECT item_id, title, year, image_tag, added_at FROM user_shows WHERE emby_user_id = $1 ORDER BY added_at, lower(title)`, userID) if err != nil { return nil, fmt.Errorf("store: list user shows: %w", err) } defer rows.Close() shows := []UserShow{} for rows.Next() { var show UserShow if err := rows.Scan(&show.ItemID, &show.Title, &show.Year, &show.ImageTag, &show.AddedAt); err != nil { return nil, fmt.Errorf("store: scan user show: %w", err) } shows = append(shows, show) } return shows, rows.Err() } func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) { prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7} err := s.pool.QueryRow(ctx, ` SELECT enabled, show_return_alerts, lead_days FROM user_notification_preferences WHERE emby_user_id = $1`, userID). Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.LeadDays) if err != nil && !isNoRows(err) { return prefs, fmt.Errorf("store: notification preferences: %w", err) } return prefs, nil } func (s *Store) SetNotificationPreferences( ctx context.Context, userID string, prefs NotificationPreferences, ) error { if prefs.LeadDays < 1 { prefs.LeadDays = 1 } if prefs.LeadDays > 30 { prefs.LeadDays = 30 } _, err := s.pool.Exec(ctx, ` INSERT INTO user_notification_preferences (emby_user_id, enabled, show_return_alerts, lead_days) VALUES ($1, $2, $3, $4) ON CONFLICT (emby_user_id) DO UPDATE SET enabled = EXCLUDED.enabled, show_return_alerts = EXCLUDED.show_return_alerts, lead_days = EXCLUDED.lead_days, updated_at = now()`, userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.LeadDays) return err } func (s *Store) UpsertNotification( ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time, ) error { _, err := s.pool.Exec(ctx, ` INSERT INTO user_notifications (emby_user_id, source_key, kind, item_id, title, message, event_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (emby_user_id, source_key) DO NOTHING`, userID, sourceKey, kind, itemID, title, message, eventAt) return err } func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) { rows, err := s.pool.Query(ctx, ` SELECT id, kind, item_id, title, message, event_at, created_at, read_at FROM user_notifications WHERE emby_user_id = $1 AND dismissed_at IS NULL ORDER BY created_at DESC LIMIT 100`, userID) if err != nil { return nil, fmt.Errorf("store: list notifications: %w", err) } defer rows.Close() notifications := []UserNotification{} for rows.Next() { var notification UserNotification if err := rows.Scan( ¬ification.ID, ¬ification.Kind, ¬ification.ItemID, ¬ification.Title, ¬ification.Message, ¬ification.EventAt, ¬ification.CreatedAt, ¬ification.ReadAt, ); err != nil { return nil, fmt.Errorf("store: scan notification: %w", err) } notifications = append(notifications, notification) } return notifications, rows.Err() } func (s *Store) MarkNotificationRead(ctx context.Context, userID string, id int64) error { _, err := s.pool.Exec(ctx, ` UPDATE user_notifications SET read_at = COALESCE(read_at, now()) WHERE id = $1 AND emby_user_id = $2`, id, userID) return err } func (s *Store) DismissNotification(ctx context.Context, userID string, id int64) error { _, err := s.pool.Exec(ctx, ` UPDATE user_notifications SET dismissed_at = now() WHERE id = $1 AND emby_user_id = $2`, id, userID) return err }