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 } // ReviewQueue returns userID's activities whose current assignment status // is needs_review, newest first. func (db *DB) ReviewQueue(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 = ? AND a.status = ? ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview) if err != nil { return nil, fmt.Errorf("review queue 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() } // 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() }