Fetch and parse Harry and Finn with a stored Compass session per child and store every item against the right children.id. - cmd/school/session.go + internal/store/session.go: read and write the per-child harness session state in the SQLite `session` table. `show` prints metadata only; the state is never printed. - scripts/compass-fetch-patchright.mjs: reuse the stored session, refresh it after login, resolve the child's userId from the parent page by name, and keep the shared calendar out of the per-child run (it would leak the eldest's timetable into the younger child). - scripts/scan-compass.sh + make scan: scan one child or both; restore the session, fetch, persist the session, then parse. A failing child is recorded in `scans` with ok = 0 and the script exits non-zero (FR-12). - cmd/school/scan.go: `school scan record` writes a fetch failure to the scans table. cmd/school/items.go: `school items purge` removes rows that a shared page stored against the wrong child. - internal/store/store_test.go: prove items and sessions are separated by child. docs/phase-4-both-children-notes.md; README + image tag 0.3.0-phase4. Live evidence: Harry 70 tasks / 1 message / 35 events (6 overdue); Finn 42 tasks / 1 message / 0 events (2 overdue). Finn's subjects are Year 7, Harry's are Year 11. Both sessions stored. Co-Authored-By: Paperclip <noreply@paperclip.ing>
91 lines
3.1 KiB
Go
91 lines
3.1 KiB
Go
// Per-child harness session state (AGENTS.md §7, FR-2, §11 Phase 4).
|
|
//
|
|
// The Compass fetcher (patchright) keeps a browser storage state — cookies and
|
|
// localStorage — so a later scan can reuse the login instead of signing in
|
|
// again. That state is a secret: it lives in the database volume only, is never
|
|
// exported to git, never logged, and never printed by a command.
|
|
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ChildSession is the stored harness session for one child.
|
|
type ChildSession struct {
|
|
ChildID int64
|
|
Child string
|
|
State string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// PutSession writes (or refreshes) the harness session state for one child. The
|
|
// state is opaque to the store; the fetcher owns its format.
|
|
func (s *Store) PutSession(ctx context.Context, childID int64, state string) error {
|
|
if state == "" {
|
|
return fmt.Errorf("put session: state is empty")
|
|
}
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO session (child_id, state, updated_at) VALUES (?, ?, datetime('now'))
|
|
ON CONFLICT(child_id) DO UPDATE SET state = excluded.state, updated_at = datetime('now')`,
|
|
childID, state); err != nil {
|
|
return fmt.Errorf("put session for child %d: %w", childID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetSession returns the stored session for one child, or nil when it has none.
|
|
func (s *Store) GetSession(ctx context.Context, childID int64) (*ChildSession, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT se.child_id, COALESCE(ch.name, ''), se.state, se.updated_at
|
|
FROM session se LEFT JOIN children ch ON ch.id = se.child_id
|
|
WHERE se.child_id = ?`, childID)
|
|
var cs ChildSession
|
|
var updated string
|
|
if err := row.Scan(&cs.ChildID, &cs.Child, &cs.State, &updated); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("get session for child %d: %w", childID, err)
|
|
}
|
|
cs.UpdatedAt = parseTime(updated)
|
|
return &cs, nil
|
|
}
|
|
|
|
// ListSessions returns the stored sessions, oldest child first. Each state is
|
|
// returned in full; callers that print must never print the state.
|
|
func (s *Store) ListSessions(ctx context.Context) ([]ChildSession, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT se.child_id, COALESCE(ch.name, ''), se.state, se.updated_at
|
|
FROM session se LEFT JOIN children ch ON ch.id = se.child_id
|
|
ORDER BY se.child_id`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list sessions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []ChildSession
|
|
for rows.Next() {
|
|
var cs ChildSession
|
|
var updated string
|
|
if err := rows.Scan(&cs.ChildID, &cs.Child, &cs.State, &updated); err != nil {
|
|
return nil, fmt.Errorf("scan session: %w", err)
|
|
}
|
|
cs.UpdatedAt = parseTime(updated)
|
|
out = append(out, cs)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// DeleteSession removes the stored session for one child. A signed-out or
|
|
// rejected login is not a failure; it just means the next scan logs in again.
|
|
func (s *Store) DeleteSession(ctx context.Context, childID int64) error {
|
|
if _, err := s.db.ExecContext(ctx, `DELETE FROM session WHERE child_id = ?`, childID); err != nil {
|
|
return fmt.Errorf("delete session for child %d: %w", childID, err)
|
|
}
|
|
return nil
|
|
}
|