861e418669
worker/server.go: dispatch now receives a per-connection context instead of context.Background(), so handler cancellation propagates on conn close. voice/server.go: same — per-conn context fed through safeDispatch into HandlePushToTalk instead of context.Background(). store/reminders.go: propagate LastInsertId error. store/nudges.go: propagate LastInsertId and RowsAffected errors. store/tools.go: propagate RowsAffected error. config/config.go: applyDefaults now respects StateDir when set, using it as the base for empty DBPath/SocketPath instead of silently ignoring it. phraser/llmphraser.go: close stderr pipe fd when cmd.Start() fails.
90 lines
2.8 KiB
Go
90 lines
2.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Reminder — user-stated future intent. fires once. relative→absolute happens
|
|
// at capture ("in 4h" → store now+4h, never the string).
|
|
type Reminder struct {
|
|
ID int64
|
|
CreatedTs time.Time
|
|
FireTs time.Time
|
|
Payload string // raw json
|
|
Status string // pending | fired | cancelled
|
|
}
|
|
|
|
var (
|
|
ErrReminderNotFound = errors.New("store: reminder not found")
|
|
ErrReminderState = errors.New("store: reminder not in a mutable state")
|
|
)
|
|
|
|
// CreateReminder persists a reminder with a resolved absolute fire time.
|
|
// The caller (router/capture path) MUST have already converted "in 4h" → now+4h.
|
|
// We do not accept strings here.
|
|
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) {
|
|
now := time.Now().UTC()
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO reminders (created_ts, fire_ts, payload, status) VALUES (?,?,?, 'pending')`,
|
|
now.UnixMilli(), fire.UnixMilli(), payload)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create reminder: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create reminder: last insert id: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// DueReminders returns pending reminders with fire_ts <= now, oldest first.
|
|
// This is the predicate input from the loop side: `fire_ts <= now AND status='pending'`.
|
|
func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, created_ts, fire_ts, payload, status
|
|
FROM reminders
|
|
WHERE status = 'pending' AND fire_ts <= ?
|
|
ORDER BY fire_ts ASC`, now.UnixMilli())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("due reminders: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []Reminder
|
|
for rows.Next() {
|
|
var r Reminder
|
|
var created, fire int64
|
|
if err := rows.Scan(&r.ID, &created, &fire, &r.Payload, &r.Status); err != nil {
|
|
return nil, err
|
|
}
|
|
r.CreatedTs = time.UnixMilli(created).UTC()
|
|
r.FireTs = time.UnixMilli(fire).UTC()
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// MarkReminder sets a reminder's status. Only valid transitions: pending→fired,
|
|
// pending→cancelled. Anything else is a programming error.
|
|
func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error {
|
|
if status != "fired" && status != "cancelled" {
|
|
return fmt.Errorf("%w: %s", ErrReminderState, status)
|
|
}
|
|
// pending → fired|cancelled only.
|
|
var current string
|
|
err := s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(¤t)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrReminderNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current != "pending" {
|
|
return fmt.Errorf("%w: currently %s", ErrReminderState, current)
|
|
}
|
|
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
|
|
return err
|
|
} |