Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
19712f3
feat(web): scaffold new frontend on Vite 8 + React 19 + TanStack Router
ieraasyl Jul 25, 2026
d34215a
feat(web): carry over design tokens, add side-by-side dev environment
ieraasyl Jul 25, 2026
20ee5e8
refactor(web): clean up scaffolded theme provider
ieraasyl Jul 25, 2026
88b692a
feat(web): add typed API client generated from the backend schema
ieraasyl Jul 25, 2026
4fdd78e
feat(web): add auth guard, session layer, and shared query conventions
ieraasyl Jul 25, 2026
1992a35
chore(infra): serve the new frontend on 6767
ieraasyl Jul 25, 2026
ce0b33e
feat(web): add app shell, infinite-list primitives, and route skeleton
ieraasyl Jul 25, 2026
885c0aa
docs(web): document the dev workflow and project conventions
ieraasyl Jul 25, 2026
92d9d07
feat(web): complete the app shell chrome
ieraasyl Jul 25, 2026
82dd096
feat(web): port the announcements home page and events
ieraasyl Jul 25, 2026
6be453f
feat(web): port communities list and detail
ieraasyl Jul 25, 2026
df99598
fix(web): send event_status on the events list
ieraasyl Jul 25, 2026
85ac475
feat(web): port the contacts directory
ieraasyl Jul 25, 2026
01f6630
feat(web): port the opportunities digest
ieraasyl Jul 25, 2026
d3f65fe
fix(og): repair event link previews and add path-param routes
ieraasyl Jul 25, 2026
07fda46
feat(web): presigned media upload, and fix media format selection
ieraasyl Jul 26, 2026
43cf59f
feat(web): public shell, static pages, and a real 404
ieraasyl Jul 26, 2026
3ffe48d
fix(auth): require a session on /connect-tg and ignore the body's sub
ieraasyl Jul 26, 2026
d59d355
feat(web): profile page with Telegram linking
ieraasyl Jul 26, 2026
23c2645
docs(web): mark opportunities authoring gate as provisional
ieraasyl Jul 26, 2026
c79c5bb
fix(web): link contact URLs by label, and document stale HMR
ieraasyl Jul 26, 2026
8e99cbd
feat(web): compact event cards on the announcements summary
ieraasyl Jul 26, 2026
2e4e98a
feat(web): courses shell with tabs as real routes, and the GPA engine
ieraasyl Jul 26, 2026
bfd706f
chore(git): sync dev into frontend rewrite
ieraasyl Jul 26, 2026
cab5a47
feat(web): fetch planner terms instead of assuming one
ieraasyl Jul 26, 2026
7fda23a
feat(web): port the schedule builder
ieraasyl Jul 26, 2026
74595d3
feat(web): edit assignments and see the GPA move
ieraasyl Jul 26, 2026
7d8bd72
fix(courses): apply the grade-statistics term filter in SQL
ieraasyl Jul 26, 2026
4f1708a
feat(web): port the Statistics tab
ieraasyl Jul 26, 2026
5d269a7
fix(registrar): verify the registrar's TLS certificate
ieraasyl Jul 26, 2026
9d49d2d
feat(web): registrar sync on My Courses
ieraasyl Jul 26, 2026
446f14c
fix(courses): stop sending a colleague's registrar username in debug
ieraasyl Jul 26, 2026
ebb4fdf
fix(search): fail loudly when a Meilisearch index is unavailable
ieraasyl Jul 26, 2026
e5aad3d
fix(registrar): tell wrong-password apart from a broken login
ieraasyl Jul 26, 2026
5f6ead9
fix(registrar): store US credits, not ECTS, from the course catalog
ieraasyl Jul 26, 2026
b35a41a
revert(registrar): restore ECTS catalog credits
ieraasyl Jul 26, 2026
dfa029d
feat(web): port the Degree Audit tab
ieraasyl Jul 26, 2026
edfb6a2
fix(audit): return a clean error instead of a 500 with a traceback
ieraasyl Jul 26, 2026
e1a362b
fix(audit): make the cached-result major filter work, and offer the CSV
ieraasyl Jul 26, 2026
6ff593d
fix(sgotinish): secure anonymous keys and repair conversations
ieraasyl Jul 26, 2026
7322782
feat(web): port SGotinish
ieraasyl Jul 26, 2026
ad25d35
chore(git): sync main into frontend rewrite
ieraasyl Jul 26, 2026
680473b
feat(web): add the shared pieces authoring needs
ieraasyl Jul 26, 2026
a8548fe
feat(web): port the Degree Audit explainer
ieraasyl Jul 26, 2026
b9ea5bf
feat(web): add opportunities authoring
ieraasyl Jul 26, 2026
6fbe651
feat(web): add event authoring
ieraasyl Jul 26, 2026
3eaf221
feat(web): add community authoring
ieraasyl Jul 26, 2026
4b91106
feat(web): render markdown descriptions
ieraasyl Jul 26, 2026
6df7573
feat(web): complete SGotinish member management and delegation
ieraasyl Jul 26, 2026
60680c3
feat(dev): seed realistic campus data
ieraasyl Jul 26, 2026
4bfd9b7
feat(web): cut over to the new frontend
ieraasyl Jul 26, 2026
849e19b
feat(web): port the multi-schedule planner
ieraasyl Jul 26, 2026
ed91f88
chore(web): regenerate the API types and route tree
ieraasyl Jul 26, 2026
afe58a0
fix(rewrite): close reliability gaps
ieraasyl Jul 26, 2026
9048f3d
feat(web): restore parity with the legacy frontend
ieraasyl Jul 26, 2026
bc5151a
fix(auth): repair logout, reauthentication, and login return paths
ieraasyl Jul 26, 2026
cb74b93
feat(theme): render system theme state in toggle
ieraasyl Jul 26, 2026
28cf29a
feat(web): migrate curated frontend UX
ieraasyl Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 73 additions & 15 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ jobs:
filters: |
backend:
- 'backend/**'
# The rebuilt app in web/ is what ships. frontend/ is still in the
# tree for one release as a fallback but is no longer deployed, so
# a change to it must not trigger a build.
frontend:
- 'frontend/**'
- 'web/**'
nginx:
- 'infra/nginx/**'
meilisearch:
Expand All @@ -59,6 +62,74 @@ jobs:
- 'infra/postgres/**'
- 'infra/backup/**'

