Removes the unused GET /api/activities list/detail endpoints, moves the review-queue list and resolve/unlock/unassign actions under /api/activities, renames resolve to assign end-to-end, and drops the now-unused store.ReviewQueue helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
130 lines
4.7 KiB
Go
130 lines
4.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
)
|
|
|
|
const (
|
|
AssignmentSourceRuleEngine = "rule_engine"
|
|
AssignmentSourceManual = "manual"
|
|
|
|
AssignmentStatusAssigned = "assigned"
|
|
AssignmentStatusNeedsReview = "needs_review"
|
|
)
|
|
|
|
// KindAssignment is one append-only classification decision for an
|
|
// activity. New assignments (rule re-run or manual override) are always
|
|
// inserted, never updated, so the full history survives.
|
|
type KindAssignment struct {
|
|
ID int64
|
|
ActivityID int64
|
|
WorkoutKindID *int64
|
|
AssignmentSource string
|
|
Status string
|
|
Confidence *float64
|
|
CandidateKindsJSON string
|
|
CreatedAt string
|
|
}
|
|
|
|
// InsertKindAssignment appends a new assignment row for an activity owned
|
|
// by userID.
|
|
func (db *DB) InsertKindAssignment(ctx context.Context, userID int64, a KindAssignment) (int64, error) {
|
|
var exists int
|
|
err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, a.ActivityID, userID).Scan(&exists)
|
|
if err == sql.ErrNoRows {
|
|
return 0, fmt.Errorf("insert kind assignment: activity %d not found for user %d", a.ActivityID, userID)
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("insert kind assignment for activity %d (user %d): %w", a.ActivityID, userID, err)
|
|
}
|
|
|
|
res, err := db.ExecContext(ctx, `
|
|
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
|
|
VALUES (?,?,?,?,?,?)`,
|
|
a.ActivityID, a.WorkoutKindID, a.AssignmentSource, a.Status, a.Confidence, a.CandidateKindsJSON)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("insert kind assignment for activity %d: %w", a.ActivityID, err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, error) {
|
|
var a KindAssignment
|
|
err := row.Scan(&a.ID, &a.ActivityID, &a.WorkoutKindID, &a.AssignmentSource, &a.Status, &a.Confidence, &a.CandidateKindsJSON, &a.CreatedAt)
|
|
return a, err
|
|
}
|
|
|
|
const kindAssignmentColumns = `a.id, a.activity_id, a.workout_kind_id, a.assignment_source, a.status, a.confidence, a.candidate_kinds_json, a.created_at`
|
|
|
|
// CurrentAssignment returns the latest assignment for an activity owned by
|
|
// userID, if any.
|
|
func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (KindAssignment, bool, error) {
|
|
row := db.QueryRowContext(ctx, `
|
|
SELECT `+kindAssignmentColumns+`
|
|
FROM current_kind_assignment a
|
|
JOIN activities ON activities.id = a.activity_id
|
|
WHERE a.activity_id = ? AND activities.user_id = ?`, activityID, userID)
|
|
a, err := scanKindAssignment(row)
|
|
if err == sql.ErrNoRows {
|
|
return KindAssignment{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d (user %d): %w", activityID, userID, err)
|
|
}
|
|
return a, true, nil
|
|
}
|
|
|
|
// AllCurrentAssignments returns the latest assignment for every one of
|
|
// userID's activities that has one, regardless of status or source -- the
|
|
// basis for deciding which activities a global reclassify pass may touch.
|
|
func (db *DB) AllCurrentAssignments(ctx context.Context, userID int64) ([]KindAssignment, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT `+kindAssignmentColumns+`
|
|
FROM current_kind_assignment a
|
|
JOIN activities ON activities.id = a.activity_id
|
|
WHERE activities.user_id = ?`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("all current assignments for user %d: %w", userID, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
assignments := []KindAssignment{}
|
|
for rows.Next() {
|
|
a, err := scanKindAssignment(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
|
}
|
|
assignments = append(assignments, a)
|
|
}
|
|
return assignments, rows.Err()
|
|
}
|
|
|
|
// AssignmentsForKind returns every historical assignment (for userID's
|
|
// activities) where the given workout kind was the resolved kind (regardless
|
|
// of source), oldest first -- the basis for progression-over-time charts.
|
|
func (db *DB) AssignmentsForKind(ctx context.Context, userID, workoutKindID int64) ([]KindAssignment, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT `+kindAssignmentColumns+`
|
|
FROM current_kind_assignment a
|
|
JOIN activities ON activities.id = a.activity_id
|
|
WHERE activities.user_id = ? AND a.workout_kind_id = ? AND a.status = ?
|
|
ORDER BY a.created_at ASC`,
|
|
userID, workoutKindID, AssignmentStatusAssigned)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assignments for kind %d (user %d): %w", workoutKindID, userID, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
assignments := []KindAssignment{}
|
|
for rows.Next() {
|
|
a, err := scanKindAssignment(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
|
}
|
|
assignments = append(assignments, a)
|
|
}
|
|
return assignments, rows.Err()
|
|
}
|