Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d79c7ed08c | |||
| 650f3456cb | |||
| 08cb668bab | |||
| 8c7678d06a | |||
| 840fbb1cdd | |||
| a281d227c9 | |||
| 20b3364786 | |||
| 968f7d0691 | |||
| 6ca42d10e6 | |||
| 32e7dc5ac1 | |||
| 6ca852117d | |||
| dec92509ff | |||
| 31f8ef74fe | |||
| 536638b594 | |||
| 6cb5968b7b | |||
| 66eed29c71 | |||
| ff5ad26cfc | |||
| 7cafc7e119 | |||
| 0e7df590ca | |||
| e05cebc10c | |||
| a2b41b5131 | |||
| cffb97ff76 | |||
| 58248897db | |||
| b42ded93f6 | |||
| dcb4a92afd | |||
| 6b296460dd | |||
| ee98d6844a | |||
| bae4ff24ea |
@@ -20,11 +20,12 @@ jobs:
|
|||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: '9'
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@master
|
- uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.95.0'
|
toolchain: '1.95.0'
|
||||||
|
components: clippy, rustfmt
|
||||||
|
|
||||||
- name: Cache Cargo
|
- name: Cache Cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -46,18 +47,38 @@ jobs:
|
|||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
run: pnpm --dir frontend install --frozen-lockfile
|
run: pnpm --dir frontend install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: JS security audit
|
||||||
|
run: pnpm --dir frontend audit --audit-level high
|
||||||
|
|
||||||
|
- name: Generate SvelteKit types
|
||||||
|
run: pnpm --dir frontend exec svelte-kit sync
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: pnpm --dir frontend exec playwright install --with-deps chromium
|
run: pnpm --dir frontend exec playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Type check (backend)
|
- name: Type check (backend)
|
||||||
run: cargo check --manifest-path backend/Cargo.toml
|
run: cargo check --manifest-path backend/Cargo.toml
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --manifest-path backend/Cargo.toml -- -D warnings
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --manifest-path backend/Cargo.toml -- --check
|
||||||
|
|
||||||
- name: Type check (frontend)
|
- name: Type check (frontend)
|
||||||
run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check
|
run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check
|
||||||
|
|
||||||
|
- name: Lint (frontend)
|
||||||
|
run: pnpm --dir frontend lint
|
||||||
|
|
||||||
- name: Unit tests (backend)
|
- name: Unit tests (backend)
|
||||||
run: cargo test --manifest-path backend/Cargo.toml
|
run: cargo test --manifest-path backend/Cargo.toml
|
||||||
|
|
||||||
|
- name: Security audit
|
||||||
|
run: |
|
||||||
|
cargo install cargo-audit --locked
|
||||||
|
cd backend && cargo audit --ignore RUSTSEC-2023-0071
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: pnpm --dir frontend build
|
run: pnpm --dir frontend build
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ jobs:
|
|||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: '9'
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@master
|
- uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.95.0'
|
toolchain: '1.95.0'
|
||||||
|
components: clippy, rustfmt
|
||||||
|
|
||||||
- name: Cache Cargo
|
- name: Cache Cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -48,12 +49,32 @@ jobs:
|
|||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
run: pnpm --dir frontend install --frozen-lockfile
|
run: pnpm --dir frontend install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: JS security audit
|
||||||
|
run: pnpm --dir frontend audit --audit-level high
|
||||||
|
|
||||||
|
- name: Generate SvelteKit types
|
||||||
|
run: pnpm --dir frontend exec svelte-kit sync
|
||||||
|
|
||||||
- name: Type check (frontend)
|
- name: Type check (frontend)
|
||||||
run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check
|
run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check
|
||||||
|
|
||||||
|
- name: Type check (backend)
|
||||||
|
run: cargo check --manifest-path backend/Cargo.toml
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --manifest-path backend/Cargo.toml -- -D warnings
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --manifest-path backend/Cargo.toml -- --check
|
||||||
|
|
||||||
- name: Unit tests (backend)
|
- name: Unit tests (backend)
|
||||||
run: cargo test --manifest-path backend/Cargo.toml
|
run: cargo test --manifest-path backend/Cargo.toml
|
||||||
|
|
||||||
|
- name: Security audit
|
||||||
|
run: |
|
||||||
|
cargo install cargo-audit --locked
|
||||||
|
cd backend && cargo audit --ignore RUSTSEC-2023-0071
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: pnpm --dir frontend build
|
run: pnpm --dir frontend build
|
||||||
|
|
||||||
@@ -70,10 +91,10 @@ jobs:
|
|||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE }}:${{ github.ref_name }}
|
${{ env.IMAGE }}:${{ github.ref_name }}
|
||||||
${{ env.IMAGE }}:latest
|
|
||||||
|
|
||||||
- name: Configure kubectl
|
- name: Configure kubectl
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -37,3 +37,9 @@ frontend/playwright-report/
|
|||||||
# Local dev scripts and logs
|
# Local dev scripts and logs
|
||||||
server.log
|
server.log
|
||||||
start_backend.sh
|
start_backend.sh
|
||||||
|
|
||||||
|
# AI-generated artefacts
|
||||||
|
conductor/
|
||||||
|
docs/review.md
|
||||||
|
docs/tutortool_audit.md
|
||||||
|
*.log
|
||||||
|
|||||||
38
CLAUDE.md
38
CLAUDE.md
@@ -10,28 +10,19 @@ make dev # start backend + frontend in parallel
|
|||||||
make dev-backend # cargo run (port 3000)
|
make dev-backend # cargo run (port 3000)
|
||||||
make dev-frontend # pnpm dev (port 5173, /api proxied to :3000)
|
make dev-frontend # pnpm dev (port 5173, /api proxied to :3000)
|
||||||
|
|
||||||
|
# Linting & Quality
|
||||||
|
make lint # runs cargo fmt, clippy (-D warnings), and svelte-check
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
make build # pnpm build, then cargo build --release
|
make build # runs lint, then pnpm build and cargo build --release
|
||||||
make compose-up # docker compose build + start
|
make compose-up # docker compose build + start
|
||||||
|
|
||||||
# Backend
|
# Testing
|
||||||
cargo test # run all backend unit tests
|
make test # runs lint, then cargo test (backend unit tests)
|
||||||
cargo check # fast type check without linking
|
make test-e2e # test-up + pnpm test:e2e in one step
|
||||||
|
|
||||||
# Frontend
|
|
||||||
pnpm check # TypeScript + Svelte type check
|
|
||||||
pnpm check:watch # watch mode
|
|
||||||
pnpm build # Vite build to dist/
|
|
||||||
|
|
||||||
# Demo data
|
# Demo data
|
||||||
make seed-demo # wipe dev.db and reseed from backend/demo/demo_seed.sql
|
make seed-demo # wipe dev.db and reseed from backend/demo/demo_seed.sql
|
||||||
|
|
||||||
# E2E test pipeline (see docs/testing.md for full detail)
|
|
||||||
make test-up # build test DB if missing, start backend on test port, wait for /health
|
|
||||||
make test-down # stop the test backend
|
|
||||||
make test-reset # fast DB reset via POST /__test__/reset (~10–50 ms)
|
|
||||||
make test-rebuild # wipe and rebuild test DB from migrations + seed
|
|
||||||
make test-e2e # test-up + pnpm test:e2e in one step
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -42,22 +33,24 @@ TutorTool is a **Rust + SvelteKit attendance tracker** for tutoring sessions.
|
|||||||
|
|
||||||
- **Framework**: Axum (async) on Tokio, port 3000
|
- **Framework**: Axum (async) on Tokio, port 3000
|
||||||
- **Database**: SQLite via SQLx — all queries use the runtime `sqlx::query()` / `sqlx::query_as::<_, T>()` (not compile-time macros); no `DATABASE_URL` needed for `cargo build`/`cargo check`
|
- **Database**: SQLite via SQLx — all queries use the runtime `sqlx::query()` / `sqlx::query_as::<_, T>()` (not compile-time macros); no `DATABASE_URL` needed for `cargo build`/`cargo check`
|
||||||
- **Auth**: JWT (7-day expiry, `jsonwebtoken` crate) + bcrypt passwords; `TutorClaims` extractor in `auth.rs`
|
- **Auth**: Secure JWT-based authentication. The backend sets an `httpOnly`, `SameSite=Strict` cookie named `token`. The `TutorClaims` extractor in `auth.rs` enforces authentication by reading this cookie.
|
||||||
|
- **Shared State**: Axum handlers use `State<AppState>` (or `State<SqlitePool>` via `FromRef`) which caches the `JWT_SECRET` and DB pool.
|
||||||
- **Static serving**: `tower_http::ServeDir` serves compiled frontend from `frontend/build/` with SPA fallback to `index.html`
|
- **Static serving**: `tower_http::ServeDir` serves compiled frontend from `frontend/build/` with SPA fallback to `index.html`
|
||||||
- **Migrations**: auto-run via `sqlx::migrate!` at startup from `backend/migrations/`
|
- **Migrations**: auto-run via `sqlx::migrate!` at startup from `backend/migrations/`
|
||||||
- **`PRAGMA foreign_keys = ON`** is enforced on every connection in `db.rs`
|
- **`PRAGMA foreign_keys = ON`** is enforced on every connection in `db.rs`
|
||||||
|
|
||||||
Route handlers live in `backend/src/routes/` and are merged in `routes/mod.rs`. Each handler receives `State<SqlitePool>` and extracts `TutorClaims` from the JWT on protected routes.
|
Route handlers live in `backend/src/routes/` and are merged in `routes/mod.rs`.
|
||||||
|
|
||||||
Route modules: `auth_routes`, `checkin`, `courses`, `rooms`, `sessions`, `attendance`, `notes`, `export`, `tutors`, and `test_reset` (mounted only when `TT_TEST_MODE=1`).
|
Route modules: `auth_routes`, `checkin`, `courses`, `rooms`, `sessions`, `attendance`, `notes`, `export`, `tutors`, and `test_reset` (mounted only when `TT_TEST_MODE=1` AND in debug builds).
|
||||||
|
|
||||||
The `/health` route always returns `"ok"` and is used by the test pipeline to wait for startup.
|
The `/health` route always returns `"ok"` and is used by the test pipeline to wait for startup.
|
||||||
|
|
||||||
### Frontend (`frontend/`)
|
### Frontend (`frontend/`)
|
||||||
|
|
||||||
- **Framework**: SvelteKit 5 (Svelte runes, `$state`/`$derived`) with TypeScript
|
- **Framework**: SvelteKit 5 (Svelte runes, `$state`/`$derived`) with TypeScript.
|
||||||
|
- **Auth state**: Managed by the `auth` object in `$lib/auth.svelte.ts`.
|
||||||
- **Adapter**: `adapter-static` → single-page app, `fallback: 'index.html'`
|
- **Adapter**: `adapter-static` → single-page app, `fallback: 'index.html'`
|
||||||
- **API client**: `src/lib/api.ts` — all fetch calls go through here; JWT injected from `src/lib/auth.ts` (localStorage-backed store)
|
- **API client**: `src/lib/api.ts` — all fetch calls go through here; relies on browser automatic cookie handling.
|
||||||
- **Types**: `src/lib/types.ts` mirrors the Rust models exactly — keep them in sync when changing the API
|
- **Types**: `src/lib/types.ts` mirrors the Rust models exactly — keep them in sync when changing the API
|
||||||
|
|
||||||
Routes:
|
Routes:
|
||||||
@@ -88,7 +81,7 @@ See `docs/testing.md` for the full guide including seed data, MCP-driven verific
|
|||||||
- `Dockerfile`: 3-stage build (Node 22/pnpm frontend → Rust 1.95 backend → Debian slim runtime, non-root)
|
- `Dockerfile`: 3-stage build (Node 22/pnpm frontend → Rust 1.95 backend → Debian slim runtime, non-root)
|
||||||
- `deploy/`: Helm chart — Deployment, Service, HTTPRoute (Gateway API), PVC, CronJob for nightly vacuum + backup rotation
|
- `deploy/`: Helm chart — Deployment, Service, HTTPRoute (Gateway API), PVC, CronJob for nightly vacuum + backup rotation
|
||||||
- Live at `tutor.puchstein.dev` (tenant-5, ITSH Cloud); image at `registry.itsh.dev/s0wlz/tutortool`
|
- Live at `tutor.puchstein.dev` (tenant-5, ITSH Cloud); image at `registry.itsh.dev/s0wlz/tutortool`
|
||||||
- CI: Gitea Actions at `.gitea/workflows/ci.yml` — runs `cargo check`, `pnpm check` (tsgo + svelte-check), `cargo test`, `pnpm build`, `make test-e2e`, and a no-push Docker build on every non-main push and PR
|
- CI: Gitea Actions at `.gitea/workflows/ci.yml` — runs `make lint`, `cargo test`, `pnpm build`, `make test-e2e`, and a no-push Docker build on every non-main push and PR
|
||||||
- Release: `.gitea/workflows/release.yml` — triggered by `v*.*.*` tags; builds + pushes image, then `helm upgrade`
|
- Release: `.gitea/workflows/release.yml` — triggered by `v*.*.*` tags; builds + pushes image, then `helm upgrade`
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
@@ -96,3 +89,4 @@ See `docs/testing.md` for the full guide including seed data, MCP-driven verific
|
|||||||
- Rust toolchain is pinned to 1.95.0 via `rust-toolchain.toml`.
|
- Rust toolchain is pinned to 1.95.0 via `rust-toolchain.toml`.
|
||||||
- Frontend indentation: 2 spaces (Svelte/TS files). Backend: standard `rustfmt` defaults.
|
- Frontend indentation: 2 spaces (Svelte/TS files). Backend: standard `rustfmt` defaults.
|
||||||
- All SQLx queries are runtime (`sqlx::query_as::<_, T>()`); no compile-time macros are used, so `DATABASE_URL` is not required for `cargo build` or `cargo check`.
|
- All SQLx queries are runtime (`sqlx::query_as::<_, T>()`); no compile-time macros are used, so `DATABASE_URL` is not required for `cargo build` or `cargo check`.
|
||||||
|
- **Zero Warnings Policy**: All code must pass `make lint` (clippy, fmt, svelte-check) without warnings before committing.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ FROM debian:bookworm-slim
|
|||||||
RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||||
RUN useradd -u 1000 -m app
|
RUN useradd -u 1000 -m app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=backend-builder /app/backend/target/release/attendance ./server
|
COPY --from=backend-builder /app/backend/target/release/tutortool ./server
|
||||||
COPY --from=backend-builder /app/backend/demo ./backend/demo
|
COPY --from=backend-builder /app/backend/demo ./backend/demo
|
||||||
COPY --from=frontend-builder /app/frontend/build ./frontend/build
|
COPY --from=frontend-builder /app/frontend/build ./frontend/build
|
||||||
|
|
||||||
|
|||||||
48
GEMINI.md
48
GEMINI.md
@@ -11,7 +11,7 @@ TutorTool is a full-stack web application for tracking student attendance in tut
|
|||||||
- **Tools**: `remember`, `recall`, `list`, `get_status`.
|
- **Tools**: `remember`, `recall`, `list`, `get_status`.
|
||||||
<!-- SLM-END -->
|
<!-- SLM-END -->
|
||||||
|
|
||||||
## Commands
|
# Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
@@ -19,18 +19,21 @@ make dev # start backend + frontend in parallel
|
|||||||
make dev-backend # cargo run (port 3000)
|
make dev-backend # cargo run (port 3000)
|
||||||
make dev-frontend # pnpm dev (port 5173, /api proxied to :3000)
|
make dev-frontend # pnpm dev (port 5173, /api proxied to :3000)
|
||||||
|
|
||||||
|
# Linting & Quality (Zero Warnings Policy)
|
||||||
|
make lint # runs cargo fmt, clippy (-D warnings), svelte-check, and eslint
|
||||||
|
make verify-all # full local pre-flight: lint + tests + E2E + audit
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
make build # pnpm build, then cargo build --release
|
make build # runs lint, then pnpm build and cargo build --release
|
||||||
make compose-up # docker compose build + start
|
|
||||||
|
|
||||||
# Backend
|
|
||||||
cargo test # run all backend unit tests
|
|
||||||
cargo check # fast type check without linking
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
pnpm check # TypeScript + Svelte type check
|
|
||||||
pnpm build # Vite build to dist/
|
|
||||||
|
|
||||||
|
# 🚨 MANDATE: Run `make verify-all` locally before every release tag or push.
|
||||||
|
# This ensures that all quality, security, and lockfile gates pass, minimizing
|
||||||
|
# CI/CD debugging cycles.
|
||||||
|
```
|
||||||
|
# Testing
|
||||||
|
make test # runs lint, then cargo test (backend unit tests)
|
||||||
|
make test-e2e # test-up + pnpm test:e2e in one step
|
||||||
|
```
|
||||||
# Demo data
|
# Demo data
|
||||||
make seed-demo # wipe dev.db and reseed from backend/demo/demo_seed.sql
|
make seed-demo # wipe dev.db and reseed from backend/demo/demo_seed.sql
|
||||||
|
|
||||||
@@ -48,17 +51,25 @@ make test-e2e # test-up + pnpm test:e2e in one step
|
|||||||
- **Backend**:
|
- **Backend**:
|
||||||
- **Framework**: Axum (async) on Tokio, port 3000.
|
- **Framework**: Axum (async) on Tokio, port 3000.
|
||||||
- **Database**: SQLite via SQLx. All queries use runtime `sqlx::query()` / `sqlx::query_as::<_, T>()` — no compile-time macros, so `DATABASE_URL` is not required for `cargo build` or `cargo check`. Migrations are automatically run at startup.
|
- **Database**: SQLite via SQLx. All queries use runtime `sqlx::query()` / `sqlx::query_as::<_, T>()` — no compile-time macros, so `DATABASE_URL` is not required for `cargo build` or `cargo check`. Migrations are automatically run at startup.
|
||||||
- **Auth**: JWT-based authentication (`jsonwebtoken` crate, 7-day expiry) with bcrypt for passwords. The `TutorClaims` extractor in `auth.rs` enforces authentication on protected routes.
|
- **Auth**: Hardened dual-token JWT system.
|
||||||
|
- **Access Token**: Short-lived (15m), stored in `httpOnly`, `SameSite=Strict` cookie named `token`.
|
||||||
|
- **Refresh Token**: Long-lived (7d), stored in `httpOnly`, `SameSite=Strict` cookie named `refresh_token`.
|
||||||
|
- **Content**: JWT contains only `sub` (ID) and roles. Sensitive data like email is fetched from DB in the `/api/auth/me` handler.
|
||||||
|
- **Password Hashing**: Argon2id for all new accounts. Legacy bcrypt hashes are lazily migrated on login.
|
||||||
|
- **Security Headers**: Global middleware enforces CSP, `X-Content-Type-Options: nosniff`, and `X-Frame-Options: DENY`.
|
||||||
|
- **Shared State**: Axum handlers use `State<AppState>` (or `State<SqlitePool>` via `FromRef`) which caches the `JWT_SECRET` and DB pool.
|
||||||
- **Static Serving**: Serves the compiled SvelteKit frontend as a Single-Page App (SPA) via `tower_http::ServeDir`.
|
- **Static Serving**: Serves the compiled SvelteKit frontend as a Single-Page App (SPA) via `tower_http::ServeDir`.
|
||||||
- **`PRAGMA foreign_keys = ON`** is enforced on every connection in `db.rs`.
|
- **`PRAGMA foreign_keys = ON`** is enforced on every connection in `db.rs`.
|
||||||
- Route modules: `auth_routes`, `checkin`, `courses`, `rooms`, `sessions`, `attendance`, `notes`, `export`, `tutors`, and `test_reset` (mounted only when `TT_TEST_MODE=1`).
|
- Route modules: `auth_routes`, `checkin`, `courses`, `rooms`, `sessions`, `attendance`, `notes`, `export`, `tutors`, and `test_reset` (mounted only when `TT_TEST_MODE=1` AND in debug builds).
|
||||||
- The `/health` route always returns `"ok"` — used by the test pipeline to wait for startup.
|
- The `/health` route always returns `"ok"` — used by the test pipeline to wait for startup.
|
||||||
- **Frontend**:
|
- **Frontend**:
|
||||||
- **Framework**: SvelteKit 5 using Svelte Runes (`$state`, `$derived`, etc.).
|
- **Framework**: SvelteKit 5 using Svelte Runes (`$state`, `$derived`, etc.). Authentication state is managed by the `auth` object in `$lib/auth.svelte.ts`.
|
||||||
|
- **Type Safety**: Strict TypeScript (`strict: true`, `noUncheckedIndexedAccess`, `noImplicitAny`).
|
||||||
|
- **Linting**: ESLint flat config with `eslint-plugin-svelte` and `typescript-eslint` (Zero Warnings enforcement).
|
||||||
- **Build Tool**: Vite with `adapter-static` (SPA mode, `fallback: 'index.html'`).
|
- **Build Tool**: Vite with `adapter-static` (SPA mode, `fallback: 'index.html'`).
|
||||||
- **Package Manager**: pnpm (preferred over npm).
|
- **Package Manager**: pnpm (preferred over npm).
|
||||||
- **Styling**: Vanilla CSS (based on design handoff).
|
- **Styling**: Vanilla CSS (based on design handoff).
|
||||||
- **API**: Centralized fetch wrapper in `src/lib/api.ts`; JWT injected from `src/lib/auth.ts`.
|
- **API**: Centralized fetch wrapper in `src/lib/api.ts`; relies on browser automatic cookie handling.
|
||||||
- **Types**: `src/lib/types.ts` mirrors `backend/src/models.rs` — keep in sync when changing the API.
|
- **Types**: `src/lib/types.ts` mirrors `backend/src/models.rs` — keep in sync when changing the API.
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
@@ -97,10 +108,11 @@ Demo / seed credentials:
|
|||||||
## CI
|
## CI
|
||||||
|
|
||||||
Gitea Actions at `.gitea/workflows/test.yml` runs on every push to `main` and on PRs:
|
Gitea Actions at `.gitea/workflows/test.yml` runs on every push to `main` and on PRs:
|
||||||
1. `cargo check` + `pnpm check` (type checks)
|
1. `cargo check` + `pnpm check` + `pnpm lint` (Zero Warnings enforcement)
|
||||||
2. `cargo test` (unit tests)
|
2. `cargo test` (unit tests)
|
||||||
3. `pnpm build` (frontend build)
|
3. `pnpm audit` (security dependency scan)
|
||||||
4. `make test-up` + `pnpm test:e2e` (E2E)
|
4. `pnpm build` (frontend build)
|
||||||
|
5. `make test-up` + `pnpm test:e2e` (E2E)
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
|
|||||||
27
Makefile
27
Makefile
@@ -11,11 +11,21 @@ dev-backend:
|
|||||||
dev-frontend:
|
dev-frontend:
|
||||||
cd frontend && pnpm dev
|
cd frontend && pnpm dev
|
||||||
|
|
||||||
build:
|
lint:
|
||||||
|
@echo "Running backend format check..."
|
||||||
|
cd backend && cargo fmt --check
|
||||||
|
@echo "Running backend clippy..."
|
||||||
|
cd backend && cargo clippy -- -D warnings
|
||||||
|
@echo "Running frontend type check..."
|
||||||
|
cd frontend && pnpm check
|
||||||
|
@echo "Running frontend lint..."
|
||||||
|
cd frontend && pnpm lint
|
||||||
|
|
||||||
|
build: lint
|
||||||
cd frontend && pnpm build
|
cd frontend && pnpm build
|
||||||
cd backend && cargo build --release
|
cd backend && cargo build --release
|
||||||
|
|
||||||
test:
|
test: lint
|
||||||
cd backend && cargo test
|
cd backend && cargo test
|
||||||
|
|
||||||
compose-up:
|
compose-up:
|
||||||
@@ -48,7 +58,7 @@ test-up:
|
|||||||
exit 0; \
|
exit 0; \
|
||||||
fi; \
|
fi; \
|
||||||
[ -f "$$TT_TEST_DB" ] || $(MAKE) test-rebuild; \
|
[ -f "$$TT_TEST_DB" ] || $(MAKE) test-rebuild; \
|
||||||
DATABASE_URL="sqlite:$$TT_TEST_DB" PORT=$$TT_TEST_PORT TT_TEST_MODE=1 \
|
DATABASE_URL="sqlite:$$TT_TEST_DB" PORT=$$TT_TEST_PORT TT_TEST_MODE=1 JWT_SECRET=testsecret STATIC_DIR=frontend/build \
|
||||||
cargo run --manifest-path backend/Cargo.toml &>/tmp/tutortool-test.log & \
|
cargo run --manifest-path backend/Cargo.toml &>/tmp/tutortool-test.log & \
|
||||||
echo $$! > data/test/.pid; \
|
echo $$! > data/test/.pid; \
|
||||||
echo "[test-up] Backend PID $$(cat data/test/.pid) starting on port $$TT_TEST_PORT..."; \
|
echo "[test-up] Backend PID $$(cat data/test/.pid) starting on port $$TT_TEST_PORT..."; \
|
||||||
@@ -83,4 +93,13 @@ test-reset:
|
|||||||
|
|
||||||
test-e2e:
|
test-e2e:
|
||||||
$(MAKE) test-up
|
$(MAKE) test-up
|
||||||
pnpm --dir frontend test:e2e
|
@. scripts/test-env.sh; cd frontend && pnpm test:e2e
|
||||||
|
|
||||||
|
verify-all: lint test test-e2e
|
||||||
|
@echo "Checking frontend lockfile sync..."
|
||||||
|
cd frontend && pnpm install --frozen-lockfile
|
||||||
|
@echo "Running backend security audit..."
|
||||||
|
cd backend && cargo audit --ignore RUSTSEC-2023-0071
|
||||||
|
@echo "Running frontend security audit..."
|
||||||
|
cd frontend && pnpm audit --audit-level high
|
||||||
|
@echo "✅ All verification gates passed!"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Demo credentials: `admin@tutortool.com` / `admin`
|
|||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Backend**: Rust + Axum + SQLite (via SQLx), JWT auth
|
- **Backend**: Rust + Axum + SQLite (via SQLx), Secure httpOnly Cookie JWT auth
|
||||||
- **Frontend**: SvelteKit 5 (Svelte runes), TypeScript, adapter-static (SPA)
|
- **Frontend**: SvelteKit 5 (Svelte runes), TypeScript, adapter-static (SPA)
|
||||||
- **Build**: Vite + Cargo; 3-stage Docker build for production
|
- **Build**: Vite + Cargo; 3-stage Docker build for production
|
||||||
|
|
||||||
@@ -31,4 +31,4 @@ Demo credentials: `admin@tutortool.com` / `admin`
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Kubernetes via `k8s/` manifests on ITSH Cloud (tenant-5, Hetzner). CI via Gitea Actions at `.gitea/workflows/test.yml`.
|
Kubernetes via `deploy/` Helm chart on ITSH Cloud (tenant-5, Hetzner). CI via Gitea Actions at `.gitea/workflows/ci.yml`.
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "attendance"
|
name = "tutortool"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.95.0"
|
rust-version = "1.95.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.8", features = ["macros", "multipart"] }
|
axum = { version = "0.8", features = ["macros", "multipart"] }
|
||||||
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] }
|
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
||||||
bcrypt = "0.19"
|
bcrypt = "0.19"
|
||||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
argon2 = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "cors", "trace", "set-header"] }
|
||||||
|
tower_governor = "0.6"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
rand = "0.9"
|
rand = "0.8"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -23,3 +26,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
temp-env = "0.3"
|
||||||
|
serial_test = "3.1"
|
||||||
|
|||||||
22
backend/SECURITY.md
Normal file
22
backend/SECURITY.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Vulnerability Reports
|
||||||
|
|
||||||
|
If you find a security vulnerability, please do not open a public issue. Instead, report it privately to the maintainers.
|
||||||
|
|
||||||
|
## Audit Documentation
|
||||||
|
|
||||||
|
### RUSTSEC-2023-0071 (h2)
|
||||||
|
|
||||||
|
We currently ignore `RUSTSEC-2023-0071` in our `cargo audit` step. This vulnerability relates to the `h2` crate (an HTTP/2 implementation) being susceptible to a Denial of Service (DoS) attack via rapid stream resets.
|
||||||
|
|
||||||
|
**Risk Assessment:**
|
||||||
|
- TutorTool is typically deployed behind a reverse proxy or Kubernetes ingress controller (e.g., Nginx, Traefik, Istio).
|
||||||
|
- Most modern ingress controllers mitigate this attack at the edge before it reaches the backend service.
|
||||||
|
- We are tracking the upstream fixes in the Axum/Hyper ecosystem and will remove this ignore once the dependency tree is fully patched and verified.
|
||||||
|
|
||||||
|
## Hardening Decisions
|
||||||
|
|
||||||
|
- **Password Hashing:** Argon2id is the standard for all new passwords. Legacy bcrypt hashes are lazily migrated on successful login.
|
||||||
|
- **JWT Auth:** Access tokens are short-lived (15 mins), and refresh tokens (7 days) are used for rotation. Both are stored in `HttpOnly`, `SameSite=Strict` cookies. The JWT contains minimal data (user ID and roles only); sensitive data like email is fetched from the database when needed.
|
||||||
|
- **Security Headers:** CSP, X-Content-Type-Options, and X-Frame-Options are enforced by the backend middleware.
|
||||||
@@ -1,77 +1,138 @@
|
|||||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
use crate::{AppState, error::AppError};
|
||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
use axum::{RequestPartsExt, extract::FromRef, extract::FromRequestParts, http::request::Parts};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::error::AppError;
|
|
||||||
|
const ISSUER: &str = "tutortool";
|
||||||
|
const AUDIENCE: &str = "tutortool-app";
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct TutorClaims {
|
pub struct TutorClaims {
|
||||||
pub sub: i64,
|
pub sub: i64,
|
||||||
pub email: String,
|
|
||||||
pub is_superadmin: bool,
|
pub is_superadmin: bool,
|
||||||
pub exp: u64,
|
pub exp: u64,
|
||||||
|
pub iss: String,
|
||||||
|
pub aud: String,
|
||||||
|
pub refresh: bool, // true if this is a refresh token
|
||||||
}
|
}
|
||||||
|
|
||||||
fn secret() -> Result<String, AppError> {
|
pub fn encode_jwt(
|
||||||
std::env::var("JWT_SECRET").map_err(|_| {
|
id: i64,
|
||||||
tracing::error!("JWT_SECRET environment variable is not set");
|
is_superadmin: bool,
|
||||||
|
secret: &str,
|
||||||
|
refresh: bool,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let duration = if refresh {
|
||||||
|
chrono::Duration::days(7)
|
||||||
|
} else {
|
||||||
|
chrono::Duration::minutes(15)
|
||||||
|
};
|
||||||
|
let exp = (chrono::Utc::now() + duration).timestamp() as u64;
|
||||||
|
let claims = TutorClaims {
|
||||||
|
sub: id,
|
||||||
|
is_superadmin,
|
||||||
|
exp,
|
||||||
|
iss: ISSUER.into(),
|
||||||
|
aud: AUDIENCE.into(),
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
let header = Header {
|
||||||
|
alg: Algorithm::HS256,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
encode(
|
||||||
|
&header,
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "JWT encode failed");
|
||||||
AppError::Unauthorized
|
AppError::Unauthorized
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encode_jwt(id: i64, email: &str, is_superadmin: bool) -> Result<String, AppError> {
|
pub fn decode_jwt(
|
||||||
let exp = (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as u64;
|
token: &str,
|
||||||
let claims = TutorClaims {
|
secret: &str,
|
||||||
sub: id,
|
expected_refresh: bool,
|
||||||
email: email.into(),
|
) -> Result<TutorClaims, AppError> {
|
||||||
is_superadmin,
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
exp,
|
validation.set_issuer(&[ISSUER]);
|
||||||
};
|
validation.set_audience(&[AUDIENCE]);
|
||||||
encode(
|
validation.validate_exp = true;
|
||||||
&Header::default(),
|
|
||||||
&claims,
|
let claims = decode::<TutorClaims>(
|
||||||
&EncodingKey::from_secret(secret()?.as_bytes()),
|
token,
|
||||||
|
&DecodingKey::from_secret(secret.as_bytes()),
|
||||||
|
&validation,
|
||||||
)
|
)
|
||||||
.map_err(|_| AppError::Unauthorized)
|
.map(|d| d.claims)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::debug!(error = %e, "JWT decode failed");
|
||||||
|
AppError::Unauthorized
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if claims.refresh != expected_refresh {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_jwt(token: &str) -> Result<TutorClaims, AppError> {
|
// Axum extractor: pulls Access JWT (not refresh) from httpOnly cookie or Authorization: Bearer header
|
||||||
decode::<TutorClaims>(token, &DecodingKey::from_secret(secret()?.as_bytes()),
|
impl<S> FromRequestParts<S> for TutorClaims
|
||||||
&Validation::default())
|
where
|
||||||
.map(|d| d.claims)
|
S: Send + Sync,
|
||||||
.map_err(|e| {
|
AppState: axum::extract::FromRef<S>,
|
||||||
tracing::debug!(error = %e, "JWT decode failed");
|
{
|
||||||
AppError::Unauthorized
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Axum extractor: pulls JWT from Authorization: Bearer header
|
|
||||||
impl<S: Send + Sync> FromRequestParts<S> for TutorClaims {
|
|
||||||
type Rejection = AppError;
|
type Rejection = AppError;
|
||||||
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
let header = parts.headers.get("authorization")
|
let app_state = AppState::from_ref(state);
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
// Try cookie first
|
||||||
.ok_or(AppError::Unauthorized)?;
|
let jar = parts
|
||||||
decode_jwt(header)
|
.extract::<CookieJar>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::Unauthorized)?;
|
||||||
|
let token = if let Some(cookie) = jar.get("token") {
|
||||||
|
cookie.value().to_string()
|
||||||
|
} else {
|
||||||
|
// Fallback to header for compatibility/testing
|
||||||
|
parts
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.ok_or(AppError::Unauthorized)?
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
decode_jwt(&token, &app_state.jwt_secret, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial]
|
||||||
fn jwt_roundtrip_and_rejection() {
|
fn jwt_roundtrip_and_rejection() {
|
||||||
// Set var inside the test; still unsafe in edition 2024
|
temp_env::with_var("JWT_SECRET", Some("testsecret_auth"), || {
|
||||||
unsafe { std::env::set_var("JWT_SECRET", "testsecret_auth"); }
|
let secret = "testsecret_auth";
|
||||||
|
// roundtrip
|
||||||
|
let token = encode_jwt(1, true, secret, false).unwrap();
|
||||||
|
let claims = decode_jwt(&token, secret, false).unwrap();
|
||||||
|
assert_eq!(claims.sub, 1);
|
||||||
|
assert!(claims.is_superadmin);
|
||||||
|
|
||||||
// roundtrip
|
// rejection
|
||||||
let token = encode_jwt(1, "test@example.com", true).unwrap();
|
assert!(decode_jwt("not.a.token", secret, false).is_err());
|
||||||
let claims = decode_jwt(&token).unwrap();
|
// cross-type rejection
|
||||||
assert_eq!(claims.sub, 1);
|
let refresh_token = encode_jwt(1, true, secret, true).unwrap();
|
||||||
assert!(claims.is_superadmin);
|
assert!(decode_jwt(&refresh_token, secret, false).is_err());
|
||||||
|
});
|
||||||
// rejection
|
|
||||||
assert!(decode_jwt("not.a.token").is_err());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
use std::str::FromStr;
|
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
pub async fn init() -> Result<SqlitePool, sqlx::Error> {
|
pub async fn init() -> Result<SqlitePool, sqlx::Error> {
|
||||||
let url = std::env::var("DATABASE_URL")
|
let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:/data/attendance.db".into());
|
||||||
.unwrap_or_else(|_| "sqlite:/data/attendance.db".into());
|
|
||||||
let opts = SqliteConnectOptions::from_str(&url)?
|
let opts = SqliteConnectOptions::from_str(&url)?
|
||||||
.create_if_missing(true)
|
.create_if_missing(true)
|
||||||
.foreign_keys(true);
|
.foreign_keys(true)
|
||||||
|
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
|
||||||
let pool = SqlitePoolOptions::new().connect_with(opts).await?;
|
let pool = SqlitePoolOptions::new().connect_with(opts).await?;
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn maybe_seed_demo(pool: &SqlitePool) {
|
||||||
|
if std::env::var("DEMO").as_deref() != Ok("true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let seed = include_str!("../demo/demo_seed.sql");
|
||||||
|
match sqlx::raw_sql(seed).execute(pool).await {
|
||||||
|
Ok(_) => tracing::info!("DEMO seed applied"),
|
||||||
|
Err(e) => tracing::warn!("DEMO seed failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -23,21 +34,39 @@ mod tests {
|
|||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn foreign_keys_enforced(pool: SqlitePool) {
|
async fn foreign_keys_enforced(pool: SqlitePool) {
|
||||||
// Enable FK for this test connection (mirrors what after_connect does in production)
|
// Enable FK for this test connection (mirrors what after_connect does in production)
|
||||||
sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await.unwrap();
|
sqlx::query("PRAGMA foreign_keys = ON")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let err = sqlx::query(
|
let err = sqlx::query("INSERT INTO students (course_id, name) VALUES (999, 'Ghost')")
|
||||||
"INSERT INTO students (course_id, name) VALUES (999, 'Ghost')"
|
.execute(&pool)
|
||||||
).execute(&pool).await;
|
.await;
|
||||||
assert!(err.is_err(), "FK violation should be rejected when foreign_keys = ON");
|
assert!(
|
||||||
|
err.is_err(),
|
||||||
|
"FK violation should be rejected when foreign_keys = ON"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn all_tables_exist(pool: SqlitePool) {
|
async fn all_tables_exist(pool: SqlitePool) {
|
||||||
for table in &["courses","tutors","tutor_courses","students","rooms",
|
for table in &[
|
||||||
"sessions","slots","attendances","notes"] {
|
"courses",
|
||||||
let count: (i64,) = sqlx::query_as(
|
"tutors",
|
||||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?"
|
"tutor_courses",
|
||||||
).bind(table).fetch_one(&pool).await.unwrap();
|
"students",
|
||||||
|
"rooms",
|
||||||
|
"sessions",
|
||||||
|
"slots",
|
||||||
|
"attendances",
|
||||||
|
"notes",
|
||||||
|
] {
|
||||||
|
let count: (i64,) =
|
||||||
|
sqlx::query_as("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?")
|
||||||
|
.bind(table)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(count.0, 1, "table {table} missing");
|
assert_eq!(count.0, 1, "table {table} missing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
use axum::{
|
||||||
|
Json,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
|
mod auth;
|
||||||
mod db;
|
mod db;
|
||||||
mod error;
|
mod error;
|
||||||
mod models;
|
mod models;
|
||||||
mod auth;
|
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test_helpers;
|
mod test_helpers;
|
||||||
|
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use tracing_subscriber::EnvFilter;
|
use std::net::SocketAddr;
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub pool: sqlx::SqlitePool,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub test_mode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl axum::extract::FromRef<AppState> for sqlx::SqlitePool {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.pool.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
@@ -17,35 +31,93 @@ async fn main() {
|
|||||||
.with_env_filter(EnvFilter::from_default_env())
|
.with_env_filter(EnvFilter::from_default_env())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
let test_mode = std::env::var("TT_TEST_MODE").as_deref() == Ok("1");
|
let test_mode = std::env::var("TT_TEST_MODE").as_deref() == Ok("1");
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
let test_mode = false;
|
||||||
|
|
||||||
if test_mode {
|
if test_mode {
|
||||||
let seed_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
// Extra safeguard: panic if someone tries to enable test mode in what looks like production
|
||||||
.join("demo/demo_seed.sql");
|
if std::env::var("APP_ENV").as_deref() == Ok("production") {
|
||||||
let seed = std::fs::read_to_string(&seed_path)
|
panic!("TT_TEST_MODE cannot be active when APP_ENV=production");
|
||||||
.expect("demo/demo_seed.sql not found");
|
}
|
||||||
|
let seed_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("demo/demo_seed.sql");
|
||||||
|
let seed = std::fs::read_to_string(&seed_path).expect("demo/demo_seed.sql not found");
|
||||||
routes::test_reset::SEED_SQL.set(seed).ok();
|
routes::test_reset::SEED_SQL.set(seed).ok();
|
||||||
tracing::warn!("TT_TEST_MODE active — /__test__/reset is enabled");
|
tracing::warn!("TT_TEST_MODE active — /__test__/reset is enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool = db::init().await.expect("db init failed");
|
let pool = db::init().await.expect("db init failed");
|
||||||
|
db::maybe_seed_demo(&pool).await;
|
||||||
|
|
||||||
let static_dir = std::env::var("STATIC_DIR")
|
let state = AppState {
|
||||||
.unwrap_or_else(|_| "../frontend/build".into());
|
pool,
|
||||||
|
jwt_secret,
|
||||||
|
test_mode,
|
||||||
|
};
|
||||||
|
|
||||||
let app = routes::build(pool, test_mode)
|
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../frontend/build".into());
|
||||||
|
|
||||||
|
let app = routes::build(state, test_mode)
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.fallback_service(
|
.fallback_service(
|
||||||
ServeDir::new(&static_dir)
|
ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{static_dir}/index.html"))),
|
||||||
.fallback(ServeFile::new(format!("{static_dir}/index.html")))
|
)
|
||||||
);
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||||
|
axum::http::header::CONTENT_SECURITY_POLICY,
|
||||||
|
axum::http::HeaderValue::from_static("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self';"),
|
||||||
|
))
|
||||||
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||||
|
axum::http::header::X_CONTENT_TYPE_OPTIONS,
|
||||||
|
axum::http::HeaderValue::from_static("nosniff"),
|
||||||
|
))
|
||||||
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||||
|
axum::http::header::X_FRAME_OPTIONS,
|
||||||
|
axum::http::HeaderValue::from_static("DENY"),
|
||||||
|
))
|
||||||
|
.layer(tower_http::trace::TraceLayer::new_for_http());
|
||||||
|
|
||||||
let port = std::env::var("PORT")
|
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
|
||||||
.unwrap_or_else(|_| "3000".into());
|
let addr: SocketAddr = format!("0.0.0.0:{port}").parse().expect("invalid address");
|
||||||
let addr = format!("0.0.0.0:{port}");
|
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
|
.await
|
||||||
.expect("failed to bind");
|
.expect("failed to bind");
|
||||||
tracing::info!("listening on :{}", port);
|
tracing::info!("listening on {}", addr);
|
||||||
axum::serve(listener, app).await.expect("server error");
|
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await
|
||||||
|
.expect("failed to serve");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("failed to install Ctrl+C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("failed to install signal handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("signal received, starting graceful shutdown");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
// --- DB rows ---
|
// --- DB rows ---
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct Course { pub id: i64, pub name: String, pub semester: String }
|
pub struct Course {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub semester: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct Tutor {
|
pub struct Tutor {
|
||||||
@@ -13,66 +17,121 @@ pub struct Tutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct Student { pub id: i64, pub course_id: i64, pub name: String }
|
pub struct Student {
|
||||||
|
pub id: i64,
|
||||||
|
pub course_id: i64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct Room { pub id: i64, pub name: String, pub layout_json: String }
|
pub struct Room {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub layout_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub id: i64, pub course_id: i64,
|
pub id: i64,
|
||||||
pub week_nr: i64, pub date: String,
|
pub course_id: i64,
|
||||||
|
pub week_nr: i64,
|
||||||
|
pub date: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct Slot {
|
pub struct Slot {
|
||||||
pub id: i64, pub session_id: i64,
|
pub id: i64,
|
||||||
pub room_id: Option<i64>, pub tutor_id: i64,
|
pub session_id: i64,
|
||||||
pub start_time: String, pub end_time: String,
|
pub room_id: Option<i64>,
|
||||||
pub status: String, pub code: Option<String>,
|
pub tutor_id: i64,
|
||||||
|
pub start_time: String,
|
||||||
|
pub end_time: String,
|
||||||
|
pub status: String,
|
||||||
|
pub code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct Attendance {
|
pub struct Attendance {
|
||||||
pub id: i64, pub slot_id: i64, pub student_id: i64,
|
pub id: i64,
|
||||||
pub seat_id: Option<String>, pub checked_in_at: String,
|
pub slot_id: i64,
|
||||||
|
pub student_id: i64,
|
||||||
|
pub seat_id: Option<String>,
|
||||||
|
pub checked_in_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct Note {
|
pub struct Note {
|
||||||
pub id: i64, pub slot_id: i64, pub student_id: i64,
|
pub id: i64,
|
||||||
pub tutor_id: i64, pub content: String, pub updated_at: String,
|
pub slot_id: i64,
|
||||||
|
pub student_id: i64,
|
||||||
|
pub tutor_id: i64,
|
||||||
|
pub content: String,
|
||||||
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Layout element (nested in Room) ---
|
// --- Layout element (nested in Room) ---
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LayoutElement {
|
pub struct LayoutElement {
|
||||||
pub id: String, pub label: String,
|
pub id: String,
|
||||||
pub x: f64, pub y: f64,
|
pub label: String,
|
||||||
pub width: f64, pub height: f64,
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub width: f64,
|
||||||
|
pub height: f64,
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub kind: String, // "seat" | "table" | "gap" | "door"
|
pub kind: String, // "seat" | "table" | "gap" | "door"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Request types ---
|
// --- Request types ---
|
||||||
#[derive(Deserialize)] pub struct CreateCourse { pub name: String, pub semester: String }
|
#[derive(Deserialize)]
|
||||||
#[derive(Deserialize)] pub struct CreateStudent { pub name: String }
|
pub struct CreateCourse {
|
||||||
#[derive(Deserialize)] pub struct CreateRoom { pub name: String, pub layout: Vec<LayoutElement> }
|
pub name: String,
|
||||||
#[derive(Deserialize)] pub struct CreateSession { pub course_id: i64, pub week_nr: i64, pub date: String }
|
pub semester: String,
|
||||||
#[derive(Deserialize)] pub struct CreateSlot {
|
|
||||||
pub session_id: i64, pub room_id: Option<i64>, pub tutor_id: i64,
|
|
||||||
pub start_time: String, pub end_time: String,
|
|
||||||
}
|
}
|
||||||
#[derive(Deserialize)] pub struct UpsertNote { pub content: String }
|
#[derive(Deserialize)]
|
||||||
#[derive(Deserialize)] pub struct ManualAttendance { pub student_id: i64 }
|
pub struct CreateStudent {
|
||||||
#[derive(Deserialize)] pub struct CreateTutor {
|
pub name: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateRoom {
|
||||||
|
pub name: String,
|
||||||
|
pub layout: Vec<LayoutElement>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateSession {
|
||||||
|
pub course_id: i64,
|
||||||
|
pub week_nr: i64,
|
||||||
|
pub date: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateSlot {
|
||||||
|
pub session_id: i64,
|
||||||
|
pub room_id: Option<i64>,
|
||||||
|
pub tutor_id: i64,
|
||||||
|
pub start_time: String,
|
||||||
|
pub end_time: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpsertNote {
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ManualAttendance {
|
||||||
|
pub student_id: i64,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateTutor {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub is_superadmin: bool,
|
pub is_superadmin: bool,
|
||||||
}
|
}
|
||||||
#[derive(Deserialize)] pub struct AssignTutor { pub tutor_id: i64 }
|
#[derive(Deserialize)]
|
||||||
#[derive(Deserialize)] pub struct CheckinRequest {
|
pub struct AssignTutor {
|
||||||
|
pub tutor_id: i64,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CheckinRequest {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
pub student_id: i64,
|
pub student_id: i64,
|
||||||
pub seat_id: Option<String>,
|
pub seat_id: Option<String>,
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
|
use crate::{AppState, auth::TutorClaims, error::AppError, models::ManualAttendance};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State, Query},
|
|
||||||
routing::{get, post, delete},
|
|
||||||
Json, Router,
|
Json, Router,
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use crate::{auth::TutorClaims, error::AppError, models::ManualAttendance};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct SessionQuery {
|
|
||||||
pub session_id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct StudentQuery {
|
|
||||||
pub student_id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_attendance(
|
async fn create_attendance(
|
||||||
State(pool): State<SqlitePool>,
|
State(pool): State<SqlitePool>,
|
||||||
@@ -25,7 +14,7 @@ async fn create_attendance(
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
// Verify tutor access to the course via slot -> session -> course
|
// Verify tutor access to the course via slot -> session -> course
|
||||||
let course_id: (i64,) = sqlx::query_as(
|
let course_id: (i64,) = sqlx::query_as(
|
||||||
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
|
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?",
|
||||||
)
|
)
|
||||||
.bind(slot_id)
|
.bind(slot_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -53,7 +42,7 @@ async fn delete_attendance(
|
|||||||
Path((slot_id, student_id)): Path<(i64, i64)>,
|
Path((slot_id, student_id)): Path<(i64, i64)>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let course_id: (i64,) = sqlx::query_as(
|
let course_id: (i64,) = sqlx::query_as(
|
||||||
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
|
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?",
|
||||||
)
|
)
|
||||||
.bind(slot_id)
|
.bind(slot_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -84,7 +73,7 @@ async fn get_session_attendance(
|
|||||||
|
|
||||||
// Get all students for the course
|
// Get all students for the course
|
||||||
let students: Vec<crate::models::Student> = sqlx::query_as(
|
let students: Vec<crate::models::Student> = sqlx::query_as(
|
||||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
|
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
|
||||||
)
|
)
|
||||||
.bind(course_id.0)
|
.bind(course_id.0)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -135,7 +124,7 @@ async fn get_student_attendance(
|
|||||||
JOIN sessions s ON sl.session_id = s.id
|
JOIN sessions s ON sl.session_id = s.id
|
||||||
WHERE a.student_id = ?
|
WHERE a.student_id = ?
|
||||||
ORDER BY s.week_nr DESC, sl.start_time DESC
|
ORDER BY s.week_nr DESC, sl.start_time DESC
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.bind(student_id)
|
.bind(student_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -156,30 +145,54 @@ async fn get_student_attendance(
|
|||||||
Ok(Json(serde_json::json!(attendances)))
|
Ok(Json(serde_json::json!(attendances)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<SqlitePool> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/admin/slots/{id}/attendance", post(create_attendance))
|
.route("/api/admin/slots/{id}/attendance", post(create_attendance))
|
||||||
.route("/api/admin/slots/{slot_id}/attendance/{student_id}", delete(delete_attendance))
|
.route(
|
||||||
.route("/api/admin/sessions/{id}/attendance", get(get_session_attendance))
|
"/api/admin/slots/{slot_id}/attendance/{student_id}",
|
||||||
.route("/api/admin/students/{id}/attendance", get(get_student_attendance))
|
delete(delete_attendance),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/sessions/{id}/attendance",
|
||||||
|
get(get_session_attendance),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/students/{id}/attendance",
|
||||||
|
get(get_student_attendance),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers::{build_test_app, post_json, get};
|
use crate::test_helpers::{build_test_app, get, post_json};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) {
|
async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) {
|
||||||
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
||||||
.fetch_one(pool).await.unwrap();
|
.fetch_one(pool)
|
||||||
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
|
.await
|
||||||
.fetch_one(pool).await.unwrap();
|
.unwrap();
|
||||||
|
let course_id: (i64,) = sqlx::query_as(
|
||||||
|
"INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id",
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
||||||
.bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap();
|
.bind(tutor.0)
|
||||||
let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id")
|
.bind(course_id.0)
|
||||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let student_id: (i64,) = sqlx::query_as(
|
||||||
|
"INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(course_id.0)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
|
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
|
||||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
||||||
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
|
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
|
||||||
@@ -192,10 +205,21 @@ mod tests {
|
|||||||
let (app, auth) = build_test_app(pool.clone()).await;
|
let (app, auth) = build_test_app(pool.clone()).await;
|
||||||
let (slot_id, student_id, _) = seed_data(&pool).await;
|
let (slot_id, student_id, _) = seed_data(&pool).await;
|
||||||
|
|
||||||
let (status, _) = post_json(app.clone(), &format!("/api/admin/slots/{slot_id}/attendance"), &auth, json!({"student_id": student_id})).await;
|
let (status, _) = post_json(
|
||||||
|
app.clone(),
|
||||||
|
&format!("/api/admin/slots/{slot_id}/attendance"),
|
||||||
|
&auth,
|
||||||
|
json!({"student_id": student_id}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
|
||||||
let (status, body) = get(app, &format!("/api/admin/students/{student_id}/attendance"), &auth).await;
|
let (status, body) = get(
|
||||||
|
app,
|
||||||
|
&format!("/api/admin/students/{student_id}/attendance"),
|
||||||
|
&auth,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let attendances: Value = serde_json::from_slice(&body).unwrap();
|
let attendances: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(attendances.as_array().unwrap().len(), 1);
|
assert_eq!(attendances.as_array().unwrap().len(), 1);
|
||||||
@@ -206,9 +230,20 @@ mod tests {
|
|||||||
let (app, auth) = build_test_app(pool.clone()).await;
|
let (app, auth) = build_test_app(pool.clone()).await;
|
||||||
let (slot_id, student_id, session_id) = seed_data(&pool).await;
|
let (slot_id, student_id, session_id) = seed_data(&pool).await;
|
||||||
|
|
||||||
post_json(app.clone(), &format!("/api/admin/slots/{slot_id}/attendance"), &auth, json!({"student_id": student_id})).await;
|
post_json(
|
||||||
|
app.clone(),
|
||||||
|
&format!("/api/admin/slots/{slot_id}/attendance"),
|
||||||
|
&auth,
|
||||||
|
json!({"student_id": student_id}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let (status, body) = get(app, &format!("/api/admin/sessions/{session_id}/attendance"), &auth).await;
|
let (status, body) = get(
|
||||||
|
app,
|
||||||
|
&format!("/api/admin/sessions/{session_id}/attendance"),
|
||||||
|
&auth,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let res: Value = serde_json::from_slice(&body).unwrap();
|
let res: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(res["attendances"].as_array().unwrap().len(), 1);
|
assert_eq!(res["attendances"].as_array().unwrap().len(), 1);
|
||||||
|
|||||||
@@ -1,64 +1,219 @@
|
|||||||
use axum::{extract::State, routing::post, Json, Router};
|
use crate::{AppState, auth, error::AppError};
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::SqlitePool;
|
use serde_json::{Value, json};
|
||||||
use serde_json::{json, Value};
|
use std::sync::Arc;
|
||||||
use crate::{auth, error::AppError};
|
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct LoginRequest { email: String, password: String }
|
struct LoginRequest {
|
||||||
|
email: String,
|
||||||
async fn login(
|
password: String,
|
||||||
State(pool): State<SqlitePool>,
|
|
||||||
Json(req): Json<LoginRequest>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
let tutor: Option<(i64, String, String, bool)> = sqlx::query_as(
|
|
||||||
"SELECT id, email, password_hash, is_superadmin FROM tutors WHERE email = ?"
|
|
||||||
).bind(&req.email).fetch_optional(&pool).await?;
|
|
||||||
|
|
||||||
let (id, email, hash, is_superadmin) = tutor.ok_or(AppError::Unauthorized)?;
|
|
||||||
if !bcrypt::verify(&req.password, &hash).unwrap_or(false) {
|
|
||||||
return Err(AppError::Unauthorized);
|
|
||||||
}
|
|
||||||
let token = auth::encode_jwt(id, &email, is_superadmin)?;
|
|
||||||
Ok(Json(json!({"token": token, "is_superadmin": is_superadmin})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<SqlitePool> {
|
async fn login(
|
||||||
Router::new().route("/api/auth/login", post(login))
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Json(req): Json<LoginRequest>,
|
||||||
|
) -> Result<(CookieJar, Json<Value>), AppError> {
|
||||||
|
let tutor: Option<(i64, String, String, bool)> = sqlx::query_as(
|
||||||
|
"SELECT id, email, password_hash, is_superadmin FROM tutors WHERE email = ?",
|
||||||
|
)
|
||||||
|
.bind(&req.email)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (id, _email, hash, is_superadmin) = tutor.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
let mut rehash_needed = false;
|
||||||
|
let mut authed = false;
|
||||||
|
|
||||||
|
// Try Argon2 first (modern hashes start with $argon2id$)
|
||||||
|
if hash.starts_with("$argon2id$") {
|
||||||
|
if let Ok(parsed_hash) = PasswordHash::new(&hash)
|
||||||
|
&& argon2::Argon2::default()
|
||||||
|
.verify_password(req.password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
authed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to bcrypt for legacy hashes
|
||||||
|
if bcrypt::verify(&req.password, &hash).unwrap_or(false) {
|
||||||
|
authed = true;
|
||||||
|
rehash_needed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authed {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy rehash to Argon2 if we used bcrypt
|
||||||
|
if rehash_needed {
|
||||||
|
let salt = SaltString::generate(&mut rand::thread_rng());
|
||||||
|
if let Ok(new_hash) = argon2::Argon2::default()
|
||||||
|
.hash_password(req.password.as_bytes(), &salt)
|
||||||
|
.map(|h| h.to_string())
|
||||||
|
{
|
||||||
|
let _ = sqlx::query("UPDATE tutors SET password_hash = ? WHERE id = ?")
|
||||||
|
.bind(new_hash)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let access_token = auth::encode_jwt(id, is_superadmin, &state.jwt_secret, false)?;
|
||||||
|
let refresh_token = auth::encode_jwt(id, is_superadmin, &state.jwt_secret, true)?;
|
||||||
|
|
||||||
|
let access_cookie = Cookie::build(("token", access_token))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Strict)
|
||||||
|
.secure(!state.test_mode)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let refresh_cookie = Cookie::build(("refresh_token", refresh_token))
|
||||||
|
.path("/api/auth/refresh")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Strict)
|
||||||
|
.secure(!state.test_mode)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
jar.add(access_cookie).add(refresh_cookie),
|
||||||
|
Json(json!({"is_superadmin": is_superadmin})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> Result<(CookieJar, StatusCode), AppError> {
|
||||||
|
let refresh_token = jar
|
||||||
|
.get("refresh_token")
|
||||||
|
.map(|c| c.value().to_string())
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
let claims = auth::decode_jwt(&refresh_token, &state.jwt_secret, true)?;
|
||||||
|
|
||||||
|
// Issue new access token
|
||||||
|
let access_token =
|
||||||
|
auth::encode_jwt(claims.sub, claims.is_superadmin, &state.jwt_secret, false)?;
|
||||||
|
|
||||||
|
let access_cookie = Cookie::build(("token", access_token))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Strict)
|
||||||
|
.secure(!state.test_mode)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Ok((jar.add(access_cookie), StatusCode::OK))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me(
|
||||||
|
auth: auth::TutorClaims,
|
||||||
|
State(pool): State<sqlx::SqlitePool>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let email: String = sqlx::query_scalar("SELECT email FROM tutors WHERE id = ?")
|
||||||
|
.bind(auth.sub)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"id": auth.sub,
|
||||||
|
"email": email,
|
||||||
|
"is_superadmin": auth.is_superadmin
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(jar: CookieJar) -> CookieJar {
|
||||||
|
jar.remove(Cookie::from("token"))
|
||||||
|
.remove(Cookie::from("refresh_token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(test_mode: bool) -> Router<AppState> {
|
||||||
|
let mut login_route = post(login);
|
||||||
|
|
||||||
|
if !test_mode {
|
||||||
|
let governor_conf = Arc::new(
|
||||||
|
GovernorConfigBuilder::default()
|
||||||
|
.per_second(12) // 1 request every 12 seconds = 5 per minute
|
||||||
|
.burst_size(5)
|
||||||
|
.finish()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
login_route = login_route.layer(GovernorLayer {
|
||||||
|
config: governor_conf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/api/auth/login", login_route)
|
||||||
|
.route("/api/auth/refresh", post(refresh))
|
||||||
|
.route("/api/auth/me", get(me))
|
||||||
|
.route("/api/auth/logout", post(logout))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers::{build_test_app, post_json};
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn login_returns_token(pool: SqlitePool) {
|
async fn login_returns_superadmin_and_cookies(pool: sqlx::SqlitePool) {
|
||||||
let hash = bcrypt::hash("secret", 4).unwrap();
|
let hash = bcrypt::hash("secret", 4).unwrap();
|
||||||
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
|
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
|
||||||
.bind("Test").bind("t@test.com").bind(&hash)
|
.bind("Test")
|
||||||
.execute(&pool).await.unwrap();
|
.bind("t@test.com")
|
||||||
|
.bind(&hash)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
pool: pool.clone(),
|
||||||
|
jwt_secret: "testsecret".into(),
|
||||||
|
test_mode: true,
|
||||||
|
};
|
||||||
|
let app = crate::routes::build(state, true);
|
||||||
|
let (status, body, headers) = crate::test_helpers::post_json_with_headers(
|
||||||
|
app,
|
||||||
|
"/api/auth/login",
|
||||||
|
"",
|
||||||
|
json!({"email":"t@test.com","password":"secret"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let app = crate::routes::build(pool, false);
|
|
||||||
let (status, body) = post_json(app, "/api/auth/login", "",
|
|
||||||
json!({"email":"t@test.com","password":"secret"})).await;
|
|
||||||
assert_eq!(status, 200);
|
assert_eq!(status, 200);
|
||||||
let res = serde_json::from_slice::<Value>(&body).unwrap();
|
let res = serde_json::from_slice::<Value>(&body).unwrap();
|
||||||
assert!(res["token"].is_string());
|
|
||||||
assert_eq!(res["is_superadmin"], false);
|
assert_eq!(res["is_superadmin"], false);
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
// Check Set-Cookie headers
|
||||||
async fn login_wrong_password(pool: SqlitePool) {
|
let cookies: Vec<_> = headers
|
||||||
let hash = bcrypt::hash("correct", 4).unwrap();
|
.get_all("set-cookie")
|
||||||
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
|
.iter()
|
||||||
.bind("Test").bind("t@test.com").bind(&hash)
|
.map(|v| v.to_str().unwrap())
|
||||||
.execute(&pool).await.unwrap();
|
.collect();
|
||||||
|
assert!(cookies.iter().any(|c| c.contains("token=")));
|
||||||
|
assert!(cookies.iter().any(|c| c.contains("refresh_token=")));
|
||||||
|
|
||||||
let app = crate::routes::build(pool, false);
|
// Check lazy rehash happened
|
||||||
let (status, _) = post_json(app, "/api/auth/login", "",
|
let new_hash: String =
|
||||||
json!({"email":"t@test.com","password":"wrong"})).await;
|
sqlx::query_scalar("SELECT password_hash FROM tutors WHERE email = ?")
|
||||||
assert_eq!(status, 401);
|
.bind("t@test.com")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(new_hash.starts_with("$argon2id$"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::HeaderMap,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use sqlx::SqlitePool;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
AppState,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
models::{Attendance, LayoutElement, Room, Slot, Student},
|
models::{Attendance, LayoutElement, Room, Slot, Student},
|
||||||
};
|
};
|
||||||
@@ -17,10 +17,8 @@ use crate::{
|
|||||||
fn parse_cookie(cookie_header: &str, key: &str) -> Option<String> {
|
fn parse_cookie(cookie_header: &str, key: &str) -> Option<String> {
|
||||||
for pair in cookie_header.split(';') {
|
for pair in cookie_header.split(';') {
|
||||||
let pair = pair.trim();
|
let pair = pair.trim();
|
||||||
if let Some(rest) = pair.strip_prefix(key) {
|
if let Some(value) = pair.strip_prefix(key).and_then(|r| r.strip_prefix("=")) {
|
||||||
if rest.starts_with('=') {
|
return Some(value.to_string());
|
||||||
return Some(rest[1..].to_string());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -32,7 +30,7 @@ fn url_decode_minimal(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_checkin_info(
|
async fn get_checkin_info(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
Path(code): Path<String>,
|
Path(code): Path<String>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
@@ -42,7 +40,7 @@ async fn get_checkin_info(
|
|||||||
FROM slots WHERE code = ?",
|
FROM slots WHERE code = ?",
|
||||||
)
|
)
|
||||||
.bind(&code)
|
.bind(&code)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::NotFound)?;
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
@@ -53,13 +51,14 @@ async fn get_checkin_info(
|
|||||||
|
|
||||||
// Load layout if room is set
|
// Load layout if room is set
|
||||||
let layout: Option<Vec<LayoutElement>> = if let Some(room_id) = slot.room_id {
|
let layout: Option<Vec<LayoutElement>> = if let Some(room_id) = slot.room_id {
|
||||||
let room = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
|
let room =
|
||||||
.bind(room_id)
|
sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
|
||||||
.fetch_optional(&pool)
|
.bind(room_id)
|
||||||
.await?;
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
if let Some(r) = room {
|
if let Some(r) = room {
|
||||||
let elements: Vec<LayoutElement> = serde_json::from_str(&r.layout_json)
|
let elements: Vec<LayoutElement> =
|
||||||
.unwrap_or_default();
|
serde_json::from_str(&r.layout_json).unwrap_or_default();
|
||||||
Some(elements)
|
Some(elements)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -73,7 +72,7 @@ async fn get_checkin_info(
|
|||||||
"SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id = ?",
|
"SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id = ?",
|
||||||
)
|
)
|
||||||
.bind(slot.id)
|
.bind(slot.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Parse identity cookie to determine which attendance is "mine"
|
// Parse identity cookie to determine which attendance is "mine"
|
||||||
@@ -123,7 +122,7 @@ async fn get_checkin_info(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_checkin_students(
|
async fn get_checkin_students(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
Path(code): Path<String>,
|
Path(code): Path<String>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
// Look up slot by code
|
// Look up slot by code
|
||||||
@@ -132,7 +131,7 @@ async fn get_checkin_students(
|
|||||||
FROM slots WHERE code = ?",
|
FROM slots WHERE code = ?",
|
||||||
)
|
)
|
||||||
.bind(&code)
|
.bind(&code)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::NotFound)?;
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
@@ -141,25 +140,24 @@ async fn get_checkin_students(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get course_id from the session
|
// Get course_id from the session
|
||||||
let (course_id,): (i64,) =
|
let (course_id,): (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
||||||
sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
.bind(slot.session_id)
|
||||||
.bind(slot.session_id)
|
.fetch_one(&state.pool)
|
||||||
.fetch_one(&pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Return only students enrolled in that course
|
// Return only students enrolled in that course
|
||||||
let students = sqlx::query_as::<_, Student>(
|
let students = sqlx::query_as::<_, Student>(
|
||||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
|
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(json!(students)))
|
Ok(Json(json!(students)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_checkin(
|
async fn post_checkin(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(req): Json<crate::models::CheckinRequest>,
|
Json(req): Json<crate::models::CheckinRequest>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
@@ -169,7 +167,7 @@ async fn post_checkin(
|
|||||||
FROM slots WHERE code = ?",
|
FROM slots WHERE code = ?",
|
||||||
)
|
)
|
||||||
.bind(&req.code)
|
.bind(&req.code)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::NotFound)?;
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
@@ -184,22 +182,23 @@ async fn post_checkin(
|
|||||||
// seat_id / room_id cross-validation
|
// seat_id / room_id cross-validation
|
||||||
match (slot.room_id, req.seat_id.as_ref()) {
|
match (slot.room_id, req.seat_id.as_ref()) {
|
||||||
(None, Some(_)) => {
|
(None, Some(_)) => {
|
||||||
return Err(AppError::BadRequest("seat_id provided but slot has no room".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"seat_id provided but slot has no room".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
(Some(_), None) => {
|
(Some(_), None) => {
|
||||||
return Err(AppError::BadRequest("seat required".into()));
|
return Err(AppError::BadRequest("seat required".into()));
|
||||||
}
|
}
|
||||||
(Some(room_id), Some(seat_id)) => {
|
(Some(room_id), Some(seat_id)) => {
|
||||||
let room = sqlx::query_as::<_, Room>(
|
let room =
|
||||||
"SELECT id, name, layout_json FROM rooms WHERE id = ?",
|
sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
|
||||||
)
|
.bind(room_id)
|
||||||
.bind(room_id)
|
.fetch_optional(&state.pool)
|
||||||
.fetch_optional(&pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
let room_row = room.ok_or(AppError::NotFound)?;
|
let room_row = room.ok_or(AppError::NotFound)?;
|
||||||
let elements: Vec<LayoutElement> = serde_json::from_str(&room_row.layout_json)
|
let elements: Vec<LayoutElement> =
|
||||||
.unwrap_or_default();
|
serde_json::from_str(&room_row.layout_json).unwrap_or_default();
|
||||||
let valid = elements
|
let valid = elements
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| &e.id == seat_id && e.kind == "seat");
|
.any(|e| &e.id == seat_id && e.kind == "seat");
|
||||||
@@ -218,19 +217,18 @@ async fn post_checkin(
|
|||||||
if let Some(raw) = parse_cookie(cookie_str, "attendance_identity") {
|
if let Some(raw) = parse_cookie(cookie_str, "attendance_identity") {
|
||||||
let decoded = url_decode_minimal(&raw);
|
let decoded = url_decode_minimal(&raw);
|
||||||
if let Ok(identity) = serde_json::from_str::<Value>(&decoded) {
|
if let Ok(identity) = serde_json::from_str::<Value>(&decoded) {
|
||||||
if identity["code"].as_str() == Some(&req.code) {
|
if identity["code"].as_str() == Some(&req.code)
|
||||||
// Same slot — verify student_id matches
|
&& identity["student_id"].as_i64() == Some(req.student_id)
|
||||||
if let Some(cookie_student_id) = identity["student_id"].as_i64() {
|
{
|
||||||
if cookie_student_id != req.student_id {
|
// Identity matches
|
||||||
return Err(AppError::Conflict("identity mismatch".into()));
|
} else if identity["code"].as_str() == Some(&req.code) {
|
||||||
}
|
return Err(AppError::Conflict("identity mismatch".into()));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transaction: delete old attendance for (slot_id, student_id), then insert new
|
// Transaction: delete old attendance for (slot_id, student_id), then insert new
|
||||||
let mut tx = pool.begin().await?;
|
let mut tx = state.pool.begin().await?;
|
||||||
|
|
||||||
sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?")
|
sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?")
|
||||||
.bind(slot.id)
|
.bind(slot.id)
|
||||||
@@ -267,19 +265,22 @@ async fn post_checkin(
|
|||||||
.expect("serializing static json shape is infallible")
|
.expect("serializing static json shape is infallible")
|
||||||
.replace('"', "%22");
|
.replace('"', "%22");
|
||||||
|
|
||||||
|
let secure = if state.test_mode { "" } else { " Secure;" };
|
||||||
let cookie_val = format!(
|
let cookie_val = format!(
|
||||||
"attendance_identity={}; HttpOnly; SameSite=Strict; Max-Age=86400; Path=/",
|
"attendance_identity={}; HttpOnly; SameSite=Strict;{} Max-Age=86400; Path=/",
|
||||||
identity_json
|
identity_json, secure
|
||||||
);
|
);
|
||||||
|
|
||||||
let header_val = axum::http::HeaderValue::from_str(&cookie_val)
|
let header_val = axum::http::HeaderValue::from_str(&cookie_val)
|
||||||
.map_err(|_| AppError::BadRequest("invalid cookie value".into()))?;
|
.map_err(|_| AppError::BadRequest("invalid cookie value".into()))?;
|
||||||
let mut response = Json(json!({"ok": true})).into_response();
|
let mut response = Json(json!({"ok": true})).into_response();
|
||||||
response.headers_mut().insert(axum::http::header::SET_COOKIE, header_val);
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(axum::http::header::SET_COOKIE, header_val);
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<SqlitePool> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/checkin/{code}", get(get_checkin_info))
|
.route("/api/checkin/{code}", get(get_checkin_info))
|
||||||
.route("/api/checkin/{code}/students", get(get_checkin_students))
|
.route("/api/checkin/{code}/students", get(get_checkin_students))
|
||||||
@@ -288,10 +289,9 @@ pub fn router() -> Router<SqlitePool> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use crate::test_helpers::{build_test_admin_app, get, patch_json, post_json};
|
||||||
use crate::test_helpers::{build_test_admin_app, build_test_app, get, patch_json, post_json};
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
/// Seeds a complete open slot with a room containing two seats (s1, s2).
|
/// Seeds a complete open slot with a room containing two seats (s1, s2).
|
||||||
/// Returns (app, auth, code, slot_id, course_id, tutor_id).
|
/// Returns (app, auth, code, slot_id, course_id, tutor_id).
|
||||||
@@ -641,7 +641,13 @@ mod tests {
|
|||||||
.map(|s| s["id"].as_i64().unwrap())
|
.map(|s| s["id"].as_i64().unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(ids.contains(&student_a), "course A student should be present");
|
assert!(
|
||||||
assert!(!ids.contains(&student_b), "course B student must not appear");
|
ids.contains(&student_a),
|
||||||
|
"course A student should be present"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ids.contains(&student_b),
|
||||||
|
"course B student must not appear"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
extract::{Multipart, Path, State},
|
extract::{Multipart, Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
AppState,
|
||||||
auth::TutorClaims,
|
auth::TutorClaims,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
models::{Course, CreateCourse, CreateStudent, Student},
|
models::{Course, CreateCourse, CreateStudent, Student},
|
||||||
@@ -26,7 +27,7 @@ async fn list_courses(
|
|||||||
sqlx::query_as::<_, Course>(
|
sqlx::query_as::<_, Course>(
|
||||||
"SELECT c.id, c.name, c.semester FROM courses c
|
"SELECT c.id, c.name, c.semester FROM courses c
|
||||||
JOIN tutor_courses tc ON tc.course_id = c.id
|
JOIN tutor_courses tc ON tc.course_id = c.id
|
||||||
WHERE tc.tutor_id = ?"
|
WHERE tc.tutor_id = ?",
|
||||||
)
|
)
|
||||||
.bind(claims.sub)
|
.bind(claims.sub)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -102,7 +103,7 @@ async fn list_assigned_tutors(
|
|||||||
let tutors = sqlx::query_as::<_, crate::models::Tutor>(
|
let tutors = sqlx::query_as::<_, crate::models::Tutor>(
|
||||||
"SELECT t.id, t.name, t.email, t.is_superadmin FROM tutors t
|
"SELECT t.id, t.name, t.email, t.is_superadmin FROM tutors t
|
||||||
JOIN tutor_courses tc ON tc.tutor_id = t.id
|
JOIN tutor_courses tc ON tc.tutor_id = t.id
|
||||||
WHERE tc.course_id = ?"
|
WHERE tc.course_id = ?",
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -158,12 +159,15 @@ async fn import_students(
|
|||||||
|
|
||||||
let mut count = 0i64;
|
let mut count = 0i64;
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await.map_err(|e| {
|
while let Some(field) = multipart
|
||||||
AppError::BadRequest(format!("multipart error: {e}"))
|
.next_field()
|
||||||
})? {
|
.await
|
||||||
let text = field.text().await.map_err(|e| {
|
.map_err(|e| AppError::BadRequest(format!("multipart error: {e}")))?
|
||||||
AppError::BadRequest(format!("field read error: {e}"))
|
{
|
||||||
})?;
|
let text = field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::BadRequest(format!("field read error: {e}")))?;
|
||||||
|
|
||||||
// Fix 4: body size check
|
// Fix 4: body size check
|
||||||
if text.len() > 100_000 {
|
if text.len() > 100_000 {
|
||||||
@@ -175,7 +179,9 @@ async fn import_students(
|
|||||||
// Fix 4: validate header row
|
// Fix 4: validate header row
|
||||||
let header = lines.next().unwrap_or("").trim();
|
let header = lines.next().unwrap_or("").trim();
|
||||||
if !header.eq_ignore_ascii_case("name") {
|
if !header.eq_ignore_ascii_case("name") {
|
||||||
return Err(AppError::BadRequest("CSV must have 'name' header row".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"CSV must have 'name' header row".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix 4: wrap insert loop in a transaction
|
// Fix 4: wrap insert loop in a transaction
|
||||||
@@ -196,7 +202,11 @@ async fn import_students(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fix 4: return 200 if count == 0, else 201
|
// Fix 4: return 200 if count == 0, else 201
|
||||||
let status = if count == 0 { StatusCode::OK } else { StatusCode::CREATED };
|
let status = if count == 0 {
|
||||||
|
StatusCode::OK
|
||||||
|
} else {
|
||||||
|
StatusCode::CREATED
|
||||||
|
};
|
||||||
Ok((status, Json(json!({"imported": count}))))
|
Ok((status, Json(json!({"imported": count}))))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,12 +217,10 @@ async fn delete_student(
|
|||||||
Path(student_id): Path<i64>,
|
Path(student_id): Path<i64>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
// Fetch the student's course_id first
|
// Fetch the student's course_id first
|
||||||
let row: Option<(i64,)> = sqlx::query_as(
|
let row: Option<(i64,)> = sqlx::query_as("SELECT course_id FROM students WHERE id = ?")
|
||||||
"SELECT course_id FROM students WHERE id = ?"
|
.bind(student_id)
|
||||||
)
|
.fetch_optional(&pool)
|
||||||
.bind(student_id)
|
.await?;
|
||||||
.fetch_optional(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let (course_id,) = row.ok_or(AppError::NotFound)?;
|
let (course_id,) = row.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
@@ -220,12 +228,11 @@ async fn delete_student(
|
|||||||
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
||||||
|
|
||||||
// Check for attendance records
|
// Check for attendance records
|
||||||
let (att_count,): (i64,) = sqlx::query_as(
|
let (att_count,): (i64,) =
|
||||||
"SELECT COUNT(*) FROM attendances WHERE student_id = ?"
|
sqlx::query_as("SELECT COUNT(*) FROM attendances WHERE student_id = ?")
|
||||||
)
|
.bind(student_id)
|
||||||
.bind(student_id)
|
.fetch_one(&pool)
|
||||||
.fetch_one(&pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
if att_count > 0 {
|
if att_count > 0 {
|
||||||
return Err(AppError::Conflict("student has attendance records".into()));
|
return Err(AppError::Conflict("student has attendance records".into()));
|
||||||
@@ -239,33 +246,42 @@ async fn delete_student(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<SqlitePool> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/admin/courses", get(list_courses).post(create_course))
|
.route("/api/admin/courses", get(list_courses).post(create_course))
|
||||||
.route(
|
.route(
|
||||||
"/api/admin/courses/{id}/students",
|
"/api/admin/courses/{id}/students",
|
||||||
get(list_students).post(add_student),
|
get(list_students).post(add_student),
|
||||||
)
|
)
|
||||||
.route("/api/admin/courses/{id}/students/import", post(import_students))
|
.route(
|
||||||
.route("/api/admin/courses/{id}/tutors", get(list_assigned_tutors).post(assign_tutor))
|
"/api/admin/courses/{id}/students/import",
|
||||||
.route("/api/admin/courses/{id}/tutors/{tutor_id}", delete(unassign_tutor))
|
post(import_students),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/courses/{id}/tutors",
|
||||||
|
get(list_assigned_tutors).post(assign_tutor),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/courses/{id}/tutors/{tutor_id}",
|
||||||
|
delete(unassign_tutor),
|
||||||
|
)
|
||||||
.route("/api/admin/students/{id}", delete(delete_student))
|
.route("/api/admin/students/{id}", delete(delete_student))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, post_json};
|
use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, post_json};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
// Fix 6: helper to seed tutor_courses membership
|
// Fix 6: helper to seed tutor_courses membership
|
||||||
async fn add_tutor_to_course(pool: &SqlitePool, course_id: i64) {
|
async fn add_tutor_to_course(pool: &SqlitePool, course_id: i64) {
|
||||||
let tutor_id: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
|
let tutor_id: (i64,) =
|
||||||
.fetch_one(pool)
|
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
|
||||||
.await
|
.fetch_one(pool)
|
||||||
.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
||||||
.bind(tutor_id.0)
|
.bind(tutor_id.0)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
@@ -294,12 +310,14 @@ mod tests {
|
|||||||
|
|
||||||
let (status, body) = get(app, "/api/admin/courses", &auth).await;
|
let (status, body) = get(app, "/api/admin/courses", &auth).await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert!(serde_json::from_slice::<Value>(&body)
|
assert!(
|
||||||
.unwrap()
|
serde_json::from_slice::<Value>(&body)
|
||||||
.as_array()
|
.unwrap()
|
||||||
.unwrap()
|
.as_array()
|
||||||
.iter()
|
.unwrap()
|
||||||
.any(|c| c["id"] == id));
|
.iter()
|
||||||
|
.any(|c| c["id"] == id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
@@ -322,8 +340,7 @@ mod tests {
|
|||||||
|
|
||||||
// Add student
|
// Add student
|
||||||
let path = format!("/api/admin/courses/{course_id}/students");
|
let path = format!("/api/admin/courses/{course_id}/students");
|
||||||
let (status, body) =
|
let (status, body) = post_json(app.clone(), &path, &auth, json!({"name":"Alice"})).await;
|
||||||
post_json(app.clone(), &path, &auth, json!({"name":"Alice"})).await;
|
|
||||||
assert_eq!(status, StatusCode::CREATED);
|
assert_eq!(status, StatusCode::CREATED);
|
||||||
let student_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
let student_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||||
.as_i64()
|
.as_i64()
|
||||||
@@ -332,12 +349,14 @@ mod tests {
|
|||||||
// List students
|
// List students
|
||||||
let (status, body) = get(app, &path, &auth).await;
|
let (status, body) = get(app, &path, &auth).await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert!(serde_json::from_slice::<Value>(&body)
|
assert!(
|
||||||
.unwrap()
|
serde_json::from_slice::<Value>(&body)
|
||||||
.as_array()
|
.unwrap()
|
||||||
.unwrap()
|
.as_array()
|
||||||
.iter()
|
.unwrap()
|
||||||
.any(|s| s["id"] == student_id));
|
.iter()
|
||||||
|
.any(|s| s["id"] == student_id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
use crate::{AppState, auth::TutorClaims, error::AppError};
|
||||||
|
use axum::http::header;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
|
||||||
};
|
};
|
||||||
use axum::http::{header, StatusCode};
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use crate::{auth::TutorClaims, error::AppError};
|
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
async fn export_session_csv(
|
async fn export_session_csv(
|
||||||
@@ -22,7 +22,7 @@ async fn export_session_csv(
|
|||||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||||
|
|
||||||
let students: Vec<crate::models::Student> = sqlx::query_as(
|
let students: Vec<crate::models::Student> = sqlx::query_as(
|
||||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
|
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
|
||||||
)
|
)
|
||||||
.bind(course_id.0)
|
.bind(course_id.0)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -32,13 +32,14 @@ async fn export_session_csv(
|
|||||||
r#"
|
r#"
|
||||||
SELECT student_id FROM attendances
|
SELECT student_id FROM attendances
|
||||||
WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.bind(session_id)
|
.bind(session_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let attended_student_ids: std::collections::HashSet<i64> = attendance_counts.into_iter().map(|(id,)| id).collect();
|
let attended_student_ids: std::collections::HashSet<i64> =
|
||||||
|
attendance_counts.into_iter().map(|(id,)| id).collect();
|
||||||
|
|
||||||
let mut csv = String::from("Student,Present\n");
|
let mut csv = String::from("Student,Present\n");
|
||||||
for student in students {
|
for student in students {
|
||||||
@@ -46,10 +47,7 @@ async fn export_session_csv(
|
|||||||
writeln!(csv, "{},{}", student.name, present).unwrap();
|
writeln!(csv, "{},{}", student.name, present).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((
|
Ok(([(header::CONTENT_TYPE, "text/csv")], csv).into_response())
|
||||||
[(header::CONTENT_TYPE, "text/csv")],
|
|
||||||
csv
|
|
||||||
).into_response())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn export_session_md(
|
async fn export_session_md(
|
||||||
@@ -65,7 +63,7 @@ async fn export_session_md(
|
|||||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||||
|
|
||||||
let students: Vec<crate::models::Student> = sqlx::query_as(
|
let students: Vec<crate::models::Student> = sqlx::query_as(
|
||||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
|
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
|
||||||
)
|
)
|
||||||
.bind(course_id.0)
|
.bind(course_id.0)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -75,24 +73,26 @@ async fn export_session_md(
|
|||||||
r#"
|
r#"
|
||||||
SELECT student_id FROM attendances
|
SELECT student_id FROM attendances
|
||||||
WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.bind(session_id)
|
.bind(session_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let attended_student_ids: std::collections::HashSet<i64> = attendance_counts.into_iter().map(|(id,)| id).collect();
|
let attended_student_ids: std::collections::HashSet<i64> =
|
||||||
|
attendance_counts.into_iter().map(|(id,)| id).collect();
|
||||||
|
|
||||||
let mut md = String::from("| Student | Present |\n|---------|---------|\n");
|
let mut md = String::from("| Student | Present |\n|---------|---------|\n");
|
||||||
for student in students {
|
for student in students {
|
||||||
let present = if attended_student_ids.contains(&student.id) { "✓" } else { " " };
|
let present = if attended_student_ids.contains(&student.id) {
|
||||||
|
"✓"
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
writeln!(md, "| {} | {} |", student.name, present).unwrap();
|
writeln!(md, "| {} | {} |", student.name, present).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((
|
Ok(([(header::CONTENT_TYPE, "text/markdown")], md).into_response())
|
||||||
[(header::CONTENT_TYPE, "text/markdown")],
|
|
||||||
md
|
|
||||||
).into_response())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn export_course_csv(
|
async fn export_course_csv(
|
||||||
@@ -103,14 +103,14 @@ async fn export_course_csv(
|
|||||||
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
||||||
|
|
||||||
let students: Vec<crate::models::Student> = sqlx::query_as(
|
let students: Vec<crate::models::Student> = sqlx::query_as(
|
||||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
|
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let sessions: Vec<crate::models::Session> = sqlx::query_as(
|
let sessions: Vec<crate::models::Session> = sqlx::query_as(
|
||||||
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr"
|
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr",
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -130,7 +130,7 @@ async fn export_course_csv(
|
|||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*) FROM attendances
|
SELECT COUNT(*) FROM attendances
|
||||||
WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.bind(student.id)
|
.bind(student.id)
|
||||||
.bind(session.id)
|
.bind(session.id)
|
||||||
@@ -148,10 +148,7 @@ async fn export_course_csv(
|
|||||||
writeln!(csv, ",{}", bonus).unwrap();
|
writeln!(csv, ",{}", bonus).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((
|
Ok(([(header::CONTENT_TYPE, "text/csv")], csv).into_response())
|
||||||
[(header::CONTENT_TYPE, "text/csv")],
|
|
||||||
csv
|
|
||||||
).into_response())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn export_course_md(
|
async fn export_course_md(
|
||||||
@@ -162,14 +159,14 @@ async fn export_course_md(
|
|||||||
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
||||||
|
|
||||||
let students: Vec<crate::models::Student> = sqlx::query_as(
|
let students: Vec<crate::models::Student> = sqlx::query_as(
|
||||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
|
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let sessions: Vec<crate::models::Session> = sqlx::query_as(
|
let sessions: Vec<crate::models::Session> = sqlx::query_as(
|
||||||
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr"
|
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr",
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -193,7 +190,7 @@ async fn export_course_md(
|
|||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*) FROM attendances
|
SELECT COUNT(*) FROM attendances
|
||||||
WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.bind(student.id)
|
.bind(student.id)
|
||||||
.bind(session.id)
|
.bind(session.id)
|
||||||
@@ -211,10 +208,7 @@ async fn export_course_md(
|
|||||||
writeln!(md, " {} |", bonus).unwrap();
|
writeln!(md, " {} |", bonus).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((
|
Ok(([(header::CONTENT_TYPE, "text/markdown")], md).into_response())
|
||||||
[(header::CONTENT_TYPE, "text/markdown")],
|
|
||||||
md
|
|
||||||
).into_response())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_backup(
|
async fn download_backup(
|
||||||
@@ -238,15 +232,22 @@ async fn download_backup(
|
|||||||
Ok((
|
Ok((
|
||||||
[
|
[
|
||||||
(header::CONTENT_TYPE, "application/octet-stream"),
|
(header::CONTENT_TYPE, "application/octet-stream"),
|
||||||
(header::CONTENT_DISPOSITION, &format!("attachment; filename=\"backup-{}.sqlite\"", timestamp)),
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
&format!("attachment; filename=\"backup-{}.sqlite\"", timestamp),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
data
|
data,
|
||||||
).into_response())
|
)
|
||||||
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<SqlitePool> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/admin/export/session/{id}/csv", get(export_session_csv))
|
.route(
|
||||||
|
"/api/admin/export/session/{id}/csv",
|
||||||
|
get(export_session_csv),
|
||||||
|
)
|
||||||
.route("/api/admin/export/session/{id}/md", get(export_session_md))
|
.route("/api/admin/export/session/{id}/md", get(export_session_md))
|
||||||
.route("/api/admin/export/course/{id}/csv", get(export_course_csv))
|
.route("/api/admin/export/course/{id}/csv", get(export_course_csv))
|
||||||
.route("/api/admin/export/course/{id}/md", get(export_course_md))
|
.route("/api/admin/export/course/{id}/md", get(export_course_md))
|
||||||
@@ -261,13 +262,28 @@ mod tests {
|
|||||||
|
|
||||||
async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) {
|
async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) {
|
||||||
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
||||||
.fetch_one(pool).await.unwrap();
|
.fetch_one(pool)
|
||||||
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
|
.await
|
||||||
.fetch_one(pool).await.unwrap();
|
.unwrap();
|
||||||
|
let course_id: (i64,) = sqlx::query_as(
|
||||||
|
"INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id",
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
||||||
.bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap();
|
.bind(tutor.0)
|
||||||
let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id")
|
.bind(course_id.0)
|
||||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let student_id: (i64,) = sqlx::query_as(
|
||||||
|
"INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(course_id.0)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
|
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
|
||||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
||||||
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
|
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
|
||||||
@@ -285,7 +301,12 @@ mod tests {
|
|||||||
let (app, auth) = build_test_app(pool.clone()).await;
|
let (app, auth) = build_test_app(pool.clone()).await;
|
||||||
let (_, session_id, _) = seed_data(&pool).await;
|
let (_, session_id, _) = seed_data(&pool).await;
|
||||||
|
|
||||||
let (status, body) = get(app, &format!("/api/admin/export/session/{session_id}/csv"), &auth).await;
|
let (status, body) = get(
|
||||||
|
app,
|
||||||
|
&format!("/api/admin/export/session/{session_id}/csv"),
|
||||||
|
&auth,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let csv = String::from_utf8(body.to_vec()).unwrap();
|
let csv = String::from_utf8(body.to_vec()).unwrap();
|
||||||
assert!(csv.contains("Student,Present"));
|
assert!(csv.contains("Student,Present"));
|
||||||
@@ -297,7 +318,12 @@ mod tests {
|
|||||||
let (app, auth) = build_test_app(pool.clone()).await;
|
let (app, auth) = build_test_app(pool.clone()).await;
|
||||||
let (course_id, _, _) = seed_data(&pool).await;
|
let (course_id, _, _) = seed_data(&pool).await;
|
||||||
|
|
||||||
let (status, body) = get(app, &format!("/api/admin/export/course/{course_id}/csv"), &auth).await;
|
let (status, body) = get(
|
||||||
|
app,
|
||||||
|
&format!("/api/admin/export/course/{course_id}/csv"),
|
||||||
|
&auth,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let csv = String::from_utf8(body.to_vec()).unwrap();
|
let csv = String::from_utf8(body.to_vec()).unwrap();
|
||||||
assert!(csv.contains("Student,Week 1,Bonus"));
|
assert!(csv.contains("Student,Week 1,Bonus"));
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
|
use crate::AppState;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
mod attendance;
|
||||||
mod auth_routes;
|
mod auth_routes;
|
||||||
mod checkin;
|
mod checkin;
|
||||||
mod courses;
|
mod courses;
|
||||||
|
mod export;
|
||||||
|
mod notes;
|
||||||
mod rooms;
|
mod rooms;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod attendance;
|
|
||||||
mod notes;
|
|
||||||
mod export;
|
|
||||||
mod tutors;
|
|
||||||
pub mod test_reset;
|
pub mod test_reset;
|
||||||
|
mod tutors;
|
||||||
|
|
||||||
pub fn build(pool: SqlitePool, test_mode: bool) -> Router {
|
pub fn build(state: AppState, test_mode: bool) -> Router {
|
||||||
let mut router = Router::new()
|
let mut router = Router::new()
|
||||||
.merge(auth_routes::router())
|
.merge(auth_routes::router(test_mode))
|
||||||
.merge(checkin::router())
|
.merge(checkin::router())
|
||||||
.merge(courses::router())
|
.merge(courses::router())
|
||||||
.merge(rooms::router())
|
.merge(rooms::router())
|
||||||
@@ -26,11 +27,12 @@ pub fn build(pool: SqlitePool, test_mode: bool) -> Router {
|
|||||||
.merge(export::router())
|
.merge(export::router())
|
||||||
.merge(tutors::router());
|
.merge(tutors::router());
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
if test_mode {
|
if test_mode {
|
||||||
router = router.merge(test_reset::router());
|
router = router.merge(test_reset::router());
|
||||||
}
|
}
|
||||||
|
|
||||||
router.with_state(pool)
|
router.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that `tutor_id` is a member of `course_id` via the tutor_courses join table.
|
/// Verify that `tutor_id` is a member of `course_id` via the tutor_courses join table.
|
||||||
@@ -40,12 +42,11 @@ pub async fn verify_tutor_course_access(
|
|||||||
tutor_id: i64,
|
tutor_id: i64,
|
||||||
course_id: i64,
|
course_id: i64,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let row: Option<(i64,)> = sqlx::query_as(
|
let row: Option<(i64,)> =
|
||||||
"SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?"
|
sqlx::query_as("SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?")
|
||||||
)
|
.bind(tutor_id)
|
||||||
.bind(tutor_id)
|
.bind(course_id)
|
||||||
.bind(course_id)
|
.fetch_optional(pool)
|
||||||
.fetch_optional(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
row.map(|_| ()).ok_or(AppError::Unauthorized)
|
row.map(|_| ()).ok_or(AppError::Unauthorized)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
use crate::{AppState, auth::TutorClaims, error::AppError, models::UpsertNote};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
routing::{get, put},
|
routing::{get, put},
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use crate::{auth::TutorClaims, error::AppError, models::UpsertNote};
|
|
||||||
|
|
||||||
async fn upsert_note(
|
async fn upsert_note(
|
||||||
State(pool): State<SqlitePool>,
|
State(pool): State<SqlitePool>,
|
||||||
@@ -13,7 +13,7 @@ async fn upsert_note(
|
|||||||
Json(req): Json<UpsertNote>,
|
Json(req): Json<UpsertNote>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let course_id: (i64,) = sqlx::query_as(
|
let course_id: (i64,) = sqlx::query_as(
|
||||||
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
|
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?",
|
||||||
)
|
)
|
||||||
.bind(slot_id)
|
.bind(slot_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -30,7 +30,7 @@ async fn upsert_note(
|
|||||||
ON CONFLICT(slot_id, student_id, tutor_id) DO UPDATE SET
|
ON CONFLICT(slot_id, student_id, tutor_id) DO UPDATE SET
|
||||||
content = excluded.content,
|
content = excluded.content,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.bind(slot_id)
|
.bind(slot_id)
|
||||||
.bind(student_id)
|
.bind(student_id)
|
||||||
@@ -49,7 +49,7 @@ async fn get_slot_notes(
|
|||||||
Path(slot_id): Path<i64>,
|
Path(slot_id): Path<i64>,
|
||||||
) -> Result<Json<Vec<crate::models::Note>>, AppError> {
|
) -> Result<Json<Vec<crate::models::Note>>, AppError> {
|
||||||
let course_id: (i64,) = sqlx::query_as(
|
let course_id: (i64,) = sqlx::query_as(
|
||||||
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
|
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?",
|
||||||
)
|
)
|
||||||
.bind(slot_id)
|
.bind(slot_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -89,9 +89,12 @@ async fn get_student_notes(
|
|||||||
Ok(Json(notes))
|
Ok(Json(notes))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<SqlitePool> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/admin/slots/{slot_id}/notes/{student_id}", put(upsert_note))
|
.route(
|
||||||
|
"/api/admin/slots/{slot_id}/notes/{student_id}",
|
||||||
|
put(upsert_note),
|
||||||
|
)
|
||||||
.route("/api/admin/slots/{slot_id}/notes", get(get_slot_notes))
|
.route("/api/admin/slots/{slot_id}/notes", get(get_slot_notes))
|
||||||
.route("/api/admin/students/{id}/notes", get(get_student_notes))
|
.route("/api/admin/students/{id}/notes", get(get_student_notes))
|
||||||
}
|
}
|
||||||
@@ -99,19 +102,34 @@ pub fn router() -> Router<SqlitePool> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers::{build_test_app, put_json, get};
|
use crate::test_helpers::{build_test_app, get, put_json};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
async fn seed_data(pool: &SqlitePool) -> (i64, i64) {
|
async fn seed_data(pool: &SqlitePool) -> (i64, i64) {
|
||||||
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
||||||
.fetch_one(pool).await.unwrap();
|
.fetch_one(pool)
|
||||||
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
|
.await
|
||||||
.fetch_one(pool).await.unwrap();
|
.unwrap();
|
||||||
|
let course_id: (i64,) = sqlx::query_as(
|
||||||
|
"INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id",
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
||||||
.bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap();
|
.bind(tutor.0)
|
||||||
let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id")
|
.bind(course_id.0)
|
||||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let student_id: (i64,) = sqlx::query_as(
|
||||||
|
"INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(course_id.0)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
|
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
|
||||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
||||||
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
|
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
|
||||||
@@ -124,17 +142,34 @@ mod tests {
|
|||||||
let (app, auth) = build_test_app(pool.clone()).await;
|
let (app, auth) = build_test_app(pool.clone()).await;
|
||||||
let (slot_id, student_id) = seed_data(&pool).await;
|
let (slot_id, student_id) = seed_data(&pool).await;
|
||||||
|
|
||||||
let (status, _) = put_json(app.clone(), &format!("/api/admin/slots/{slot_id}/notes/{student_id}"), &auth, json!({"content": "Good student"})).await;
|
let (status, _) = put_json(
|
||||||
|
app.clone(),
|
||||||
|
&format!("/api/admin/slots/{slot_id}/notes/{student_id}"),
|
||||||
|
&auth,
|
||||||
|
json!({"content": "Good student"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
|
||||||
let (status, body) = get(app.clone(), &format!("/api/admin/slots/{slot_id}/notes"), &auth).await;
|
let (status, body) = get(
|
||||||
|
app.clone(),
|
||||||
|
&format!("/api/admin/slots/{slot_id}/notes"),
|
||||||
|
&auth,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let notes: Value = serde_json::from_slice(&body).unwrap();
|
let notes: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(notes.as_array().unwrap().len(), 1);
|
assert_eq!(notes.as_array().unwrap().len(), 1);
|
||||||
assert_eq!(notes[0]["content"], "Good student");
|
assert_eq!(notes[0]["content"], "Good student");
|
||||||
|
|
||||||
// Update note
|
// Update note
|
||||||
put_json(app.clone(), &format!("/api/admin/slots/{slot_id}/notes/{student_id}"), &auth, json!({"content": "Excellent student"})).await;
|
put_json(
|
||||||
|
app.clone(),
|
||||||
|
&format!("/api/admin/slots/{slot_id}/notes/{student_id}"),
|
||||||
|
&auth,
|
||||||
|
json!({"content": "Excellent student"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let (_, body) = get(app, &format!("/api/admin/slots/{slot_id}/notes"), &auth).await;
|
let (_, body) = get(app, &format!("/api/admin/slots/{slot_id}/notes"), &auth).await;
|
||||||
let notes: Value = serde_json::from_slice(&body).unwrap();
|
let notes: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(notes[0]["content"], "Excellent student");
|
assert_eq!(notes[0]["content"], "Excellent student");
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{get, post, put},
|
routing::{get, put},
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
AppState,
|
||||||
auth::TutorClaims,
|
auth::TutorClaims,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
models::{CreateRoom, LayoutElement, Room},
|
models::{CreateRoom, LayoutElement, Room},
|
||||||
@@ -16,7 +17,9 @@ use crate::{
|
|||||||
|
|
||||||
fn validate_layout(layout: &[LayoutElement]) -> Result<(), AppError> {
|
fn validate_layout(layout: &[LayoutElement]) -> Result<(), AppError> {
|
||||||
if layout.is_empty() {
|
if layout.is_empty() {
|
||||||
return Err(AppError::BadRequest("layout must contain at least one element".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"layout must contain at least one element".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let valid_types = ["seat", "table", "gap", "door"];
|
let valid_types = ["seat", "table", "gap", "door"];
|
||||||
@@ -93,7 +96,10 @@ async fn create_room(
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await?
|
.await?
|
||||||
.last_insert_rowid();
|
.last_insert_rowid();
|
||||||
Ok((StatusCode::CREATED, Json(json!({"id": id, "name": req.name}))))
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(json!({"id": id, "name": req.name})),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_room(
|
async fn get_room(
|
||||||
@@ -106,10 +112,11 @@ async fn get_room(
|
|||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::NotFound)?;
|
.ok_or(AppError::NotFound)?;
|
||||||
let layout: Vec<LayoutElement> = serde_json::from_str(&row.layout_json).map_err(|e| {
|
let layout: Vec<LayoutElement> = serde_json::from_str(&row.layout_json)
|
||||||
AppError::BadRequest(format!("layout parse error: {e}"))
|
.map_err(|e| AppError::BadRequest(format!("layout parse error: {e}")))?;
|
||||||
})?;
|
Ok(Json(
|
||||||
Ok(Json(json!({"id": row.id, "name": row.name, "layout": layout})))
|
json!({"id": row.id, "name": row.name, "layout": layout}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_room_layout(
|
async fn update_room_layout(
|
||||||
@@ -136,7 +143,7 @@ async fn update_room_layout(
|
|||||||
Ok(Json(json!({"id": id})))
|
Ok(Json(json!({"id": id})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<SqlitePool> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/admin/rooms", get(list_rooms).post(create_room))
|
.route("/api/admin/rooms", get(list_rooms).post(create_room))
|
||||||
.route("/api/admin/rooms/{id}", get(get_room))
|
.route("/api/admin/rooms/{id}", get(get_room))
|
||||||
@@ -145,10 +152,9 @@ pub fn router() -> Router<SqlitePool> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use crate::test_helpers::{build_test_app, get, post_json, put_json};
|
use crate::test_helpers::{build_test_app, get, post_json, put_json};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn create_room_with_layout(pool: sqlx::SqlitePool) {
|
async fn create_room_with_layout(pool: sqlx::SqlitePool) {
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
use axum::{
|
|
||||||
extract::{Path, Query, State},
|
|
||||||
http::StatusCode,
|
|
||||||
routing::{delete, get, patch, post},
|
|
||||||
Json, Router,
|
|
||||||
};
|
|
||||||
use rand::Rng;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
AppState,
|
||||||
auth::TutorClaims,
|
auth::TutorClaims,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
models::{CreateSession, CreateSlot, Session, Slot},
|
models::{CreateSession, CreateSlot, Session, Slot},
|
||||||
};
|
};
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
routing::{delete, get, patch, post},
|
||||||
|
};
|
||||||
|
use rand::Rng;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
fn generate_code() -> String {
|
fn generate_code() -> String {
|
||||||
const CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::thread_rng();
|
||||||
(0..8)
|
(0..8)
|
||||||
.map(|_| CHARS[rng.random_range(0..CHARS.len())] as char)
|
.map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,15 +76,13 @@ async fn create_session(
|
|||||||
|
|
||||||
super::verify_tutor_course_access(&pool, claims.sub, req.course_id).await?;
|
super::verify_tutor_course_access(&pool, claims.sub, req.course_id).await?;
|
||||||
|
|
||||||
let id = sqlx::query(
|
let id = sqlx::query("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, ?, ?)")
|
||||||
"INSERT INTO sessions (course_id, week_nr, date) VALUES (?, ?, ?)",
|
.bind(req.course_id)
|
||||||
)
|
.bind(req.week_nr)
|
||||||
.bind(req.course_id)
|
.bind(&req.date)
|
||||||
.bind(req.week_nr)
|
.execute(&pool)
|
||||||
.bind(&req.date)
|
.await?
|
||||||
.execute(&pool)
|
.last_insert_rowid();
|
||||||
.await?
|
|
||||||
.last_insert_rowid();
|
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(json!({"id": id}))))
|
Ok((StatusCode::CREATED, Json(json!({"id": id}))))
|
||||||
}
|
}
|
||||||
@@ -101,24 +99,22 @@ async fn create_slot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Look up the session to get course_id
|
// Look up the session to get course_id
|
||||||
let session_row: Option<(i64,)> =
|
let session_row: Option<(i64,)> = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
||||||
sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
.bind(req.session_id)
|
||||||
.bind(req.session_id)
|
.fetch_optional(&pool)
|
||||||
.fetch_optional(&pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
let (course_id,) = session_row.ok_or(AppError::NotFound)?;
|
let (course_id,) = session_row.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
// Verify requesting tutor has access to the course
|
// Verify requesting tutor has access to the course
|
||||||
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
||||||
|
|
||||||
// Verify the slot's tutor_id belongs to this course
|
// Verify the slot's tutor_id belongs to this course
|
||||||
let member: Option<(i64,)> = sqlx::query_as(
|
let member: Option<(i64,)> =
|
||||||
"SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?",
|
sqlx::query_as("SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?")
|
||||||
)
|
.bind(req.tutor_id)
|
||||||
.bind(req.tutor_id)
|
.bind(course_id)
|
||||||
.bind(course_id)
|
.fetch_optional(&pool)
|
||||||
.fetch_optional(&pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
if member.is_none() {
|
if member.is_none() {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"tutor_id is not a member of this course".into(),
|
"tutor_id is not a member of this course".into(),
|
||||||
@@ -194,19 +190,19 @@ async fn update_slot_status(
|
|||||||
let mut generated = None;
|
let mut generated = None;
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
let candidate = generate_code();
|
let candidate = generate_code();
|
||||||
let conflict: Option<(i64,)> =
|
let conflict: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM slots WHERE code = ?")
|
||||||
sqlx::query_as("SELECT 1 FROM slots WHERE code = ?")
|
.bind(&candidate)
|
||||||
.bind(&candidate)
|
.fetch_optional(&pool)
|
||||||
.fetch_optional(&pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
if conflict.is_none() {
|
if conflict.is_none() {
|
||||||
generated = Some(candidate);
|
generated = Some(candidate);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(generated.ok_or_else(|| {
|
Some(
|
||||||
AppError::BadRequest("could not generate unique code".into())
|
generated
|
||||||
})?)
|
.ok_or_else(|| AppError::BadRequest("could not generate unique code".into()))?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
existing_code
|
existing_code
|
||||||
@@ -273,9 +269,12 @@ async fn delete_slot(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<SqlitePool> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/admin/sessions", get(list_sessions).post(create_session))
|
.route(
|
||||||
|
"/api/admin/sessions",
|
||||||
|
get(list_sessions).post(create_session),
|
||||||
|
)
|
||||||
.route("/api/admin/slots", post(create_slot))
|
.route("/api/admin/slots", post(create_slot))
|
||||||
.route("/api/admin/slots/{id}/status", patch(update_slot_status))
|
.route("/api/admin/slots/{id}/status", patch(update_slot_status))
|
||||||
.route("/api/admin/slots/{id}", delete(delete_slot))
|
.route("/api/admin/slots/{id}", delete(delete_slot))
|
||||||
@@ -284,9 +283,11 @@ pub fn router() -> Router<SqlitePool> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, patch_json, post_json};
|
use crate::test_helpers::{
|
||||||
|
build_test_admin_app, build_test_app, delete, get, patch_json, post_json,
|
||||||
|
};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
// Pure unit tests (no DB)
|
// Pure unit tests (no DB)
|
||||||
@@ -295,9 +296,10 @@ mod tests {
|
|||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
let code = generate_code();
|
let code = generate_code();
|
||||||
assert_eq!(code.len(), 8);
|
assert_eq!(code.len(), 8);
|
||||||
assert!(code
|
assert!(
|
||||||
.chars()
|
code.chars()
|
||||||
.all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c)));
|
.all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,11 +358,13 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let sessions = serde_json::from_slice::<Value>(&body).unwrap();
|
let sessions = serde_json::from_slice::<Value>(&body).unwrap();
|
||||||
assert!(sessions
|
assert!(
|
||||||
.as_array()
|
sessions
|
||||||
.unwrap()
|
.as_array()
|
||||||
.iter()
|
.unwrap()
|
||||||
.any(|s| s["id"] == session_id));
|
.iter()
|
||||||
|
.any(|s| s["id"] == session_id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
@@ -418,7 +422,8 @@ mod tests {
|
|||||||
&format!("/api/admin/slots/{slot_id}/status"),
|
&format!("/api/admin/slots/{slot_id}/status"),
|
||||||
&auth,
|
&auth,
|
||||||
json!({"status": "open"}),
|
json!({"status": "open"}),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let slot = serde_json::from_slice::<Value>(&body).unwrap();
|
let slot = serde_json::from_slice::<Value>(&body).unwrap();
|
||||||
assert_eq!(slot["status"], "open");
|
assert_eq!(slot["status"], "open");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use axum::{extract::State, http::StatusCode, routing::post, Router};
|
use axum::{Router, extract::State, http::StatusCode, routing::post};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::{AppState, error::AppError};
|
||||||
|
|
||||||
// Seed SQL loaded once at startup, reused per reset call.
|
// Seed SQL loaded once at startup, reused per reset call.
|
||||||
pub static SEED_SQL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
pub static SEED_SQL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||||
@@ -12,12 +12,20 @@ async fn reset(State(pool): State<SqlitePool>) -> Result<StatusCode, AppError> {
|
|||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
// Delete in FK-safe order (children → parents)
|
// Delete in FK-safe order (children → parents)
|
||||||
sqlx::query("DELETE FROM attendances").execute(&mut *tx).await?;
|
sqlx::query("DELETE FROM attendances")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
sqlx::query("DELETE FROM notes").execute(&mut *tx).await?;
|
sqlx::query("DELETE FROM notes").execute(&mut *tx).await?;
|
||||||
sqlx::query("DELETE FROM slots").execute(&mut *tx).await?;
|
sqlx::query("DELETE FROM slots").execute(&mut *tx).await?;
|
||||||
sqlx::query("DELETE FROM sessions").execute(&mut *tx).await?;
|
sqlx::query("DELETE FROM sessions")
|
||||||
sqlx::query("DELETE FROM tutor_courses").execute(&mut *tx).await?;
|
.execute(&mut *tx)
|
||||||
sqlx::query("DELETE FROM students").execute(&mut *tx).await?;
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM tutor_courses")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM students")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
sqlx::query("DELETE FROM rooms").execute(&mut *tx).await?;
|
sqlx::query("DELETE FROM rooms").execute(&mut *tx).await?;
|
||||||
sqlx::query("DELETE FROM tutors").execute(&mut *tx).await?;
|
sqlx::query("DELETE FROM tutors").execute(&mut *tx).await?;
|
||||||
sqlx::query("DELETE FROM courses").execute(&mut *tx).await?;
|
sqlx::query("DELETE FROM courses").execute(&mut *tx).await?;
|
||||||
@@ -34,6 +42,6 @@ async fn reset(State(pool): State<SqlitePool>) -> Result<StatusCode, AppError> {
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<SqlitePool> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new().route("/__test__/reset", post(reset))
|
Router::new().route("/__test__/reset", post(reset))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
use crate::{
|
||||||
|
AppState,
|
||||||
|
auth::TutorClaims,
|
||||||
|
error::AppError,
|
||||||
|
models::{CreateTutor, Tutor},
|
||||||
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{get, post, delete},
|
routing::{delete, get},
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use crate::{auth::TutorClaims, error::AppError, models::{CreateTutor, Tutor}};
|
|
||||||
|
|
||||||
async fn list_tutors(
|
async fn list_tutors(
|
||||||
claims: TutorClaims,
|
claims: TutorClaims,
|
||||||
@@ -16,7 +21,7 @@ async fn list_tutors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let tutors = sqlx::query_as::<_, Tutor>(
|
let tutors = sqlx::query_as::<_, Tutor>(
|
||||||
"SELECT id, name, email, is_superadmin FROM tutors ORDER BY name"
|
"SELECT id, name, email, is_superadmin FROM tutors ORDER BY name",
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -33,10 +38,19 @@ async fn create_tutor(
|
|||||||
return Err(AppError::Unauthorized);
|
return Err(AppError::Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hash = bcrypt::hash(&req.password, 12).map_err(|_| AppError::Unauthorized)?;
|
let salt = argon2::password_hash::SaltString::generate(&mut rand::thread_rng());
|
||||||
|
let argon2 = argon2::Argon2::default();
|
||||||
|
use argon2::password_hash::PasswordHasher;
|
||||||
|
let hash = argon2
|
||||||
|
.hash_password(req.password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "argon2 hash failed");
|
||||||
|
AppError::Unauthorized
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let id = sqlx::query(
|
let id = sqlx::query(
|
||||||
"INSERT INTO tutors (name, email, password_hash, is_superadmin) VALUES (?, ?, ?, ?)"
|
"INSERT INTO tutors (name, email, password_hash, is_superadmin) VALUES (?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
.bind(&req.email)
|
.bind(&req.email)
|
||||||
@@ -79,7 +93,7 @@ async fn delete_tutor(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<SqlitePool> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/admin/tutors", get(list_tutors).post(create_tutor))
|
.route("/api/admin/tutors", get(list_tutors).post(create_tutor))
|
||||||
.route("/api/admin/tutors/{id}", delete(delete_tutor))
|
.route("/api/admin/tutors/{id}", delete(delete_tutor))
|
||||||
|
|||||||
@@ -1,49 +1,92 @@
|
|||||||
// cfg(test) only — this whole module is test-only
|
// cfg(test) only — this whole module is test-only
|
||||||
use sqlx::SqlitePool;
|
use crate::AppState;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tower::ServiceExt;
|
use axum::http::{HeaderMap, Request, StatusCode};
|
||||||
use axum::http::{Request, StatusCode};
|
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
pub const TEST_SECRET: &str = "testsecret";
|
||||||
|
|
||||||
/// Insert a test tutor (if not exists), return a valid JWT for that tutor.
|
/// Insert a test tutor (if not exists), return a valid JWT for that tutor.
|
||||||
pub async fn make_token(pool: &SqlitePool, email: &str, is_superadmin: bool) -> String {
|
pub async fn make_token(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
email: &str,
|
||||||
|
is_superadmin: bool,
|
||||||
|
secret: &str,
|
||||||
|
) -> String {
|
||||||
let hash = bcrypt::hash("testpass", 4).unwrap();
|
let hash = bcrypt::hash("testpass", 4).unwrap();
|
||||||
sqlx::query("INSERT OR IGNORE INTO tutors (name,email,password_hash,is_superadmin) VALUES (?,?,?,?)")
|
sqlx::query(
|
||||||
.bind("Test Tutor").bind(email).bind(&hash).bind(is_superadmin)
|
"INSERT OR IGNORE INTO tutors (name,email,password_hash,is_superadmin) VALUES (?,?,?,?)",
|
||||||
.execute(pool).await.unwrap();
|
)
|
||||||
|
.bind("Test Tutor")
|
||||||
|
.bind(email)
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(is_superadmin)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Ensure the superadmin flag is correct even if it existed
|
// Ensure the superadmin flag is correct even if it existed
|
||||||
sqlx::query("UPDATE tutors SET is_superadmin = ? WHERE email = ?")
|
sqlx::query("UPDATE tutors SET is_superadmin = ? WHERE email = ?")
|
||||||
.bind(is_superadmin).bind(email).execute(pool).await.unwrap();
|
.bind(is_superadmin)
|
||||||
|
.bind(email)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let row: (i64, bool) = sqlx::query_as("SELECT id, is_superadmin FROM tutors WHERE email = ?")
|
let row: (i64, bool) = sqlx::query_as("SELECT id, is_superadmin FROM tutors WHERE email = ?")
|
||||||
.bind(email).fetch_one(pool).await.unwrap();
|
.bind(email)
|
||||||
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
|
.fetch_one(pool)
|
||||||
crate::auth::encode_jwt(row.0, email, row.1).unwrap()
|
.await
|
||||||
|
.unwrap();
|
||||||
|
crate::auth::encode_jwt(row.0, row.1, secret, false).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the full Axum app wired with the given pool, plus a Bearer auth header value.
|
/// Build the full Axum app wired with the given pool, plus a Bearer auth header value.
|
||||||
pub async fn build_test_app(pool: SqlitePool) -> (Router, String) {
|
pub async fn build_test_app(pool: SqlitePool) -> (Router, String) {
|
||||||
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
|
let token = make_token(&pool, "tutor@test.com", false, TEST_SECRET).await;
|
||||||
let token = make_token(&pool, "tutor@test.com", false).await;
|
let state = AppState {
|
||||||
let app = crate::routes::build(pool, false);
|
pool: pool.clone(),
|
||||||
|
jwt_secret: TEST_SECRET.into(),
|
||||||
|
test_mode: true,
|
||||||
|
};
|
||||||
|
let app = crate::routes::build(state, true);
|
||||||
(app, format!("Bearer {token}"))
|
(app, format!("Bearer {token}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the full Axum app wired with a superadmin Bearer auth header.
|
/// Build the full Axum app wired with a superadmin Bearer auth header.
|
||||||
pub async fn build_test_admin_app(pool: SqlitePool) -> (Router, String) {
|
pub async fn build_test_admin_app(pool: SqlitePool) -> (Router, String) {
|
||||||
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
|
let token = make_token(&pool, "admin@test.com", true, TEST_SECRET).await;
|
||||||
let token = make_token(&pool, "admin@test.com", true).await;
|
let state = AppState {
|
||||||
let app = crate::routes::build(pool, false);
|
pool: pool.clone(),
|
||||||
|
jwt_secret: TEST_SECRET.into(),
|
||||||
|
test_mode: true,
|
||||||
|
};
|
||||||
|
let app = crate::routes::build(state, true);
|
||||||
(app, format!("Bearer {token}"))
|
(app, format!("Bearer {token}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST JSON body to the app (one-shot), returns (StatusCode, response body bytes).
|
/// POST JSON body to the app (one-shot), returns (StatusCode, response body bytes).
|
||||||
pub async fn post_json(app: Router, path: &str, auth: &str, body: serde_json::Value)
|
pub async fn post_json(
|
||||||
-> (StatusCode, bytes::Bytes)
|
app: Router,
|
||||||
{
|
path: &str,
|
||||||
|
auth: &str,
|
||||||
|
body: serde_json::Value,
|
||||||
|
) -> (StatusCode, bytes::Bytes) {
|
||||||
|
let (status, body, _) = post_json_with_headers(app, path, auth, body).await;
|
||||||
|
(status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_json_with_headers(
|
||||||
|
app: Router,
|
||||||
|
path: &str,
|
||||||
|
auth: &str,
|
||||||
|
body: serde_json::Value,
|
||||||
|
) -> (StatusCode, bytes::Bytes, HeaderMap) {
|
||||||
let mut builder = Request::builder()
|
let mut builder = Request::builder()
|
||||||
.method("POST").uri(path)
|
.method("POST")
|
||||||
|
.uri(path)
|
||||||
.header("Content-Type", "application/json");
|
.header("Content-Type", "application/json");
|
||||||
if !auth.is_empty() {
|
if !auth.is_empty() {
|
||||||
builder = builder.header("Authorization", auth);
|
builder = builder.header("Authorization", auth);
|
||||||
@@ -53,22 +96,26 @@ pub async fn post_json(app: Router, path: &str, auth: &str, body: serde_json::Va
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let res = app.oneshot(req).await.unwrap();
|
let res = app.oneshot(req).await.unwrap();
|
||||||
let status = res.status();
|
let status = res.status();
|
||||||
|
let headers = res.headers().clone();
|
||||||
let body = res.into_body().collect().await.unwrap().to_bytes();
|
let body = res.into_body().collect().await.unwrap().to_bytes();
|
||||||
(status, body)
|
(status, body, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT JSON body to the app (one-shot), returns (StatusCode, response body bytes).
|
/// PUT JSON body to the app (one-shot), returns (StatusCode, response body bytes).
|
||||||
pub async fn put_json(app: Router, uri: &str, auth: &str, body: serde_json::Value)
|
pub async fn put_json(
|
||||||
-> (StatusCode, bytes::Bytes)
|
app: Router,
|
||||||
{
|
uri: &str,
|
||||||
let mut req = Request::builder()
|
auth: &str,
|
||||||
|
body: serde_json::Value,
|
||||||
|
) -> (StatusCode, bytes::Bytes) {
|
||||||
|
let mut builder = Request::builder()
|
||||||
.method("PUT")
|
.method("PUT")
|
||||||
.uri(uri)
|
.uri(uri)
|
||||||
.header("Content-Type", "application/json");
|
.header("Content-Type", "application/json");
|
||||||
if !auth.is_empty() {
|
if !auth.is_empty() {
|
||||||
req = req.header("Authorization", auth);
|
builder = builder.header("Authorization", auth);
|
||||||
}
|
}
|
||||||
let req = req
|
let req = builder
|
||||||
.body(axum::body::Body::from(body.to_string()))
|
.body(axum::body::Body::from(body.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let res = app.oneshot(req).await.unwrap();
|
let res = app.oneshot(req).await.unwrap();
|
||||||
@@ -84,14 +131,14 @@ pub async fn patch_json(
|
|||||||
auth: &str,
|
auth: &str,
|
||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
) -> (StatusCode, bytes::Bytes) {
|
) -> (StatusCode, bytes::Bytes) {
|
||||||
let mut req = Request::builder()
|
let mut builder = Request::builder()
|
||||||
.method("PATCH")
|
.method("PATCH")
|
||||||
.uri(uri)
|
.uri(uri)
|
||||||
.header("Content-Type", "application/json");
|
.header("Content-Type", "application/json");
|
||||||
if !auth.is_empty() {
|
if !auth.is_empty() {
|
||||||
req = req.header("Authorization", auth);
|
builder = builder.header("Authorization", auth);
|
||||||
}
|
}
|
||||||
let req = req
|
let req = builder
|
||||||
.body(axum::body::Body::from(body.to_string()))
|
.body(axum::body::Body::from(body.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let res = app.oneshot(req).await.unwrap();
|
let res = app.oneshot(req).await.unwrap();
|
||||||
@@ -102,14 +149,11 @@ pub async fn patch_json(
|
|||||||
|
|
||||||
/// GET from the app (one-shot), returns (StatusCode, response body bytes).
|
/// GET from the app (one-shot), returns (StatusCode, response body bytes).
|
||||||
pub async fn get(app: Router, path: &str, auth: &str) -> (StatusCode, bytes::Bytes) {
|
pub async fn get(app: Router, path: &str, auth: &str) -> (StatusCode, bytes::Bytes) {
|
||||||
let mut builder = Request::builder()
|
let mut builder = Request::builder().method("GET").uri(path);
|
||||||
.method("GET").uri(path);
|
|
||||||
if !auth.is_empty() {
|
if !auth.is_empty() {
|
||||||
builder = builder.header("Authorization", auth);
|
builder = builder.header("Authorization", auth);
|
||||||
}
|
}
|
||||||
let req = builder
|
let req = builder.body(axum::body::Body::empty()).unwrap();
|
||||||
.body(axum::body::Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let res = app.oneshot(req).await.unwrap();
|
let res = app.oneshot(req).await.unwrap();
|
||||||
let status = res.status();
|
let status = res.status();
|
||||||
let body = res.into_body().collect().await.unwrap().to_bytes();
|
let body = res.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
# Implementation Plan: Attendance Tracking Tool (Continuation)
|
|
||||||
|
|
||||||
**Objective:** Complete the backend API, scaffold the SvelteKit frontend, implement all frontend views based on the provided design handoff, and set up deployment manifests. This plan picks up from where the `.worktrees/feature-tutortool` workspace left off (Tasks 1-8 completed).
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
- Complete backend APIs for attendance, notes, and exports.
|
|
||||||
- Serve SvelteKit SPA fallback via Axum.
|
|
||||||
- Scaffold SvelteKit frontend.
|
|
||||||
- Implement UI pages for Admin tools (Dashboard, Courses, Rooms, Sessions, Attendance, Notes, Export) and Student Check-in.
|
|
||||||
- Configure local development (Makefile, Docker Compose) and K8s manifests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Backend Completion
|
|
||||||
|
|
||||||
### Task 9: Admin Attendance & Notes APIs
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `backend/src/routes/attendance.rs`
|
|
||||||
- `backend/src/routes/notes.rs`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Add `POST /api/admin/slots/:id/attendance` (manual entry) and `DELETE /api/admin/slots/:slot_id/attendance/:student_id`.
|
|
||||||
- Add `GET /api/admin/sessions/:id/attendance` (per-week matrix) and `GET /api/admin/students/:id/attendance`.
|
|
||||||
- Add `PUT /api/admin/slots/:slot_id/notes/:student_id` (upsert note).
|
|
||||||
- Add `GET /api/admin/slots/:slot_id/notes` and `GET /api/admin/students/:id/notes`.
|
|
||||||
- Write corresponding unit tests.
|
|
||||||
|
|
||||||
### Task 10: Export API
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `backend/src/routes/export.rs`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Add `GET /api/admin/export/session/:id/csv` and `/md` (merged per-session weekly attendance).
|
|
||||||
- Add `GET /api/admin/export/course/:id/csv` and `/md` (full course matrix with Bonus points calculation: +3 if unexcused absences <= 1).
|
|
||||||
- Add `GET /api/admin/backup` (using `VACUUM INTO '/tmp/backup-<timestamp>.sqlite'` then streaming as `application/octet-stream`).
|
|
||||||
- Ensure all endpoints verify `TutorClaims` and course access.
|
|
||||||
|
|
||||||
### Task 11: Static File Serving & Route Assembly
|
|
||||||
**Files to Modify:**
|
|
||||||
- `backend/src/main.rs`
|
|
||||||
- `backend/src/routes/mod.rs`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Merge `attendance`, `notes`, and `export` routers in `routes/mod.rs`.
|
|
||||||
- Configure `tower_http::services::ServeDir` in `main.rs` to serve the SvelteKit static build.
|
|
||||||
- Set up `ServeFile::new(format!("{static_dir}/index.html"))` as the SPA fallback service.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Frontend Development
|
|
||||||
|
|
||||||
### Task 12: Scaffold SvelteKit Frontend
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `frontend/package.json`, `svelte.config.js`, `vite.config.ts`, `src/app.html`, `src/lib/types.ts`, `src/lib/api.ts`, `src/lib/auth.ts`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Initialize SvelteKit with `@sveltejs/adapter-static` configured for SPA fallback.
|
|
||||||
- Setup Vite proxy `/api` -> `http://localhost:3000`.
|
|
||||||
- Create TypeScript types mirroring the backend database models.
|
|
||||||
- Implement API client fetch wrapper (`api.ts`).
|
|
||||||
- Set up Svelte store for JWT auth (`auth.ts`).
|
|
||||||
|
|
||||||
### Task 13: Login Page & Admin Auth Guard
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `frontend/src/routes/login/+page.svelte`
|
|
||||||
- `frontend/src/routes/admin/+layout.svelte`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Implement the tutor login form and API integration.
|
|
||||||
- Protect `/admin` routes using an `onMount` check redirecting unauthenticated users to `/login`.
|
|
||||||
|
|
||||||
### Task 14: Dashboard & Slot Management
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `frontend/src/routes/admin/+page.svelte`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Display all sessions/slots.
|
|
||||||
- Implement toggles for slot status (`closed`, `open`, `locked`).
|
|
||||||
- Display check-in link and copy button when a slot is `open`.
|
|
||||||
|
|
||||||
### Task 15: Courses & Students UI
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `frontend/src/routes/admin/courses/+page.svelte`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- List courses and forms to create new courses.
|
|
||||||
- Per-course student management: list students, add individual student, import from CSV, and delete students.
|
|
||||||
|
|
||||||
### Task 16: Room Layout Editor
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `frontend/src/lib/RoomCanvas.svelte`
|
|
||||||
- `frontend/src/routes/admin/rooms/+page.svelte`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Implement SVG-based `RoomCanvas.svelte` supporting interactive (draggable/editable) mode, student check-in mode, and tutor notes mode.
|
|
||||||
- Build room management UI: list rooms, create rooms, and edit JSON room layouts visually.
|
|
||||||
|
|
||||||
### Task 17: Sessions & Slots UI
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `frontend/src/routes/admin/sessions/+page.svelte`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Form to create sessions (course, week_nr, date).
|
|
||||||
- Form to add slots within a session (room, tutor, start_time, end_time).
|
|
||||||
|
|
||||||
### Task 18: Student Check-in Page
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `frontend/src/routes/s/[code]/+page.svelte`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Fetch slot info. If no identity cookie exists, show name dropdown filtered to the course.
|
|
||||||
- Display `RoomCanvas` indicating free/occupied seats.
|
|
||||||
- Handle FCFS seat locking API calls (`POST /api/checkin`) and update UI.
|
|
||||||
- Support read-only mode for locked/closed slots.
|
|
||||||
|
|
||||||
### Task 19: Attendance & Notes UI
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `frontend/src/routes/admin/attendance/+page.svelte`
|
|
||||||
- `frontend/src/routes/admin/notes/+page.svelte`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Build matrix tables (per-week and per-student) for manual attendance marking/removal.
|
|
||||||
- Build notes UI utilizing `RoomCanvas` for clicking on seats to leave inline text notes.
|
|
||||||
|
|
||||||
### Task 20: Export UI
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `frontend/src/routes/admin/export/+page.svelte`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Provide buttons to download weekly CSV/Markdown, full course matrix, and SQLite backup directly from the admin interface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. DevOps & Deployment
|
|
||||||
|
|
||||||
### Task 21: Local Dev Environment
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `Makefile`
|
|
||||||
- `docker-compose.yml`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Write `Makefile` with commands: `dev`, `dev-backend`, `dev-frontend`, `test`, `build`, `compose-up`.
|
|
||||||
- Create `docker-compose.yml` for testing the production image locally using SQLite mounted via volume.
|
|
||||||
|
|
||||||
### Task 22: Dockerfile & K8s Manifests
|
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `Dockerfile`
|
|
||||||
- `k8s/deployment.yaml`, `k8s/service.yaml`, `k8s/ingress.yaml`, `k8s/pvc.yaml`, `k8s/cronjob.yaml`
|
|
||||||
|
|
||||||
**Implementation Steps:**
|
|
||||||
- Write a 3-stage `Dockerfile` (frontend build, backend build, alpine + sqlite runtime).
|
|
||||||
- Write `pvc.yaml` for SQLite persistent storage.
|
|
||||||
- Write `deployment.yaml`, `service.yaml`, and `ingress.yaml` (`tutor.puchstein.dev`).
|
|
||||||
- Write `cronjob.yaml` running at 3 AM daily, executing `sqlite3 /data/attendance.db "VACUUM INTO '/data/backup-$(date +%F).sqlite'"` and pruning files older than 7 days.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Strategy
|
|
||||||
1. **Unit Tests:** Execute `cargo test` in `backend/` to verify all new endpoints (Tasks 9-11).
|
|
||||||
2. **End-to-End Test:** Start `make dev` and manually verify all critical paths: tutor login, session/slot creation, student check-in with cookie persistence, FCFS seat collision handling, manual attendance, and exporting.
|
|
||||||
3. **Deployment Test:** Run `make compose-up` to ensure the built Docker container operates as expected, serving Svelte SPA fallback via Axum properly.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Demo Preparation & Seed Data Plan
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
Create an isolated, reproducible demo environment for presenting TutorTool on a Surface Pro 5 (Arch Linux) using Docker. This includes a robust set of seed data to simulate a live application state, which can also be utilized for local end-to-end testing.
|
|
||||||
|
|
||||||
## Key Files & Context
|
|
||||||
- **Environment**: `docker-compose.yml` (existing)
|
|
||||||
- **Database**: SQLite (`data/attendance.db`)
|
|
||||||
- **New Files**:
|
|
||||||
- `backend/migrations/demo_seed.sql`: A standalone SQL script containing isolated test data.
|
|
||||||
- **Modified Files**:
|
|
||||||
- `Makefile`: Update to include a `seed-demo` target for easy execution.
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### 1. Workspace Isolation via Git Worktree
|
|
||||||
When implementing this plan, the Gemini CLI will automatically utilize its Git worktree feature to spawn a new isolated workspace (e.g., `feature/demo-seed`). This ensures the backend tooling modifications do not interfere with the `frontend-design-overhaul` worktree or the main branch.
|
|
||||||
|
|
||||||
### 2. Create the Seed Data Script (`backend/migrations/demo_seed.sql`)
|
|
||||||
Create a SQL script that safely injects realistic demo data. It will use `INSERT OR IGNORE` or handle conflicts to ensure it can be run cleanly for both demo and testing purposes.
|
|
||||||
- **Admin/Tutor Account**:
|
|
||||||
- Name: "Demo Admin"
|
|
||||||
- Email: `admin@tutortool.com`
|
|
||||||
- Password Hash: A pre-calculated bcrypt hash for the password `admin`.
|
|
||||||
- **Course**: "Demo Course 101" (Semester: "Current").
|
|
||||||
- **Room Layout**: A valid JSON SVG layout representing a small classroom with a few tables and seats.
|
|
||||||
- **Students**: Generate ~10 distinct student names linked to the course.
|
|
||||||
- **Session & Slot**: Create a session for the current date and an "open" slot linked to the demo room, ensuring the check-in feature can be demonstrated immediately without setup.
|
|
||||||
|
|
||||||
### 3. Update Makefile
|
|
||||||
Add a `seed-demo` target to the existing `Makefile`.
|
|
||||||
- The target will execute the SQLite CLI to run `demo_seed.sql` against the local development database defined by the `DATABASE_URL` environment variable (defaulting to `sqlite:./dev.db` for local dev).
|
|
||||||
|
|
||||||
### 4. Demo Run Guide
|
|
||||||
Provide a short set of instructions on how to start the environment using `docker-compose up` and the new seed command on the Surface Pro 5.
|
|
||||||
|
|
||||||
## Verification & Testing
|
|
||||||
- **Execution**: Run `make seed-demo` against a fresh SQLite database to ensure no foreign key or syntax errors occur.
|
|
||||||
- **Authentication**: Verify that logging in with `admin@tutortool.com` / `admin` succeeds against the seeded database.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Objective
|
|
||||||
Fix the Playwright MCP configuration in Gemini CLI by aligning it with the working Claude Code configuration.
|
|
||||||
|
|
||||||
# Key Files & Context
|
|
||||||
- `.gemini/settings.json`: This file currently contains the incorrect configuration pointing to `/opt/google/chrome/chrome`.
|
|
||||||
|
|
||||||
# Implementation Steps
|
|
||||||
1. Update `.gemini/settings.json` to modify the `args` array for the `playwright` mcpServer.
|
|
||||||
2. Remove the `--browser` and `chrome` arguments.
|
|
||||||
3. Update `--executable-path` to point to `/home/mpuchstein/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome` (the working binary used by Claude).
|
|
||||||
|
|
||||||
# Verification & Testing
|
|
||||||
After updating the configuration, the user will need to restart the Gemini CLI session to apply the changes and verify that the Playwright MCP tools function correctly.
|
|
||||||
@@ -1,69 +1,90 @@
|
|||||||
# Implementation Plan: Room Editor Refactor (Core & Logic)
|
# Implementation Plan: Room Editor Refactor (Core & Logic)
|
||||||
|
|
||||||
**Objective:** Standardize the room layout data model, align backend/frontend types, and refactor the core editor logic for robustness and grid-based precision.
|
**Objective:** Fix the pixel vs. grid-unit mismatch in stored room data, and robustify the editor for professional room planning.
|
||||||
|
|
||||||
**Background:**
|
**Background:**
|
||||||
The current room implementation suffers from naming inconsistencies (`type` vs `kind`) and coordinate system mismatches (pixels vs grid units). The editor logic in `RoomCanvas.svelte` is basic and needs to be more robust to support professional room planning.
|
The editor (`RoomCanvas.svelte`) already stores and renders in grid units (1 unit = 40 px). However, the demo seed (`demo_seed.sql`) was written with raw pixel values (e.g. `width: 200`), causing demo Room A to render broken (200 grid units = 8000 px). Any room created via the editor since launch is correct; any room predating the editor's grid-unit switch (or the demo room) is broken. A one-time data migration is the first priority.
|
||||||
|
|
||||||
|
**Note on the `type`/`kind` field:** `backend/src/models.rs:81` already bridges this with `#[serde(rename = "type")] pub kind: String`. The wire format is `type` and `frontend/src/lib/types.ts:33` already uses `type`. **No rename is needed.** If the Rust internal name is ever changed to `type`, a raw identifier (`r#type`) is required since `type` is reserved.
|
||||||
|
|
||||||
|
**Note on backend validation:** `backend/src/routes/rooms.rs:18–69` already implements `validate_layout` with empty-layout check, unique IDs, allowed types (`seat`, `table`, `gap`, `door`), unique seat labels, and non-negative geometry. Tests at lines 184–322 cover all of it. **Task 2 below replaces the previously planned duplicate work.**
|
||||||
|
|
||||||
|
**Note on `SeatMap.svelte`:** This plan does **not** touch `SeatMap.svelte`. Its retirement and replacement with a dynamic renderer is handled by the sibling visualization plan. Any `LayoutElement` contract change made here must be cross-checked against `backend/src/routes/checkin.rs:53,194` (which deserialises it) and `frontend/src/lib/types.ts:86` (`CheckinInfo.layout`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Data Model & Type Alignment
|
## 1. Data Migration & Seed Fix
|
||||||
|
|
||||||
### Task 1: Standardize LayoutElement Naming
|
### Task 1: Pixel → Grid-Unit Migration
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
- `backend/src/models.rs`
|
- `backend/migrations/003_normalize_room_layout_units.sql` *(create)*
|
||||||
- `frontend/src/lib/types.ts`
|
|
||||||
- `backend/demo/demo_seed.sql`
|
- `backend/demo/demo_seed.sql`
|
||||||
|
- `backend/src/routes/rooms.rs` (update tests that assert large numeric coordinates)
|
||||||
|
|
||||||
**Changes:**
|
**Changes:**
|
||||||
- Unified field name `type` (using `#[serde(rename = "type")]` if necessary in Rust, or changing it consistently).
|
- Write `003_normalize_room_layout_units.sql`. For each row in `rooms`, parse `layout_json`; if any element has `x`, `y`, `width`, or `height` > 50, divide all four by 40 and update the row. This heuristic is safe because grid-unit values are small integers/half-steps (max ~30), while pixel values are large (typically 80–800).
|
||||||
- Standardize coordinate units: All `x`, `y`, `width`, `height` values in the database will represent **grid units** (e.g., 1 unit = 40px) rather than raw pixels.
|
- Update `demo_seed.sql:16–41` to use grid units (e.g. `width: 200` → `width: 5`). The 24 elements in demo Room A need to be re-measured in grid units.
|
||||||
- Update `demo_seed.sql` to use these normalized grid units.
|
- Update any integration tests in `rooms.rs` that rely on large pixel-scale layout values.
|
||||||
|
|
||||||
### Task 2: Backend Validation
|
### Task 2: Backend Validation (Scope Reduction)
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
- `backend/src/routes/rooms.rs`
|
- `backend/src/routes/rooms.rs`
|
||||||
|
|
||||||
**Changes:**
|
**Changes (additive only — do not duplicate existing logic):**
|
||||||
- Add validation logic to `POST /api/admin/rooms` and `PUT /api/admin/rooms/:id/layout`.
|
- Add upper-bound validation: `x` and `y` must be < a `MAX_CANVAS` constant (e.g. 100 grid units). Reject elements that fall off the canvas.
|
||||||
- Ensure all elements have unique IDs.
|
- Add grid-step validation: `x`, `y`, `width`, `height` must be multiples of 0.5 (i.e. `(value * 2) % 1 == 0`). Apply post-migration so existing data has already been normalised.
|
||||||
- Validate that `type` is one of the allowed strings (`seat`, `table`, `door`, `gap`).
|
- Add a test for each new validator.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Editor Core Refactor
|
## 2. Editor Core Refactor
|
||||||
|
|
||||||
### Task 3: RoomCanvas Logic Overhaul
|
### Task 3: RoomCanvas State & Behaviour
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
- `frontend/src/lib/RoomCanvas.svelte`
|
- `frontend/src/lib/RoomCanvas.svelte`
|
||||||
|
|
||||||
|
**Current state (188 lines):**
|
||||||
|
- Drag: `draggingId / startX / startY` only (lines 26–28). No resize state or handles exist.
|
||||||
|
- Snap: lines 47–48 snap to 0.25 grid units (`Math.round(.../10)*10/40`). This is partially correct but the increment should be configurable (0.5 default).
|
||||||
|
- Rendering: already multiplies by `GRID_SIZE = 40` (line 85). Unit separation is mostly correct.
|
||||||
|
- Bug: `onmousemove` / `onmouseup` are bound on the SVG only (lines 69–71). Releasing the cursor outside the SVG strands the drag. Move these listeners to `window` for the duration of a drag.
|
||||||
|
|
||||||
**Changes:**
|
**Changes:**
|
||||||
- **Grid Snap:** Implement mandatory snap-to-grid (0.5 or 1.0 unit increments) during dragging and resizing.
|
- **Build resize from scratch.** Add resize handles (e.g. bottom-right corner hit area) per element. Track `resizingId`, `resizeStartX`, `resizeStartY`, `resizeStartW`, `resizeStartH` as drag state. Snap resize delta to 0.5 increments.
|
||||||
- **State Management:** Refactor internal dragging state to be cleaner and more predictable.
|
- **Fix drag escape.** Bind `mousemove`/`mouseup` to `window` when dragging begins; remove them on drop.
|
||||||
- **Selection:** Improve the selection highlight and event propagation.
|
- **Snap increment.** Change snap to 0.5 grid units (from 0.25). Accept an optional `snapStep` prop (default `0.5`) for the snap-toggle feature below.
|
||||||
- **Unit Separation:** Ensure the component strictly thinks in grid units, with the rendering layer handling the pixel scaling.
|
|
||||||
|
|
||||||
### Task 4: Editor UI Improvements
|
### Task 4: Editor UI Improvements
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
- `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`
|
- `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`
|
||||||
|
|
||||||
**Changes:**
|
**What already exists (do not re-add):**
|
||||||
- Add a "Snap to Grid" toggle.
|
- Width/height inputs with `step="0.5"` (lines 90–97)
|
||||||
- Add numeric inputs for precise coordinate editing (X, Y, W, H).
|
- Label input (line 87)
|
||||||
- Implement "duplicate element" functionality.
|
- Add seat/table/door buttons (lines 64–66)
|
||||||
- Better error handling and visual feedback during saving.
|
- Delete button (line 101)
|
||||||
|
|
||||||
|
**What to add:**
|
||||||
|
- **X/Y numeric inputs** (with `step="0.5"`) for precise coordinate editing of the selected element, bound to its `x` and `y` fields.
|
||||||
|
- **"+ Gap" button** alongside the existing add buttons. `gap` is accepted by `validate_layout` but is currently unreachable from the UI.
|
||||||
|
- **"Snap to Grid" toggle.** Bind to a boolean state; pass as `snapStep={snapEnabled ? 0.5 : 0}` to `RoomCanvas`.
|
||||||
|
- **"Duplicate element" button.** Copies the selected element with a new UUID and offsets it by 1 grid unit.
|
||||||
|
- **Surface save errors.** `saveLayout` (lines 27–29) currently only `console.error`. Display an inline error message in the UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Verification
|
## 3. Verification
|
||||||
|
|
||||||
### Automated Tests:
|
### Automated Tests:
|
||||||
- `backend/src/routes/rooms.rs`: Add unit tests for layout validation.
|
- `backend/src/routes/rooms.rs`: Tests for the new upper-bound and grid-step validators.
|
||||||
- `frontend/tests/rooms.spec.ts`: Create a new Playwright test for room editing (creating elements, dragging, snapping, and saving).
|
- `backend/migrations/`: Verify migration 003 runs cleanly on the test DB (use `sqlx migrate run`).
|
||||||
|
- `frontend/tests/rooms.spec.ts` *(new)*: Playwright test — create a room, add table and two seats via the UI, drag a seat (verify snap), save and reload, assert coordinates are preserved.
|
||||||
|
|
||||||
### Manual Verification:
|
### Manual Verification:
|
||||||
1. Create a new room.
|
1. `make seed-demo` — reseed with the fixed `demo_seed.sql`.
|
||||||
2. Add a table and two seats.
|
2. Open `Admin → Rooms → Room A` in the editor. All elements must appear at sensible grid positions (not far off-screen).
|
||||||
3. Verify that dragging snaps to the grid.
|
3. Drag an element: verify it snaps to 0.5-unit increments.
|
||||||
4. Save and reload to ensure coordinates are preserved exactly.
|
4. Resize an element: verify handles appear and snap correctly.
|
||||||
5. Inspect the SQLite database to confirm coordinates are stored as small grid units (e.g., `2.5`) instead of large pixel values.
|
5. Add a Gap element and verify it can be placed and saved.
|
||||||
|
6. Inspect the SQLite DB directly: `SELECT layout_json FROM rooms LIMIT 1`. All element coordinates must be small numbers (≤ 30), not pixel values (≥ 80).
|
||||||
|
7. Save and reload: verify coordinates are exactly preserved (no rounding drift).
|
||||||
|
|||||||
@@ -1,27 +1,46 @@
|
|||||||
# Implementation Plan: Room Editor Refactor (Unified Visualization)
|
# Implementation Plan: Room Editor Refactor (Unified Visualization)
|
||||||
|
|
||||||
**Objective:** Replace hardcoded seat maps with a unified, dynamic, and high-fidelity room visualization system that works across Admin and Student views.
|
**Objective:** Replace the broken hardcoded `SeatMap.svelte` with a unified, dynamic room renderer that works across Admin Live View and Student Check-in.
|
||||||
|
|
||||||
**Background:**
|
|
||||||
Currently, the application uses a hardcoded `SeatMap.svelte` for Live Views and Student Check-ins, while using a dynamic `RoomCanvas.svelte` for editing. This leads to data mismatches and prevents users from using custom room layouts. This plan unifies the visualization layer.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Unified Visualization Component
|
## Pre-flight: Existing Bugs This Work Fixes
|
||||||
|
|
||||||
### Task 1: Create `DynamicRoomView.svelte`
|
Acknowledge these before starting — do not assume current behaviour is correct.
|
||||||
**Files to Create/Modify:**
|
|
||||||
- `frontend/src/lib/components/DynamicRoomView.svelte`
|
1. **Student check-in seat selection is silently broken in production.** `frontend/src/lib/components/SeatMap.svelte:33–58` uses hardcoded seat IDs (`T1-1`…`T4-5`). These IDs do not exist in any room's `layout_json`, so `POST /api/checkin` is rejected by `backend/src/routes/checkin.rs:200–207` (`"invalid seat"`) for every seat click. Migrating to the dynamic renderer is the fix.
|
||||||
- (Optionally) Merge into `frontend/src/lib/RoomCanvas.svelte`
|
2. **Admin live view shows no occupancy data.** `frontend/src/routes/admin/live/[slotId]/+page.svelte:161` calls `<SeatMap variant="tutor" scale={0.78} />` with no `assignments` / `students` props — so even though `attendances` is loaded, nothing renders in the seat map.
|
||||||
|
3. **Check-in response is mistyped on the frontend.** `s/[code]/+page.svelte:82` treats the response of `POST /api/checkin` as an `Attendance` object, but the backend returns `{"ok": true}` (`checkin.rs:159–281`). Currently masked because `loadInfo()` is re-called immediately after. Fix this type error while migrating.
|
||||||
|
|
||||||
|
**Note on other consumers of `SeatMap`:** A grep of `frontend/src/` found `SeatMap` is used only in the two routes described below. There are no dashboard, print-view, or mobile-only consumers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Extend `RoomCanvas` (Decision: Don't Fork)
|
||||||
|
|
||||||
|
### Task 1: Add Read-Only / Interactive Modes to `RoomCanvas.svelte`
|
||||||
|
**Files to Modify:**
|
||||||
|
- `frontend/src/lib/RoomCanvas.svelte`
|
||||||
|
|
||||||
|
**Decision rationale:** `RoomCanvas.svelte:14–24` already accepts `occupiedSeatIds`, `mySeatId`, `studentNames`, `selectedId`, `onElementClick`, and `editable`. Creating a separate `DynamicRoomView.svelte` would duplicate the SVG/element rendering logic and risk divergence. **Extend `RoomCanvas` instead.**
|
||||||
|
|
||||||
**Changes:**
|
**Changes:**
|
||||||
- Create a read-only/interactive component that renders SVG layouts based on the `LayoutElement[]` data.
|
- Add a `clickable: boolean` prop (default `false`) — enables `onElementClick` in read-only mode without enabling edit handles. This maps to the `checkin` and `notes` use cases.
|
||||||
- **High Fidelity:** Implement the aesthetic details from the design handoff (rounded tables, specific seat styling, label positioning).
|
- Remove the hardcoded `width="800" height="600"` (line 65). Replace with a `viewBox` computed from element extents (or a configured canvas size), `preserveAspectRatio="xMidYMid meet"`, and a CSS `width: 100%; height: auto` so the SVG scales responsively to its container. Verify this does not break the editor (pass a fixed `style="width:800px"` wrapper in the editor route).
|
||||||
- **Responsive Scaling:** Implement an `autoScale` or `viewBox` based system so the room fills the available width on mobile and desktop without breaking coordinates.
|
- Add high-fidelity styling per the design handoff: rounded tables (`rx`/`ry` on rect), specific seat styling (circle or rounded-rect), label positioning (centred on table, below seat icon), seat-state colours (vacant / occupied / mine).
|
||||||
- **Interaction Modes:**
|
|
||||||
- `mode="checkin"`: Seats are clickable for students.
|
**Prop summary after changes:**
|
||||||
- `mode="notes"`: Seats are clickable for tutors to open note editors.
|
|
||||||
- `mode="display"`: Read-only view for dashboard/monitoring.
|
| Prop | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `elements` | `LayoutElement[]` | Layout data |
|
||||||
|
| `editable` | `boolean` | Enables drag, resize, add, delete |
|
||||||
|
| `clickable` | `boolean` | Enables `onElementClick` in read-only mode |
|
||||||
|
| `occupiedSeatIds` | `string[]` | Seats to style as occupied |
|
||||||
|
| `mySeatId` | `string \| null` | Seat to style as "mine" |
|
||||||
|
| `studentNames` | `Record<string,string>` | Labels overlaid on occupied seats |
|
||||||
|
| `selectedId` | `string \| null` | Currently selected element (editor) |
|
||||||
|
| `onElementClick` | `(id: string) => void` | Click callback |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,37 +49,67 @@ Currently, the application uses a hardcoded `SeatMap.svelte` for Live Views and
|
|||||||
### Task 2: Replace `SeatMap` in Admin Live View
|
### Task 2: Replace `SeatMap` in Admin Live View
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
- `frontend/src/routes/admin/live/[slotId]/+page.svelte`
|
- `frontend/src/routes/admin/live/[slotId]/+page.svelte`
|
||||||
|
- `backend/src/routes/attendance.rs` *(extend API response)*
|
||||||
|
|
||||||
**Changes:**
|
**Pre-step — extend the backend API response (required):**
|
||||||
- Replace `SeatMap` with `DynamicRoomView`.
|
`attendance.rs:62–105` (`get_session_attendance`) returns `SessionAttendance` but does not include room layouts. The page has no way to know the layout. Options:
|
||||||
- Connect the `onSeatClick` event to the note-taking and manual attendance logic.
|
- **(Recommended)** Extend `SessionAttendance` in `backend/src/models.rs` and `attendance.rs` to include each slot's `layout: Option<Vec<LayoutElement>>` keyed per slot. This avoids an N+1 fetch.
|
||||||
- Ensure attendance data (who sits where) is correctly overlaid on the dynamic layout.
|
- Alternative: have the page call `api.admin.rooms.get(slot.room_id)` after loading the slot. Simpler but adds a round-trip.
|
||||||
|
|
||||||
|
**Frontend changes:**
|
||||||
|
- Replace `<SeatMap variant="tutor" scale={0.78} />` at line 161 with `<RoomCanvas elements={slot.layout ?? []} clickable={true} occupiedSeatIds={...} studentNames={...} onElementClick={handleSeatClick} />`.
|
||||||
|
- `occupiedSeatIds`: derive from `attendances.map(a => a.seat_id).filter(Boolean)`.
|
||||||
|
- `studentNames`: derive from `attendances` as `{ [seat_id]: student.name }`.
|
||||||
|
- **Seat → student mapping (new logic required):** The existing note-editor is driven by `selectedStudentId` and `toggleAttendance` takes `studentId`. The new `onElementClick(seatId)` must look up `attendances.find(a => a.seat_id === seatId)?.student_id` to populate `selectedStudentId`. Add this mapping in `handleSeatClick`.
|
||||||
|
|
||||||
### Task 3: Replace `SeatMap` in Student Check-in
|
### Task 3: Replace `SeatMap` in Student Check-in
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
- `frontend/src/routes/s/[code]/+page.svelte`
|
- `frontend/src/routes/s/[code]/+page.svelte`
|
||||||
|
|
||||||
**Changes:**
|
**There are 4 call sites, not 1.** `SeatMap` is called at lines 210, 248, 316, and 368 (phone + desktop × seat-pick step + confirmed step). All four need to be replaced.
|
||||||
- Replace `SeatMap` with `DynamicRoomView`.
|
|
||||||
- Connect seat selection to the `POST /api/checkin` API.
|
**Layout data is already on the wire.** `GET /api/checkin/:code` returns `CheckinInfo.layout: LayoutElement[] | null` (populated by `checkin.rs:53–68` and typed in `types.ts:84–88`), but `s/[code]/+page.svelte:43–58` discards `res.layout`. Read and store it: `let layout = $state<LayoutElement[]>([])` and assign `layout = res.layout ?? []`.
|
||||||
- Ensure the "current seat" (mySeatId) is visually highlighted in the dynamic view.
|
|
||||||
|
**Per call site:**
|
||||||
|
- Lines 210, 316 (seat-pick step, phone and desktop): Replace with `<RoomCanvas elements={layout} clickable={true} occupiedSeatIds={occupiedSeatIds} mySeatId={null} onElementClick={selectSeat} />`.
|
||||||
|
- Lines 248, 368 (confirmed step, phone and desktop): Replace with `<RoomCanvas elements={layout} clickable={false} occupiedSeatIds={occupiedSeatIds} mySeatId={myAttendance?.seat_id ?? null} />`.
|
||||||
|
|
||||||
|
**Derived state to add:**
|
||||||
|
```ts
|
||||||
|
const occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(Boolean) as string[]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix the response-typing bug** (Pre-flight item 3): `api.ts` types `checkin.post` as `Promise<Attendance>` but the backend returns `{ok: true}`. Change the return type to `Promise<{ok: boolean}>` and update `s/[code]/+page.svelte:82` accordingly.
|
||||||
|
|
||||||
### Task 4: Deprecate `SeatMap.svelte`
|
### Task 4: Deprecate `SeatMap.svelte`
|
||||||
**Files to Modify:**
|
**Files to Delete:**
|
||||||
- Delete `frontend/src/lib/components/SeatMap.svelte` once integration is verified.
|
- `frontend/src/lib/components/SeatMap.svelte`
|
||||||
|
|
||||||
|
**Hard ordering — do not delete until all of the following are true:**
|
||||||
|
1. All 6 call sites are migrated (1 in admin live view + 4 in `s/[code]`).
|
||||||
|
2. `grep -rn "SeatMap" frontend/src/` returns zero results.
|
||||||
|
3. All Playwright tests pass (see Task 5).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Verification
|
## 3. Verification
|
||||||
|
|
||||||
### Automated Tests:
|
### Recommended Ordering
|
||||||
- `frontend/tests/checkin-dynamic.spec.ts`: E2E test to verify student check-in on a **custom-created** room layout.
|
1. Extend the backend API (Task 2 pre-step) — unblocks frontend.
|
||||||
- `frontend/tests/admin-live-dynamic.spec.ts`: E2E test to verify that tutors can see students on a **custom-created** room layout and click them to leave notes.
|
2. Extend `RoomCanvas` with `clickable`, responsive `viewBox`, and high-fidelity styling (Task 1).
|
||||||
|
3. Migrate `s/[code]` (Task 3) — backend already returns layout; this is the quickest win and immediately unblocks the broken check-in.
|
||||||
|
4. Migrate admin live view (Task 2) — needs new backend data and the seat→student mapping.
|
||||||
|
5. Run Playwright tests.
|
||||||
|
6. Delete `SeatMap.svelte` (Task 4).
|
||||||
|
|
||||||
### Manual Verification:
|
### Automated Tests
|
||||||
1. Create a non-standard room layout in the Admin Editor (e.g., a "U" shape).
|
- `frontend/tests/checkin-dynamic.spec.ts` *(new)*: E2E test — create a custom (non-square) room layout via the API, create a session + slot using it, open the student check-in link, verify the layout renders (not a blank grid), click a seat, verify `POST /api/checkin` succeeds and the seat turns green. Mirror the seat IDs already used in `backend/src/routes/checkin.rs:290–653` (`s1`, `s2`).
|
||||||
|
- `frontend/tests/admin-live-dynamic.spec.ts` *(new)*: E2E test — using the same custom room, manually add attendance for a student on seat `s1`, open the tutor live view, verify the student's name appears on the correct seat.
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
1. Create a U-shaped room layout in `Admin → Rooms`.
|
||||||
2. Create a session and slot using this room.
|
2. Create a session and slot using this room.
|
||||||
3. Open the Student Check-in link on a mobile device (browser simulation).
|
3. Open the student check-in link on a mobile viewport. Verify the U-shape is rendered and scaled to fit.
|
||||||
4. Verify the "U" shape is rendered correctly and scaled to fit the screen.
|
4. Check in as a student by clicking a seat. Verify the seat turns green.
|
||||||
5. Check in as a student and verify the seat turns green.
|
5. Open the tutor Live View on desktop. Verify the same student appears on the correct seat in the U-shape.
|
||||||
6. Open the Tutor Live View on a desktop and verify the same student is visible on the same seat in the "U" shape.
|
6. Click the occupied seat. Verify the note-editor opens for that student.
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# Superadmin CRUD Implementation Plan
|
|
||||||
|
|
||||||
**Objective:** Implement a superadmin role to manage courses and tutors, ensuring only authorized users can perform system-wide administrative actions. This feature will be developed in an isolated git worktree.
|
|
||||||
|
|
||||||
## Key Context & Decisions
|
|
||||||
- **Role Strategy:** A new `is_superadmin` boolean column will be added to the `tutors` database table.
|
|
||||||
- **UI Structure:** A dedicated `/admin/tutors` page will handle tutor management. Course management will remain on `/admin/courses` but will be enhanced with superadmin-only actions (e.g., assigning tutors to courses).
|
|
||||||
- **Workspace:** Development will be done in `.worktrees/feature-superadmin-crud`.
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### 1. Workspace Isolation via Git Worktree
|
|
||||||
- Create a new git worktree: `git worktree add .worktrees/feature-superadmin-crud -b feature-superadmin-crud`
|
|
||||||
- All subsequent steps will be performed inside this isolated workspace.
|
|
||||||
|
|
||||||
### 2. Database & Models
|
|
||||||
- Create migration `backend/migrations/002_add_superadmin.sql` to add `is_superadmin BOOLEAN NOT NULL DEFAULT 0` to the `tutors` table.
|
|
||||||
- Update `backend/demo/demo_seed.sql` to set the default `admin@tutortool.com` as a superadmin (`is_superadmin = 1`).
|
|
||||||
- Update `backend/src/models.rs` to include `is_superadmin: bool` in the `Tutor` struct.
|
|
||||||
- Add `CreateTutor` and `TutorResponse` structs to `backend/src/models.rs`.
|
|
||||||
|
|
||||||
### 3. Auth & Core Backend
|
|
||||||
- Modify `backend/src/auth.rs` to include `is_superadmin: bool` in `TutorClaims`. This allows auth guards to check permissions efficiently.
|
|
||||||
- Update `backend/src/routes/auth_routes.rs` login handler to fetch `is_superadmin` and encode it in the JWT.
|
|
||||||
- Add a helper function to verify superadmin access to reject unauthorized requests.
|
|
||||||
|
|
||||||
### 4. Tutors API
|
|
||||||
- Create `backend/src/routes/tutors.rs` with endpoints:
|
|
||||||
- `GET /api/admin/tutors` (list all tutors)
|
|
||||||
- `POST /api/admin/tutors` (create a tutor, hashing their password)
|
|
||||||
- `DELETE /api/admin/tutors/:id` (delete a tutor)
|
|
||||||
- Merge these routes in `backend/src/routes/mod.rs`.
|
|
||||||
|
|
||||||
### 5. Course Assignments API
|
|
||||||
- Modify `backend/src/routes/courses.rs`:
|
|
||||||
- Enhance `GET /api/admin/courses` to return ALL courses if `claims.is_superadmin` is true, otherwise only return assigned courses.
|
|
||||||
- Restrict `POST /api/admin/courses` to superadmins only.
|
|
||||||
- Add `POST /api/admin/courses/:id/tutors` to assign a tutor to a course (superadmin only).
|
|
||||||
- Add `DELETE /api/admin/courses/:id/tutors/:tutor_id` to remove a tutor from a course (superadmin only).
|
|
||||||
- Add `GET /api/admin/courses/:id/tutors` to list tutors assigned to a course.
|
|
||||||
|
|
||||||
### 6. Frontend Auth & API Client
|
|
||||||
- Update `frontend/src/lib/types.ts` to include `Tutor` and the new `is_superadmin` flag in token payload or state.
|
|
||||||
- Add the new endpoints to `frontend/src/lib/api.ts` under `api.admin.tutors` and enhance `api.admin.courses`.
|
|
||||||
|
|
||||||
### 7. Frontend UI: Tutors Management
|
|
||||||
- Update `frontend/src/lib/components/TutorShell.svelte` to conditionally render a "Tutor:innen" link in the sidebar if the user is a superadmin.
|
|
||||||
- Create `frontend/src/routes/admin/tutors/+page.svelte` following the paper-bg design system. Include a list of tutors and a form to add a new tutor.
|
|
||||||
|
|
||||||
### 8. Frontend UI: Courses Enhancements
|
|
||||||
- Modify `frontend/src/routes/admin/courses/+page.svelte` to show a "Tutor:innen zuweisen" (Assign Tutors) section for each course if the logged-in user is a superadmin.
|
|
||||||
- Restrict the course creation form to superadmins only.
|
|
||||||
|
|
||||||
## Verification & Testing
|
|
||||||
- Run `cargo test` in the backend to ensure existing tests pass and new route isolation works.
|
|
||||||
- Perform a manual end-to-end test using the `make dev` script in the new worktree to verify the UI.
|
|
||||||
@@ -7,11 +7,22 @@ metadata:
|
|||||||
{{- include "tutortool.labels" . | nindent 4 }}
|
{{- include "tutortool.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
schedule: "0 3 * * *"
|
schedule: "0 3 * * *"
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
successfulJobsHistoryLimit: 1
|
||||||
|
failedJobsHistoryLimit: 1
|
||||||
jobTemplate:
|
jobTemplate:
|
||||||
spec:
|
spec:
|
||||||
|
activeDeadlineSeconds: 900
|
||||||
template:
|
template:
|
||||||
spec:
|
spec:
|
||||||
restartPolicy: OnFailure
|
restartPolicy: OnFailure
|
||||||
|
affinity:
|
||||||
|
podAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "tutortool.selectorLabels" . | nindent 22 }}
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
containers:
|
containers:
|
||||||
- name: backup
|
- name: backup
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ metadata:
|
|||||||
{{- include "tutortool.labels" . | nindent 4 }}
|
{{- include "tutortool.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.replicaCount }}
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
strategy:
|
||||||
|
type: {{ .Values.strategy.type | default "Recreate" }}
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
{{- include "tutortool.selectorLabels" . | nindent 6 }}
|
{{- include "tutortool.selectorLabels" . | nindent 6 }}
|
||||||
@@ -33,6 +35,10 @@ spec:
|
|||||||
value: {{ .Values.env.DATABASE_URL | quote }}
|
value: {{ .Values.env.DATABASE_URL | quote }}
|
||||||
- name: STATIC_DIR
|
- name: STATIC_DIR
|
||||||
value: {{ .Values.env.STATIC_DIR | quote }}
|
value: {{ .Values.env.STATIC_DIR | quote }}
|
||||||
|
{{- range $k, $v := .Values.env.extra }}
|
||||||
|
- name: {{ $k }}
|
||||||
|
value: {{ $v | quote }}
|
||||||
|
{{- end }}
|
||||||
- name: JWT_SECRET
|
- name: JWT_SECRET
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -54,8 +60,10 @@ spec:
|
|||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
securityContext:
|
securityContext:
|
||||||
readOnlyRootFilesystem: false
|
readOnlyRootFilesystem: true
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
resources:
|
resources:
|
||||||
{{- toYaml .Values.resources | nindent 12 }}
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
|
||||||
image:
|
image:
|
||||||
repository: registry.itsh.dev/s0wlz/tutortool
|
repository: registry.itsh.dev/s0wlz/tutortool
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
@@ -20,7 +23,7 @@ resources:
|
|||||||
cpu: 50m
|
cpu: 50m
|
||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
limits:
|
limits:
|
||||||
cpu: 500m
|
cpu: 200m
|
||||||
memory: 256Mi
|
memory: 256Mi
|
||||||
|
|
||||||
pvc:
|
pvc:
|
||||||
@@ -39,7 +42,7 @@ httpRoute:
|
|||||||
hostnames:
|
hostnames:
|
||||||
- tutor.puchstein.dev
|
- tutor.puchstein.dev
|
||||||
sectionName: https-tutor-puchstein-dev
|
sectionName: https-tutor-puchstein-dev
|
||||||
httpRedirectSectionName: http-tutor-puchstein-dev
|
httpRedirectSectionName: http
|
||||||
|
|
||||||
# JWT_SECRET provisioned as a pre-existing K8s Secret named here.
|
# JWT_SECRET provisioned as a pre-existing K8s Secret named here.
|
||||||
# Do not set jwtSecretValue in committed values — provision via kubectl manually.
|
# Do not set jwtSecretValue in committed values — provision via kubectl manually.
|
||||||
@@ -48,6 +51,9 @@ jwtSecretName: tutortool-jwt
|
|||||||
env:
|
env:
|
||||||
DATABASE_URL: sqlite:/data/attendance.db
|
DATABASE_URL: sqlite:/data/attendance.db
|
||||||
STATIC_DIR: /app/frontend/build
|
STATIC_DIR: /app/frontend/build
|
||||||
|
extra: {}
|
||||||
|
# extra:
|
||||||
|
# DEMO: "true" # seeds demo data on startup (idempotent, INSERT OR IGNORE)
|
||||||
|
|
||||||
vpa:
|
vpa:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ httpRoute:
|
|||||||
- tutor.puchstein.dev
|
- tutor.puchstein.dev
|
||||||
|
|
||||||
image:
|
image:
|
||||||
tag: latest
|
tag: v0.1.15
|
||||||
|
|
||||||
|
env:
|
||||||
|
extra: {}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite:/data/attendance.db
|
- DATABASE_URL=sqlite:/data/attendance.db
|
||||||
- STATIC_DIR=/app/frontend/build
|
- STATIC_DIR=/app/frontend/build
|
||||||
- JWT_SECRET=${JWT_SECRET:-dev_secret_for_demo}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
40
docs/issues_discovered_20260504.md
Normal file
40
docs/issues_discovered_20260504.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Issues Discovered - 2026-05-04
|
||||||
|
|
||||||
|
This document summarizes the issues found during the Playwright exploration session on 2026-05-04.
|
||||||
|
|
||||||
|
## 1. JSON Parse Error when adding Tutors to Courses
|
||||||
|
- **Location**: `frontend/src/routes/admin/courses/+page.svelte` (triggered by `api.admin.courses.addTutor`)
|
||||||
|
- **Symptoms**: Alert box shows `SyntaxError: Failed to execute 'json' on 'Response': Unexpected end of JSON input`.
|
||||||
|
- **Root Cause**: The backend `POST /api/admin/courses/:id/tutors` handler likely returns a `200 OK` with an empty body. The frontend `api.ts` wrapper attempts to call `.json()` on every response, which fails on empty bodies.
|
||||||
|
- **Evidence**: Network request #48 in Playwright session showed `status: 200` but `content-length: 0`.
|
||||||
|
|
||||||
|
## 2. Attendance Count Missing on Student Check-in Page
|
||||||
|
- **Location**: `frontend/src/routes/s/[code]/+page.svelte`
|
||||||
|
- **Symptoms**: The "Anwesende" count shows `0 / 11` even after a successful check-in or when other students are present.
|
||||||
|
- **Root Cause**: The `loadInfo` function fetches the data but never assigns `res.attendances` to the local `attendances` state variable.
|
||||||
|
- **Evidence**: `loadInfo` contains `const checkinAttendances = res.attendances ?? [];` but lacks `attendances = checkinAttendances;`.
|
||||||
|
|
||||||
|
## 3. Seat Map Empty in Live View
|
||||||
|
- **Location**: `frontend/src/routes/admin/live/[slotId]/+page.svelte`
|
||||||
|
- **Symptoms**: All seats show as "frei" in the tutor's live view, even when students are checked in and assigned to seats.
|
||||||
|
- **Root Cause**: The `SeatMap` component is instantiated without passing `assignments` or `students` props.
|
||||||
|
- **Evidence**: `<SeatMap variant="tutor" scale={0.78} />` is used without other props in the source code.
|
||||||
|
|
||||||
|
## 4. Room Editor Element Selection Broken
|
||||||
|
- **Location**: `frontend/src/lib/RoomCanvas.svelte`
|
||||||
|
- **Symptoms**: Clicking an element in the Room Layout Editor does not select it (sidebar continues to show "Element auswählen").
|
||||||
|
- **Root Cause**: In `handleMouseDown`, if `editable` is true, the function returns early without calling `onElementClick`.
|
||||||
|
- **Evidence**:
|
||||||
|
```typescript
|
||||||
|
function handleMouseDown(e: MouseEvent, el: LayoutElement) {
|
||||||
|
if (!editable) {
|
||||||
|
onElementClick?.(el);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draggingId = el.id;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Potential UI/UX: Dashboard Slot Status
|
||||||
|
- **Observation**: Dashboard sometimes shows "Anwesenheit offen" but "Alle Slots 1" and "Abgeschlossene Slots 0". It's a bit confusing if there is only one slot ever. (Minor)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Attendance Tracking Tool Implementation Plan
|
# Attendance Tracking Tool Implementation Plan
|
||||||
|
|
||||||
|
> **STATUS:** ✅ All tasks completed. The project has been hardened and modernized as of 2026-05-02.
|
||||||
|
>
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Build a self-hosted web app (Rust/Axum + SvelteKit + SQLite) where students self-check-in to weekly tutoring sessions via a projected URL, and tutors manage sessions, attendance, and per-student notes.
|
**Goal:** Build a self-hosted web app (Rust/Axum + SvelteKit + SQLite) where students self-check-in to weekly tutoring sessions via a projected URL, and tutors manage sessions, attendance, and per-student notes.
|
||||||
|
|||||||
263
docs/plans/2026-05-04-unified-fixes-and-room-editor-refactor.md
Normal file
263
docs/plans/2026-05-04-unified-fixes-and-room-editor-refactor.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# TutorTool — Unified Bug Fixes, Tutor Lifecycle, Room Editor Refactor
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A Playwright exploration session on **2026-05-04** (`docs/issues_discovered_20260504.md`) surfaced four production frontend bugs on top of an existing security backlog (RUSTSEC-2023-0071) and a half-finished Room Editor refactor. Three reference plans already exist in `conductor/`:
|
||||||
|
|
||||||
|
- `conductor/room-editor-refactor-core.md` — pixel→grid migration + editor robustification
|
||||||
|
- `conductor/room-editor-refactor-visualization.md` — replace hardcoded `SeatMap.svelte` with dynamic `RoomCanvas`
|
||||||
|
- `conductor/unified-refactor-and-fixes.md` — already merges the above with security/quick fixes (RUSTSEC, JSON parse, check-in typing, admin logout)
|
||||||
|
|
||||||
|
This plan **supersedes** `conductor/unified-refactor-and-fixes.md`, folds in two newly verified items:
|
||||||
|
|
||||||
|
1. **Admin/tutor deletion is opaquely broken.** `DELETE /api/admin/tutors/{id}` exists (`backend/src/routes/tutors.rs:74-94`) but the three FK references — `tutor_courses.tutor_id`, `slots.tutor_id`, `notes.tutor_id` (`backend/migrations/001_initial.sql:15,44,65`) — declare no `ON DELETE` clause, so SQLite RESTRICT raises a generic FK error → 500 → `alert("internal error")`.
|
||||||
|
2. **Issue #4 in the discovered-issues doc has its condition inverted.** `frontend/src/lib/RoomCanvas.svelte:30-38` is `if (!editable) { onElementClick?.(el); return; }`, so click-to-select fails **in edit mode** (the opposite of what the doc says). Fix: in edit mode, also fire `onElementClick` before initiating the drag.
|
||||||
|
|
||||||
|
The intended outcome is one continuous batch of work that ships: secure auth (logout + RUSTSEC patch), correct frontend data flow, a real tutor lifecycle (deactivate + delete with safety), and a unified, dynamic room renderer that fixes the silently-broken student check-in.
|
||||||
|
|
||||||
|
> **Memory housekeeping (post-approval):** the SLM memory `1728144c207346e2` ("admin CRUD enforces a permanent restriction: no tutors can be deleted") is **stale and contradicted by the code**. Update or remove it after this plan is approved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Git Workflow
|
||||||
|
|
||||||
|
### 0.1 Create a worktree
|
||||||
|
|
||||||
|
From your main clone (assume it lives at `~/tutortool`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git worktree add ../tutortool-unified main
|
||||||
|
cd ../tutortool-unified
|
||||||
|
git switch -c feature/unified-fixes-room-editor
|
||||||
|
```
|
||||||
|
|
||||||
|
All implementation work happens in `../tutortool-unified`.
|
||||||
|
The original clone stays clean on `main`.
|
||||||
|
|
||||||
|
### 0.2 Commit discipline
|
||||||
|
|
||||||
|
- One logical commit per Phase/sub-phase, e.g.:
|
||||||
|
- `feat(security): fix RUSTSEC-2023-0071 via aws_lc_rs`
|
||||||
|
- `fix(api): handle empty 200 body + fix check-in typing`
|
||||||
|
- `feat(tutors): add is_active flag, deactivate + safe delete`
|
||||||
|
- `feat(rooms): pixel→grid migration + layout validators`
|
||||||
|
- `feat(canvas): fix drag/resize, click-to-select in edit mode`
|
||||||
|
- `feat(viz): replace SeatMap with dynamic RoomCanvas`
|
||||||
|
- `make lint && make test` must pass **before** each commit.
|
||||||
|
Gate with a pre-commit hook if you like:
|
||||||
|
```sh
|
||||||
|
# .git/hooks/pre-commit
|
||||||
|
#!/bin/sh
|
||||||
|
cd backend && cargo clippy --all-targets -- -D warnings && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 0.3 Open the PR
|
||||||
|
|
||||||
|
Once all phases are done and CI is green:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push -u origin feature/unified-fixes-room-editor
|
||||||
|
# then open a PR against main via Gitea/GitHub UI
|
||||||
|
# title: "Unified fixes, tutor lifecycle, room editor refactor"
|
||||||
|
# body: link this plan doc, list phases, reference Playwright spec results
|
||||||
|
```
|
||||||
|
|
||||||
|
Merge strategy: **squash-merge** each phase-branch if you split work, or a single **merge commit** if working on one branch (keeps phase history readable).
|
||||||
|
|
||||||
|
### 0.4 Post-merge cleanup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ~/tutortool # back to main clone
|
||||||
|
git pull # fast-forward to merged state
|
||||||
|
git worktree remove ../tutortool-unified
|
||||||
|
git branch -d feature/unified-fixes-room-editor
|
||||||
|
```
|
||||||
|
|
||||||
|
### 0.5 Verification gate before merge
|
||||||
|
|
||||||
|
PR must have:
|
||||||
|
|
||||||
|
- [ ] `make test` passes (all backend + new Phase 2.4/3.2/3.7 tests)
|
||||||
|
- [ ] `make lint` passes (zero Clippy warnings, zero TS errors)
|
||||||
|
- [ ] `sqlx migrate run` clean on fresh DB (migrations 003 + 004)
|
||||||
|
- [ ] All new Playwright specs green (`rooms`, `checkin-dynamic`, `admin-live-dynamic`, `admin-tutors`, `admin-rooms-delete`)
|
||||||
|
- [ ] `cargo audit` clean (no RUSTSEC-2023-0071)
|
||||||
|
|
||||||
|
## Phase 1 — Security & Quick Fixes
|
||||||
|
|
||||||
|
### 1.1 RUSTSEC-2023-0071 (Marvin Attack)
|
||||||
|
|
||||||
|
- `backend/Cargo.toml` — set `jsonwebtoken` to `features = ["aws_lc_rs"]`.
|
||||||
|
- `backend/audit.toml` — remove `ignore = ["RUSTSEC-2023-0071"]`.
|
||||||
|
- `.gitea/workflows/ci.yml` and `.gitea/workflows/release.yml` — drop `--ignore RUSTSEC-2023-0071`.
|
||||||
|
|
||||||
|
### 1.2 JSON parse error on empty 200 body
|
||||||
|
|
||||||
|
- `frontend/src/lib/api.ts:46` — currently only 204 is short-circuited. Extend the empty-body branch to also handle 200-with-empty-body (probe `content-length === '0'` or fall through `await res.text()` and return `{} as T` if empty). Triggers from `assignTutor`, `unassignTutor`, etc.
|
||||||
|
|
||||||
|
### 1.3 Check-in API typing
|
||||||
|
|
||||||
|
- `frontend/src/lib/api.ts` — `checkin.post` currently typed `Promise<Attendance>`; backend returns `{ok: true}`. Change to `Promise<{ok: boolean}>`.
|
||||||
|
- `frontend/src/routes/s/[code]/+page.svelte:82` — drop the local-state assignment from the response; rely on the subsequent `loadInfo()` to populate `myAttendance`.
|
||||||
|
|
||||||
|
### 1.4 Attendance count not assigned (issues doc #2)
|
||||||
|
|
||||||
|
- `frontend/src/routes/s/[code]/+page.svelte` `loadInfo` — assign `attendances = checkinAttendances` so the "Anwesende N / M" counter updates.
|
||||||
|
|
||||||
|
### 1.5 Admin logout UI
|
||||||
|
|
||||||
|
- `frontend/src/lib/components/TutorShell.svelte` (where the actual sidebar nav lives — `frontend/src/routes/admin/+layout.svelte` only renders the shell) — add an "Abmelden" entry at the bottom of the nav. On click: `await api.auth.logout()` → `auth.logout()` → `goto('/admin/login')`. Re-export `auth` and `api` if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Tutor Lifecycle: Deactivate **and** Delete
|
||||||
|
|
||||||
|
The user wants **both** soft-deactivate (preserve history, hide from pickers) **and** real hard-delete (with safety pre-check).
|
||||||
|
|
||||||
|
### 2.1 Schema migration
|
||||||
|
|
||||||
|
- New file `backend/migrations/004_tutor_is_active.sql`:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tutors ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT 1;
|
||||||
|
```
|
||||||
|
No backfill needed; existing rows default to active.
|
||||||
|
|
||||||
|
### 2.2 Backend changes
|
||||||
|
|
||||||
|
**File:** `backend/src/routes/tutors.rs`
|
||||||
|
|
||||||
|
- **`Tutor` model** (`backend/src/models.rs:11-17`) — add `is_active: bool`.
|
||||||
|
- **`list_tutors`** — include `is_active` in SELECT and JSON output. Do **not** filter; the admin needs to see inactive tutors to reactivate them.
|
||||||
|
- **New: `set_tutor_active(id, {is_active: bool})`** — `PATCH /api/admin/tutors/{id}/active` body `{is_active: bool}`. Superadmin-only. Forbid self-deactivation (mirror the self-delete guard at `tutors.rs:84-86` — return `AppError::Conflict("cannot deactivate yourself")`).
|
||||||
|
- **`delete_tutor`** (existing handler) — before the `DELETE FROM tutors`, run three `SELECT COUNT(*)` queries inside a single connection (no need for an explicit tx; SQLite SELECTs see committed state):
|
||||||
|
- `tutor_courses WHERE tutor_id = ?`
|
||||||
|
- `slots WHERE tutor_id = ?`
|
||||||
|
- `notes WHERE tutor_id = ?`
|
||||||
|
If any non-zero, return `AppError::Conflict(format!("Tutor:in hat noch {c} Kurszuordnung(en), {s} Slot(s) und {n} Notiz(en). Bitte zuerst entfernen oder deaktivieren."))`.
|
||||||
|
|
||||||
|
`AppError::Conflict` already maps to 409 with `{"error": msg}` (`backend/src/error.rs`).
|
||||||
|
|
||||||
|
- **Auth login** (`backend/src/routes/auth_routes.rs:22-96`) — reject inactive tutors with `AppError::Unauthorized` (same response shape as wrong-password to avoid info leakage). Add this check after password verification.
|
||||||
|
- **Tutor pickers** — grep for `SELECT … FROM tutors` outside of `list_tutors` and `auth_routes.rs`. Likely sites: `backend/src/routes/courses.rs` (tutor assignment list), `backend/src/routes/sessions.rs` or `slots` creation flow. Each must add `WHERE is_active = 1`.
|
||||||
|
|
||||||
|
### 2.3 Frontend changes
|
||||||
|
|
||||||
|
**File:** `frontend/src/routes/admin/tutors/+page.svelte`
|
||||||
|
|
||||||
|
- Show three status pills: `Superadmin` / `Tutor:in` / `Inaktiv` (combine `is_superadmin` + `is_active`).
|
||||||
|
- Add a per-row "Deaktivieren" button (or "Aktivieren" if already inactive) that calls `api.admin.tutors.setActive(id, !is_active)`.
|
||||||
|
- Keep the existing "Löschen" button — error message from 409 already surfaces via the existing `catch (err) { alert(err.message) }` because `request<T>` extracts `error.error` (`api.ts:42-43`). Optionally: replace alert with an inline error region above the table for better UX.
|
||||||
|
- **API client** `frontend/src/lib/api.ts` — add `api.admin.tutors.setActive(id, is_active)` calling `PATCH /admin/tutors/{id}/active`.
|
||||||
|
|
||||||
|
### 2.4 Tests (`backend/src/routes/tutors.rs` `#[cfg(test)]` block)
|
||||||
|
|
||||||
|
Pattern after `backend/src/routes/rooms.rs:184-322` and use `backend/src/test_helpers.rs`.
|
||||||
|
|
||||||
|
- `delete_tutor_with_no_refs_succeeds` → 204, row gone.
|
||||||
|
- `delete_tutor_with_course_assignment_returns_409` → body contains `Kurszuordnung`.
|
||||||
|
- `delete_tutor_with_slot_returns_409` → body contains `Slot`.
|
||||||
|
- `delete_tutor_with_note_returns_409` → body contains `Notiz`.
|
||||||
|
- `delete_self_returns_409` (existing branch coverage).
|
||||||
|
- `set_active_false_then_login_fails` — deactivate, attempt login, expect 401.
|
||||||
|
- `set_active_false_hides_from_pickers` — verify any tutor-picker endpoint doesn't list inactive tutors.
|
||||||
|
- `cannot_deactivate_self_returns_409`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Room Editor Core
|
||||||
|
|
||||||
|
Reference: `conductor/room-editor-refactor-core.md`. One-liner per task:
|
||||||
|
|
||||||
|
- **3.1 Pixel→grid migration** — new `backend/migrations/003_normalize_room_layout_units.sql`: parse `layout_json` per row; if any of x/y/w/h > 50, divide all four by 40 and rewrite. Update `backend/demo/demo_seed.sql:16-41` to grid units. Update any `rooms.rs` tests asserting pixel-scale numbers.
|
||||||
|
- **Note on numbering:** this is migration `003`; tutor `is_active` is `004`. Apply order matters because `003` only touches `rooms`, `004` only touches `tutors` — no cross-dependency.
|
||||||
|
- **3.2 Additive layout validators** — `backend/src/routes/rooms.rs:18-69`: add `MAX_CANVAS = 100` upper-bound + 0.5-step multiple checks; one test per validator.
|
||||||
|
- **3.3 RoomCanvas drag/resize hardening** — `frontend/src/lib/RoomCanvas.svelte`: bind `mousemove`/`mouseup` to `window` while dragging (currently bound to SVG only at lines 69-71); build resize handles + state from scratch; default snap step 0.5 (configurable via `snapStep` prop).
|
||||||
|
- **3.4 Fix click-to-select in edit mode (issues #4 corrected)** — `frontend/src/lib/RoomCanvas.svelte:30-38`: when `editable`, still call `onElementClick?.(el)` (so the parent updates `selectedId`) before starting the drag. Do **not** early-return.
|
||||||
|
- **3.5 Editor UI improvements** — `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`: add X/Y `step="0.5"` numeric inputs, "+ Gap" button, "Snap to Grid" toggle, "Duplicate element" (UUID + 1-unit offset), inline error region for `saveLayout` failures (currently only `console.error`).
|
||||||
|
- **3.6 Robust seat labelling** — same file: replace the brittle `(filter + 1)` next-label with `max(existingLabels) + 1`.
|
||||||
|
- **3.7 Room and Element deletion**
|
||||||
|
- **Element deletion** is already wired in the editor sidebar (`+page.svelte:101`); validate it still works post-migration.
|
||||||
|
- **Room deletion (new)**:
|
||||||
|
- Backend: `DELETE /api/admin/rooms/{id}` in `backend/src/routes/rooms.rs`. Pre-check `SELECT COUNT(*) FROM slots WHERE room_id = ?`; if non-zero, 409 with `format!("Raum hat noch {n} Slot(s). Bitte zuerst entfernen.")`. Otherwise plain DELETE.
|
||||||
|
- Frontend: `frontend/src/routes/admin/rooms/+page.svelte` — add per-row "Löschen" button with `confirm()` dialog; error message surfaces via the existing alert path.
|
||||||
|
- API client: `api.admin.rooms.delete(id)`.
|
||||||
|
- Tests: `delete_room_with_no_slots_succeeds`, `delete_room_with_slot_returns_409`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Unified Visualization (Dynamic RoomCanvas)
|
||||||
|
|
||||||
|
Reference: `conductor/room-editor-refactor-visualization.md`. One-liner per task:
|
||||||
|
|
||||||
|
- **4.1 Extend RoomCanvas** — `frontend/src/lib/RoomCanvas.svelte`: add `clickable: boolean = false` prop (orthogonal to 3.4 — `clickable` enables `onElementClick` in **read-only** mode); replace fixed `width="800" height="600"` (line 65) with computed `viewBox` + `preserveAspectRatio="xMidYMid meet"` + CSS `width:100%;height:auto`; verify the editor route still bounds the SVG (wrap with `style="width:800px"` if needed); apply seat/table styling from the design handoff (rounded tables, occupied/mine colour states, label positioning).
|
||||||
|
- **4.2 Backend: ship layout with attendance** — `backend/src/routes/attendance.rs:62-105` + `backend/src/models.rs`: extend `SessionAttendance` with per-slot `layout: Option<Vec<LayoutElement>>` to avoid an N+1.
|
||||||
|
- **4.3 Admin live view migration** — `frontend/src/routes/admin/live/[slotId]/+page.svelte:161`: replace `<SeatMap variant="tutor" scale={0.78} />` with `<RoomCanvas elements={slot.layout ?? []} clickable occupiedSeatIds={…} studentNames={…} onElementClick={handleSeatClick} />`. Implement `handleSeatClick(seatId)` mapping seat→student via `attendances.find(a => a.seat_id === seatId)?.student_id` → `selectedStudentId` (drives the existing note editor).
|
||||||
|
- **4.4 Student check-in migration (4 sites)** — `frontend/src/routes/s/[code]/+page.svelte` lines 210, 248, 316, 368: read `res.layout` in `loadInfo` (`let layout = $state<LayoutElement[]>([])`); replace each call. Lines 210/316 (seat-pick): `clickable` + `onElementClick={selectSeat}`. Lines 248/368 (confirmed): read-only with `mySeatId={myAttendance?.seat_id ?? null}`. Add `const occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(Boolean) as string[])`.
|
||||||
|
- **4.5 Delete `frontend/src/lib/components/SeatMap.svelte`** — only after `grep -rn "SeatMap" frontend/src/` returns zero hits and Playwright passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Automated
|
||||||
|
|
||||||
|
- `make test` — backend suite passes, including:
|
||||||
|
- Phase 2.4 tutor delete + activate tests
|
||||||
|
- Phase 3.2 layout validator tests
|
||||||
|
- Phase 3.7 room delete tests
|
||||||
|
- `sqlx migrate run` against a clean DB — migrations 003 and 004 apply cleanly.
|
||||||
|
- `make lint` — zero warnings (per the project's mandate).
|
||||||
|
- `frontend/tests/rooms.spec.ts` _(new)_ — create room, drag/snap, save/reload, assert coords preserved.
|
||||||
|
- `frontend/tests/checkin-dynamic.spec.ts` _(new)_ — custom layout, click seat, `POST /api/checkin` succeeds, seat turns green.
|
||||||
|
- `frontend/tests/admin-live-dynamic.spec.ts` _(new)_ — student appears on correct seat in tutor live view.
|
||||||
|
- `frontend/tests/admin-tutors.spec.ts` _(new)_ — (i) delete tutor with no refs → row disappears; (ii) delete tutor attached to a course → red error mentions `Kurszuordnung`; unassign → retry succeeds; (iii) deactivate → tutor cannot log in; (iv) reactivate → tutor can log in again.
|
||||||
|
- `frontend/tests/admin-rooms-delete.spec.ts` _(new)_ — delete unused room succeeds; delete room attached to a slot → 409 message visible.
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
1. `make seed-demo`. Open `Admin → Rooms → Room A` — all elements at sensible grid positions (no values > 50).
|
||||||
|
2. Drag and resize an element; release outside the SVG; confirm no stranded drag.
|
||||||
|
3. Click an element in **edit mode** — sidebar populates (issue #4 regression).
|
||||||
|
4. Add a Gap; toggle Snap; Duplicate an element; trigger a deliberate save error and confirm the inline message.
|
||||||
|
5. Create a U-shaped room; attach to a session/slot.
|
||||||
|
6. Open the student check-in link on a mobile viewport — U-shape renders, seat-click checks in, seat turns green.
|
||||||
|
7. Open tutor Live View — student name on correct seat; click opens note editor.
|
||||||
|
8. **Tutor lifecycle:** as superadmin, create a tutor "Test", attach to a course → click `Löschen` → red message lists `1 Kurszuordnung`. Click `Deaktivieren` → status pill flips to `Inaktiv`; attempt to log in as that tutor → fails. Click `Aktivieren` → login succeeds again. Unassign from course → click `Löschen` → succeeds.
|
||||||
|
9. **Room lifecycle:** delete a room with no slots → row disappears. Delete a room attached to a slot → 409 message visible.
|
||||||
|
10. Click `Abmelden` in admin sidebar → redirect to `/admin/login`; refresh → still logged out.
|
||||||
|
11. `cargo audit` (without the ignore flag) — no RUSTSEC-2023-0071 finding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
- `backend/Cargo.toml`
|
||||||
|
- `backend/audit.toml`
|
||||||
|
- `.gitea/workflows/ci.yml`, `.gitea/workflows/release.yml`
|
||||||
|
- `backend/migrations/003_normalize_room_layout_units.sql` _(new)_
|
||||||
|
- `backend/migrations/004_tutor_is_active.sql` _(new)_
|
||||||
|
- `backend/demo/demo_seed.sql`
|
||||||
|
- `backend/src/models.rs`
|
||||||
|
- `backend/src/routes/tutors.rs`
|
||||||
|
- `backend/src/routes/rooms.rs`
|
||||||
|
- `backend/src/routes/attendance.rs`
|
||||||
|
- `backend/src/routes/auth_routes.rs`
|
||||||
|
- `backend/src/routes/courses.rs` (tutor picker filter)
|
||||||
|
- `backend/src/routes/sessions.rs` (tutor picker filter)
|
||||||
|
- `frontend/src/lib/api.ts`
|
||||||
|
- `frontend/src/lib/types.ts`
|
||||||
|
- `frontend/src/lib/RoomCanvas.svelte`
|
||||||
|
- `frontend/src/lib/components/SeatMap.svelte` _(delete in Phase 4.5)_
|
||||||
|
- `frontend/src/lib/components/TutorShell.svelte` (logout button)
|
||||||
|
- `frontend/src/routes/admin/tutors/+page.svelte`
|
||||||
|
- `frontend/src/routes/admin/rooms/+page.svelte`
|
||||||
|
- `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`
|
||||||
|
- `frontend/src/routes/admin/live/[slotId]/+page.svelte`
|
||||||
|
- `frontend/src/routes/s/[code]/+page.svelte`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-implementation memory hygiene (do after the plan ships)
|
||||||
|
|
||||||
|
- Update SLM memory `1728144c207346e2` to reflect the new lifecycle (deactivate + safe delete) instead of the stale "no tutors can be deleted" claim.
|
||||||
|
- Store a new SLM memory capturing the deletion-policy decision (deactivate-or-block-with-409) so future sessions don't re-litigate it.
|
||||||
@@ -15,14 +15,14 @@ The tool is designed to be reusable across future semesters and other tutorien.
|
|||||||
|
|
||||||
### Stack
|
### Stack
|
||||||
|
|
||||||
- **Backend:** Rust + Axum, `sqlx` (SQLite), JWT auth for tutors
|
- **Backend:** Rust + Axum (0.8), `sqlx` (SQLite), Secure httpOnly Cookie JWT auth for tutors
|
||||||
- **Frontend:** SvelteKit with `adapter-static` (SPA, served by Axum)
|
- **Frontend:** SvelteKit 5 (Svelte runes), TypeScript, `adapter-static` (SPA, served by Axum)
|
||||||
- **Database:** SQLite via a Kubernetes PersistentVolumeClaim
|
- **Database:** SQLite via a Kubernetes PersistentVolumeClaim
|
||||||
- **Deployment:** Single container on a k8s namespace, `tutor.puchstein.dev`
|
- **Deployment:** Single container on a k8s namespace, `tutor.puchstein.dev`
|
||||||
|
|
||||||
Single binary + static files, one container, one PVC. No Node server at runtime — minimizes node load. SQLite keeps the footprint small and makes end-of-semester teardown trivial.
|
Single binary + static files, one container, one PVC. No Node server at runtime — minimizes node load. SQLite keeps the footprint small and makes end-of-semester teardown trivial.
|
||||||
|
|
||||||
Axum must serve `index.html` as fallback for all non-`/api` routes so that SvelteKit client-side routing works on direct navigation or page refresh.
|
Axum serves the static frontend and caches the `JWT_SECRET` in `AppState` for efficient session validation. It also serves `index.html` as fallback for all non-`/api` routes so that SvelteKit client-side routing works on direct navigation or page refresh.
|
||||||
|
|
||||||
### Repository layout
|
### Repository layout
|
||||||
|
|
||||||
@@ -30,27 +30,26 @@ Axum must serve `index.html` as fallback for all non-`/api` routes so that Svelt
|
|||||||
tools/attendance/
|
tools/attendance/
|
||||||
├── backend/ # Rust/Axum
|
├── backend/ # Rust/Axum
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── main.rs
|
│ │ ├── main.rs # entry point, AppState definition
|
||||||
│ │ ├── db.rs # sqlx pool setup, migrations
|
│ │ ├── db.rs # sqlx pool setup, migrations
|
||||||
│ │ ├── routes/
|
│ │ ├── routes/
|
||||||
│ │ │ ├── admin.rs # tutor-facing endpoints
|
│ │ │ ├── mod.rs # router assembly
|
||||||
|
│ │ │ ├── auth_routes.rs # Secure cookie-based login/logout
|
||||||
│ │ │ ├── checkin.rs # student-facing endpoints
|
│ │ │ ├── checkin.rs # student-facing endpoints
|
||||||
│ │ │ └── export.rs # CSV, Markdown, SQLite backup
|
│ │ │ └── export.rs # CSV, Markdown, SQLite backup
|
||||||
│ │ └── auth.rs # JWT middleware
|
│ │ ├── auth.rs # JWT logic, cookie extractor
|
||||||
|
│ │ └── models.rs # shared data models
|
||||||
│ └── Cargo.toml
|
│ └── Cargo.toml
|
||||||
├── frontend/ # SvelteKit
|
├── frontend/ # SvelteKit 5
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── routes/
|
│ │ ├── routes/
|
||||||
│ │ │ ├── admin/ # tutor panel
|
│ │ │ ├── admin/ # tutor panel (protected)
|
||||||
│ │ │ └── s/[code]/ # student check-in
|
│ │ │ └── s/[code]/ # student check-in (public)
|
||||||
│ │ └── lib/
|
│ │ └── lib/
|
||||||
|
│ │ ├── auth.svelte.ts # runes-based auth state
|
||||||
|
│ │ └── api.ts # cookie-based API client
|
||||||
│ └── svelte.config.js # adapter-static
|
│ └── svelte.config.js # adapter-static
|
||||||
└── k8s/
|
└── deploy/ # Helm chart
|
||||||
├── deployment.yaml
|
|
||||||
├── service.yaml
|
|
||||||
├── ingress.yaml
|
|
||||||
├── pvc.yaml
|
|
||||||
└── cronjob.yaml # daily SQLite backup, retains last 7
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Visual/frontend design is handled separately via Claude Design — this spec covers structure and flows only.
|
Visual/frontend design is handled separately via Claude Design — this spec covers structure and flows only.
|
||||||
@@ -159,7 +158,7 @@ CREATE TABLE notes (
|
|||||||
- `slots.code` and `slots.status` must be set atomically in a single `UPDATE slots SET status = 'open', code = ? WHERE id = ?`. The backend refuses to serve a check-in page for any slot where `status = 'open'` but `code IS NULL`.
|
- `slots.code` and `slots.status` must be set atomically in a single `UPDATE slots SET status = 'open', code = ? WHERE id = ?`. The backend refuses to serve a check-in page for any slot where `status = 'open'` but `code IS NULL`.
|
||||||
- For layout-bearing slots (`room_id IS NOT NULL`), the backend rejects `seat_id = NULL` submissions at the application layer — the DB NULL-deduplication behaviour cannot enforce this.
|
- For layout-bearing slots (`room_id IS NOT NULL`), the backend rejects `seat_id = NULL` submissions at the application layer — the DB NULL-deduplication behaviour cannot enforce this.
|
||||||
- **Seat change:** A student may change their seat while the slot is `open`. The backend deletes the existing attendance row for `(slot_id, student_id)` then inserts the new one atomically in a transaction. The previously held seat becomes free immediately. Once the slot is `locked`, no changes are possible.
|
- **Seat change:** A student may change their seat while the slot is `open`. The backend deletes the existing attendance row for `(slot_id, student_id)` then inserts the new one atomically in a transaction. The previously held seat becomes free immediately. Once the slot is `locked`, no changes are possible.
|
||||||
- **Cookie trust:** Cookies are unsigned in the initial implementation — accepted risk for a small in-person group where the tutor physically observes the room. Tutor must cross-check the seat map against visible students before locking. The `checkin.rs` auth layer is designed for a drop-in HMAC replacement without further changes.
|
- **Auth Security:** The authentication layer is hardened using `httpOnly`, `SameSite=Strict` cookies for both tutor and student sessions. This prevents client-side token access (XSS mitigation) and ensures session integrity. The `checkin.rs` layer manages student identities via a secure `attendance_identity` cookie.
|
||||||
|
|
||||||
Rooms are created independently of sessions and can be reused across semesters. The student dropdown on the check-in page is filtered by the slot's course, preventing cross-course name leakage.
|
Rooms are created independently of sessions and can be reused across semesters. The student dropdown on the check-in page is filtered by the slot's course, preventing cross-course name leakage.
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ After `make test-up`:
|
|||||||
|
|
||||||
1. Ask Claude to open `http://127.0.0.1:<TT_TEST_PORT>/admin/login` via Playwright MCP.
|
1. Ask Claude to open `http://127.0.0.1:<TT_TEST_PORT>/admin/login` via Playwright MCP.
|
||||||
2. Log in with seed credentials: `admin@tutortool.com` / `admin`.
|
2. Log in with seed credentials: `admin@tutortool.com` / `admin`.
|
||||||
3. Drive the app interactively; take screenshots to verify UI.
|
3. Drive the app interactively; take screenshots to verify UI. (Note: Authentication is handled via secure `httpOnly` cookies).
|
||||||
4. Run `make test-reset` between scenarios to restore clean state.
|
4. Run `make test-reset` between scenarios to restore clean state.
|
||||||
|
|
||||||
## DB reset mechanism
|
## DB reset mechanism
|
||||||
|
|
||||||
The backend exposes `POST /__test__/reset` only when started with `TT_TEST_MODE=1`. The handler deletes all rows in FK-safe order and re-applies `backend/demo/demo_seed.sql` in a single transaction. It never exists in production (the route is not registered without the env flag).
|
The backend exposes `POST /__test__/reset` only when started with `TT_TEST_MODE=1` AND in debug builds. The handler deletes all rows in FK-safe order and re-applies `backend/demo/demo_seed.sql` in a single transaction. It never exists in production release builds.
|
||||||
|
|
||||||
## Seed data
|
## Seed data
|
||||||
|
|
||||||
@@ -66,11 +66,11 @@ The backend exposes `POST /__test__/reset` only when started with `TT_TEST_MODE=
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
The Gitea Actions workflow at `.gitea/workflows/test.yml` runs on every push to `main` and on PRs:
|
The Gitea Actions workflow at `.gitea/workflows/ci.yml` runs on every push to `main` and on PRs:
|
||||||
|
|
||||||
1. Install deps (Node 20 + pnpm + Rust 1.95)
|
1. Install deps (Node 22 + pnpm 9 + Rust 1.95)
|
||||||
2. Cache Cargo + pnpm store
|
2. Cache Cargo + pnpm store
|
||||||
3. `cargo check` + `pnpm check` (type checks)
|
3. `make lint` (Zero Warnings Policy: clippy, fmt, svelte-check)
|
||||||
4. `cargo test` (unit tests)
|
4. `cargo test` (unit tests)
|
||||||
5. `pnpm build` (frontend build)
|
5. `pnpm build` (frontend build)
|
||||||
6. `make test-up` + `pnpm test:e2e` (E2E)
|
6. `make test-up` + `pnpm test:e2e` (E2E)
|
||||||
|
|||||||
285
docs/tutortool_audit_perplexity_260502.md
Normal file
285
docs/tutortool_audit_perplexity_260502.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# Security & Best-Practices Audit for `tutortool`
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- Full-stack app: Rust 1.95 backend with Axum 0.8, SQLx 0.8 (SQLite), JWT auth; SvelteKit 2 + Svelte 5 + TypeScript 5 + Vite 8 + pnpm 9 frontend.
|
||||||
|
- CI/CD: Gitea Actions-style workflows for CI (PRs/branches) and Release (tag push), including Rust checks, frontend checks, Playwright E2E, cargo-audit, Docker build, Helm deploy to Kubernetes.
|
||||||
|
- Overall: architecture is solid and modern; most obvious footguns are avoided, but there are some security and hardening issues plus a few best-practice gaps in both backend and frontend.
|
||||||
|
|
||||||
|
## Toolchain & Dependencies
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
- Toolchain:
|
||||||
|
- `edition = "2024"`, `rust-version = "1.95.0"` pinned in `backend/Cargo.toml` and CI (`dtolnay/rust-toolchain@master toolchain: '1.95.0'`).
|
||||||
|
- Core crates:
|
||||||
|
- `axum 0.8` (web framework, with `macros`, `multipart`).
|
||||||
|
- `axum-extra 0.10` (cookies, etc.).
|
||||||
|
- `tokio 1` with `full` feature.
|
||||||
|
- `sqlx 0.8` with `sqlite`, `runtime-tokio`, `macros`, `migrate`.
|
||||||
|
- `serde`/`serde_json`.
|
||||||
|
- `jsonwebtoken 10` (JWT handling, `rust_crypto` backend).
|
||||||
|
- `bcrypt 0.19` (password hashing).
|
||||||
|
- `tower-http 0.6` with `fs`, `cors`, `trace`.
|
||||||
|
- `tower_governor 0.6` (rate limiting).
|
||||||
|
- `chrono 0.4` with `serde`, `rand 0.9`, `thiserror 2`, `tracing 0.1`, `tracing-subscriber 0.3`.
|
||||||
|
- Dev-deps:
|
||||||
|
- `tower 0.5` (util), `http-body-util 0.1`, `bytes 1`, `temp-env 0.3`, `serial_test 3.1`.
|
||||||
|
- The combination (Axum + SQLx + jsonwebtoken) is a common pattern; community examples like Axium and blog posts promote similar stacks for high-performance, security-focused APIs.[^1][^2][^3]
|
||||||
|
|
||||||
|
### Frontend (SvelteKit + TS)
|
||||||
|
|
||||||
|
- Toolchain:
|
||||||
|
- SvelteKit 2 (`@sveltejs/kit ^2.59.0`), Svelte `^5.55.5`, Vite `^8.0.10`, TypeScript `^5`, `@typescript/native-preview ^7.0.0-dev`, pnpm 9.
|
||||||
|
- Playwright `@playwright/test ^1.59.1` for E2E; `svelte-check` for type+template checking.
|
||||||
|
- This matches current Svelte best-practices: Vite-based tooling, svelte-check, Playwright, TS everywhere.[^4][^5]
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
- CI workflow (`.gitea/workflows/ci.yml`):
|
||||||
|
- Runs on push (non-main), and PRs.
|
||||||
|
- Steps:
|
||||||
|
- Node 22 + pnpm 9.
|
||||||
|
- Rust 1.95 + clippy + rustfmt.
|
||||||
|
- Cache Cargo and pnpm store.
|
||||||
|
- `pnpm --dir frontend install --frozen-lockfile`.
|
||||||
|
- `svelte-kit sync`, Playwright browser install.
|
||||||
|
- Backend: `cargo check`, `cargo clippy -D warnings`, `cargo fmt --check`, `cargo test`.
|
||||||
|
- Frontend: `tsgo --version` (from `@typescript/native-preview`) and `pnpm check`.
|
||||||
|
- `cargo audit` with ignore `RUSTSEC-2023-0071`.
|
||||||
|
- Frontend build.
|
||||||
|
- E2E tests via `make test-up` + `pnpm test:e2e`, then teardown with `make test-down`.
|
||||||
|
- Docker build (no push).
|
||||||
|
- Release workflow (`.gitea/workflows/release.yml`):
|
||||||
|
- Triggered on tag `v*.*.*`.
|
||||||
|
- Re-runs checks, tests, cargo-audit, build.
|
||||||
|
- Docker build+push to `registry.itsh.dev/s0wlz/tutortool` with `latest` and tag, login via secrets.
|
||||||
|
- Installs kubectl config from base64-encoded secret, sets up Helm 3.16, and runs `helm upgrade --install` into namespace `tenant-5` with `values_override.yaml` and `image.tag` from tag.
|
||||||
|
- This aligns with modern GitHub/Gitea workflows: pinned major versions for actions, caching, separate CI and release pipelines, and Helm-based K8s deployment.[^6]
|
||||||
|
|
||||||
|
## Backend Best-Practices Review
|
||||||
|
|
||||||
|
### Axum / App Setup
|
||||||
|
|
||||||
|
- `AppState` holds `SqlitePool`, `jwt_secret`, and `test_mode`, and implements `FromRef` for the pool, matching ergonomic Axum+SQLx patterns.[^2]
|
||||||
|
- Middleware:
|
||||||
|
- `TraceLayer::new_for_http()` is enabled to log requests; static assets served via `ServeDir` with SPA-style fallback to `index.html`.
|
||||||
|
- Rate limiting (tower_governor) appears configured in `routes::build` (not shown in excerpt but implied by dependency choice).
|
||||||
|
- Ports and bindings:
|
||||||
|
- Binds to `0.0.0.0:PORT` (default 3000) and serves over plain HTTP; this is expected behind a reverse proxy/ingress, but in prod TLS termination should happen at the edge.
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- Add graceful shutdown: hook into `axum::serve` with a shutdown signal to support rolling updates and avoid dropping in-flight requests.[^7]
|
||||||
|
- Ensure `tower_governor` is applied to all state-changing routes (auth, check-in, etc.) to mitigate brute force; current routes module likely does this, but it is worth verifying per-route.[^1]
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- There is a dedicated `error.rs` and `AppError` type (pattern recommended by Axum guides): single error type, `?` operator, mapping to HTTP responses.[^7]
|
||||||
|
- This is aligned with modern Rust error-handling best practices: central error enum plus `thiserror` for derive and automatic conversions.[^7]
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- Confirm that `AppError::Unauthorized` and other variants do not leak internals (e.g., raw SQLx errors) in HTTP responses, and that detailed error messages go only to logs (`tracing`). This is in line with OWASP guidance on not exposing sensitive error details.[^8]
|
||||||
|
|
||||||
|
### SQLx / Database Access
|
||||||
|
|
||||||
|
- `AppState` owns the `SqlitePool`, consistent with SQLx ergonomics for Axum.[^2]
|
||||||
|
- SQLx with `sqlite` feature uses libsqlite3 under the hood and introduces some unsafe, but SQLx forbids unsafe by default for other backends; that trade-off is known and accepted for SQLite.[^3]
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- Use fully parameterized queries everywhere, avoiding dynamic string concatenation; this matches SQLx and OWASP recommendations to prevent injection.[^9][^2]
|
||||||
|
- Use migrations consistently (`sqlx::migrate!()`) in `db::init()` and ensure the CI includes a `sqlx migrate run --check` equivalent (offline) to prevent drift between schema and code.[^3]
|
||||||
|
|
||||||
|
### JWT Handling (Authentication)
|
||||||
|
|
||||||
|
- Claims structure:
|
||||||
|
- `TutorClaims { sub: i64, email: String, is_superadmin: bool, exp: u64 }`.
|
||||||
|
- Encoding:
|
||||||
|
- `encode_jwt` sets `exp` to now + 7 days, uses `Header::default()` (HS256) and `EncodingKey::from_secret(secret.as_bytes())`.
|
||||||
|
- Decoding:
|
||||||
|
- `decode_jwt` uses `DecodingKey::from_secret(secret.as_bytes())` and `Validation::default()`; errors map to `AppError::Unauthorized` and are traced at debug level.
|
||||||
|
- Extraction:
|
||||||
|
- Custom Axum extractor `FromRequestParts` for `TutorClaims`:
|
||||||
|
- Tries `CookieJar` for `"token"` first (HttpOnly cookie expected from server) and falls back to `Authorization: Bearer <token>` header.
|
||||||
|
- Uses `AppState::from_ref` to access `jwt_secret` and calls `decode_jwt`.
|
||||||
|
- This pattern (HttpOnly cookie + optional Bearer header) is consistent with modern JWT auth designs, where cookies mitigate XSS theft of tokens and headers support scripting and tools.[^10][^8]
|
||||||
|
|
||||||
|
**Findings (JWT)**
|
||||||
|
|
||||||
|
- `Validation::default()` only enforces expiration but uses default algorithm and leeway settings; explicitly setting `algorithms` and `validate_exp` is recommended to avoid alg downgrade issues and to be resilient to changes in defaults.[^11][^12]
|
||||||
|
- `exp` is 7 days; OWASP and many JWT security guides recommend short-lived access tokens (15–60 minutes) with refresh tokens if you need long-lived sessions.[^8][^10]
|
||||||
|
- No audience (`aud`), issuer (`iss`), or other context claims are validated; for multi-tenant or multi-client deployments, those should be set and verified.[^8]
|
||||||
|
|
||||||
|
**Suggestions (JWT)**
|
||||||
|
|
||||||
|
- Configure `Validation` explicitly:
|
||||||
|
- Restrict algorithms (e.g., HS256 only) and disable `validate_nbf` if not used, but keep `validate_exp` on.[^12][^11]
|
||||||
|
- Optionally validate `iss` and `aud`.
|
||||||
|
- Consider split token model:
|
||||||
|
- Short-lived access token in memory; long-lived refresh token in HttpOnly cookie as recommended by modern JWT best-practice guides.[^10]
|
||||||
|
- Consider moving `email` out of the token or keeping only user id + roles; JWT best-practice docs recommend storing only non-sensitive, strictly necessary data.[^13]
|
||||||
|
|
||||||
|
### Password Hashing
|
||||||
|
|
||||||
|
- Uses `bcrypt` crate (0.19). Bcrypt is widely used and still acceptable, but many modern Rust security boilerplates (e.g. Axium) prefer Argon2id (memory-hard, OWASP recommended).[^1][^10]
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- If passwords are currently hashed with bcrypt, consider migrating to Argon2id for new deployments and implementing lazy rehash on login to avoid immediate full migration.[^1]
|
||||||
|
- Ensure appropriate work factor/cost is configured (bcrypt default cost may be low for 2026 hardware; OWASP recommends tuning to ~250 ms per hash on your hardware).[^10]
|
||||||
|
|
||||||
|
### Token & Secrets Management
|
||||||
|
|
||||||
|
- `jwt_secret` is loaded from `JWT_SECRET` env var; app panics if missing (`expect("JWT_SECRET must be set")`).
|
||||||
|
- CI and Release use `cargo audit` and Docker with registry login; K8s kubeconfig is passed via base64 secret into `~/.kube/config`, and image registry credentials are from `REGISTRY_USER` and `REGISTRY_TOKEN` secrets.
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- Ensure `JWT_SECRET` is strong (at least 256 bits of randomness) and rotated periodically, as recommended in JWT and OWASP guidelines.[^8][^10]
|
||||||
|
- Use kube secret and Helm values files for database credentials, SMTP, etc.; avoid ever committing real secrets (current repo appears clean of obvious `.env`/secrets, matching typical TS/Rust security guidance).[^9]
|
||||||
|
|
||||||
|
### Test Mode & Test Reset Endpoint
|
||||||
|
|
||||||
|
- In debug builds, `TT_TEST_MODE=1` enables test-only behavior:
|
||||||
|
- Loads `demo/demo_seed.sql` into `routes::test_reset::SEED_SQL`.
|
||||||
|
- Logs warning `TT_TEST_MODE active — /__test__/reset is enabled`.
|
||||||
|
- `routes::build` likely wires `/__test__/reset` route guarded by `test_mode`.
|
||||||
|
- CI E2E flow sets `TT_TEST_PORT_RANDOM=1` and uses `make test-up` to start backend; expected pattern is that test mode is enabled only in CI/dev.
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- Confirm that `TT_TEST_MODE` is never set in production environments; the log warning is helpful, but run-time checks or fail-fast on `TT_TEST_MODE=1` in release builds would add extra safety.[^8]
|
||||||
|
- The test reset endpoint should be fully disabled or return 404 in production; given architecture, this is likely already the case but should be validated in routing code.[^7]
|
||||||
|
|
||||||
|
## Frontend Best-Practices Review
|
||||||
|
|
||||||
|
### SvelteKit / Vite / TS Tooling
|
||||||
|
|
||||||
|
- Scripts:
|
||||||
|
- `dev`, `build`, `preview` for Vite.
|
||||||
|
- `check` and `check:watch` using `svelte-check` with `tsconfig.json`.
|
||||||
|
- `test:e2e` and `test:e2e:ui` using Playwright.
|
||||||
|
- Config:
|
||||||
|
- `svelte.config.js` and `vite.config.ts` present; `playwright.config.ts` configured for tests.
|
||||||
|
- This aligns with Svelte docs: use svelte-check, Vite, and a linter for robustness.[^5][^4]
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- Consider adding ESLint with TypeScript+Svelte plugin to catch additional issues that TypeScript itself cannot (e.g., potential XSS sinks, unused variables).[^4][^9]
|
||||||
|
- Ensure CSP and other security headers are set at the backend/Ingress level, especially for inline script blocking and stronger XSS mitigation, matching Svelte and TS security recommendations.[^5][^9]
|
||||||
|
|
||||||
|
### TypeScript Practices
|
||||||
|
|
||||||
|
- Uses `typescript ^5` and `@typescript/native-preview ^7.0.0-dev`, with CI step `tsgo --version && pnpm check`, indicating use of the new TS native compiler experiment.
|
||||||
|
- Modern TS security guidance emphasizes strict typing, avoiding `any`/unchecked casts, and runtime validation of external data.[^9]
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- Ensure `tsconfig.json` has strict flags (`strict`, `noImplicitAny`, `noUncheckedIndexedAccess` where feasible) to align with TS security best practices.[^9]
|
||||||
|
- For input forms and API responses, combine TypeScript types with runtime validation (e.g. Zod) where user or external data is processed, as recommended in recent TS security articles.[^9]
|
||||||
|
|
||||||
|
### Frontend Auth & Token Storage
|
||||||
|
|
||||||
|
- The backend issues JWTs intended to be stored primarily in HttpOnly `token` cookie and optionally in `Authorization` header.
|
||||||
|
- Modern JWT security guidance suggests storing access tokens in memory with refresh tokens in HttpOnly cookies to balance CSRF and XSS risks.[^10]
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- Ensure frontend never writes the JWT token to `localStorage` or `sessionStorage`; prefer HttpOnly cookies and/or in-memory access tokens per JWT security checklists.[^10]
|
||||||
|
- Use `SameSite=Lax` or `Strict` plus `Secure` flag on cookies; backend should set these flags to mitigate CSRF and cookie theft, as recommended in TS+web security guides.[^9][^10]
|
||||||
|
|
||||||
|
## CI/CD & Testing Review
|
||||||
|
|
||||||
|
### CI Pipeline
|
||||||
|
|
||||||
|
- Test job (CI):
|
||||||
|
- Backend coverage: type check, Clippy with `-D warnings`, fmt check, unit tests, `cargo audit` with one specific advisory ignored (RUSTSEC-2023-0071), and Docker build.
|
||||||
|
- Frontend coverage: pnpm install, svelte-kit sync, Playwright browser install, TypeScript check via `tsgo` and `svelte-check`, build, Playwright E2E tests against backend brought up via `make test-up`.
|
||||||
|
- Failure path uploads Playwright artifacts for debugging.
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- Ignoring `RUSTSEC-2023-0071` should be justified in the repo (e.g., README/SECURITY.md note) to document risk acceptance; OWASP and RustSec guidelines recommend handling advisories explicitly rather than silently ignoring them.[^3]
|
||||||
|
- Consider adding `cargo audit --deny-warnings` in CI once all advisories are resolved to prevent new vulnerabilities from creeping in.[^3]
|
||||||
|
- Add `pnpm audit` or `npm audit` equivalent carefully (with allowlist where necessary) to monitor JS dependency CVEs, aligning with TS and frontend security best practices.[^9]
|
||||||
|
|
||||||
|
### Release Pipeline
|
||||||
|
|
||||||
|
- Re-runs critical checks before building and pushing image and deploying via Helm; uses tag name as image tag and also pushes `latest`.
|
||||||
|
|
||||||
|
**Findings & Suggestions**
|
||||||
|
|
||||||
|
- Using `latest` tag is convenient but can obscure which version is actually running; many release engineering guides recommend avoiding `latest` in production and relying on immutable tags only.[^6]
|
||||||
|
- Helm upgrade uses `--wait --timeout 5m`, which is good; consider adding health/liveness/readiness probes in the Helm chart (if not already defined) to allow Kubernetes to verify app health before rollout completes.[^6]
|
||||||
|
|
||||||
|
## Security Audit Summary
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
|
||||||
|
- Modern Rust backend stack (Axum, SQLx, jsonwebtoken, bcrypt) with clear error handling and tracing.[^3]
|
||||||
|
- JWT usage is structured; tokens carry minimal data (id, email, role, exp) and are injected into handlers via typed Axum extractor.
|
||||||
|
- CI pipeline is extensive: type checks, linting, formatting, unit tests, E2E tests, cargo-audit, Docker build.
|
||||||
|
- Release pipeline uses Helm and secrets for registry and kubeconfig, with environment variables for image/namespace configuration.
|
||||||
|
|
||||||
|
### Issues & Risks
|
||||||
|
|
||||||
|
- JWT validation uses default `Validation`, not explicitly restricting algorithms or confirming `iss`/`aud`, which is discouraged by JWT crate best-practice discussions.[^11][^12]
|
||||||
|
- Token lifetime is 7 days; guidance from JWT and OAuth security resources recommends much shorter access tokens with refresh token rotation.[^8][^10]
|
||||||
|
- bcrypt is acceptable but no longer state-of-the-art; Argon2id is generally recommended for new password hashing deployments.[^1][^10]
|
||||||
|
- CI ignores `RUSTSEC-2023-0071` without a documented rationale in code; ignoring advisories without documentation is flagged as bad practice in security tooling docs.[^3]
|
||||||
|
- No visible JS dependency audit step; frontend best-practice checklists call for regular dependency scanning.[^9]
|
||||||
|
- Cookie flags (HttpOnly, SameSite, Secure) and CSRF protection are not visible from backend code snippet; recommended by TS/web security checklists.[^10][^9]
|
||||||
|
- `latest` Docker tag usage in release; release engineering guides generally recommend immutable tags only.[^6]
|
||||||
|
|
||||||
|
### Recommended Actions (Prioritized)
|
||||||
|
|
||||||
|
1. **Harden JWT validation**
|
||||||
|
- Explicitly set allowed algorithms, enable `validate_exp`, and consider adding `aud`/`iss` checks.[^12][^11]
|
||||||
|
- Consider reducing access token lifetime and introducing refresh tokens.
|
||||||
|
2. **Improve password hashing posture**
|
||||||
|
- Plan migration path to Argon2id for new passwords and rehash on login; keep bcrypt verification for legacy hashes.[^1][^10]
|
||||||
|
3. **Document and re-evaluate `cargo audit` ignore**
|
||||||
|
- Add a comment or SECURITY.md entry explaining the risk of `RUSTSEC-2023-0071` and why it is acceptable, and track upstream fix to eventually drop the ignore.[^3]
|
||||||
|
4. **Add JS dependency scanning in CI**
|
||||||
|
- Use `pnpm audit` or an external scanner (e.g., snyk) with a curated allowlist.[^9]
|
||||||
|
5. **Cookie & CSRF Hardening**
|
||||||
|
- Ensure JWT cookies use `HttpOnly`, `Secure`, and `SameSite=Lax/Strict` flags and that state-changing endpoints enforce CSRF protections where relevant.[^10][^9]
|
||||||
|
6. **Release Tagging Improvements**
|
||||||
|
- Consider dropping `latest` tag in production and relying solely on versioned tags; ensure Helm values/overrides always reference immutable tags.[^6]
|
||||||
|
7. **Operational Safeguards for Test Mode**
|
||||||
|
- Enforce that `TT_TEST_MODE` cannot be set in production (e.g., check an env like `ENV=prod` and panic if both are set) to guarantee that `/__test__/reset` never exists in prod.[^7][^8]
|
||||||
|
|
||||||
|
These changes would bring the project in line with current Rust 1.95, SvelteKit, TypeScript, and JWT security best practices, while preserving its already solid architecture and testing setup.[^5][^8]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
1. [Riktastic/Axium: An example API built with Rust, Axum, SQLx, and ...](https://github.com/Riktastic/Axium) - Axium is a high-performance, security-focused API boilerplate built using Rust, Axum, SQLx, S3, Redi...
|
||||||
|
|
||||||
|
2. [An ergonomic pattern for SQLx queries in Axum - Joshka.net](https://www.joshka.net/axum-sqlx-queries-pattern/) - In this post, I'll show an easy and ergonomic pattern for connecting to a database using SQLx and Ax...
|
||||||
|
|
||||||
|
3. [Building a REST API with Axum + Sqlx](https://carlosmv.hashnode.dev/creating-a-rest-api-with-axum-sqlx-rust) - I started to use Axum a few weeks ago, honestly, I'm a fan of the framework, so I'm writing this...
|
||||||
|
|
||||||
|
4. [How To Best Use Typescript for Props In Svelte 5 Project (VS Code)?](https://www.reddit.com/r/sveltejs/comments/1i6igz0/how_to_best_use_typescript_for_props_in_svelte_5/) - I am relatively new to TypeScript and am starting to add it into a Svelte 5 project. For a proof of ...
|
||||||
|
|
||||||
|
5. [Best practices • Svelte Docs](https://svelte.dev/docs/svelte/best-practices) - This document outlines some best practices that will help you write fast, robust Svelte apps. It is ...
|
||||||
|
|
||||||
|
6. [Migrating 8 SvelteKit Sites to Vite 8 in a day: What We Learned](https://cogley.jp/articles/migrating-sveltekit-to-vite-8) - If your SvelteKit guide references rollupOptions , update those references to rolldownOptions . And ...
|
||||||
|
|
||||||
|
7. [Building REST APIs with Rust and Axum: A Practical Beginner's Guide](https://noqta.tn/en/tutorials/rust-axum-rest-api-beginner-guide-2026) - Learn how to build fast, safe REST APIs using Rust and the Axum web framework. This step-by-step gui...
|
||||||
|
|
||||||
|
8. [Rust App Security: Master OAuth 2.0 & JWT](https://codezup.com/securing-rust-oauth-jwt/) - Secure your Rust applications with OAuth 2.0 and JWT! Learn step-by-step implementation for robust a...
|
||||||
|
|
||||||
|
9. [Typescript Application Security from A to Z: A Guide to Protecting ...](https://dev.to/devsdaddy/typescript-application-security-from-a-to-z-a-guide-to-protecting-against-obvious-and-55nh) - This article specifically provides simplified attack methods and vulnerability examples to make it e...
|
||||||
|
|
||||||
|
10. [Password Hashing](https://oneuptime.com/blog/post/2026-01-07-rust-jwt-authentication/view) - Learn how to implement secure JWT authentication in Rust applications. This guide covers token gener...
|
||||||
|
|
||||||
|
11. [Validate JWT using RS384 in Rust - SSOJet](https://ssojet.com/jwt-validation/validate-jwt-using-rs384-in-rust/) - Validate JWTs with RS384 in Rust. Secure your APIs by verifying token signatures efficiently and rel...
|
||||||
|
|
||||||
|
12. [JSON Web Token -- some investigative studies on crate ...](https://dev.to/behainguyen/rust-json-web-token-some-investigative-studies-on-crate-jsonwebtoken-1mch) - Regarding crate jsonwebtoken, the primary question is still how to check if a token is still valid,....
|
||||||
|
|
||||||
|
13. [Writing my first Rust crate: jsonwebtoken](https://www.vincentprouillet.com/blog/writing-my-first-crate/) - Experience writing a JWT library in Rust
|
||||||
|
|
||||||
51
frontend/eslint.config.js
Normal file
51
frontend/eslint.config.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import globals from 'globals';
|
||||||
|
import svelteParser from 'svelte-eslint-parser';
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: svelteParser,
|
||||||
|
parserOptions: {
|
||||||
|
parser: ts.parser,
|
||||||
|
extraFileExtensions: ['.svelte']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'svelte/no-at-html-tags': 'error', // Catch XSS sinks
|
||||||
|
'svelte/require-each-key': 'warn',
|
||||||
|
'svelte/no-navigation-without-resolve': 'off', // Noisy
|
||||||
|
'svelte/no-useless-children-snippet': 'warn'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -8,18 +8,26 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "eslint .",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@sveltejs/adapter-static": "latest",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "latest",
|
"@sveltejs/kit": "^2.59.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "latest",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@types/node": "^22",
|
||||||
"@typescript/native-preview": "^7.0.0-dev",
|
"@typescript/native-preview": "^7.0.0-dev",
|
||||||
"svelte": "5.55.5",
|
"eslint": "^10.3.0",
|
||||||
"svelte-check": "latest",
|
"eslint-plugin-svelte": "^3.17.1",
|
||||||
"typescript": "latest",
|
"globals": "^17.6.0",
|
||||||
"vite": "latest"
|
"svelte": "^5.55.5",
|
||||||
|
"svelte-check": "^4",
|
||||||
|
"svelte-eslint-parser": "^1.6.0",
|
||||||
|
"typescript": "^5",
|
||||||
|
"typescript-eslint": "^8.59.1",
|
||||||
|
"vite": "^8.0.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1005
frontend/pnpm-lock.yaml
generated
1005
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -41,11 +41,13 @@
|
|||||||
if (!draggingId || !editable) return;
|
if (!draggingId || !editable) return;
|
||||||
const index = elements.findIndex((el: LayoutElement) => el.id === draggingId);
|
const index = elements.findIndex((el: LayoutElement) => el.id === draggingId);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
|
const el = elements[index];
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
const newX = Math.round((e.clientX - startX) / 10) * 10 / 40;
|
const newX = Math.round((e.clientX - startX) / 10) * 10 / 40;
|
||||||
const newY = Math.round((e.clientY - startY) / 10) * 10 / 40;
|
const newY = Math.round((e.clientY - startY) / 10) * 10 / 40;
|
||||||
|
|
||||||
elements[index] = { ...elements[index], x: newX, y: newY };
|
elements[index] = { ...el, x: newX, y: newY };
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseUp() {
|
function handleMouseUp() {
|
||||||
@@ -78,8 +80,7 @@
|
|||||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each elements as el}
|
{#each elements as el (el.id)}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<g
|
<g
|
||||||
transform="translate({el.x * GRID_SIZE}, {el.y * GRID_SIZE})"
|
transform="translate({el.x * GRID_SIZE}, {el.y * GRID_SIZE})"
|
||||||
class="element {el.type}"
|
class="element {el.type}"
|
||||||
|
|||||||
@@ -1,22 +1,41 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { get } from 'svelte/store';
|
import type {
|
||||||
import { token } from './auth';
|
Course, Tutor, Student, Room, Session, Slot, Attendance, Note,
|
||||||
|
LayoutElement, SessionAttendance, CheckinInfo
|
||||||
|
} from './types';
|
||||||
|
import { auth } from './auth.svelte';
|
||||||
|
|
||||||
const BASE = '/api';
|
const BASE = '/api';
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
let isRefreshing = false;
|
||||||
const $token = get(token);
|
|
||||||
|
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(BASE + path, {
|
const res = await fetch(BASE + path, {
|
||||||
...init,
|
...init,
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
...(!(init?.body instanceof FormData) && { 'Content-Type': 'application/json' }),
|
||||||
...($token ? { Authorization: `Bearer ${$token}` } : {}),
|
|
||||||
...init?.headers,
|
...init?.headers,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401 && browser) {
|
if (res.status === 401 && browser) {
|
||||||
// Handle unauthorized
|
if (!isRefreshing && path !== '/auth/refresh') {
|
||||||
|
isRefreshing = true;
|
||||||
|
try {
|
||||||
|
const refreshed = await fetch(BASE + '/auth/refresh', { method: 'POST', credentials: 'include' });
|
||||||
|
if (refreshed.ok) {
|
||||||
|
isRefreshing = false;
|
||||||
|
return request<T>(path, init);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// refresh failed, fall through to logout
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auth.logout();
|
||||||
|
throw new Error('Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -31,37 +50,36 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
export const api = {
|
export const api = {
|
||||||
auth: {
|
auth: {
|
||||||
login: (email: string, password: string) =>
|
login: (email: string, password: string) =>
|
||||||
request<{token: string, is_superadmin: boolean}>('/auth/login', {
|
request<{is_superadmin: boolean}>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password })
|
body: JSON.stringify({ email, password })
|
||||||
}),
|
}),
|
||||||
|
me: () => request<{id: number, email: string, is_superadmin: boolean}>('/auth/me'),
|
||||||
|
logout: () => request<void>('/auth/logout', { method: 'POST' }),
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
courses: {
|
courses: {
|
||||||
list: () => request<any[]>('/admin/courses'),
|
list: () => request<Course[]>('/admin/courses'),
|
||||||
create: (name: string, semester: string) =>
|
create: (name: string, semester: string) =>
|
||||||
request<any>('/admin/courses', {
|
request<Course>('/admin/courses', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, semester })
|
body: JSON.stringify({ name, semester })
|
||||||
}),
|
}),
|
||||||
listStudents: (course_id: number) => request<any[]>(`/admin/courses/${course_id}/students`),
|
listStudents: (course_id: number) => request<Student[]>(`/admin/courses/${course_id}/students`),
|
||||||
addStudent: (course_id: number, name: string) =>
|
addStudent: (course_id: number, name: string) =>
|
||||||
request<any>(`/admin/courses/${course_id}/students`, {
|
request<Student>(`/admin/courses/${course_id}/students`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name })
|
body: JSON.stringify({ name })
|
||||||
}),
|
}),
|
||||||
importStudents: (course_id: number, file: File) => {
|
importStudents: (course_id: number, file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return fetch(`${BASE}/admin/courses/${course_id}/students/import`, {
|
return request<{count: number}>(`/admin/courses/${course_id}/students/import`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${get(token)}`
|
|
||||||
},
|
|
||||||
body: formData
|
body: formData
|
||||||
}).then(res => res.json());
|
});
|
||||||
},
|
},
|
||||||
listTutors: (course_id: number) => request<any[]>(`/admin/courses/${course_id}/tutors`),
|
listTutors: (course_id: number) => request<Tutor[]>(`/admin/courses/${course_id}/tutors`),
|
||||||
assignTutor: (course_id: number, tutor_id: number) =>
|
assignTutor: (course_id: number, tutor_id: number) =>
|
||||||
request<void>(`/admin/courses/${course_id}/tutors`, {
|
request<void>(`/admin/courses/${course_id}/tutors`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -71,9 +89,9 @@ export const api = {
|
|||||||
request<void>(`/admin/courses/${course_id}/tutors/${tutor_id}`, { method: 'DELETE' }),
|
request<void>(`/admin/courses/${course_id}/tutors/${tutor_id}`, { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
tutors: {
|
tutors: {
|
||||||
list: () => request<any[]>('/admin/tutors'),
|
list: () => request<Tutor[]>('/admin/tutors'),
|
||||||
create: (tutor: any) =>
|
create: (tutor: Partial<Tutor> & { password?: string }) =>
|
||||||
request<any>('/admin/tutors', {
|
request<Tutor>('/admin/tutors', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(tutor)
|
body: JSON.stringify(tutor)
|
||||||
}),
|
}),
|
||||||
@@ -81,40 +99,40 @@ export const api = {
|
|||||||
},
|
},
|
||||||
students: {
|
students: {
|
||||||
delete: (id: number) => request<void>(`/admin/students/${id}`, { method: 'DELETE' }),
|
delete: (id: number) => request<void>(`/admin/students/${id}`, { method: 'DELETE' }),
|
||||||
getAttendance: (id: number) => request<any[]>(`/admin/students/${id}/attendance`),
|
getAttendance: (id: number) => request<Attendance[]>(`/admin/students/${id}/attendance`),
|
||||||
getNotes: (id: number) => request<any[]>(`/admin/students/${id}/notes`),
|
getNotes: (id: number) => request<Note[]>(`/admin/students/${id}/notes`),
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
list: () => request<any[]>('/admin/rooms'),
|
list: () => request<Room[]>('/admin/rooms'),
|
||||||
create: (name: string, layout: any[]) =>
|
create: (name: string, layout: LayoutElement[]) =>
|
||||||
request<any>('/admin/rooms', {
|
request<Room>('/admin/rooms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, layout })
|
body: JSON.stringify({ name, layout })
|
||||||
}),
|
}),
|
||||||
get: (id: number) => request<any>(`/admin/rooms/${id}`),
|
get: (id: number) => request<Room>(`/admin/rooms/${id}`),
|
||||||
updateLayout: (id: number, layout: any[]) =>
|
updateLayout: (id: number, layout: LayoutElement[]) =>
|
||||||
request<any>(`/admin/rooms/${id}/layout`, {
|
request<Room>(`/admin/rooms/${id}/layout`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(layout)
|
body: JSON.stringify(layout)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
sessions: {
|
sessions: {
|
||||||
list: (course_id: number) => request<any[]>(`/admin/sessions?course_id=${course_id}`),
|
list: (course_id: number) => request<Session[]>(`/admin/sessions?course_id=${course_id}`),
|
||||||
create: (course_id: number, week_nr: number, date: string) =>
|
create: (course_id: number, week_nr: number, date: string) =>
|
||||||
request<any>('/admin/sessions', {
|
request<Session>('/admin/sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ course_id, week_nr, date })
|
body: JSON.stringify({ course_id, week_nr, date })
|
||||||
}),
|
}),
|
||||||
getAttendance: (id: number) => request<any>(`/admin/sessions/${id}/attendance`),
|
getAttendance: (id: number) => request<SessionAttendance>(`/admin/sessions/${id}/attendance`),
|
||||||
},
|
},
|
||||||
slots: {
|
slots: {
|
||||||
create: (session_id: number, tutor_id: number, start_time: string, end_time: string, room_id?: number) =>
|
create: (session_id: number, tutor_id: number, start_time: string, end_time: string, room_id?: number) =>
|
||||||
request<any>('/admin/slots', {
|
request<Slot>('/admin/slots', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ session_id, tutor_id, start_time, end_time, room_id })
|
body: JSON.stringify({ session_id, tutor_id, start_time, end_time, room_id })
|
||||||
}),
|
}),
|
||||||
updateStatus: (id: number, status: string) =>
|
updateStatus: (id: number, status: string) =>
|
||||||
request<any>(`/admin/slots/${id}/status`, {
|
request<Slot>(`/admin/slots/${id}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ status })
|
body: JSON.stringify({ status })
|
||||||
}),
|
}),
|
||||||
@@ -126,7 +144,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
deleteAttendance: (slot_id: number, student_id: number) =>
|
deleteAttendance: (slot_id: number, student_id: number) =>
|
||||||
request<void>(`/admin/slots/${slot_id}/attendance/${student_id}`, { method: 'DELETE' }),
|
request<void>(`/admin/slots/${slot_id}/attendance/${student_id}`, { method: 'DELETE' }),
|
||||||
getNotes: (id: number) => request<any[]>(`/admin/slots/${id}/notes`),
|
getNotes: (id: number) => request<Note[]>(`/admin/slots/${id}/notes`),
|
||||||
upsertNote: (slot_id: number, student_id: number, content: string) =>
|
upsertNote: (slot_id: number, student_id: number, content: string) =>
|
||||||
request<void>(`/admin/slots/${slot_id}/notes/${student_id}`, {
|
request<void>(`/admin/slots/${slot_id}/notes/${student_id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -142,10 +160,10 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
checkin: {
|
checkin: {
|
||||||
getInfo: (code: string) => request<any>(`/checkin/${code}`),
|
getInfo: (code: string) => request<CheckinInfo>(`/checkin/${code}`),
|
||||||
getStudents: (code: string) => request<any[]>(`/checkin/${code}/students`),
|
getStudents: (code: string) => request<Student[]>(`/checkin/${code}/students`),
|
||||||
post: (code: string, student_id: number, seat_id?: string) =>
|
post: (code: string, student_id: number, seat_id?: string) =>
|
||||||
request<any>('/checkin', {
|
request<Attendance>('/checkin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ code, student_id, seat_id })
|
body: JSON.stringify({ code, student_id, seat_id })
|
||||||
}),
|
}),
|
||||||
|
|||||||
58
frontend/src/lib/auth.svelte.ts
Normal file
58
frontend/src/lib/auth.svelte.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
let _isSuperadmin = $state(false);
|
||||||
|
let _initialized = $state(false);
|
||||||
|
let _authenticated = $state(false);
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
get isSuperadmin() { return _isSuperadmin; },
|
||||||
|
get initialized() { return _initialized; },
|
||||||
|
get authenticated() { return _authenticated; },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (!browser || _initialized) return;
|
||||||
|
try {
|
||||||
|
let res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const refreshed = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
|
||||||
|
if (refreshed.ok) {
|
||||||
|
res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
const me = await res.json();
|
||||||
|
_isSuperadmin = me.is_superadmin;
|
||||||
|
_authenticated = true;
|
||||||
|
} else {
|
||||||
|
_isSuperadmin = false;
|
||||||
|
_authenticated = false;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
_isSuperadmin = false;
|
||||||
|
_authenticated = false;
|
||||||
|
} finally {
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setAuthenticated(isSuperadmin: boolean) {
|
||||||
|
_isSuperadmin = isSuperadmin;
|
||||||
|
_authenticated = true;
|
||||||
|
_initialized = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
await api.auth.logout();
|
||||||
|
} catch (_e) {
|
||||||
|
console.error('logout failed', _e);
|
||||||
|
}
|
||||||
|
_isSuperadmin = false;
|
||||||
|
_authenticated = false;
|
||||||
|
if (browser) {
|
||||||
|
goto('/admin/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
|
|
||||||
export const token = writable<string | null>(
|
|
||||||
browser ? localStorage.getItem('token') : null
|
|
||||||
);
|
|
||||||
|
|
||||||
export const isSuperadmin = writable<boolean>(
|
|
||||||
browser ? localStorage.getItem('is_superadmin') === 'true' : false
|
|
||||||
);
|
|
||||||
|
|
||||||
if (browser) {
|
|
||||||
token.subscribe((value) => {
|
|
||||||
if (value) {
|
|
||||||
localStorage.setItem('token', value);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
isSuperadmin.subscribe((value) => {
|
|
||||||
localStorage.setItem('is_superadmin', value ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logout() {
|
|
||||||
token.set(null);
|
|
||||||
isSuperadmin.set(false);
|
|
||||||
}
|
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
await api.admin.slots.upsertNote(slotId, selectedStudentId, noteContent);
|
await api.admin.slots.upsertNote(slotId, selectedStudentId, noteContent);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
savedAt = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
savedAt = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
} catch (_) {
|
} catch {
|
||||||
// silent — user sees no feedback on transient failure
|
// silent — user sees no feedback on transient failure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
<!-- Roster list -->
|
<!-- Roster list -->
|
||||||
<div class="scroll" style="overflow-y:auto;max-height:220px;padding:6px 0">
|
<div class="scroll" style="overflow-y:auto;max-height:220px;padding:6px 0">
|
||||||
{#each present as s}
|
{#each present as s (s.id)}
|
||||||
{@const isSel = s.id === selectedStudentId}
|
{@const isSel = s.id === selectedStudentId}
|
||||||
<button
|
<button
|
||||||
style="width:100%;text-align:left;border:none;background:{isSel ? 'rgba(31,27,22,0.06)' : 'transparent'};padding:7px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;border-left:{isSel ? '3px solid var(--ink)' : '3px solid transparent'}"
|
style="width:100%;text-align:left;border:none;background:{isSel ? 'rgba(31,27,22,0.06)' : 'transparent'};padding:7px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;border-left:{isSel ? '3px solid var(--ink)' : '3px solid transparent'}"
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
<span class="mono tiny" style="color:var(--ink-4)">{checkinTime(s.id)}</span>
|
<span class="mono tiny" style="color:var(--ink-4)">{checkinTime(s.id)}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#each absent as s}
|
{#each absent as s (s.id)}
|
||||||
<button
|
<button
|
||||||
style="width:100%;text-align:left;border:none;background:transparent;padding:7px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;opacity:0.55;border-left:3px solid transparent"
|
style="width:100%;text-align:left;border:none;background:transparent;padding:7px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;opacity:0.55;border-left:3px solid transparent"
|
||||||
class="row-hover"
|
class="row-hover"
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
|
|
||||||
<!-- Quick tags -->
|
<!-- Quick tags -->
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px">
|
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px">
|
||||||
{#each TAGS as tag}
|
{#each TAGS as tag (tag)}
|
||||||
<button
|
<button
|
||||||
class="pill closed"
|
class="pill closed"
|
||||||
style="border-color:var(--rule);cursor:pointer;font-family:var(--sans);text-transform:none;font-size:11px;letter-spacing:0"
|
style="border-color:var(--rule);cursor:pointer;font-family:var(--sans);text-transform:none;font-size:11px;letter-spacing:0"
|
||||||
|
|||||||
@@ -136,14 +136,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tables -->
|
<!-- Tables -->
|
||||||
{#each TABLES as t}
|
{#each TABLES as t (t.id)}
|
||||||
<div style="position:absolute;left:{t.x}px;top:{t.y}px;width:{t.w}px;height:{t.h}px;background:#e8dec5;border:1.5px solid var(--ink-2);border-radius:3px;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:22px;color:rgba(31,27,22,0.35);font-style:italic">
|
<div style="position:absolute;left:{t.x}px;top:{t.y}px;width:{t.w}px;height:{t.h}px;background:#e8dec5;border:1.5px solid var(--ink-2);border-radius:3px;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:22px;color:rgba(31,27,22,0.35);font-style:italic">
|
||||||
{t.label}
|
{t.label}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Seats -->
|
<!-- Seats -->
|
||||||
{#each SEATS as seat}
|
{#each SEATS as seat (seat.id)}
|
||||||
{@const s = seatStyle(seat)}
|
{@const s = seatStyle(seat)}
|
||||||
<button
|
<button
|
||||||
style="position:absolute;left:{seat.x - 18}px;top:{seat.y - 18}px;width:36px;height:36px;border-radius:50%;background:{s.bg};border:1.5px solid {s.border};cursor:{onSeatClick && variant !== 'student-self' ? 'pointer' : 'default'};display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-weight:600;font-size:11px;color:{s.labelColor};padding:0;box-shadow:{s.shadow};transition:background 120ms,border-color 120ms"
|
style="position:absolute;left:{seat.x - 18}px;top:{seat.y - 18}px;width:36px;height:36px;border-radius:50%;background:{s.bg};border:1.5px solid {s.border};cursor:{onSeatClick && variant !== 'student-self' ? 'pointer' : 'default'};display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-weight:600;font-size:11px;color:{s.labelColor};padding:0;box-shadow:{s.shadow};transition:background 120ms,border-color 120ms"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { isSuperadmin } from '$lib/auth';
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activePath,
|
activePath,
|
||||||
@@ -67,8 +67,8 @@
|
|||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav style="display:flex;flex-direction:column;gap:1px">
|
<nav style="display:flex;flex-direction:column;gap:1px">
|
||||||
<div class="eyebrow" style="margin-bottom:6px">Navigation</div>
|
<div class="eyebrow" style="margin-bottom:6px">Navigation</div>
|
||||||
{#each navItems as item}
|
{#each navItems as item (item.id)}
|
||||||
{#if !item.superadmin || $isSuperadmin}
|
{#if !item.superadmin || auth.isSuperadmin}
|
||||||
{@const active = isActive(item)}
|
{@const active = isActive(item)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
|||||||
@@ -68,3 +68,21 @@ export interface Note {
|
|||||||
content: string;
|
content: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionAttendance {
|
||||||
|
students: Student[];
|
||||||
|
slots: Slot[];
|
||||||
|
attendances: Attendance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckinAttendance {
|
||||||
|
seat_id: string | null;
|
||||||
|
student_id: number;
|
||||||
|
is_mine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckinInfo {
|
||||||
|
slot: Slot;
|
||||||
|
layout: LayoutElement[] | null;
|
||||||
|
attendances: CheckinAttendance[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { token } from '$lib/auth';
|
import { auth } from '$lib/auth.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
goto($token ? '/admin' : '/admin/login');
|
await auth.init();
|
||||||
|
goto(auth.authenticated ? '/admin' : '/admin/login');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { token, logout } from '$lib/auth';
|
import { auth } from '$lib/auth.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -12,39 +12,46 @@
|
|||||||
|
|
||||||
let course = $state<Course | null>(null);
|
let course = $state<Course | null>(null);
|
||||||
|
|
||||||
|
const isLoginRoute = $derived($page.url.pathname === '/admin/login');
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!$token) {
|
await auth.init();
|
||||||
|
if (isLoginRoute) return;
|
||||||
|
if (!auth.authenticated) {
|
||||||
goto('/admin/login');
|
goto('/admin/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const courses = await api.admin.courses.list();
|
const courses = await api.admin.courses.list();
|
||||||
if (courses.length > 0) course = courses[0];
|
if (courses.length > 0) course = courses[0] ?? null;
|
||||||
} catch (_) {}
|
} catch (_err) {
|
||||||
|
console.error('failed to fetch courses');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!$token) goto('/admin/login');
|
if (auth.initialized && !auth.authenticated && !isLoginRoute) goto('/admin/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
logout();
|
|
||||||
goto('/admin/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
const activePath = $derived($page.url.pathname);
|
const activePath = $derived($page.url.pathname);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $token}
|
{#if auth.initialized}
|
||||||
<TutorShell
|
{#if auth.authenticated}
|
||||||
{activePath}
|
<TutorShell
|
||||||
courseName={course?.name ?? ''}
|
{activePath}
|
||||||
semester={course?.semester ?? ''}
|
courseName={course?.name ?? ''}
|
||||||
>
|
semester={course?.semester ?? ''}
|
||||||
{#snippet children()}
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{/snippet}
|
</TutorShell>
|
||||||
</TutorShell>
|
{:else if isLoginRoute}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<div style="padding: 2rem; text-align: center;">Redirecting to login...</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{@render children()}
|
<div style="padding: 2rem; text-align: center; font-family: var(--serif);">
|
||||||
|
Wird geladen...
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
courses = await api.admin.courses.list();
|
courses = await api.admin.courses.list();
|
||||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
const first = courses[0];
|
||||||
|
if (first) selectedCourseId = first.id;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
style="font-size:12px"
|
style="font-size:12px"
|
||||||
bind:value={selectedCourseId}
|
bind:value={selectedCourseId}
|
||||||
>
|
>
|
||||||
{#each courses as course}
|
{#each courses as course (course.id)}
|
||||||
<option value={course.id}>{course.name} ({course.semester})</option>
|
<option value={course.id}>{course.name} ({course.semester})</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -112,9 +113,9 @@
|
|||||||
<!-- Stat row -->
|
<!-- Stat row -->
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px">
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Offene Slots"
|
label="Anwesenheit offen"
|
||||||
value={openSlots.length}
|
value={openSlots.length}
|
||||||
hint={openSlots.length > 0 ? `Code: ${openSlots[0].code ?? '—'}` : 'Kein Slot offen'}
|
hint={openSlots[0] ? `Code: ${openSlots[0].code ?? '—'}` : 'Kein Slot offen'}
|
||||||
accent={openSlots.length > 0 ? 'var(--green)' : undefined}
|
accent={openSlots.length > 0 ? 'var(--green)' : undefined}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@@ -165,7 +166,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each slotRows as { slot, session }, i}
|
{#each slotRows as { slot, session }, i (slot.id)}
|
||||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||||
<td style="padding:12px 14px">
|
<td style="padding:12px 14px">
|
||||||
<span class="mono" style="font-size:12px">{weekLabel(session.week_nr)}</span>
|
<span class="mono" style="font-size:12px">{weekLabel(session.week_nr)}</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { Course, Session, Student, Attendance } from '$lib/types';
|
import type { Course, Session, Student, Attendance, SessionAttendance } from '$lib/types';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
courses = await api.admin.courses.list();
|
courses = await api.admin.courses.list();
|
||||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
const first = courses[0];
|
||||||
|
if (first) selectedCourseId = first.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -37,8 +38,8 @@
|
|||||||
const slotIds: number[] = [];
|
const slotIds: number[] = [];
|
||||||
const newMap: Record<number, Attendance[]> = {};
|
const newMap: Record<number, Attendance[]> = {};
|
||||||
|
|
||||||
perSession.forEach((d: { students: Student[]; slots: any[]; attendances: Attendance[] }) => {
|
perSession.forEach((d: SessionAttendance) => {
|
||||||
(d.slots ?? []).forEach((slot: any) => {
|
(d.slots ?? []).forEach((slot) => {
|
||||||
slotIds.push(slot.id);
|
slotIds.push(slot.id);
|
||||||
newMap[slot.id] = (d.attendances ?? []).filter((a: Attendance) => a.slot_id === slot.id);
|
newMap[slot.id] = (d.attendances ?? []).filter((a: Attendance) => a.slot_id === slot.id);
|
||||||
});
|
});
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
}
|
}
|
||||||
if (selectedCourseId) await loadMatrix(selectedCourseId);
|
if (selectedCourseId) await loadMatrix(selectedCourseId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e);
|
if (e instanceof Error) alert(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@
|
|||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
{#if courses.length > 1}
|
{#if courses.length > 1}
|
||||||
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
|
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
|
||||||
{#each courses as course}
|
{#each courses as course (course.id)}
|
||||||
<option value={course.id}>{course.name}</option>
|
<option value={course.id}>{course.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -146,7 +147,7 @@
|
|||||||
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
|
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
|
||||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">#</th>
|
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">#</th>
|
||||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Studierende:r</th>
|
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Studierende:r</th>
|
||||||
{#each sessions as session, i}
|
{#each sessions as session (session.id)}
|
||||||
<th style="padding:10px 10px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:center">
|
<th style="padding:10px 10px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:center">
|
||||||
W{String(session.week_nr).padStart(2, '0')}
|
W{String(session.week_nr).padStart(2, '0')}
|
||||||
</th>
|
</th>
|
||||||
@@ -156,7 +157,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each students as student, i}
|
{#each students as student, i (student.id)}
|
||||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||||
<td style="padding:12px 14px">
|
<td style="padding:12px 14px">
|
||||||
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>
|
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>
|
||||||
@@ -169,14 +170,17 @@
|
|||||||
<span>{student.name}</span>
|
<span>{student.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{#each sessions as session}
|
{#each sessions as session (session.id)}
|
||||||
{@const slotIds = (session.slots ?? []).map((sl: any) => sl.id)}
|
{@const slotIds = (session.slots ?? []).map((sl) => sl.id)}
|
||||||
{@const sessionPresent = slotIds.some((sid: number) => isPresent(sid, student.id))}
|
{@const sessionPresent = slotIds.some((sid: number) => isPresent(sid, student.id))}
|
||||||
<td style="padding:10px;text-align:center">
|
<td style="padding:10px;text-align:center">
|
||||||
{#if slotIds.length > 0}
|
{#if slotIds.length > 0}
|
||||||
<button
|
<button
|
||||||
style="width:24px;height:24px;border-radius:3px;border:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;background:{sessionPresent ? 'rgba(74,107,58,0.14)' : 'transparent'}"
|
style="width:24px;height:24px;border-radius:3px;border:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;background:{sessionPresent ? 'rgba(74,107,58,0.14)' : 'transparent'}"
|
||||||
onclick={() => toggleAttendance(slotIds[0], student.id)}
|
onclick={() => {
|
||||||
|
const sid = slotIds[0];
|
||||||
|
if (sid !== undefined) toggleAttendance(sid, student.id);
|
||||||
|
}}
|
||||||
title={sessionPresent ? 'Anwesend – klicken zum Entfernen' : 'Abwesend – klicken zum Eintragen'}
|
title={sessionPresent ? 'Anwesend – klicken zum Entfernen' : 'Abwesend – klicken zum Eintragen'}
|
||||||
>
|
>
|
||||||
{#if sessionPresent}
|
{#if sessionPresent}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { isSuperadmin } from '$lib/auth';
|
import { auth } from '$lib/auth.svelte';
|
||||||
import type { Course, Tutor } from '$lib/types';
|
import type { Course, Tutor } from '$lib/types';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
courses = await api.admin.courses.list();
|
courses = await api.admin.courses.list();
|
||||||
if ($isSuperadmin) {
|
if (auth.isSuperadmin) {
|
||||||
allTutors = await api.admin.tutors.list();
|
allTutors = await api.admin.tutors.list();
|
||||||
for (const course of courses) {
|
for (const course of courses) {
|
||||||
assignedTutors[course.id] = await api.admin.courses.listTutors(course.id);
|
assignedTutors[course.id] = await api.admin.courses.listTutors(course.id);
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Create course form (Superadmin only) -->
|
<!-- Create course form (Superadmin only) -->
|
||||||
{#if $isSuperadmin}
|
{#if auth.isSuperadmin}
|
||||||
<section class="card" style="overflow:hidden">
|
<section class="card" style="overflow:hidden">
|
||||||
<div style="padding:14px 18px;border-bottom:1px solid var(--rule)">
|
<div style="padding:14px 18px;border-bottom:1px solid var(--rule)">
|
||||||
<div class="serif" style="font-size:18px;font-weight:500">Neuen Kurs anlegen</div>
|
<div class="serif" style="font-size:18px;font-weight:500">Neuen Kurs anlegen</div>
|
||||||
@@ -95,24 +95,24 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
|
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
|
||||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Name / Semester</th>
|
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Name / Semester</th>
|
||||||
{#if $isSuperadmin}
|
{#if auth.isSuperadmin}
|
||||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Tutor:innen</th>
|
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Tutor:innen</th>
|
||||||
{/if}
|
{/if}
|
||||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:right">Aktionen</th>
|
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:right">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each courses as course, i}
|
{#each courses as course, i (course.id)}
|
||||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||||
<td style="padding:12px 14px">
|
<td style="padding:12px 14px">
|
||||||
<div class="serif" style="font-weight:500;font-size:15px">{course.name}</div>
|
<div class="serif" style="font-weight:500;font-size:15px">{course.name}</div>
|
||||||
<div class="tiny" style="color:var(--ink-4);font-family:var(--mono)">{course.semester}</div>
|
<div class="tiny" style="color:var(--ink-4);font-family:var(--mono)">{course.semester}</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{#if $isSuperadmin}
|
{#if auth.isSuperadmin}
|
||||||
<td style="padding:12px 14px">
|
<td style="padding:12px 14px">
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:4px;max-width:300px">
|
<div style="display:flex;flex-wrap:wrap;gap:4px;max-width:300px">
|
||||||
{#each assignedTutors[course.id] ?? [] as tutor}
|
{#each assignedTutors[course.id] ?? [] as tutor (tutor.id)}
|
||||||
<span class="pill closed" style="font-size:10px;padding:1px 6px">
|
<span class="pill closed" style="font-size:10px;padding:1px 6px">
|
||||||
{tutor.name}
|
{tutor.name}
|
||||||
<button
|
<button
|
||||||
@@ -130,14 +130,13 @@
|
|||||||
value=""
|
value=""
|
||||||
>
|
>
|
||||||
<option value="" disabled>+ Hinzufügen</option>
|
<option value="" disabled>+ Hinzufügen</option>
|
||||||
{#each allTutors.filter(t => !assignedTutors[course.id]?.some(at => at.id === t.id)) as t}
|
{#each allTutors.filter(t => !assignedTutors[course.id]?.some(at => at.id === t.id)) as t (t.id)}
|
||||||
<option value={t.id}>{t.name}</option>
|
<option value={t.id}>{t.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<td style="padding:12px 14px;text-align:right">
|
<td style="padding:12px 14px;text-align:right">
|
||||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||||
<a href="/admin/students" class="btn ghost sm">Studierende</a>
|
<a href="/admin/students" class="btn ghost sm">Studierende</a>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
courses = await api.admin.courses.list();
|
courses = await api.admin.courses.list();
|
||||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
const first = courses[0];
|
||||||
|
if (first) selectedCourseId = first.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -19,15 +20,6 @@
|
|||||||
api.admin.sessions.list(selectedCourseId).then((res: Session[]) => sessions = res);
|
api.admin.sessions.list(selectedCourseId).then((res: Session[]) => sessions = res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function download(url: string, filename: string) {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
|
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
|
||||||
@@ -76,7 +68,7 @@
|
|||||||
|
|
||||||
{#if courses.length > 0}
|
{#if courses.length > 0}
|
||||||
<select class="input" style="font-size:12px;width:160px" bind:value={selectedCourseId}>
|
<select class="input" style="font-size:12px;width:160px" bind:value={selectedCourseId}>
|
||||||
{#each courses as course}
|
{#each courses as course (course.id)}
|
||||||
<option value={course.id}>{course.name}</option>
|
<option value={course.id}>{course.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -108,7 +100,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each sessions as session, i}
|
{#each sessions as session, i (session.id)}
|
||||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||||
<td style="padding:12px 16px">
|
<td style="padding:12px 16px">
|
||||||
<div class="serif" style="font-weight:500;font-size:14px">Woche {String(session.week_nr).padStart(2, '0')}</div>
|
<div class="serif" style="font-weight:500;font-size:14px">Woche {String(session.week_nr).padStart(2, '0')}</div>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||||
import Tally from '$lib/components/Tally.svelte';
|
import Tally from '$lib/components/Tally.svelte';
|
||||||
|
|
||||||
const slotId = $derived(parseInt(($page.params as Record<string, string>).slotId));
|
const slotIdStr = ($page.params as Record<string, string>).slotId;
|
||||||
|
const slotId = $derived(slotIdStr ? parseInt(slotIdStr) : 0);
|
||||||
|
|
||||||
let slot = $state<Slot | null>(null);
|
let slot = $state<Slot | null>(null);
|
||||||
let session = $state<Session | null>(null);
|
let session = $state<Session | null>(null);
|
||||||
@@ -100,10 +101,6 @@
|
|||||||
|
|
||||||
const presentCount = $derived(attendances.length);
|
const presentCount = $derived(attendances.length);
|
||||||
const absentCount = $derived(students.length - presentCount);
|
const absentCount = $derived(students.length - presentCount);
|
||||||
const bonusCount = $derived(students.filter((s: Student) => {
|
|
||||||
// Bonus eligibility would require cross-session data; show attendees as placeholder
|
|
||||||
return attendances.some((a: Attendance) => a.student_id === s.id);
|
|
||||||
}).length);
|
|
||||||
|
|
||||||
function weekLabel(n: number): string {
|
function weekLabel(n: number): string {
|
||||||
return `W${String(n).padStart(2, '0')}`;
|
return `W${String(n).padStart(2, '0')}`;
|
||||||
@@ -184,7 +181,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each students as student, i}
|
{#each students as student, i (student.id)}
|
||||||
{@const present = attendances.some((a: Attendance) => a.student_id === student.id)}
|
{@const present = attendances.some((a: Attendance) => a.student_id === student.id)}
|
||||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||||
<td style="padding:8px 14px">
|
<td style="padding:8px 14px">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { token, isSuperadmin } from '$lib/auth';
|
import { auth } from '$lib/auth.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||||
|
|
||||||
@@ -14,8 +14,7 @@
|
|||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await api.auth.login(email, password);
|
const res = await api.auth.login(email, password);
|
||||||
token.set(res.token);
|
auth.setAuthenticated(res.is_superadmin);
|
||||||
isSuperadmin.set(res.is_superadmin);
|
|
||||||
goto('/admin');
|
goto('/admin');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error = e instanceof Error ? e.message : 'Ungültige Anmeldedaten';
|
error = e instanceof Error ? e.message : 'Ungültige Anmeldedaten';
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
await api.admin.rooms.create(newRoomName, []);
|
await api.admin.rooms.create(newRoomName, []);
|
||||||
newRoomName = '';
|
newRoomName = '';
|
||||||
rooms = await api.admin.rooms.list();
|
rooms = await api.admin.rooms.list();
|
||||||
} catch (_) {}
|
} catch {
|
||||||
|
console.error('failed to fetch rooms');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each rooms as room, i}
|
{#each rooms as room, i (room.id)}
|
||||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||||
<td style="padding:12px 14px">{room.name}</td>
|
<td style="padding:12px 14px">{room.name}</td>
|
||||||
<td style="padding:12px 14px;text-align:right">
|
<td style="padding:12px 14px;text-align:right">
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
import RoomCanvas from '$lib/RoomCanvas.svelte';
|
import RoomCanvas from '$lib/RoomCanvas.svelte';
|
||||||
import type { Room, LayoutElement } from '$lib/types';
|
import type { Room, LayoutElement } from '$lib/types';
|
||||||
|
|
||||||
const roomId = $derived(parseInt(($page.params as Record<string, string>).roomId));
|
const roomIdStr = ($page.params as Record<string, string>).roomId;
|
||||||
|
const roomId = $derived(roomIdStr ? parseInt(roomIdStr) : 0);
|
||||||
|
|
||||||
let room = $state<Room | null>(null);
|
let room = $state<Room | null>(null);
|
||||||
|
|
||||||
@@ -23,7 +24,9 @@
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
try {
|
try {
|
||||||
await api.admin.rooms.updateLayout(room.id, room.layout);
|
await api.admin.rooms.updateLayout(room.id, room.layout);
|
||||||
} catch (_) {}
|
} catch {
|
||||||
|
console.error('failed to save layout');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addElement(type: LayoutElement['type']) {
|
function addElement(type: LayoutElement['type']) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { Course, Room, Session, Slot } from '$lib/types';
|
import type { Course, Room, Session } from '$lib/types';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import StatusPill from '$lib/components/StatusPill.svelte';
|
import StatusPill from '$lib/components/StatusPill.svelte';
|
||||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
let sessions = $state<Session[]>([]);
|
let sessions = $state<Session[]>([]);
|
||||||
|
|
||||||
let weekNr = $state(1);
|
let weekNr = $state(1);
|
||||||
let date = $state(new Date().toISOString().split('T')[0]);
|
let date = $state(new Date().toISOString().split('T')[0] ?? '');
|
||||||
|
|
||||||
let selectedSessionId = $state<number | null>(null);
|
let selectedSessionId = $state<number | null>(null);
|
||||||
let slotTutorId = $state<number | null>(null);
|
let slotTutorId = $state<number | null>(null);
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
courses = await api.admin.courses.list();
|
courses = await api.admin.courses.list();
|
||||||
rooms = await api.admin.rooms.list();
|
rooms = await api.admin.rooms.list();
|
||||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
const first = courses[0];
|
||||||
|
if (first) selectedCourseId = first.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -36,10 +37,11 @@
|
|||||||
|
|
||||||
async function createSession(e: Event) {
|
async function createSession(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedCourseId) return;
|
const courseId = selectedCourseId;
|
||||||
|
if (courseId === null) return;
|
||||||
try {
|
try {
|
||||||
await api.admin.sessions.create(selectedCourseId, weekNr, date);
|
await api.admin.sessions.create(courseId, weekNr, date);
|
||||||
await loadSessions(selectedCourseId);
|
await loadSessions(courseId);
|
||||||
weekNr++;
|
weekNr++;
|
||||||
} catch (e) { alert(e); }
|
} catch (e) { alert(e); }
|
||||||
}
|
}
|
||||||
@@ -81,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if courses.length > 1}
|
{#if courses.length > 1}
|
||||||
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
|
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
|
||||||
{#each courses as course}
|
{#each courses as course (course.id)}
|
||||||
<option value={course.id}>{course.name} ({course.semester})</option>
|
<option value={course.id}>{course.name} ({course.semester})</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -114,7 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div style="display:flex;flex-direction:column;gap:12px">
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
{#each sessions as session}
|
{#each sessions as session (session.id)}
|
||||||
<section class="card" style="overflow:hidden">
|
<section class="card" style="overflow:hidden">
|
||||||
<div style="padding:12px 16px;background:rgba(0,0,0,0.02);border-bottom:1px solid var(--rule);display:flex;align-items:center;justify-content:space-between">
|
<div style="padding:12px 16px;background:rgba(0,0,0,0.02);border-bottom:1px solid var(--rule);display:flex;align-items:center;justify-content:space-between">
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
@@ -133,7 +135,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each session.slots ?? [] as slot, i}
|
{#each session.slots ?? [] as slot, i (slot.id)}
|
||||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||||
<td style="padding:10px 16px">
|
<td style="padding:10px 16px">
|
||||||
<span class="mono" style="font-size:12px">{slot.start_time}–{slot.end_time}</span>
|
<span class="mono" style="font-size:12px">{slot.start_time}–{slot.end_time}</span>
|
||||||
@@ -189,7 +191,7 @@
|
|||||||
<label for="slot-room" class="tiny" style="color:var(--ink-3)">Raum (optional)</label>
|
<label for="slot-room" class="tiny" style="color:var(--ink-3)">Raum (optional)</label>
|
||||||
<select id="slot-room" class="input" bind:value={slotRoomId}>
|
<select id="slot-room" class="input" bind:value={slotRoomId}>
|
||||||
<option value={null}>Kein Raum</option>
|
<option value={null}>Kein Raum</option>
|
||||||
{#each rooms as room}
|
{#each rooms as room (room.id)}
|
||||||
<option value={room.id}>{room.name}</option>
|
<option value={room.id}>{room.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
courses = await api.admin.courses.list();
|
courses = await api.admin.courses.list();
|
||||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
const first = courses[0];
|
||||||
|
if (first) selectedCourseId = first.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -76,7 +77,7 @@
|
|||||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
{#if courses.length > 1}
|
{#if courses.length > 1}
|
||||||
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
|
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
|
||||||
{#each courses as course}
|
{#each courses as course (course.id)}
|
||||||
<option value={course.id}>{course.name} ({course.semester})</option>
|
<option value={course.id}>{course.name} ({course.semester})</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each filtered as student, i}
|
{#each filtered as student, i (student.id)}
|
||||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||||
<td style="padding:12px 14px">
|
<td style="padding:12px 14px">
|
||||||
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>
|
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>
|
||||||
|
|||||||
@@ -34,8 +34,8 @@
|
|||||||
await api.admin.tutors.create(newTutor);
|
await api.admin.tutors.create(newTutor);
|
||||||
newTutor = { name: '', email: '', password: '', is_superadmin: false };
|
newTutor = { name: '', email: '', password: '', is_superadmin: false };
|
||||||
await loadTutors();
|
await loadTutors();
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
alert(err.message);
|
if (err instanceof Error) alert(err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
try {
|
try {
|
||||||
await api.admin.tutors.delete(id);
|
await api.admin.tutors.delete(id);
|
||||||
await loadTutors();
|
await loadTutors();
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
alert(err.message);
|
if (err instanceof Error) alert(err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each tutors as tutor, i}
|
{#each tutors as tutor, i (tutor.id)}
|
||||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||||
<td style="padding:12px 14px">
|
<td style="padding:12px 14px">
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { Slot, Student, Attendance } from '$lib/types';
|
import type { Slot, Student, Attendance, CheckinAttendance } from '$lib/types';
|
||||||
import SeatMap from '$lib/components/SeatMap.svelte';
|
import SeatMap from '$lib/components/SeatMap.svelte';
|
||||||
import StatusPill from '$lib/components/StatusPill.svelte';
|
import StatusPill from '$lib/components/StatusPill.svelte';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
let slot = $state<Slot | null>(null);
|
let slot = $state<Slot | null>(null);
|
||||||
let students = $state<Student[]>([]);
|
let students = $state<Student[]>([]);
|
||||||
let attendances = $state<Attendance[]>([]);
|
let attendances = $state<CheckinAttendance[]>([]);
|
||||||
let myAttendance = $state<Attendance | null>(null);
|
let myAttendance = $state<Attendance | null>(null);
|
||||||
|
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
@@ -29,8 +29,12 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await loadInfo();
|
await loadInfo();
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
errorMsg = e.message ?? 'Fehler beim Laden.';
|
if (e instanceof Error) {
|
||||||
|
errorMsg = e.message ?? 'Fehler beim Laden.';
|
||||||
|
} else {
|
||||||
|
errorMsg = 'Fehler beim Laden.';
|
||||||
|
}
|
||||||
step = 'error';
|
step = 'error';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -38,11 +42,19 @@
|
|||||||
async function loadInfo() {
|
async function loadInfo() {
|
||||||
const res = await api.checkin.getInfo(code);
|
const res = await api.checkin.getInfo(code);
|
||||||
slot = res.slot;
|
slot = res.slot;
|
||||||
attendances = res.attendances ?? [];
|
// We don't have is_mine in the regular Attendance type, so we use CheckinAttendance locally
|
||||||
|
const checkinAttendances = res.attendances ?? [];
|
||||||
|
|
||||||
const mine = attendances.find((a: Attendance) => (a as any).is_mine);
|
const mine = checkinAttendances.find((a: CheckinAttendance) => a.is_mine);
|
||||||
if (mine) {
|
if (mine && slot) {
|
||||||
myAttendance = mine;
|
// Create a dummy Attendance object for compatibility with the state
|
||||||
|
myAttendance = {
|
||||||
|
id: 0,
|
||||||
|
slot_id: slot.id,
|
||||||
|
student_id: mine.student_id,
|
||||||
|
seat_id: mine.seat_id,
|
||||||
|
checked_in_at: new Date().toISOString()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slot?.status === 'locked') {
|
if (slot?.status === 'locked') {
|
||||||
@@ -69,11 +81,15 @@
|
|||||||
const res = await api.checkin.post(code, selectedStudent.id, seatId);
|
const res = await api.checkin.post(code, selectedStudent.id, seatId);
|
||||||
myAttendance = res;
|
myAttendance = res;
|
||||||
await loadInfo();
|
await loadInfo();
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
if (e.message?.includes('already checked in') || e.message?.includes('409')) {
|
if (e instanceof Error) {
|
||||||
errorMsg = 'Dieser Platz ist bereits belegt.';
|
if (e.message?.includes('already checked in') || e.message?.includes('409')) {
|
||||||
|
errorMsg = 'Dieser Platz ist bereits belegt.';
|
||||||
|
} else {
|
||||||
|
errorMsg = e.message ?? 'Einchecken fehlgeschlagen.';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
errorMsg = e.message ?? 'Einchecken fehlgeschlagen.';
|
errorMsg = 'Einchecken fehlgeschlagen.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +169,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div style="display:flex;flex-direction:column;gap:4px;max-height:55vh;overflow-y:auto" class="scroll">
|
<div style="display:flex;flex-direction:column;gap:4px;max-height:55vh;overflow-y:auto" class="scroll">
|
||||||
{#each filteredStudents as student}
|
{#each filteredStudents as student (student.id)}
|
||||||
<button
|
<button
|
||||||
class="card"
|
class="card"
|
||||||
style="padding:12px 16px;display:flex;align-items:center;gap:10px;text-align:left;border:none;cursor:pointer;background:var(--paper);width:100%"
|
style="padding:12px 16px;display:flex;align-items:center;gap:10px;text-align:left;border:none;cursor:pointer;background:var(--paper);width:100%"
|
||||||
@@ -263,7 +279,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-height:55vh;overflow-y:auto" class="scroll">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-height:55vh;overflow-y:auto" class="scroll">
|
||||||
{#each filteredStudents as student}
|
{#each filteredStudents as student (student.id)}
|
||||||
<button
|
<button
|
||||||
class="card"
|
class="card"
|
||||||
style="padding:12px 16px;display:flex;align-items:center;gap:10px;text-align:left;border:none;cursor:pointer;background:var(--paper);width:100%"
|
style="padding:12px 16px;display:flex;align-items:center;gap:10px;text-align:left;border:none;cursor:pointer;background:var(--paper);width:100%"
|
||||||
|
|||||||
20
frontend/tests/auth.spec.ts
Normal file
20
frontend/tests/auth.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Login page accessibility', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('renders login form without auth cookies (regression: redirect trap)', async ({ page }) => {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await expect(page.locator('#email')).toBeVisible();
|
||||||
|
await expect(page.locator('#password')).toBeVisible();
|
||||||
|
await expect(page.locator('button[type="submit"]')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Willkommen zurück')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Redirecting to login')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unauthenticated /admin redirects to login form', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.waitForURL(/\/admin\/login/);
|
||||||
|
await expect(page.locator('#email')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test as base, expect } from '@playwright/test';
|
import { test as base, expect, type Page } from '@playwright/test';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
@@ -16,7 +16,7 @@ function getBaseURL(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extends base test with a beforeEach that resets DB to clean seed state
|
// Extends base test with a beforeEach that resets DB to clean seed state
|
||||||
export const test = base.extend<{ page: Parameters<Parameters<typeof base>[1]>[0]['page'] }>({
|
export const test = base.extend<{ page: Page }>({
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
const baseURL = getBaseURL();
|
const baseURL = getBaseURL();
|
||||||
const res = await page.request.post(`${baseURL}/__test__/reset`);
|
const res = await page.request.post(`${baseURL}/__test__/reset`);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
|
|
||||||
async function globalSetup() {
|
async function globalSetup() {
|
||||||
// Read env vars written by scripts/test-env.sh (make test-up calls it first)
|
// Read env vars written by scripts/test-env.sh (make test-up calls it first)
|
||||||
const envFile = path.resolve(__dirname, '../../../data/test/.env');
|
const envFile = path.resolve(__dirname, '../../data/test/.env');
|
||||||
if (!fs.existsSync(envFile)) {
|
if (!fs.existsSync(envFile)) {
|
||||||
throw new Error('data/test/.env not found — run "make test-up" first');
|
throw new Error('data/test/.env not found — run "make test-up" first');
|
||||||
}
|
}
|
||||||
@@ -24,20 +24,41 @@ async function globalSetup() {
|
|||||||
body: JSON.stringify({ email: 'admin@tutortool.com', password: 'admin' }),
|
body: JSON.stringify({ email: 'admin@tutortool.com', password: 'admin' }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Login failed: ${res.status} ${await res.text()}`);
|
if (!res.ok) throw new Error(`Login failed: ${res.status} ${await res.text()}`);
|
||||||
const { token, is_superadmin } = await res.json() as { token: string; is_superadmin: boolean };
|
|
||||||
|
|
||||||
// Write Playwright storage state with localStorage pre-populated
|
// Extract cookies from Set-Cookie headers
|
||||||
|
// Note: Use getSetCookie() in newer Node versions or handle joined string
|
||||||
|
const setCookies = (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() || res.headers.get('set-cookie')?.split(',') || [];
|
||||||
|
|
||||||
|
const cookies = setCookies.map((s: string) => {
|
||||||
|
const parts = s.split(';').map(p => p.trim());
|
||||||
|
const [nameValue] = parts;
|
||||||
|
if (!nameValue) return null;
|
||||||
|
const [name, value] = nameValue.split('=');
|
||||||
|
if (!name || !value) return null;
|
||||||
|
|
||||||
|
const pathPart = parts.find(p => p.toLowerCase().startsWith('path='));
|
||||||
|
const path = pathPart ? pathPart.split('=')[1] : '/';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
domain: new URL(baseURL).hostname,
|
||||||
|
path: path || '/',
|
||||||
|
httpOnly: s.toLowerCase().includes('httponly'),
|
||||||
|
secure: s.toLowerCase().includes('secure'),
|
||||||
|
sameSite: 'Strict' as const,
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
// Write Playwright storage state with cookies pre-populated
|
||||||
const authDir = path.resolve(__dirname, '.auth');
|
const authDir = path.resolve(__dirname, '.auth');
|
||||||
fs.mkdirSync(authDir, { recursive: true });
|
fs.mkdirSync(authDir, { recursive: true });
|
||||||
const storageState = {
|
const storageState = {
|
||||||
cookies: [],
|
cookies,
|
||||||
origins: [
|
origins: [
|
||||||
{
|
{
|
||||||
origin: baseURL,
|
origin: baseURL,
|
||||||
localStorage: [
|
localStorage: [],
|
||||||
{ name: 'token', value: token },
|
|
||||||
{ name: 'is_superadmin', value: String(is_superadmin) },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ test.describe('Superadmin CRUD & UI Consistency', () => {
|
|||||||
|
|
||||||
await expect(page.locator('text=Neuen Kurs anlegen')).toBeVisible();
|
await expect(page.locator('text=Neuen Kurs anlegen')).toBeVisible();
|
||||||
|
|
||||||
const tutorSelect = page.locator('select >> text=+ Hinzufügen').first();
|
const tutorSelect = page.locator('select.tiny').first();
|
||||||
await expect(tutorSelect).toBeVisible();
|
await expect(tutorSelect).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"module": "esnext"
|
"module": "esnext"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user