# Validate the exact source contract before publishing an image or static
# artifact. The dummy env supplies import-time Config values only; FastAPI
# lifespan is not started by the exporter.
- name: Install PostgreSQL build tools
if: steps.changes.outputs.backend == 'true' || steps.changes.outputs.frontend == 'true'
run: sudo apt-get update && sudo apt-get install -y libpq-dev

- name: Set up Python for backend checks
if: steps.changes.outputs.backend == 'true' || steps.changes.outputs.frontend == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Set up uv
if: steps.changes.outputs.backend == 'true' || steps.changes.outputs.frontend == 'true'
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: backend/uv.lock

- name: Install backend dependencies
if: steps.changes.outputs.backend == 'true' || steps.changes.outputs.frontend == 'true'
working-directory: backend
run: uv sync --frozen

- name: Test backend
if: steps.changes.outputs.backend == 'true'
working-directory: backend
run: uv run --env-file ../infra/.env.example pytest

- name: Export OpenAPI contract
if: steps.changes.outputs.backend == 'true' || steps.changes.outputs.frontend == 'true'
working-directory: backend
run: uv run python scripts/export_openapi.py --output "${{ runner.temp }}/openapi.json"

- name: Enable pnpm
if: steps.changes.outputs.backend == 'true' || steps.changes.outputs.frontend == 'true'
run: corepack enable

- name: Set up Node.js for checks
if: steps.changes.outputs.backend == 'true' || steps.changes.outputs.frontend == 'true'
uses: actions/setup-node@v4
with:
node-version: '22'
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml

- name: Install web dependencies
if: steps.changes.outputs.backend == 'true' || steps.changes.outputs.frontend == 'true'
working-directory: web
run: pnpm install --frozen-lockfile

