196 lines
6.0 KiB
Go
196 lines
6.0 KiB
Go
|
|
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
|
||
|
|
}
|