Files
apex-public/internal/tracker/tracker.go
T
2026-07-06 11:05:50 -04:00

334 lines
9.0 KiB
Go

package tracker
import (
"database/sql"
"errors"
"fmt"
"time"
)
type Status string
const (
StatusQueued Status = "Queued"
StatusEvaluated Status = "Evaluated"
StatusApplied Status = "Applied"
StatusResponded Status = "Responded"
StatusContact Status = "Contact"
StatusInterview Status = "Interview"
StatusOffer Status = "Offer"
StatusRejected Status = "Rejected"
StatusDiscarded Status = "Discarded"
StatusSkip Status = "SKIP"
)
var AllStatuses = []Status{
StatusQueued, StatusEvaluated, StatusApplied, StatusResponded, StatusContact,
StatusInterview, StatusOffer, StatusRejected, StatusDiscarded, StatusSkip,
}
// Terminal states accept no further moves; re-applying after a rejection
// requires a brand-new application row.
var transitions = map[Status][]Status{
StatusQueued: {StatusEvaluated, StatusDiscarded},
StatusEvaluated: {StatusApplied, StatusDiscarded, StatusSkip},
StatusApplied: {StatusResponded, StatusContact, StatusInterview, StatusRejected, StatusDiscarded},
StatusResponded: {StatusContact, StatusInterview, StatusRejected, StatusDiscarded},
StatusContact: {StatusResponded, StatusInterview, StatusRejected, StatusDiscarded},
StatusInterview: {StatusOffer, StatusRejected, StatusDiscarded},
}
var ErrInvalidTransition = errors.New("invalid status transition")
var ErrRateLimited = errors.New("daily application rate limit reached")
func CanAdvance(from, to Status) bool {
if from == to {
return false
}
for _, s := range transitions[from] {
if s == to {
return true
}
}
return false
}
func AllowedNext(from Status) []Status {
out := append([]Status{}, transitions[from]...)
return out
}
type Manager struct {
db *sql.DB
dailyCap int
now func() time.Time
followUpDur time.Duration
}
// NewManager — dailyCap is the max number of transitions to StatusApplied
// within the last 24h (0 disables the cap). followUpDur is how long after
// Applied to schedule the default follow-up (0 → 7d).
func NewManager(db *sql.DB, dailyCap int, followUpDur time.Duration) *Manager {
if followUpDur <= 0 {
followUpDur = 7 * 24 * time.Hour
}
return &Manager{db: db, dailyCap: dailyCap, now: time.Now, followUpDur: followUpDur}
}
type Application struct {
ID int64
JobID int64
Status Status
Score sql.NullFloat64
Company string
Title string
URL string
AppliedAt sql.NullTime
UpdatedAt sql.NullTime
Legitimacy sql.NullString
Notes sql.NullString
}
type Event struct {
ID int64
From sql.NullString
To Status
Note sql.NullString
OccurredAt time.Time
}
func (m *Manager) List(statusFilter Status) ([]Application, error) {
q := `
SELECT a.id, a.job_id, a.status, a.score,
COALESCE(j.company, ''), COALESCE(j.title, ''), COALESCE(j.url, ''),
a.applied_at, a.updated_at, a.legitimacy, a.notes
FROM applications a
LEFT JOIN jobs j ON j.id = a.job_id
`
args := []any{}
if statusFilter != "" {
q += "WHERE a.status = ? "
args = append(args, string(statusFilter))
}
q += "ORDER BY COALESCE(a.updated_at, a.created_at) DESC"
rows, err := m.db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("list applications: %w", err)
}
defer rows.Close()
var out []Application
for rows.Next() {
var a Application
var status string
if err := rows.Scan(&a.ID, &a.JobID, &status, &a.Score,
&a.Company, &a.Title, &a.URL, &a.AppliedAt, &a.UpdatedAt,
&a.Legitimacy, &a.Notes); err != nil {
return nil, err
}
a.Status = Status(status)
out = append(out, a)
}
return out, rows.Err()
}
func (m *Manager) Get(id int64) (Application, error) {
row := m.db.QueryRow(`
SELECT a.id, a.job_id, a.status, a.score,
COALESCE(j.company, ''), COALESCE(j.title, ''), COALESCE(j.url, ''),
a.applied_at, a.updated_at, a.legitimacy, a.notes
FROM applications a
LEFT JOIN jobs j ON j.id = a.job_id
WHERE a.id = ?`, id)
var a Application
var status string
if err := row.Scan(&a.ID, &a.JobID, &status, &a.Score,
&a.Company, &a.Title, &a.URL, &a.AppliedAt, &a.UpdatedAt,
&a.Legitimacy, &a.Notes); err != nil {
return Application{}, fmt.Errorf("get application %d: %w", id, err)
}
a.Status = Status(status)
return a, nil
}
func (m *Manager) Events(applicationID int64) ([]Event, error) {
rows, err := m.db.Query(`
SELECT id, from_status, to_status, note, occurred_at
FROM tracker_events WHERE application_id = ?
ORDER BY occurred_at DESC`, applicationID)
if err != nil {
return nil, fmt.Errorf("events: %w", err)
}
defer rows.Close()
var out []Event
for rows.Next() {
var e Event
var to string
if err := rows.Scan(&e.ID, &e.From, &to, &e.Note, &e.OccurredAt); err != nil {
return nil, err
}
e.To = Status(to)
out = append(out, e)
}
return out, rows.Err()
}
func (m *Manager) Advance(applicationID int64, to Status, note string) error {
app, err := m.Get(applicationID)
if err != nil {
return err
}
if !CanAdvance(app.Status, to) {
return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, app.Status, to)
}
if to == StatusApplied && m.dailyCap > 0 {
used, err := m.appliedToday()
if err != nil {
return err
}
if used >= m.dailyCap {
return fmt.Errorf("%w: %d/%d used in last 24h", ErrRateLimited, used, m.dailyCap)
}
}
tx, err := m.db.Begin()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
now := m.now().UTC()
if to == StatusApplied {
if _, err := tx.Exec(`
UPDATE applications SET status = ?, applied_at = ?, updated_at = ?
WHERE id = ?`, string(to), now, now, applicationID); err != nil {
return fmt.Errorf("update application: %w", err)
}
if _, err := tx.Exec(`
INSERT INTO follow_ups (application_id, follow_up_at, notes)
VALUES (?, ?, ?)`, applicationID, now.Add(m.followUpDur), "auto: 7d after apply"); err != nil {
return fmt.Errorf("schedule follow_up: %w", err)
}
} else {
if _, err := tx.Exec(`
UPDATE applications SET status = ?, updated_at = ?
WHERE id = ?`, string(to), now, applicationID); err != nil {
return fmt.Errorf("update application: %w", err)
}
}
if _, err := tx.Exec(`
INSERT INTO tracker_events (application_id, from_status, to_status, note, occurred_at)
VALUES (?, ?, ?, ?, ?)`,
applicationID, string(app.Status), string(to), nullableNote(note), now); err != nil {
return fmt.Errorf("insert event: %w", err)
}
return tx.Commit()
}
func (m *Manager) AddNote(applicationID int64, note string) error {
app, err := m.Get(applicationID)
if err != nil {
return err
}
now := m.now().UTC()
_, err = m.db.Exec(`
INSERT INTO tracker_events (application_id, from_status, to_status, note, occurred_at)
VALUES (?, ?, ?, ?, ?)`,
applicationID, string(app.Status), string(app.Status), note, now)
if err != nil {
return fmt.Errorf("insert note: %w", err)
}
return nil
}
func (m *Manager) AppliedToday() (int, error) {
return m.appliedToday()
}
func (m *Manager) appliedToday() (int, error) {
cutoff := m.now().UTC().Add(-24 * time.Hour)
var n int
err := m.db.QueryRow(`
SELECT COUNT(*) FROM tracker_events
WHERE to_status = ? AND occurred_at >= ?`,
string(StatusApplied), cutoff).Scan(&n)
return n, err
}
type PendingFollowUp struct {
ID int64
ApplicationID int64
Company string
Title string
DueAt time.Time
Notes sql.NullString
}
func (m *Manager) PendingFollowUps() ([]PendingFollowUp, error) {
rows, err := m.db.Query(`
SELECT f.id, f.application_id, COALESCE(j.company,''), COALESCE(j.title,''),
f.follow_up_at, f.notes
FROM follow_ups f
JOIN applications a ON a.id = f.application_id
LEFT JOIN jobs j ON j.id = a.job_id
WHERE f.contacted_at IS NULL AND f.follow_up_at <= ?
ORDER BY f.follow_up_at ASC`, m.now().UTC())
if err != nil {
return nil, fmt.Errorf("pending follow_ups: %w", err)
}
defer rows.Close()
var out []PendingFollowUp
for rows.Next() {
var f PendingFollowUp
if err := rows.Scan(&f.ID, &f.ApplicationID, &f.Company, &f.Title, &f.DueAt, &f.Notes); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func (m *Manager) MarkFollowUpContacted(followUpID int64) error {
_, err := m.db.Exec(`UPDATE follow_ups SET contacted_at = ? WHERE id = ?`,
m.now().UTC(), followUpID)
return err
}
type Stats struct {
Total int
ByStatus map[Status]int
AppliedLast24h int
OpenFollowUps int
}
func (m *Manager) Stats() (Stats, error) {
s := Stats{ByStatus: make(map[Status]int)}
rows, err := m.db.Query(`SELECT status, COUNT(*) FROM applications GROUP BY status`)
if err != nil {
return s, fmt.Errorf("stats: %w", err)
}
defer rows.Close()
for rows.Next() {
var status string
var n int
if err := rows.Scan(&status, &n); err != nil {
return s, err
}
s.ByStatus[Status(status)] = n
s.Total += n
}
if n, err := m.appliedToday(); err == nil {
s.AppliedLast24h = n
}
_ = m.db.QueryRow(`
SELECT COUNT(*) FROM follow_ups
WHERE contacted_at IS NULL AND follow_up_at <= ?`, m.now().UTC()).Scan(&s.OpenFollowUps)
return s, nil
}
func nullableNote(s string) any {
if s == "" {
return nil
}
return s
}