- name: Check generated API types
if: steps.changes.outputs.backend == 'true' || steps.changes.outputs.frontend == 'true'
working-directory: web
env:
OPENAPI_URL: ${{ runner.temp }}/openapi.json
run: pnpm api:check

- name: Validate frontend
if: steps.changes.outputs.frontend == 'true'
working-directory: web
run: |
pnpm format:check
pnpm lint
pnpm test
pnpm build

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
Expand Down Expand Up @@ -120,22 +191,9 @@ jobs:
cache-to: type=gha,mode=max,scope=nuspace-backup

# --- Frontend static export (Vite SPA) — built on the runner, not on the VM ---
- name: Set up Node.js for frontend build
if: steps.changes.outputs.frontend == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Build frontend static output
if: steps.changes.outputs.frontend == 'true'
working-directory: frontend
run: npm ci && npm run build

- name: Pack frontend out/ for Ansible
if: steps.changes.outputs.frontend == 'true'
run: tar czf "${{ runner.temp }}/frontend-static.tar.gz" -C frontend/out .
run: tar czf "${{ runner.temp }}/frontend-static.tar.gz" -C web/out .

- name: Frontend tarball path for Ansible
id: fe_artifact
Expand Down
107 changes: 107 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: Quality

on:
pull_request:
workflow_dispatch:

jobs:
backend:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install PostgreSQL build tools
run: sudo apt-get update && sudo apt-get install -y libpq-dev

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: backend/uv.lock

- name: Install backend dependencies
working-directory: backend
run: uv sync --frozen

- name: Run backend tests
working-directory: backend
run: uv run --env-file ../infra/.env.example pytest

- name: Export OpenAPI
working-directory: backend
run: uv run python scripts/export_openapi.py --output "${{ runner.temp }}/openapi.json"

- name: Upload OpenAPI contract
uses: actions/upload-artifact@v4
with:
name: openapi-contract
path: ${{ runner.temp }}/openapi.json

web:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Enable pnpm
run: corepack enable

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml

- name: Install web dependencies
working-directory: web
run: pnpm install --frozen-lockfile

- name: Check formatting
working-directory: web
run: pnpm format:check

- name: Lint web
working-directory: web
run: pnpm lint

- name: Test web
working-directory: web
run: pnpm test

- name: Build web
working-directory: web
run: pnpm build

contract:
needs: [backend, web]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Download OpenAPI contract
uses: actions/download-artifact@v4
with:
name: openapi-contract
path: openapi

- name: Enable pnpm
run: corepack enable

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"

- name: Check generated API types
working-directory: web
env:
OPENAPI_URL: ${{ github.workspace }}/openapi/openapi.json
run: pnpm api:check
26 changes: 20 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

