Coursemap has three product areas:
- Public catalogue discovery and prerequisite exploration.
- Authenticated student profiles, attempts and degree plans.
- Authorised catalogue synchronisation, editing and administration.
Next.js owns routing, server rendering and mutations. Supabase Auth owns identity. Supabase Postgres is the durable source of truth. Vercel builds and serves the application.
apps/webowns Next.js routes, application UI, domain modules, assets, scripts and tests.packages/uiowns retained ReUI primitives, extended components, supporting hooks and compatibility styles. It exports TypeScript source through concrete subpaths and cannot import application code.- Root tooling owns pnpm, Turbo, Prettier, CI and shared commands. Supabase remains at the root.
- Next.js transpiles
@coursemap/ui; Tailwind explicitly scans its sources. Product branding remains inapps/web/app/globals.css, with keyframes inanimations.cssand third-party corrections invendor.css. - Turbo caches build, lint, type checking and unit tests. Build inputs include application environment files and relevant environment variables. Development, database operations and Playwright run uncached.
- Route components load data on the server by default.
- Client components are limited to interaction boundaries such as search, drag-and-drop and graph exploration.
- Onboarding is optional. New sign-ups are offered
/onboarding, which creates the profile and primary plan in one server action; students without a plan otherwise see the dashboard empty state. - Domain rules remain framework-independent and operate on typed inputs.
- Supabase clients are request-scoped. Server and browser clients live behind separate modules.
- Generated database types are committed and used at every query boundary.
The repository uses these terms for these concepts and no synonyms. They are what the interface says, what the schema is named after and what a commit message should use.
| Concept | Term |
|---|---|
| Identity across years | Catalogue code |
| One code in one year | Catalogue record |
| The one mutable working aggregate | Draft |
| An immutable content snapshot | Version |
| A version made public | Publication |
| What ANU lists for a year | Listing, produced by discovery |
| One ANU check for one record | Sync |
| One ANU change awaiting decision | Sync change |
| What ANU returned | Source document, projected to a source version |
| Where a draft field came from | Provenance |
| What happened locally | Change event and field change |
itemYear, snapshot, target, importRun, candidate, appliedSnapshot
and section review named these concepts before the redesign and must not
come back.
Administrators and students see: Course, 2027, Draft, Published, Unpublished
changes, Sync from ANU, Changes, Changelog, Student view, and No longer listed
by ANU. They never see catalogue record, catalogue code, sync target, source
version, provenance, revision 14 or materialisation. The developer operations
screens under /admin/operations are the one exception, and are permissioned
separately.
Courses, programmes, majors, minors and specialisations share one code, record and version model. These concepts are deliberately separate:
academic_yearscatalogue_codes: a stable typed code shared across yearscatalogue_records: that code in one academic year, with the currentpublished_version_idpointer and optionalarchived_at. There is no draft pointer in the core record model.catalogue_versions: immutable meaningful states of a record. Versions may identify the earlier version they are based on and are sealed before use.catalogue_drafts: at most one private mutableCatalogueContentaggregate per annual record. Its revision is the optimistic-concurrency contract for autosave; it is never read by public or student catalogue routes.catalogue_draft_provenance: current path-specific provenance for a draft. A manual edit replaces provenance only for its semantic changed paths.catalogue_change_eventsandcatalogue_field_changes: append-only accepted editing, publication, discard and restore operations with exact old and new values.editing_session_idgroups autosaves in the changelog without rewriting raw history, andsync_change_idnames the ANU review row a source decision answeredcatalogue_publications: historical visibility intervals recording the version, publisher, publication time, unpublisher and unpublication time.course_version_detailsandstructure_version_detailshold the scalar content for their kind. Course child tables (offerings, sessions, outcomes, assessments, fees, attributes, unit options, areas of interest, related courses and requisite rules) and structure child tables (sections, summary fields, outcomes, fees, relationships, requirement groups, conditions, options and unmodelled requirements) reference the shared version throughversion_id. Child rows can be assembled until the version is sealed.catalogue_version_provenance: shared field-level source evidencerequirement_rules, nestedrequirement_groups, typedrequirement_conditions,requirement_condition_optionsfor set members andrequirement_item_referencesfor graph edges. One rule per kind per version: course requisites (prerequisite,corequisite,incompatibility,permission,assumed_knowledge) and structure completion requirements (structure) share fifteen condition kindscatalogue_sourcesand immutablecatalogue_source_pages: discovery and university-calendar retrieval provenancecatalogue_source_documents: immutable detailed ANU material for one annual record, addressed by content hashcatalogue_syncs: one independently queued source check for one record, with its trigger, model and parser contracts, lease, attempts and terminal resultcatalogue_sync_changes: ANU changes waiting for a decision, as a three-way comparison of the previous ANU value, the local value and the new ANU value over one review unit. Distinct fromcatalogue_field_changes, which audits what happened locally. A record has one current review; a later sync supersedes the earlier rows rather than deleting their decisionscatalogue_sync_stages,catalogue_sync_artifactsandcatalogue_extractions: technical execution evidence and validated reusable model responses, read only underimports.manageand surfaced only under/admin/operations/cataloguepublished_course_summaries: a security-invoker view joining published course versions to their code for the directory. The public reads (published_course_detail,published_structure_detailand this view) resolvepublished_version_idand nothing else, and their Next.js cache tags are built inlib/coursemap/published-cache.tsso publication can drop themuniversity_calendar_eventskeyed by academic year, date and title,university_calendar_reviewsholding syncs staged from the admin console until they are approved, anduniversity_calendar_importsrecording each publication from the console or the command line
Published reads resolve through catalogue_records.published_version_id
where archived_at is null. Anonymous readers see published versions and
their children, identities with a published year, and identities referenced
as placeholders by a published rule. Students keep reading the exact version
their recorded attempts point at.
User-owned planning data is also separate:
profilesplans, orderedplan_itemsandplan_structuresreferencing annualcatalogue_recordscourse_attempts, each pinned to the exactcatalogue_version_idthat was published when the attempt was recorded- approval requests and immutable approval events
The development cutover clears every previous course identity, version,
snapshot, plan, attempt and academic-structure row, then removes the old
course_versions, academic_structure_versions, requirement_groups,
requirement_conditions, academic_structure_relationships and directory
compatibility schema. No legacy course or academic-structure lineage is
retained. The generic catalogue_years, batch run and per-item tables have
been removed. The current catalogue_source_documents table belongs only to
record-level synchronisation.
Discovery records each ANU listing attempt in catalogue_discovery_checks,
including its completeness and immutable source pages through
catalogue_discovery_check_source_pages.
catalogue_listings holds the lightweight ANU listing metadata for a real
annual catalogue_record; discovery creates the code and record immediately,
before detailed content is synced. An incomplete discovery updates records it
observed but cannot mark unseen listings as no longer current.
Detailed ANU checks run through apps/web/lib/catalogue-sync/. A sync owns one
record and one queue message, and starts only when an administrator syncs that
record; nothing calls the model on its own. The worker claims the sync with a
versioned lease, captures immutable source material and artefacts, converts the
whole page to Markdown and asks the model for the complete record through the
kind adapter. The model owns every field. The adapter keeps each part of the
response that fits the extraction contract, leaves the rest empty with an error
flag, and warns about wording the page does not contain; nothing is rejected
for review to see. The projection is then validated and persisted as an
immutable source version. Queue retries reuse safe completed evidence and
cannot finish after losing a lease. Expired work is recovered up to five
attempts. Hosted syncs use the catalogue-sync-v1 Vercel Queue topic; local
development processes the same sync inline after responding.
The record keeps latest_source_version_id and source_checked_at separately
from its draft and published_version_id. An unchanged check advances only the
check time. The first source version populates an empty draft, but never
publishes. Later source changes, or a first sync where local work already
exists, leave local content untouched and finish review_required. Branch 04
shows that state on the record; the full source comparison and merge workflow
belongs to Branch 05.
Opening Content creates or resumes the record's mutable draft. A new draft is initialised from the published version, or from a valid kind-specific empty aggregate when the record has never been published. Autosave validates the whole aggregate, checks the expected revision, stores semantic changes and their audit/provenance rows in one transaction, and rejects stale tabs. Publishing materialises that JSON aggregate into a sealed normalised version, moves the publication pointer and clears the draft atomically. Unpublishing closes the visibility interval without deleting versions. Discarding meaningful work first materialises an immutable checkpoint; restoring a version copies its content and provenance into a new draft without changing the original.
Import administrators (imports.manage) sync key dates from
/admin/key-dates/<year>. A sync fetches the ANU page on the server, parses
it and stages the result with stage_university_calendar_review, which
supersedes any pending review for that year. The page compares the staged
dates with the published ones. approve_university_calendar_review then
publishes and archives exactly as the command-line import below does, under
the same advisory lock, and records the run. A sync with error diagnostics
can be discarded but not approved. Students see nothing until approval.
Administrators can also add, edit and remove dates by hand with
save_university_calendar_event and remove_university_calendar_event. Those
dates are marked origin = 'manual', neither an approved sync nor the
command-line import archives them, and each change is kept in
university_calendar_event_changes for the year's changelog.
For local work, or where the server cannot reach the ANU site, fetch a reviewable manifest from the ANU university calendar, then import it into local Supabase:
pnpm calendar:fetch --year 2026 --output .catalogue-cache/anu-calendar-2026.json
pnpm calendar:import .catalogue-cache/anu-calendar-2026.jsonChange the year and filename together. The import script refuses hosted database connections. Each manifest keeps the source URL, retrieval time, content hash and parser diagnostics.
A clean import registers the year in academic_years if needed, records the
manifest as a catalogue_source_pages row, publishes validated events
idempotently using year, date and title, archives previously published events
missing from the manifest and stamps academic_years.calendar_published_at. A
manifest with error diagnostics records a failed university_calendar_imports
row and leaves published events untouched.
Review diagnostics and removals before importing. Calendar publication differs
from the draft-review workflow for course and academic-structure snapshots.
Academic periods inferred from class dates still need verification against the official calendar; importing calendar events does not itself reconcile them.
- Published catalogue rows may be readable publicly.
- Draft catalogue and source-sync operations require database-backed application roles.
- A user can access only their own profile, plans, items and attempts.
- Every exposed table has RLS and explicit Data API grants.
- Privileged functions have a deliberate
search_path, minimal execution grants and database tests.
Changes move through focused branches and pull requests. GitHub Actions checks formatting, linting, types, tests and the production build. Vercel creates preview deployments and promotes main after checks. Supabase schema changes remain forward-only migrations in the same pull request as their application code. Pull requests exercise the complete migration history locally. After every gate passes on main, the production database job previews and applies pending migrations through the protected GitHub Production environment.
supabase/migrations/ is an eight-part baseline that states the schema as it
is rather than the ninety-five migrations that reached it. The hosted project
is recreated from it. Add changes after it; see the
database setup for what a rebuild has to carry.
Copy apps/web/.env.example to apps/web/.env.local. The OpenRouter key is
only needed when adding or refreshing models. Select the active extraction model in admin;
public.import_models stores enabled model choices and USD token rates. Admins add, refresh and disable models through the dashboard. app_settings[imports.model] stores the default. Price badges estimate one target at 10,000 input and 2,000 output tokens, with a short estimate tooltip. Pricing timestamps remain stored with the rates. OpenRouter supplies rates when a model is added or refreshed. Import models can be hidden from selection without removing them from management. Visibility is stored in the catalogue and enforced when choosing a default. The current default must remain visible. Refreshing pricing preserves visibility.
Local catalogue scripts read the database port from supabase/config.toml.
COURSEMAP_DATABASE_URL overrides that connection, with DATABASE_URL as a
fallback. Both overrides must resolve to loopback; hosted connections are refused.
Room Finder uses built-in map style, terrain and walking-route endpoints. Optional
NEXT_PUBLIC_ROOM_MAP_STYLE_URL, NEXT_PUBLIC_ROOM_MAP_TERRAIN_URL and
ROOM_MAP_ROUTING_URL overrides are available when using another provider.