package learnermemory import ( "context" "encoding/json" "fmt" "github.com/jackc/pgx/v5/pgxpool" ) type PostgresStore struct { pool *pgxpool.Pool } func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { return &PostgresStore{pool: pool} } func toJSON(v any) string { b, _ := json.Marshal(v) return string(b) } func (s *PostgresStore) UpsertProfile(profile Profile) (Profile, error) { _, err := s.pool.Exec(context.Background(), `INSERT INTO learner_profiles (user_id, target_role, stack, interview_timeline, preferences, updated_at) VALUES ($1, $2, $3::jsonb, $4, $5::jsonb, $6) ON CONFLICT (user_id) DO UPDATE SET target_role = EXCLUDED.target_role, stack = EXCLUDED.stack, interview_timeline = EXCLUDED.interview_timeline, preferences = EXCLUDED.preferences, updated_at = EXCLUDED.updated_at`, profile.UserID, profile.TargetRole, toJSON(profile.Stack), profile.InterviewTimeline, toJSON(profile.Preferences), profile.UpdatedAt, ) if err != nil { return Profile{}, fmt.Errorf("upsert profile: %w", err) } return profile, nil } func (s *PostgresStore) GetProfile(userID string) (Profile, error) { var p Profile var stackJSON, prefsJSON string err := s.pool.QueryRow(context.Background(), `SELECT user_id, target_role, stack, interview_timeline, preferences, updated_at FROM learner_profiles WHERE user_id = $1`, userID, ).Scan(&p.UserID, &p.TargetRole, &stackJSON, &p.InterviewTimeline, &prefsJSON, &p.UpdatedAt) if err != nil { return Profile{}, ErrProfileNotFound } json.Unmarshal([]byte(stackJSON), &p.Stack) json.Unmarshal([]byte(prefsJSON), &p.Preferences) return p, nil } func (s *PostgresStore) UpsertMastery(mastery ConceptMastery) error { _, err := s.pool.Exec(context.Background(), `INSERT INTO learner_mastery (user_id, concept_id, concept_label, state, evidence, updated_at) VALUES ($1, $2, $3, $4, $5::jsonb, $6) ON CONFLICT (user_id, concept_id) DO UPDATE SET concept_label = EXCLUDED.concept_label, state = EXCLUDED.state, evidence = EXCLUDED.evidence, updated_at = EXCLUDED.updated_at`, mastery.UserID, mastery.Concept.ID, mastery.Concept.Label, string(mastery.State), toJSON(mastery.Evidence), mastery.UpdatedAt, ) if err != nil { return fmt.Errorf("upsert mastery: %w", err) } return nil } func (s *PostgresStore) AddMisconception(m Misconception) error { _, err := s.pool.Exec(context.Background(), `INSERT INTO learner_misconceptions (id, user_id, concept, description, evidence, created_at) VALUES ($1, $2, $3::jsonb, $4, $5::jsonb, $6)`, m.ID, m.UserID, toJSON(m.Concept), m.Description, toJSON(m.Evidence), m.UpdatedAt, ) if err != nil { return fmt.Errorf("insert misconception: %w", err) } return nil } func (s *PostgresStore) AddIntervention(i Intervention) error { _, err := s.pool.Exec(context.Background(), `INSERT INTO learner_interventions (id, user_id, kind, reason, concept, created_at) VALUES ($1, $2, $3, $4, $5::jsonb, $6)`, i.ID, i.UserID, i.Summary, i.Summary, toJSON(i.Concept), i.UpdatedAt, ) if err != nil { return fmt.Errorf("insert intervention: %w", err) } return nil } func (s *PostgresStore) AddReviewSchedule(r ReviewSchedule) error { _, err := s.pool.Exec(context.Background(), `INSERT INTO learner_review_schedules (id, user_id, concept, due_at, created_at) VALUES ($1, $2, $3::jsonb, $4, $5)`, r.ID, r.UserID, toJSON(r.Concept), r.UpdatedAt, r.UpdatedAt, ) if err != nil { return fmt.Errorf("insert review schedule: %w", err) } return nil } func (s *PostgresStore) Snapshot(userID string) (Snapshot, error) { profile, err := s.GetProfile(userID) if err != nil { return Snapshot{}, err } rows, err := s.pool.Query(context.Background(), `SELECT concept_id, concept_label, state, evidence FROM learner_mastery WHERE user_id = $1`, userID) if err != nil { return Snapshot{}, fmt.Errorf("query mastery: %w", err) } defer rows.Close() var mastery []ConceptMastery for rows.Next() { var m ConceptMastery var evidenceJSON string err := rows.Scan(&m.Concept.ID, &m.Concept.Label, &m.State, &evidenceJSON) if err != nil { continue } m.UserID = userID json.Unmarshal([]byte(evidenceJSON), &m.Evidence) mastery = append(mastery, m) } return Snapshot{ Profile: profile, Mastery: mastery, Misconceptions: fetchMisconceptions(s.pool, userID), Interventions: fetchInterventions(s.pool, userID), ReviewSchedule: fetchReviewSchedules(s.pool, userID), }, nil } func fetchMisconceptions(pool *pgxpool.Pool, userID string) []Misconception { rows, _ := pool.Query(context.Background(), `SELECT id, concept, description, evidence, created_at FROM learner_misconceptions WHERE user_id = $1`, userID) defer rows.Close() var items []Misconception for rows.Next() { var m Misconception var conceptJSON, evidenceJSON string rows.Scan(&m.ID, &conceptJSON, &m.Description, &evidenceJSON, &m.UpdatedAt) m.UserID = userID json.Unmarshal([]byte(conceptJSON), &m.Concept) json.Unmarshal([]byte(evidenceJSON), &m.Evidence) items = append(items, m) } return items } func fetchInterventions(pool *pgxpool.Pool, userID string) []Intervention { rows, _ := pool.Query(context.Background(), `SELECT id, kind, reason, concept, created_at FROM learner_interventions WHERE user_id = $1`, userID) defer rows.Close() var items []Intervention for rows.Next() { var i Intervention var conceptJSON string rows.Scan(&i.ID, &i.Summary, &i.Summary, &conceptJSON, &i.UpdatedAt) i.UserID = userID json.Unmarshal([]byte(conceptJSON), &i.Concept) items = append(items, i) } return items } func fetchReviewSchedules(pool *pgxpool.Pool, userID string) []ReviewSchedule { rows, _ := pool.Query(context.Background(), `SELECT id, concept, due_at, created_at FROM learner_review_schedules WHERE user_id = $1`, userID) defer rows.Close() var items []ReviewSchedule for rows.Next() { var r ReviewSchedule var conceptJSON string rows.Scan(&r.ID, &conceptJSON, &r.UpdatedAt, &r.UpdatedAt) r.UserID = userID json.Unmarshal([]byte(conceptJSON), &r.Concept) items = append(items, r) } return items }