```
backend/ Python 3.12 / FastAPI app (package name: nuros)
frontend/ React 19 / Vite 7 / TypeScript SPA
web/ React 19 / Vite 8 / TypeScript SPA — the app that ships
frontend/ The previous SPA. Kept one release as a fallback; not built,
not routed to, not deployed. Work in web/.
infra/ Docker Compose, Nginx, monitoring, service configs
terraform/ GCP infrastructure-as-code
ansible/ Deployment playbooks (runs from CI, not locally)
Expand Down Expand Up @@ -39,15 +41,27 @@ uv run pytest # tests

Pre-commit hooks run `ruff --fix` then `black` automatically.

### Frontend (`frontend/`)
### Frontend (`web/`)

pnpm, not npm. The lockfile is `web/pnpm-lock.yaml` and CI installs with
`--frozen-lockfile`, so a dependency added with npm will fail the build.

```sh
npm ci && npm run build # production build → out/
npm run dev # dev server on :5173
npm run test:url-validation # run a single test file via tsx
pnpm install # deps
pnpm build # typecheck + production build → out/
pnpm dev # dev server on :5173
pnpm typecheck # tsc -b --noEmit
pnpm lint # oxlint, including type-aware rules
pnpm format # prettier, sorts Tailwind classes
pnpm test # node --test over src/**/*.test.ts
pnpm api:check # fail if src/api/schema.d.ts is stale
```

TypeScript strict mode. `@` alias maps to `src/`.
`out/`, not Vite's default `dist/` — the deployment path expects it (see
`web/vite.config.ts`).

TypeScript strict mode. `@` alias maps to `src/`. Read `web/README.md` before
changing anything there; several of its conventions are load-bearing.

## Backend Architecture

Expand Down
14 changes: 9 additions & 5 deletions ansible/roles/frontend/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
---
# Static files are built in GitHub Actions and shipped as a tarball on the controller.
# `frontend_tarball_path` is set by CI when frontend paths changed; empty skips deploy.
#
# The path is web/out, not frontend/out: the app that ships is the rebuild in
# web/. The frontend/ tree is still in the repository for one release, but
# nothing unpacks into it and nginx no longer serves it.

- name: Ensure frontend out directory exists with proper permissions
ansible.builtin.file:
path: "/home/{{ ansible_user }}/nuspace/frontend/out"
path: "/home/{{ ansible_user }}/nuspace/web/out"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
Expand All @@ -14,23 +18,23 @@
- name: Clear previous static export
ansible.builtin.shell: |
set -e
rm -rf /home/{{ ansible_user }}/nuspace/frontend/out/*
rm -rf /home/{{ ansible_user }}/nuspace/web/out/*
args:
executable: /bin/bash
when: frontend_tarball_path | default('') | length > 0

- name: Unpack frontend build from CI runner to VM
ansible.builtin.unarchive:
src: "{{ frontend_tarball_path }}"
dest: "/home/{{ ansible_user }}/nuspace/frontend/out"
dest: "/home/{{ ansible_user }}/nuspace/web/out"
remote_src: false
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
when: frontend_tarball_path | default('') | length > 0

- name: Verify out files were created
ansible.builtin.stat:
path: "/home/{{ ansible_user }}/nuspace/frontend/out/index.html"
path: "/home/{{ ansible_user }}/nuspace/web/out/index.html"
register: dist_check
when: frontend_tarball_path | default('') | length > 0

Expand All @@ -43,7 +47,7 @@

- name: Show frontend deploy success message
ansible.builtin.debug:
msg: "Frontend static files from CI are deployed under frontend/out."
msg: "Frontend static files from CI are deployed under web/out."
when: frontend_tarball_path | default('') | length > 0

- name: Recreate nginx service to pick up new out files
Expand Down
19 changes: 19 additions & 0 deletions backend/common/utils/meilisearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import Type

from backend.core.database.manager import AsyncDatabaseManager
from fastapi import HTTPException
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.orm import DeclarativeBase
Expand Down Expand Up @@ -81,6 +82,24 @@ async def get(
payload["filter"] = filters

response = await client.post(f"/indexes/{storage_name}/search", json=payload)

# An error response has no "hits", and every caller reads result["hits"]
# directly -- so without this the failure surfaces as an opaque
# `KeyError: 'hits'` and a 500 with no indication of what went wrong.
#
# This is not a rare edge: index setup deletes and recreates every index on
# startup, so there is a window after each deploy where searching an index
# that does not exist yet 500s.
if response.status_code >= 400:
body = response.json()
raise HTTPException(
status_code=503,
detail=(
f"Search index '{storage_name}' is unavailable "
f"({body.get('code', response.status_code)})."
),
)

return response.json()


Expand Down
9 changes: 9 additions & 0 deletions backend/core/configs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ class Config(BaseSettings):
GCP_SIGNING_SERVICE_ACCOUNT_KEY_JSON: str | None = None
ORIGINS: List[str] = ["*"]
MOCK_KEYCLOAK: bool # always set True in local dev
# Your own registrar username, for testing the registrar sync locally.
#
# Mock users have no real NU email, so the username cannot be derived from
# the session the way it is in production. This used to be a colleague's
# username written into the source, which meant every developer's local sync
# attempt was a login attempt against that person's real registrar account --
# and enough wrong passwords locks them out. Set your own here, or leave it
# unset and the sync will simply fail to authenticate.
REGISTRAR_DEBUG_USERNAME: str | None = None
USE_GCS_EMULATOR: bool # keep True for local dev; For staging/prod .env will have it False
GCS_EMULATOR_HOST: str
INTEGRATION_SECRET: str
Expand Down
Loading
Loading