Compare commits
21 Commits
pipeline-o
...
v0.1.12
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ca42d10e6 | |||
| 32e7dc5ac1 | |||
| 6ca852117d | |||
| dec92509ff | |||
| 31f8ef74fe | |||
| 536638b594 | |||
| 6cb5968b7b | |||
| 66eed29c71 | |||
| ff5ad26cfc | |||
| 7cafc7e119 | |||
| 0e7df590ca | |||
| e05cebc10c | |||
| a2b41b5131 | |||
| cffb97ff76 | |||
| 58248897db | |||
| b42ded93f6 | |||
| dcb4a92afd | |||
| 6b296460dd | |||
| ee98d6844a | |||
| bae4ff24ea | |||
| 03a1e70df3 |
@@ -20,11 +20,12 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
version: '9'
|
||||
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.95.0'
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -46,18 +47,32 @@ jobs:
|
||||
- name: Install frontend deps
|
||||
run: pnpm --dir frontend install --frozen-lockfile
|
||||
|
||||
- name: Generate SvelteKit types
|
||||
run: pnpm --dir frontend exec svelte-kit sync
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --dir frontend exec playwright install --with-deps chromium
|
||||
|
||||
- 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: Type check (frontend)
|
||||
run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check
|
||||
|
||||
- name: Unit tests (backend)
|
||||
run: cargo test --manifest-path backend/Cargo.toml
|
||||
|
||||
- name: Security audit
|
||||
run: |
|
||||
cargo install cargo-audit --locked
|
||||
cargo audit --manifest-path backend/Cargo.toml
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm --dir frontend build
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
version: '9'
|
||||
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.95.0'
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -48,12 +49,29 @@ jobs:
|
||||
- name: Install frontend deps
|
||||
run: pnpm --dir frontend install --frozen-lockfile
|
||||
|
||||
- name: Generate SvelteKit types
|
||||
run: pnpm --dir frontend exec svelte-kit sync
|
||||
|
||||
- name: Type check (frontend)
|
||||
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)
|
||||
run: cargo test --manifest-path backend/Cargo.toml
|
||||
|
||||
- name: Security audit
|
||||
run: |
|
||||
cargo install cargo-audit --locked
|
||||
cargo audit --manifest-path backend/Cargo.toml
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm --dir frontend build
|
||||
|
||||
@@ -70,6 +88,7 @@ jobs:
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE }}:${{ github.ref_name }}
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -37,3 +37,9 @@ frontend/playwright-report/
|
||||
# Local dev scripts and logs
|
||||
server.log
|
||||
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-frontend # pnpm dev (port 5173, /api proxied to :3000)
|
||||
|
||||
# Linting & Quality
|
||||
make lint # runs cargo fmt, clippy (-D warnings), and svelte-check
|
||||
|
||||
# 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 check:watch # watch mode
|
||||
pnpm build # Vite build to dist/
|
||||
# Testing
|
||||
make test # runs lint, then cargo test (backend unit tests)
|
||||
make test-e2e # test-up + pnpm test:e2e in one step
|
||||
|
||||
# Demo data
|
||||
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
|
||||
@@ -42,22 +33,24 @@ TutorTool is a **Rust + SvelteKit attendance tracker** for tutoring sessions.
|
||||
|
||||
- **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`
|
||||
- **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`
|
||||
- **Migrations**: auto-run via `sqlx::migrate!` at startup from `backend/migrations/`
|
||||
- **`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.
|
||||
|
||||
### 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'`
|
||||
- **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
|
||||
|
||||
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)
|
||||
- `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`
|
||||
- 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`
|
||||
|
||||
## 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`.
|
||||
- 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`.
|
||||
- **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 useradd -u 1000 -m 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=frontend-builder /app/frontend/build ./frontend/build
|
||||
|
||||
|
||||
28
GEMINI.md
28
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`.
|
||||
<!-- SLM-END -->
|
||||
|
||||
## Commands
|
||||
# Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
@@ -19,18 +19,17 @@ make dev # start backend + frontend in parallel
|
||||
make dev-backend # cargo run (port 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
|
||||
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/
|
||||
|
||||
# Testing
|
||||
make test # runs lint, then cargo test (backend unit tests)
|
||||
make test-e2e # test-up + pnpm test:e2e in one step
|
||||
```
|
||||
# Demo data
|
||||
make seed-demo # wipe dev.db and reseed from backend/demo/demo_seed.sql
|
||||
|
||||
@@ -48,17 +47,18 @@ make test-e2e # test-up + pnpm test:e2e in one step
|
||||
- **Backend**:
|
||||
- **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.
|
||||
- **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**: 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**: 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`.
|
||||
- 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.
|
||||
- **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`.
|
||||
- **Build Tool**: Vite with `adapter-static` (SPA mode, `fallback: 'index.html'`).
|
||||
- **Package Manager**: pnpm (preferred over npm).
|
||||
- **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.
|
||||
|
||||
## Routes
|
||||
|
||||
16
Makefile
16
Makefile
@@ -11,11 +11,19 @@ dev-backend:
|
||||
dev-frontend:
|
||||
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
|
||||
|
||||
build: lint
|
||||
cd frontend && pnpm build
|
||||
cd backend && cargo build --release
|
||||
|
||||
test:
|
||||
test: lint
|
||||
cd backend && cargo test
|
||||
|
||||
compose-up:
|
||||
@@ -48,7 +56,7 @@ test-up:
|
||||
exit 0; \
|
||||
fi; \
|
||||
[ -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 & \
|
||||
echo $$! > data/test/.pid; \
|
||||
echo "[test-up] Backend PID $$(cat data/test/.pid) starting on port $$TT_TEST_PORT..."; \
|
||||
@@ -83,4 +91,4 @@ test-reset:
|
||||
|
||||
test-e2e:
|
||||
$(MAKE) test-up
|
||||
pnpm --dir frontend test:e2e
|
||||
@. scripts/test-env.sh; cd frontend && pnpm test:e2e
|
||||
|
||||
@@ -14,7 +14,7 @@ Demo credentials: `admin@tutortool.com` / `admin`
|
||||
|
||||
## 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)
|
||||
- **Build**: Vite + Cargo; 3-stage Docker build for production
|
||||
|
||||
@@ -31,4 +31,4 @@ Demo credentials: `admin@tutortool.com` / `admin`
|
||||
|
||||
## 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,18 +1,20 @@
|
||||
[package]
|
||||
name = "attendance"
|
||||
name = "tutortool"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95.0"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["macros", "multipart"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
||||
bcrypt = "0.19"
|
||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "cors", "trace"] }
|
||||
tower_governor = "0.6"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rand = "0.9"
|
||||
thiserror = "2"
|
||||
@@ -23,3 +25,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
bytes = "1"
|
||||
temp-env = "0.3"
|
||||
serial_test = "3.1"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use crate::{AppState, error::AppError};
|
||||
use axum::{RequestPartsExt, extract::FromRef, extract::FromRequestParts, http::request::Parts};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::error::AppError;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TutorClaims {
|
||||
@@ -11,14 +12,12 @@ pub struct TutorClaims {
|
||||
pub exp: u64,
|
||||
}
|
||||
|
||||
fn secret() -> Result<String, AppError> {
|
||||
std::env::var("JWT_SECRET").map_err(|_| {
|
||||
tracing::error!("JWT_SECRET environment variable is not set");
|
||||
AppError::Unauthorized
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_jwt(id: i64, email: &str, is_superadmin: bool) -> Result<String, AppError> {
|
||||
pub fn encode_jwt(
|
||||
id: i64,
|
||||
email: &str,
|
||||
is_superadmin: bool,
|
||||
secret: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let exp = (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as u64;
|
||||
let claims = TutorClaims {
|
||||
sub: id,
|
||||
@@ -29,49 +28,74 @@ pub fn encode_jwt(id: i64, email: &str, is_superadmin: bool) -> Result<String, A
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret()?.as_bytes()),
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|_| AppError::Unauthorized)
|
||||
}
|
||||
|
||||
pub fn decode_jwt(token: &str) -> Result<TutorClaims, AppError> {
|
||||
decode::<TutorClaims>(token, &DecodingKey::from_secret(secret()?.as_bytes()),
|
||||
&Validation::default())
|
||||
.map(|d| d.claims)
|
||||
.map_err(|e| {
|
||||
tracing::debug!(error = %e, "JWT decode failed");
|
||||
AppError::Unauthorized
|
||||
})
|
||||
pub fn decode_jwt(token: &str, secret: &str) -> Result<TutorClaims, AppError> {
|
||||
decode::<TutorClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map(|d| d.claims)
|
||||
.map_err(|e| {
|
||||
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 {
|
||||
// Axum extractor: pulls JWT from httpOnly cookie or Authorization: Bearer header
|
||||
impl<S> FromRequestParts<S> for TutorClaims
|
||||
where
|
||||
S: Send + Sync,
|
||||
AppState: axum::extract::FromRef<S>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
|
||||
let header = parts.headers.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
decode_jwt(header)
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = AppState::from_ref(state);
|
||||
|
||||
// Try cookie first
|
||||
let jar = parts
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn jwt_roundtrip_and_rejection() {
|
||||
// Set var inside the test; still unsafe in edition 2024
|
||||
unsafe { std::env::set_var("JWT_SECRET", "testsecret_auth"); }
|
||||
temp_env::with_var("JWT_SECRET", Some("testsecret_auth"), || {
|
||||
let secret = "testsecret_auth";
|
||||
// roundtrip
|
||||
let token = encode_jwt(1, "test@example.com", true, secret).unwrap();
|
||||
let claims = decode_jwt(&token, secret).unwrap();
|
||||
assert_eq!(claims.sub, 1);
|
||||
assert!(claims.is_superadmin);
|
||||
|
||||
// roundtrip
|
||||
let token = encode_jwt(1, "test@example.com", true).unwrap();
|
||||
let claims = decode_jwt(&token).unwrap();
|
||||
assert_eq!(claims.sub, 1);
|
||||
assert!(claims.is_superadmin);
|
||||
|
||||
// rejection
|
||||
assert!(decode_jwt("not.a.token").is_err());
|
||||
// rejection
|
||||
assert!(decode_jwt("not.a.token", secret).is_err());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
use std::str::FromStr;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
|
||||
pub async fn init() -> Result<SqlitePool, sqlx::Error> {
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite:/data/attendance.db".into());
|
||||
let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:/data/attendance.db".into());
|
||||
let opts = SqliteConnectOptions::from_str(&url)?
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
.foreign_keys(true)
|
||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
|
||||
let pool = SqlitePoolOptions::new().connect_with(opts).await?;
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -23,21 +34,39 @@ mod tests {
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn foreign_keys_enforced(pool: SqlitePool) {
|
||||
// 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(
|
||||
"INSERT INTO students (course_id, name) VALUES (999, 'Ghost')"
|
||||
).execute(&pool).await;
|
||||
assert!(err.is_err(), "FK violation should be rejected when foreign_keys = ON");
|
||||
let err = sqlx::query("INSERT INTO students (course_id, name) VALUES (999, 'Ghost')")
|
||||
.execute(&pool)
|
||||
.await;
|
||||
assert!(
|
||||
err.is_err(),
|
||||
"FK violation should be rejected when foreign_keys = ON"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn all_tables_exist(pool: SqlitePool) {
|
||||
for table in &["courses","tutors","tutor_courses","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();
|
||||
for table in &[
|
||||
"courses",
|
||||
"tutors",
|
||||
"tutor_courses",
|
||||
"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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 thiserror::Error;
|
||||
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
mod auth;
|
||||
mod db;
|
||||
mod error;
|
||||
mod models;
|
||||
mod auth;
|
||||
mod routes;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_helpers;
|
||||
|
||||
use axum::routing::get;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use std::net::SocketAddr;
|
||||
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]
|
||||
async fn main() {
|
||||
@@ -17,35 +31,49 @@ async fn main() {
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.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");
|
||||
#[cfg(not(debug_assertions))]
|
||||
let test_mode = false;
|
||||
|
||||
if test_mode {
|
||||
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");
|
||||
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();
|
||||
tracing::warn!("TT_TEST_MODE active — /__test__/reset is enabled");
|
||||
}
|
||||
|
||||
let pool = db::init().await.expect("db init failed");
|
||||
db::maybe_seed_demo(&pool).await;
|
||||
|
||||
let static_dir = std::env::var("STATIC_DIR")
|
||||
.unwrap_or_else(|_| "../frontend/build".into());
|
||||
let state = AppState {
|
||||
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" }))
|
||||
.fallback_service(
|
||||
ServeDir::new(&static_dir)
|
||||
.fallback(ServeFile::new(format!("{static_dir}/index.html")))
|
||||
);
|
||||
ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{static_dir}/index.html"))),
|
||||
)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http());
|
||||
|
||||
let port = std::env::var("PORT")
|
||||
.unwrap_or_else(|_| "3000".into());
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
|
||||
let addr: SocketAddr = format!("0.0.0.0:{port}").parse().expect("invalid address");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.expect("failed to bind");
|
||||
tracing::info!("listening on :{}", port);
|
||||
axum::serve(listener, app).await.expect("server error");
|
||||
tracing::info!("listening on {}", addr);
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.expect("failed to serve");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
// --- DB rows ---
|
||||
#[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)]
|
||||
pub struct Tutor {
|
||||
@@ -13,66 +17,121 @@ pub struct Tutor {
|
||||
}
|
||||
|
||||
#[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)]
|
||||
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)]
|
||||
pub struct Session {
|
||||
pub id: i64, pub course_id: i64,
|
||||
pub week_nr: i64, pub date: String,
|
||||
pub id: i64,
|
||||
pub course_id: i64,
|
||||
pub week_nr: i64,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct Slot {
|
||||
pub id: i64, pub session_id: i64,
|
||||
pub room_id: Option<i64>, pub tutor_id: i64,
|
||||
pub start_time: String, pub end_time: String,
|
||||
pub status: String, pub code: Option<String>,
|
||||
pub id: i64,
|
||||
pub session_id: i64,
|
||||
pub room_id: Option<i64>,
|
||||
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)]
|
||||
pub struct Attendance {
|
||||
pub id: i64, pub slot_id: i64, pub student_id: i64,
|
||||
pub seat_id: Option<String>, pub checked_in_at: String,
|
||||
pub id: i64,
|
||||
pub slot_id: i64,
|
||||
pub student_id: i64,
|
||||
pub seat_id: Option<String>,
|
||||
pub checked_in_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct Note {
|
||||
pub id: i64, pub slot_id: i64, pub student_id: i64,
|
||||
pub tutor_id: i64, pub content: String, pub updated_at: String,
|
||||
pub id: i64,
|
||||
pub slot_id: i64,
|
||||
pub student_id: i64,
|
||||
pub tutor_id: i64,
|
||||
pub content: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
// --- Layout element (nested in Room) ---
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LayoutElement {
|
||||
pub id: String, pub label: String,
|
||||
pub x: f64, pub y: f64,
|
||||
pub width: f64, pub height: f64,
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String, // "seat" | "table" | "gap" | "door"
|
||||
pub kind: String, // "seat" | "table" | "gap" | "door"
|
||||
}
|
||||
|
||||
// --- Request types ---
|
||||
#[derive(Deserialize)] pub struct CreateCourse { pub name: String, pub semester: String }
|
||||
#[derive(Deserialize)] pub struct CreateStudent { 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 CreateCourse {
|
||||
pub name: String,
|
||||
pub semester: String,
|
||||
}
|
||||
#[derive(Deserialize)] pub struct UpsertNote { pub content: String }
|
||||
#[derive(Deserialize)] pub struct ManualAttendance { pub student_id: i64 }
|
||||
#[derive(Deserialize)] pub struct CreateTutor {
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateStudent {
|
||||
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 email: String,
|
||||
pub password: String,
|
||||
pub is_superadmin: bool,
|
||||
}
|
||||
#[derive(Deserialize)] pub struct AssignTutor { pub tutor_id: i64 }
|
||||
#[derive(Deserialize)] pub struct CheckinRequest {
|
||||
#[derive(Deserialize)]
|
||||
pub struct AssignTutor {
|
||||
pub tutor_id: i64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckinRequest {
|
||||
pub code: String,
|
||||
pub student_id: i64,
|
||||
pub seat_id: Option<String>,
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
use crate::{AppState, auth::TutorClaims, error::AppError, models::ManualAttendance};
|
||||
use axum::{
|
||||
extract::{Path, State, Query},
|
||||
routing::{get, post, delete},
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
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(
|
||||
State(pool): State<SqlitePool>,
|
||||
@@ -25,7 +14,7 @@ async fn create_attendance(
|
||||
) -> Result<(), AppError> {
|
||||
// Verify tutor access to the course via slot -> session -> course
|
||||
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)
|
||||
.fetch_one(&pool)
|
||||
@@ -53,7 +42,7 @@ async fn delete_attendance(
|
||||
Path((slot_id, student_id)): Path<(i64, i64)>,
|
||||
) -> Result<(), AppError> {
|
||||
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)
|
||||
.fetch_one(&pool)
|
||||
@@ -84,7 +73,7 @@ async fn get_session_attendance(
|
||||
|
||||
// Get all students for the course
|
||||
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)
|
||||
.fetch_all(&pool)
|
||||
@@ -135,7 +124,7 @@ async fn get_student_attendance(
|
||||
JOIN sessions s ON sl.session_id = s.id
|
||||
WHERE a.student_id = ?
|
||||
ORDER BY s.week_nr DESC, sl.start_time DESC
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.bind(student_id)
|
||||
.fetch_all(&pool)
|
||||
@@ -156,30 +145,54 @@ async fn get_student_attendance(
|
||||
Ok(Json(serde_json::json!(attendances)))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/admin/slots/{id}/attendance", post(create_attendance))
|
||||
.route("/api/admin/slots/{slot_id}/attendance/{student_id}", delete(delete_attendance))
|
||||
.route("/api/admin/sessions/{id}/attendance", get(get_session_attendance))
|
||||
.route("/api/admin/students/{id}/attendance", get(get_student_attendance))
|
||||
.route(
|
||||
"/api/admin/slots/{slot_id}/attendance/{student_id}",
|
||||
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)]
|
||||
mod tests {
|
||||
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 serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) {
|
||||
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.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 (?, ?)")
|
||||
.bind(tutor.0).bind(course_id.0).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();
|
||||
.bind(tutor.0)
|
||||
.bind(course_id.0)
|
||||
.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")
|
||||
.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")
|
||||
@@ -192,10 +205,21 @@ mod tests {
|
||||
let (app, auth) = build_test_app(pool.clone()).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);
|
||||
|
||||
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);
|
||||
let attendances: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(attendances.as_array().unwrap().len(), 1);
|
||||
@@ -206,9 +230,20 @@ mod tests {
|
||||
let (app, auth) = build_test_app(pool.clone()).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);
|
||||
let res: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(res["attendances"].as_array().unwrap().len(), 1);
|
||||
|
||||
@@ -1,64 +1,156 @@
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
use crate::{AppState, auth, error::AppError};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
use serde_json::{json, Value};
|
||||
use crate::{auth, error::AppError};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginRequest { email: String, password: String }
|
||||
struct LoginRequest {
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(pool): State<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
) -> 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(&pool).await?;
|
||||
"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)?;
|
||||
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})))
|
||||
|
||||
let token = auth::encode_jwt(id, &email, is_superadmin, &state.jwt_secret)?;
|
||||
|
||||
let cookie = Cookie::build(("token", token.clone()))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.secure(!state.test_mode)
|
||||
.build();
|
||||
Ok((
|
||||
jar.add(cookie),
|
||||
Json(json!({"is_superadmin": is_superadmin})),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
Router::new().route("/api/auth/login", post(login))
|
||||
async fn me(auth: auth::TutorClaims) -> impl axum::response::IntoResponse {
|
||||
(
|
||||
[("Cache-Control", "no-store")],
|
||||
Json(json!({
|
||||
"id": auth.sub,
|
||||
"email": auth.email,
|
||||
"is_superadmin": auth.is_superadmin
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn logout(jar: CookieJar) -> CookieJar {
|
||||
jar.remove(Cookie::from("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/me", get(me))
|
||||
.route("/api/auth/logout", post(logout))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{build_test_app, post_json};
|
||||
use crate::test_helpers::post_json;
|
||||
use serde_json::json;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn login_returns_token(pool: SqlitePool) {
|
||||
async fn login_returns_superadmin_and_cookie(pool: sqlx::SqlitePool) {
|
||||
let hash = bcrypt::hash("secret", 4).unwrap();
|
||||
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
|
||||
.bind("Test").bind("t@test.com").bind(&hash)
|
||||
.execute(&pool).await.unwrap();
|
||||
.bind("Test")
|
||||
.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);
|
||||
let res = serde_json::from_slice::<Value>(&body).unwrap();
|
||||
assert!(res["token"].is_string());
|
||||
assert_eq!(res["is_superadmin"], false);
|
||||
|
||||
// Check Set-Cookie header
|
||||
let cookie = headers.get("set-cookie").unwrap().to_str().unwrap();
|
||||
assert!(cookie.contains("token="));
|
||||
assert!(cookie.contains("HttpOnly"));
|
||||
assert!(cookie.contains("SameSite=Strict"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn login_wrong_password(pool: SqlitePool) {
|
||||
async fn login_wrong_password(pool: sqlx::SqlitePool) {
|
||||
let hash = bcrypt::hash("correct", 4).unwrap();
|
||||
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
|
||||
.bind("Test").bind("t@test.com").bind(&hash)
|
||||
.execute(&pool).await.unwrap();
|
||||
.bind("Test")
|
||||
.bind("t@test.com")
|
||||
.bind(&hash)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = crate::routes::build(pool, false);
|
||||
let (status, _) = post_json(app, "/api/auth/login", "",
|
||||
json!({"email":"t@test.com","password":"wrong"})).await;
|
||||
let state = AppState {
|
||||
pool: pool.clone(),
|
||||
jwt_secret: "testsecret".into(),
|
||||
test_mode: true,
|
||||
};
|
||||
let app = crate::routes::build(state, true);
|
||||
let (status, _) = post_json(
|
||||
app,
|
||||
"/api/auth/login",
|
||||
"",
|
||||
json!({"email":"t@test.com","password":"wrong"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 401);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::SqlitePool;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
error::AppError,
|
||||
models::{Attendance, LayoutElement, Room, Slot, Student},
|
||||
};
|
||||
@@ -17,10 +17,8 @@ use crate::{
|
||||
fn parse_cookie(cookie_header: &str, key: &str) -> Option<String> {
|
||||
for pair in cookie_header.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(rest) = pair.strip_prefix(key) {
|
||||
if rest.starts_with('=') {
|
||||
return Some(rest[1..].to_string());
|
||||
}
|
||||
if let Some(value) = pair.strip_prefix(key).and_then(|r| r.strip_prefix("=")) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -32,7 +30,7 @@ fn url_decode_minimal(s: &str) -> String {
|
||||
}
|
||||
|
||||
async fn get_checkin_info(
|
||||
State(pool): State<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
Path(code): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
@@ -42,7 +40,7 @@ async fn get_checkin_info(
|
||||
FROM slots WHERE code = ?",
|
||||
)
|
||||
.bind(&code)
|
||||
.fetch_optional(&pool)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
@@ -53,13 +51,14 @@ async fn get_checkin_info(
|
||||
|
||||
// Load layout if room is set
|
||||
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 = ?")
|
||||
.bind(room_id)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
let room =
|
||||
sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
|
||||
.bind(room_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
if let Some(r) = room {
|
||||
let elements: Vec<LayoutElement> = serde_json::from_str(&r.layout_json)
|
||||
.unwrap_or_default();
|
||||
let elements: Vec<LayoutElement> =
|
||||
serde_json::from_str(&r.layout_json).unwrap_or_default();
|
||||
Some(elements)
|
||||
} else {
|
||||
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 = ?",
|
||||
)
|
||||
.bind(slot.id)
|
||||
.fetch_all(&pool)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Parse identity cookie to determine which attendance is "mine"
|
||||
@@ -123,7 +122,7 @@ async fn get_checkin_info(
|
||||
}
|
||||
|
||||
async fn get_checkin_students(
|
||||
State(pool): State<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
Path(code): Path<String>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
// Look up slot by code
|
||||
@@ -132,7 +131,7 @@ async fn get_checkin_students(
|
||||
FROM slots WHERE code = ?",
|
||||
)
|
||||
.bind(&code)
|
||||
.fetch_optional(&pool)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
@@ -141,25 +140,24 @@ async fn get_checkin_students(
|
||||
}
|
||||
|
||||
// Get course_id from the session
|
||||
let (course_id,): (i64,) =
|
||||
sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
||||
.bind(slot.session_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
let (course_id,): (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
||||
.bind(slot.session_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Return only students enrolled in that course
|
||||
let students = sqlx::query_as::<_, Student>(
|
||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!(students)))
|
||||
}
|
||||
|
||||
async fn post_checkin(
|
||||
State(pool): State<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<crate::models::CheckinRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -169,7 +167,7 @@ async fn post_checkin(
|
||||
FROM slots WHERE code = ?",
|
||||
)
|
||||
.bind(&req.code)
|
||||
.fetch_optional(&pool)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
@@ -184,22 +182,23 @@ async fn post_checkin(
|
||||
// seat_id / room_id cross-validation
|
||||
match (slot.room_id, req.seat_id.as_ref()) {
|
||||
(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) => {
|
||||
return Err(AppError::BadRequest("seat required".into()));
|
||||
}
|
||||
(Some(room_id), Some(seat_id)) => {
|
||||
let room = sqlx::query_as::<_, Room>(
|
||||
"SELECT id, name, layout_json FROM rooms WHERE id = ?",
|
||||
)
|
||||
.bind(room_id)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
let room =
|
||||
sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
|
||||
.bind(room_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let room_row = room.ok_or(AppError::NotFound)?;
|
||||
let elements: Vec<LayoutElement> = serde_json::from_str(&room_row.layout_json)
|
||||
.unwrap_or_default();
|
||||
let elements: Vec<LayoutElement> =
|
||||
serde_json::from_str(&room_row.layout_json).unwrap_or_default();
|
||||
let valid = elements
|
||||
.iter()
|
||||
.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") {
|
||||
let decoded = url_decode_minimal(&raw);
|
||||
if let Ok(identity) = serde_json::from_str::<Value>(&decoded) {
|
||||
if identity["code"].as_str() == Some(&req.code) {
|
||||
// Same slot — verify student_id matches
|
||||
if let Some(cookie_student_id) = identity["student_id"].as_i64() {
|
||||
if cookie_student_id != req.student_id {
|
||||
return Err(AppError::Conflict("identity mismatch".into()));
|
||||
}
|
||||
}
|
||||
if identity["code"].as_str() == Some(&req.code)
|
||||
&& identity["student_id"].as_i64() == Some(req.student_id)
|
||||
{
|
||||
// Identity matches
|
||||
} 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
|
||||
let mut tx = pool.begin().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?")
|
||||
.bind(slot.id)
|
||||
@@ -267,19 +265,22 @@ async fn post_checkin(
|
||||
.expect("serializing static json shape is infallible")
|
||||
.replace('"', "%22");
|
||||
|
||||
let secure = if state.test_mode { "" } else { " Secure;" };
|
||||
let cookie_val = format!(
|
||||
"attendance_identity={}; HttpOnly; SameSite=Strict; Max-Age=86400; Path=/",
|
||||
identity_json
|
||||
"attendance_identity={}; HttpOnly; SameSite=Strict;{} Max-Age=86400; Path=/",
|
||||
identity_json, secure
|
||||
);
|
||||
|
||||
let header_val = axum::http::HeaderValue::from_str(&cookie_val)
|
||||
.map_err(|_| AppError::BadRequest("invalid cookie value".into()))?;
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/checkin/{code}", get(get_checkin_info))
|
||||
.route("/api/checkin/{code}/students", get(get_checkin_students))
|
||||
@@ -288,10 +289,9 @@ pub fn router() -> Router<SqlitePool> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{build_test_admin_app, build_test_app, get, patch_json, post_json};
|
||||
use crate::test_helpers::{build_test_admin_app, get, patch_json, post_json};
|
||||
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).
|
||||
/// Returns (app, auth, code, slot_id, course_id, tutor_id).
|
||||
@@ -641,7 +641,13 @@ mod tests {
|
||||
.map(|s| s["id"].as_i64().unwrap())
|
||||
.collect();
|
||||
|
||||
assert!(ids.contains(&student_a), "course A student should be present");
|
||||
assert!(!ids.contains(&student_b), "course B student must not appear");
|
||||
assert!(
|
||||
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::{
|
||||
Json, Router,
|
||||
extract::{Multipart, Path, State},
|
||||
http::StatusCode,
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::TutorClaims,
|
||||
error::AppError,
|
||||
models::{Course, CreateCourse, CreateStudent, Student},
|
||||
@@ -26,7 +27,7 @@ async fn list_courses(
|
||||
sqlx::query_as::<_, Course>(
|
||||
"SELECT c.id, c.name, c.semester FROM courses c
|
||||
JOIN tutor_courses tc ON tc.course_id = c.id
|
||||
WHERE tc.tutor_id = ?"
|
||||
WHERE tc.tutor_id = ?",
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.fetch_all(&pool)
|
||||
@@ -93,7 +94,7 @@ async fn list_assigned_tutors(
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(course_id): Path<i64>,
|
||||
) -> Result<Json<Vec<crate::models::Tutor>>, AppError> {
|
||||
// Only superadmins or assigned tutors can see who else is assigned?
|
||||
// Only superadmins or assigned tutors can see who else is assigned?
|
||||
// Let's allow superadmins or anyone assigned to the course.
|
||||
if !claims.is_superadmin {
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
||||
@@ -102,7 +103,7 @@ async fn list_assigned_tutors(
|
||||
let tutors = sqlx::query_as::<_, crate::models::Tutor>(
|
||||
"SELECT t.id, t.name, t.email, t.is_superadmin FROM tutors t
|
||||
JOIN tutor_courses tc ON tc.tutor_id = t.id
|
||||
WHERE tc.course_id = ?"
|
||||
WHERE tc.course_id = ?",
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
@@ -158,12 +159,15 @@ async fn import_students(
|
||||
|
||||
let mut count = 0i64;
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| {
|
||||
AppError::BadRequest(format!("multipart error: {e}"))
|
||||
})? {
|
||||
let text = field.text().await.map_err(|e| {
|
||||
AppError::BadRequest(format!("field read error: {e}"))
|
||||
})?;
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("multipart error: {e}")))?
|
||||
{
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("field read error: {e}")))?;
|
||||
|
||||
// Fix 4: body size check
|
||||
if text.len() > 100_000 {
|
||||
@@ -175,7 +179,9 @@ async fn import_students(
|
||||
// Fix 4: validate header row
|
||||
let header = lines.next().unwrap_or("").trim();
|
||||
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
|
||||
@@ -196,7 +202,11 @@ async fn import_students(
|
||||
}
|
||||
|
||||
// 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}))))
|
||||
}
|
||||
|
||||
@@ -207,12 +217,10 @@ async fn delete_student(
|
||||
Path(student_id): Path<i64>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// Fetch the student's course_id first
|
||||
let row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT course_id FROM students WHERE id = ?"
|
||||
)
|
||||
.bind(student_id)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
let row: Option<(i64,)> = sqlx::query_as("SELECT course_id FROM students WHERE id = ?")
|
||||
.bind(student_id)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
|
||||
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?;
|
||||
|
||||
// Check for attendance records
|
||||
let (att_count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM attendances WHERE student_id = ?"
|
||||
)
|
||||
.bind(student_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
let (att_count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM attendances WHERE student_id = ?")
|
||||
.bind(student_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
if att_count > 0 {
|
||||
return Err(AppError::Conflict("student has attendance records".into()));
|
||||
@@ -239,33 +246,42 @@ async fn delete_student(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/admin/courses", get(list_courses).post(create_course))
|
||||
.route(
|
||||
"/api/admin/courses/{id}/students",
|
||||
get(list_students).post(add_student),
|
||||
)
|
||||
.route("/api/admin/courses/{id}/students/import", 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/courses/{id}/students/import",
|
||||
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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, post_json};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
// Fix 6: helper to seed tutor_courses membership
|
||||
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'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let tutor_id: (i64,) =
|
||||
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
||||
.bind(tutor_id.0)
|
||||
.bind(course_id)
|
||||
@@ -294,12 +310,14 @@ mod tests {
|
||||
|
||||
let (status, body) = get(app, "/api/admin/courses", &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(serde_json::from_slice::<Value>(&body)
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["id"] == id));
|
||||
assert!(
|
||||
serde_json::from_slice::<Value>(&body)
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["id"] == id)
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
@@ -322,8 +340,7 @@ mod tests {
|
||||
|
||||
// Add student
|
||||
let path = format!("/api/admin/courses/{course_id}/students");
|
||||
let (status, body) =
|
||||
post_json(app.clone(), &path, &auth, json!({"name":"Alice"})).await;
|
||||
let (status, body) = post_json(app.clone(), &path, &auth, json!({"name":"Alice"})).await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let student_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
@@ -332,12 +349,14 @@ mod tests {
|
||||
// List students
|
||||
let (status, body) = get(app, &path, &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(serde_json::from_slice::<Value>(&body)
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|s| s["id"] == student_id));
|
||||
assert!(
|
||||
serde_json::from_slice::<Value>(&body)
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|s| s["id"] == student_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::{AppState, auth::TutorClaims, error::AppError};
|
||||
use axum::http::header;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum::http::{header, StatusCode};
|
||||
use sqlx::SqlitePool;
|
||||
use crate::{auth::TutorClaims, error::AppError};
|
||||
use std::fmt::Write;
|
||||
|
||||
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?;
|
||||
|
||||
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)
|
||||
.fetch_all(&pool)
|
||||
@@ -32,13 +32,14 @@ async fn export_session_csv(
|
||||
r#"
|
||||
SELECT student_id FROM attendances
|
||||
WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(&pool)
|
||||
.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");
|
||||
for student in students {
|
||||
@@ -46,10 +47,7 @@ async fn export_session_csv(
|
||||
writeln!(csv, "{},{}", student.name, present).unwrap();
|
||||
}
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "text/csv")],
|
||||
csv
|
||||
).into_response())
|
||||
Ok(([(header::CONTENT_TYPE, "text/csv")], csv).into_response())
|
||||
}
|
||||
|
||||
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?;
|
||||
|
||||
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)
|
||||
.fetch_all(&pool)
|
||||
@@ -75,24 +73,26 @@ async fn export_session_md(
|
||||
r#"
|
||||
SELECT student_id FROM attendances
|
||||
WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(&pool)
|
||||
.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");
|
||||
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();
|
||||
}
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "text/markdown")],
|
||||
md
|
||||
).into_response())
|
||||
Ok(([(header::CONTENT_TYPE, "text/markdown")], md).into_response())
|
||||
}
|
||||
|
||||
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?;
|
||||
|
||||
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)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
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)
|
||||
.fetch_all(&pool)
|
||||
@@ -130,7 +130,7 @@ async fn export_course_csv(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM attendances
|
||||
WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.bind(student.id)
|
||||
.bind(session.id)
|
||||
@@ -148,10 +148,7 @@ async fn export_course_csv(
|
||||
writeln!(csv, ",{}", bonus).unwrap();
|
||||
}
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "text/csv")],
|
||||
csv
|
||||
).into_response())
|
||||
Ok(([(header::CONTENT_TYPE, "text/csv")], csv).into_response())
|
||||
}
|
||||
|
||||
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?;
|
||||
|
||||
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)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
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)
|
||||
.fetch_all(&pool)
|
||||
@@ -193,7 +190,7 @@ async fn export_course_md(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM attendances
|
||||
WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.bind(student.id)
|
||||
.bind(session.id)
|
||||
@@ -211,10 +208,7 @@ async fn export_course_md(
|
||||
writeln!(md, " {} |", bonus).unwrap();
|
||||
}
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "text/markdown")],
|
||||
md
|
||||
).into_response())
|
||||
Ok(([(header::CONTENT_TYPE, "text/markdown")], md).into_response())
|
||||
}
|
||||
|
||||
async fn download_backup(
|
||||
@@ -238,15 +232,22 @@ async fn download_backup(
|
||||
Ok((
|
||||
[
|
||||
(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
|
||||
).into_response())
|
||||
data,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
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/course/{id}/csv", get(export_course_csv))
|
||||
.route("/api/admin/export/course/{id}/md", get(export_course_md))
|
||||
@@ -261,18 +262,33 @@ mod tests {
|
||||
|
||||
async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) {
|
||||
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.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 (?, ?)")
|
||||
.bind(tutor.0).bind(course_id.0).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();
|
||||
.bind(tutor.0)
|
||||
.bind(course_id.0)
|
||||
.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")
|
||||
.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")
|
||||
.bind(session_id.0).bind(tutor.0).fetch_one(pool).await.unwrap();
|
||||
|
||||
|
||||
// Mark present
|
||||
sqlx::query("INSERT INTO attendances (slot_id, student_id, checked_in_at) VALUES (?, ?, '2026-04-28T09:00:00Z')")
|
||||
.bind(slot_id.0).bind(student_id.0).execute(pool).await.unwrap();
|
||||
@@ -285,7 +301,12 @@ mod tests {
|
||||
let (app, auth) = build_test_app(pool.clone()).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);
|
||||
let csv = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(csv.contains("Student,Present"));
|
||||
@@ -297,7 +318,12 @@ mod tests {
|
||||
let (app, auth) = build_test_app(pool.clone()).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);
|
||||
let csv = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(csv.contains("Student,Week 1,Bonus"));
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
use crate::AppState;
|
||||
use axum::Router;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
mod attendance;
|
||||
mod auth_routes;
|
||||
mod checkin;
|
||||
mod courses;
|
||||
mod export;
|
||||
mod notes;
|
||||
mod rooms;
|
||||
mod sessions;
|
||||
mod attendance;
|
||||
mod notes;
|
||||
mod export;
|
||||
mod tutors;
|
||||
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()
|
||||
.merge(auth_routes::router())
|
||||
.merge(auth_routes::router(test_mode))
|
||||
.merge(checkin::router())
|
||||
.merge(courses::router())
|
||||
.merge(rooms::router())
|
||||
@@ -26,11 +27,12 @@ pub fn build(pool: SqlitePool, test_mode: bool) -> Router {
|
||||
.merge(export::router())
|
||||
.merge(tutors::router());
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if test_mode {
|
||||
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.
|
||||
@@ -40,12 +42,11 @@ pub async fn verify_tutor_course_access(
|
||||
tutor_id: i64,
|
||||
course_id: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?"
|
||||
)
|
||||
.bind(tutor_id)
|
||||
.bind(course_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let row: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?")
|
||||
.bind(tutor_id)
|
||||
.bind(course_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(|_| ()).ok_or(AppError::Unauthorized)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::{AppState, auth::TutorClaims, error::AppError, models::UpsertNote};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use crate::{auth::TutorClaims, error::AppError, models::UpsertNote};
|
||||
|
||||
async fn upsert_note(
|
||||
State(pool): State<SqlitePool>,
|
||||
@@ -13,7 +13,7 @@ async fn upsert_note(
|
||||
Json(req): Json<UpsertNote>,
|
||||
) -> Result<(), AppError> {
|
||||
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)
|
||||
.fetch_one(&pool)
|
||||
@@ -30,7 +30,7 @@ async fn upsert_note(
|
||||
ON CONFLICT(slot_id, student_id, tutor_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
updated_at = excluded.updated_at
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.bind(slot_id)
|
||||
.bind(student_id)
|
||||
@@ -49,7 +49,7 @@ async fn get_slot_notes(
|
||||
Path(slot_id): Path<i64>,
|
||||
) -> Result<Json<Vec<crate::models::Note>>, AppError> {
|
||||
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)
|
||||
.fetch_one(&pool)
|
||||
@@ -89,9 +89,12 @@ async fn get_student_notes(
|
||||
Ok(Json(notes))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
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/students/{id}/notes", get(get_student_notes))
|
||||
}
|
||||
@@ -99,19 +102,34 @@ pub fn router() -> Router<SqlitePool> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn seed_data(pool: &SqlitePool) -> (i64, i64) {
|
||||
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.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 (?, ?)")
|
||||
.bind(tutor.0).bind(course_id.0).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();
|
||||
.bind(tutor.0)
|
||||
.bind(course_id.0)
|
||||
.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")
|
||||
.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")
|
||||
@@ -124,17 +142,34 @@ mod tests {
|
||||
let (app, auth) = build_test_app(pool.clone()).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);
|
||||
|
||||
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);
|
||||
let notes: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(notes.as_array().unwrap().len(), 1);
|
||||
assert_eq!(notes[0]["content"], "Good student");
|
||||
|
||||
// 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 notes: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(notes[0]["content"], "Excellent student");
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
routing::{get, put},
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::TutorClaims,
|
||||
error::AppError,
|
||||
models::{CreateRoom, LayoutElement, Room},
|
||||
@@ -16,7 +17,9 @@ use crate::{
|
||||
|
||||
fn validate_layout(layout: &[LayoutElement]) -> Result<(), AppError> {
|
||||
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"];
|
||||
@@ -93,7 +96,10 @@ async fn create_room(
|
||||
.execute(&pool)
|
||||
.await?
|
||||
.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(
|
||||
@@ -106,10 +112,11 @@ async fn get_room(
|
||||
.fetch_optional(&pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let layout: Vec<LayoutElement> = serde_json::from_str(&row.layout_json).map_err(|e| {
|
||||
AppError::BadRequest(format!("layout parse error: {e}"))
|
||||
})?;
|
||||
Ok(Json(json!({"id": row.id, "name": row.name, "layout": layout})))
|
||||
let layout: Vec<LayoutElement> = serde_json::from_str(&row.layout_json)
|
||||
.map_err(|e| AppError::BadRequest(format!("layout parse error: {e}")))?;
|
||||
Ok(Json(
|
||||
json!({"id": row.id, "name": row.name, "layout": layout}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_room_layout(
|
||||
@@ -136,7 +143,7 @@ async fn update_room_layout(
|
||||
Ok(Json(json!({"id": id})))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/admin/rooms", get(list_rooms).post(create_room))
|
||||
.route("/api/admin/rooms/{id}", get(get_room))
|
||||
@@ -145,10 +152,9 @@ pub fn router() -> Router<SqlitePool> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{build_test_app, get, post_json, put_json};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_room_with_layout(pool: sqlx::SqlitePool) {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
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::{
|
||||
AppState,
|
||||
auth::TutorClaims,
|
||||
error::AppError,
|
||||
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 {
|
||||
const CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
@@ -76,15 +76,13 @@ async fn create_session(
|
||||
|
||||
super::verify_tutor_course_access(&pool, claims.sub, req.course_id).await?;
|
||||
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO sessions (course_id, week_nr, date) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(req.course_id)
|
||||
.bind(req.week_nr)
|
||||
.bind(&req.date)
|
||||
.execute(&pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
let id = sqlx::query("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, ?, ?)")
|
||||
.bind(req.course_id)
|
||||
.bind(req.week_nr)
|
||||
.bind(&req.date)
|
||||
.execute(&pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({"id": id}))))
|
||||
}
|
||||
@@ -101,24 +99,22 @@ async fn create_slot(
|
||||
}
|
||||
|
||||
// Look up the session to get course_id
|
||||
let session_row: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
||||
.bind(req.session_id)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
let session_row: Option<(i64,)> = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
||||
.bind(req.session_id)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
let (course_id,) = session_row.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Verify requesting tutor has access to the course
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
||||
|
||||
// Verify the slot's tutor_id belongs to this course
|
||||
let member: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?",
|
||||
)
|
||||
.bind(req.tutor_id)
|
||||
.bind(course_id)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
let member: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?")
|
||||
.bind(req.tutor_id)
|
||||
.bind(course_id)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
if member.is_none() {
|
||||
return Err(AppError::BadRequest(
|
||||
"tutor_id is not a member of this course".into(),
|
||||
@@ -194,19 +190,19 @@ async fn update_slot_status(
|
||||
let mut generated = None;
|
||||
for _ in 0..5 {
|
||||
let candidate = generate_code();
|
||||
let conflict: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT 1 FROM slots WHERE code = ?")
|
||||
.bind(&candidate)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
let conflict: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM slots WHERE code = ?")
|
||||
.bind(&candidate)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
if conflict.is_none() {
|
||||
generated = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(generated.ok_or_else(|| {
|
||||
AppError::BadRequest("could not generate unique code".into())
|
||||
})?)
|
||||
Some(
|
||||
generated
|
||||
.ok_or_else(|| AppError::BadRequest("could not generate unique code".into()))?,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
existing_code
|
||||
@@ -273,9 +269,12 @@ async fn delete_slot(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
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/{id}/status", patch(update_slot_status))
|
||||
.route("/api/admin/slots/{id}", delete(delete_slot))
|
||||
@@ -284,9 +283,11 @@ pub fn router() -> Router<SqlitePool> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Pure unit tests (no DB)
|
||||
@@ -295,9 +296,10 @@ mod tests {
|
||||
for _ in 0..100 {
|
||||
let code = generate_code();
|
||||
assert_eq!(code.len(), 8);
|
||||
assert!(code
|
||||
.chars()
|
||||
.all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c)));
|
||||
assert!(
|
||||
code.chars()
|
||||
.all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,11 +358,13 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let sessions = serde_json::from_slice::<Value>(&body).unwrap();
|
||||
assert!(sessions
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|s| s["id"] == session_id));
|
||||
assert!(
|
||||
sessions
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|s| s["id"] == session_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
@@ -418,7 +422,8 @@ mod tests {
|
||||
&format!("/api/admin/slots/{slot_id}/status"),
|
||||
&auth,
|
||||
json!({"status": "open"}),
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let slot = serde_json::from_slice::<Value>(&body).unwrap();
|
||||
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 crate::error::AppError;
|
||||
use crate::{AppState, error::AppError};
|
||||
|
||||
// Seed SQL loaded once at startup, reused per reset call.
|
||||
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?;
|
||||
|
||||
// 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 slots").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM sessions").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM tutor_courses").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM students").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM sessions")
|
||||
.execute(&mut *tx)
|
||||
.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 tutors").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)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/__test__/reset", post(reset))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::TutorClaims,
|
||||
error::AppError,
|
||||
models::{CreateTutor, Tutor},
|
||||
};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post, delete},
|
||||
Json, Router,
|
||||
routing::{delete, get},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use crate::{auth::TutorClaims, error::AppError, models::{CreateTutor, Tutor}};
|
||||
|
||||
async fn list_tutors(
|
||||
claims: TutorClaims,
|
||||
@@ -16,7 +21,7 @@ async fn list_tutors(
|
||||
}
|
||||
|
||||
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)
|
||||
.await?;
|
||||
@@ -36,7 +41,7 @@ async fn create_tutor(
|
||||
let hash = bcrypt::hash(&req.password, 12).map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
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.email)
|
||||
@@ -79,7 +84,7 @@ async fn delete_tutor(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/admin/tutors", get(list_tutors).post(create_tutor))
|
||||
.route("/api/admin/tutors/{id}", delete(delete_tutor))
|
||||
|
||||
@@ -1,49 +1,92 @@
|
||||
// cfg(test) only — this whole module is test-only
|
||||
use sqlx::SqlitePool;
|
||||
use crate::AppState;
|
||||
use axum::Router;
|
||||
use tower::ServiceExt;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::http::{HeaderMap, Request, StatusCode};
|
||||
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.
|
||||
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();
|
||||
sqlx::query("INSERT OR IGNORE INTO tutors (name,email,password_hash,is_superadmin) VALUES (?,?,?,?)")
|
||||
.bind("Test Tutor").bind(email).bind(&hash).bind(is_superadmin)
|
||||
.execute(pool).await.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO tutors (name,email,password_hash,is_superadmin) VALUES (?,?,?,?)",
|
||||
)
|
||||
.bind("Test Tutor")
|
||||
.bind(email)
|
||||
.bind(&hash)
|
||||
.bind(is_superadmin)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Ensure the superadmin flag is correct even if it existed
|
||||
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 = ?")
|
||||
.bind(email).fetch_one(pool).await.unwrap();
|
||||
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
|
||||
crate::auth::encode_jwt(row.0, email, row.1).unwrap()
|
||||
.bind(email)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
crate::auth::encode_jwt(row.0, email, row.1, secret).unwrap()
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
|
||||
let token = make_token(&pool, "tutor@test.com", false).await;
|
||||
let app = crate::routes::build(pool, false);
|
||||
let token = make_token(&pool, "tutor@test.com", false, TEST_SECRET).await;
|
||||
let state = AppState {
|
||||
pool: pool.clone(),
|
||||
jwt_secret: TEST_SECRET.into(),
|
||||
test_mode: true,
|
||||
};
|
||||
let app = crate::routes::build(state, true);
|
||||
(app, format!("Bearer {token}"))
|
||||
}
|
||||
|
||||
/// Build the full Axum app wired with a superadmin Bearer auth header.
|
||||
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).await;
|
||||
let app = crate::routes::build(pool, false);
|
||||
let token = make_token(&pool, "admin@test.com", true, TEST_SECRET).await;
|
||||
let state = AppState {
|
||||
pool: pool.clone(),
|
||||
jwt_secret: TEST_SECRET.into(),
|
||||
test_mode: true,
|
||||
};
|
||||
let app = crate::routes::build(state, true);
|
||||
(app, format!("Bearer {token}"))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
-> (StatusCode, bytes::Bytes)
|
||||
{
|
||||
pub async fn post_json(
|
||||
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()
|
||||
.method("POST").uri(path)
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("Content-Type", "application/json");
|
||||
if !auth.is_empty() {
|
||||
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();
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let headers = res.headers().clone();
|
||||
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).
|
||||
pub async fn put_json(app: Router, uri: &str, auth: &str, body: serde_json::Value)
|
||||
-> (StatusCode, bytes::Bytes)
|
||||
{
|
||||
let mut req = Request::builder()
|
||||
pub async fn put_json(
|
||||
app: Router,
|
||||
uri: &str,
|
||||
auth: &str,
|
||||
body: serde_json::Value,
|
||||
) -> (StatusCode, bytes::Bytes) {
|
||||
let mut builder = Request::builder()
|
||||
.method("PUT")
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json");
|
||||
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()))
|
||||
.unwrap();
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
@@ -84,14 +131,14 @@ pub async fn patch_json(
|
||||
auth: &str,
|
||||
body: serde_json::Value,
|
||||
) -> (StatusCode, bytes::Bytes) {
|
||||
let mut req = Request::builder()
|
||||
let mut builder = Request::builder()
|
||||
.method("PATCH")
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json");
|
||||
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()))
|
||||
.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).
|
||||
pub async fn get(app: Router, path: &str, auth: &str) -> (StatusCode, bytes::Bytes) {
|
||||
let mut builder = Request::builder()
|
||||
.method("GET").uri(path);
|
||||
let mut builder = Request::builder().method("GET").uri(path);
|
||||
if !auth.is_empty() {
|
||||
builder = builder.header("Authorization", auth);
|
||||
}
|
||||
let req = builder
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let req = builder.body(axum::body::Body::empty()).unwrap();
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let body = res.into_body().collect().await.unwrap().to_bytes();
|
||||
|
||||
@@ -7,6 +7,7 @@ metadata:
|
||||
{{- include "tutortool.labels" . | nindent 4 }}
|
||||
spec:
|
||||
schedule: "0 3 * * *"
|
||||
successfulJobsHistoryLimit: 1
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
|
||||
@@ -7,6 +7,8 @@ metadata:
|
||||
{{- include "tutortool.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
strategy:
|
||||
type: {{ .Values.strategy.type | default "Recreate" }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "tutortool.selectorLabels" . | nindent 6 }}
|
||||
@@ -16,6 +18,10 @@ spec:
|
||||
{{- include "tutortool.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "tutortool.serviceAccountName" . }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
@@ -29,6 +35,10 @@ spec:
|
||||
value: {{ .Values.env.DATABASE_URL | quote }}
|
||||
- name: STATIC_DIR
|
||||
value: {{ .Values.env.STATIC_DIR | quote }}
|
||||
{{- range $k, $v := .Values.env.extra }}
|
||||
- name: {{ $k }}
|
||||
value: {{ $v | quote }}
|
||||
{{- end }}
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -50,8 +60,10 @@ spec:
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: false
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumes:
|
||||
|
||||
@@ -5,10 +5,17 @@ metadata:
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "tutortool.labels" . | nindent 4 }}
|
||||
{{- with .Values.httpRoute.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: itsh-gateway
|
||||
sectionName: {{ .Values.httpRoute.sectionName }}
|
||||
{{- range .Values.httpRoute.parentRefs }}
|
||||
- name: {{ .name }}
|
||||
namespace: {{ .namespace }}
|
||||
sectionName: {{ $.Values.httpRoute.sectionName }}
|
||||
{{- end }}
|
||||
hostnames:
|
||||
{{- range .Values.httpRoute.hostnames }}
|
||||
- {{ . | quote }}
|
||||
@@ -31,8 +38,11 @@ metadata:
|
||||
{{- include "tutortool.labels" . | nindent 4 }}
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: itsh-gateway
|
||||
sectionName: http-tutor-puchstein-dev
|
||||
{{- range .Values.httpRoute.parentRefs }}
|
||||
- name: {{ .name }}
|
||||
namespace: {{ .namespace }}
|
||||
sectionName: {{ $.Values.httpRoute.httpRedirectSectionName }}
|
||||
{{- end }}
|
||||
hostnames:
|
||||
{{- range .Values.httpRoute.hostnames }}
|
||||
- {{ . | quote }}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
replicaCount: 1
|
||||
|
||||
strategy:
|
||||
type: Recreate
|
||||
|
||||
image:
|
||||
repository: registry.itsh.dev/s0wlz/tutortool
|
||||
pullPolicy: IfNotPresent
|
||||
@@ -20,17 +23,26 @@ resources:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
|
||||
pvc:
|
||||
storageClassName: hcloud-volumes
|
||||
storage: 1Gi
|
||||
|
||||
imagePullSecrets:
|
||||
- name: itsh-registry
|
||||
|
||||
httpRoute:
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
parentRefs:
|
||||
- name: default
|
||||
namespace: nginx-gateway
|
||||
hostnames:
|
||||
- tutor.puchstein.dev
|
||||
sectionName: https-tutor-puchstein-dev
|
||||
httpRedirectSectionName: http
|
||||
|
||||
# JWT_SECRET provisioned as a pre-existing K8s Secret named here.
|
||||
# Do not set jwtSecretValue in committed values — provision via kubectl manually.
|
||||
@@ -39,6 +51,9 @@ jwtSecretName: tutortool-jwt
|
||||
env:
|
||||
DATABASE_URL: sqlite:/data/attendance.db
|
||||
STATIC_DIR: /app/frontend/build
|
||||
extra: {}
|
||||
# extra:
|
||||
# DEMO: "true" # seeds demo data on startup (idempotent, INSERT OR IGNORE)
|
||||
|
||||
vpa:
|
||||
enabled: false
|
||||
|
||||
@@ -3,4 +3,7 @@ httpRoute:
|
||||
- tutor.puchstein.dev
|
||||
|
||||
image:
|
||||
tag: latest
|
||||
tag: v0.1.12
|
||||
|
||||
env:
|
||||
extra: {}
|
||||
|
||||
@@ -8,6 +8,6 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:/data/attendance.db
|
||||
- STATIC_DIR=/app/frontend/build
|
||||
- JWT_SECRET=${JWT_SECRET:-dev_secret_for_demo}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- PORT=3000
|
||||
restart: always
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -15,14 +15,14 @@ The tool is designed to be reusable across future semesters and other tutorien.
|
||||
|
||||
### Stack
|
||||
|
||||
- **Backend:** Rust + Axum, `sqlx` (SQLite), JWT auth for tutors
|
||||
- **Frontend:** SvelteKit with `adapter-static` (SPA, served by Axum)
|
||||
- **Backend:** Rust + Axum (0.8), `sqlx` (SQLite), Secure httpOnly Cookie JWT auth for tutors
|
||||
- **Frontend:** SvelteKit 5 (Svelte runes), TypeScript, `adapter-static` (SPA, served by Axum)
|
||||
- **Database:** SQLite via a Kubernetes PersistentVolumeClaim
|
||||
- **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.
|
||||
|
||||
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
|
||||
|
||||
@@ -30,27 +30,26 @@ Axum must serve `index.html` as fallback for all non-`/api` routes so that Svelt
|
||||
tools/attendance/
|
||||
├── backend/ # Rust/Axum
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs
|
||||
│ │ ├── main.rs # entry point, AppState definition
|
||||
│ │ ├── db.rs # sqlx pool setup, migrations
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── admin.rs # tutor-facing endpoints
|
||||
│ │ │ ├── mod.rs # router assembly
|
||||
│ │ │ ├── auth_routes.rs # Secure cookie-based login/logout
|
||||
│ │ │ ├── checkin.rs # student-facing endpoints
|
||||
│ │ │ └── export.rs # CSV, Markdown, SQLite backup
|
||||
│ │ └── auth.rs # JWT middleware
|
||||
│ │ ├── auth.rs # JWT logic, cookie extractor
|
||||
│ │ └── models.rs # shared data models
|
||||
│ └── Cargo.toml
|
||||
├── frontend/ # SvelteKit
|
||||
├── frontend/ # SvelteKit 5
|
||||
│ ├── src/
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── admin/ # tutor panel
|
||||
│ │ │ └── s/[code]/ # student check-in
|
||||
│ │ │ ├── admin/ # tutor panel (protected)
|
||||
│ │ │ └── s/[code]/ # student check-in (public)
|
||||
│ │ └── lib/
|
||||
│ │ ├── auth.svelte.ts # runes-based auth state
|
||||
│ │ └── api.ts # cookie-based API client
|
||||
│ └── svelte.config.js # adapter-static
|
||||
└── k8s/
|
||||
├── deployment.yaml
|
||||
├── service.yaml
|
||||
├── ingress.yaml
|
||||
├── pvc.yaml
|
||||
└── cronjob.yaml # daily SQLite backup, retains last 7
|
||||
└── deploy/ # Helm chart
|
||||
```
|
||||
|
||||
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`.
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -66,11 +66,11 @@ The backend exposes `POST /__test__/reset` only when started with `TT_TEST_MODE=
|
||||
|
||||
## 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
|
||||
3. `cargo check` + `pnpm check` (type checks)
|
||||
3. `make lint` (Zero Warnings Policy: clippy, fmt, svelte-check)
|
||||
4. `cargo test` (unit tests)
|
||||
5. `pnpm build` (frontend build)
|
||||
6. `make test-up` + `pnpm test:e2e` (E2E)
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@sveltejs/adapter-static": "latest",
|
||||
"@sveltejs/kit": "latest",
|
||||
"@sveltejs/vite-plugin-svelte": "latest",
|
||||
"@types/node": "^22",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.59.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@typescript/native-preview": "^7.0.0-dev",
|
||||
"svelte": "5.55.5",
|
||||
"svelte-check": "latest",
|
||||
"typescript": "latest",
|
||||
"vite": "latest"
|
||||
"svelte": "^5.55.5",
|
||||
"svelte-check": "^4",
|
||||
"typescript": "^5",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
80
frontend/pnpm-lock.yaml
generated
80
frontend/pnpm-lock.yaml
generated
@@ -12,29 +12,32 @@ importers:
|
||||
specifier: ^1.59.1
|
||||
version: 1.59.1
|
||||
'@sveltejs/adapter-static':
|
||||
specifier: latest
|
||||
version: 3.0.10(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10))(svelte@5.55.5)(typescript@6.0.3)(vite@8.0.10))
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10(@sveltejs/kit@2.59.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17)))(svelte@5.55.5)(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.17)))
|
||||
'@sveltejs/kit':
|
||||
specifier: latest
|
||||
version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10))(svelte@5.55.5)(typescript@6.0.3)(vite@8.0.10)
|
||||
specifier: ^2.59.0
|
||||
version: 2.59.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17)))(svelte@5.55.5)(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.17))
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: latest
|
||||
version: 7.0.0(svelte@5.55.5)(vite@8.0.10)
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17))
|
||||
'@types/node':
|
||||
specifier: ^22
|
||||
version: 22.19.17
|
||||
'@typescript/native-preview':
|
||||
specifier: ^7.0.0-dev
|
||||
version: 7.0.0-dev.20260428.1
|
||||
svelte:
|
||||
specifier: 5.55.5
|
||||
specifier: ^5.55.5
|
||||
version: 5.55.5
|
||||
svelte-check:
|
||||
specifier: latest
|
||||
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.5)(typescript@6.0.3)
|
||||
specifier: ^4
|
||||
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: latest
|
||||
version: 6.0.3
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: latest
|
||||
version: 8.0.10
|
||||
specifier: ^8.0.10
|
||||
version: 8.0.10(@types/node@22.19.17)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -191,8 +194,8 @@ packages:
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.0.0
|
||||
|
||||
'@sveltejs/kit@2.58.0':
|
||||
resolution: {integrity: sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==}
|
||||
'@sveltejs/kit@2.59.0':
|
||||
resolution: {integrity: sha512-WeJaGKvDf3uVQB4bnDHhM+BXCY34LC1v0HiPqnSpvNkjB54r8DAUP1rpk73s+5zprIirEKtUcVfgh6+fPODjzQ==}
|
||||
engines: {node: '>=18.13'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -223,6 +226,9 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
@@ -510,11 +516,14 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
vite@8.0.10:
|
||||
resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -678,15 +687,15 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10))(svelte@5.55.5)(typescript@6.0.3)(vite@8.0.10))':
|
||||
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.59.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17)))(svelte@5.55.5)(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.17)))':
|
||||
dependencies:
|
||||
'@sveltejs/kit': 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10))(svelte@5.55.5)(typescript@6.0.3)(vite@8.0.10)
|
||||
'@sveltejs/kit': 2.59.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17)))(svelte@5.55.5)(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.17))
|
||||
|
||||
'@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10))(svelte@5.55.5)(typescript@6.0.3)(vite@8.0.10)':
|
||||
'@sveltejs/kit@2.59.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17)))(svelte@5.55.5)(typescript@5.9.3)(vite@8.0.10(@types/node@22.19.17))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
|
||||
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.5)(vite@8.0.10)
|
||||
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17))
|
||||
'@types/cookie': 0.6.0
|
||||
acorn: 8.16.0
|
||||
cookie: 0.6.0
|
||||
@@ -698,18 +707,18 @@ snapshots:
|
||||
set-cookie-parser: 3.1.0
|
||||
sirv: 3.0.2
|
||||
svelte: 5.55.5
|
||||
vite: 8.0.10
|
||||
vite: 8.0.10(@types/node@22.19.17)
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
typescript: 5.9.3
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10)':
|
||||
'@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5)(vite@8.0.10(@types/node@22.19.17))':
|
||||
dependencies:
|
||||
deepmerge: 4.3.1
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
svelte: 5.55.5
|
||||
vite: 8.0.10
|
||||
vitefu: 1.1.3(vite@8.0.10)
|
||||
vite: 8.0.10(@types/node@22.19.17)
|
||||
vitefu: 1.1.3(vite@8.0.10(@types/node@22.19.17))
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
dependencies:
|
||||
@@ -720,6 +729,10 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/node@22.19.17':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260428.1':
|
||||
@@ -913,7 +926,7 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
svelte-check@4.4.6(picomatch@4.0.4)(svelte@5.55.5)(typescript@6.0.3):
|
||||
svelte-check@4.4.6(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
chokidar: 4.0.3
|
||||
@@ -921,7 +934,7 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
sade: 1.8.1
|
||||
svelte: 5.55.5
|
||||
typescript: 6.0.3
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- picomatch
|
||||
|
||||
@@ -956,9 +969,11 @@ snapshots:
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
typescript@6.0.3: {}
|
||||
typescript@5.9.3: {}
|
||||
|
||||
vite@8.0.10:
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
vite@8.0.10(@types/node@22.19.17):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
@@ -966,10 +981,11 @@ snapshots:
|
||||
rolldown: 1.0.0-rc.17
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.17
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitefu@1.1.3(vite@8.0.10):
|
||||
vitefu@1.1.3(vite@8.0.10(@types/node@22.19.17)):
|
||||
optionalDependencies:
|
||||
vite: 8.0.10
|
||||
vite: 8.0.10(@types/node@22.19.17)
|
||||
|
||||
zimmerframe@1.1.4: {}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { get } from 'svelte/store';
|
||||
import { token } from './auth';
|
||||
import type {
|
||||
Course, Tutor, Student, Room, Session, Slot, Attendance, Note
|
||||
} from './types';
|
||||
import { auth } from './auth.svelte';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const $token = get(token);
|
||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...($token ? { Authorization: `Bearer ${$token}` } : {}),
|
||||
...(!(init?.body instanceof FormData) && { 'Content-Type': 'application/json' }),
|
||||
...init?.headers,
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status === 401 && browser) {
|
||||
// Handle unauthorized
|
||||
auth.logout();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -31,37 +33,36 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export const api = {
|
||||
auth: {
|
||||
login: (email: string, password: string) =>
|
||||
request<{token: string, is_superadmin: boolean}>('/auth/login', {
|
||||
request<{is_superadmin: boolean}>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
}),
|
||||
me: () => request<{id: number, email: string, is_superadmin: boolean}>('/auth/me'),
|
||||
logout: () => request<void>('/auth/logout', { method: 'POST' }),
|
||||
},
|
||||
admin: {
|
||||
courses: {
|
||||
list: () => request<any[]>('/admin/courses'),
|
||||
list: () => request<Course[]>('/admin/courses'),
|
||||
create: (name: string, semester: string) =>
|
||||
request<any>('/admin/courses', {
|
||||
request<Course>('/admin/courses', {
|
||||
method: 'POST',
|
||||
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) =>
|
||||
request<any>(`/admin/courses/${course_id}/students`, {
|
||||
request<Student>(`/admin/courses/${course_id}/students`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name })
|
||||
}),
|
||||
importStudents: (course_id: number, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetch(`${BASE}/admin/courses/${course_id}/students/import`, {
|
||||
return request<any>(`/admin/courses/${course_id}/students/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${get(token)}`
|
||||
},
|
||||
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) =>
|
||||
request<void>(`/admin/courses/${course_id}/tutors`, {
|
||||
method: 'POST',
|
||||
@@ -71,9 +72,9 @@ export const api = {
|
||||
request<void>(`/admin/courses/${course_id}/tutors/${tutor_id}`, { method: 'DELETE' }),
|
||||
},
|
||||
tutors: {
|
||||
list: () => request<any[]>('/admin/tutors'),
|
||||
create: (tutor: any) =>
|
||||
request<any>('/admin/tutors', {
|
||||
list: () => request<Tutor[]>('/admin/tutors'),
|
||||
create: (tutor: Partial<Tutor> & { password?: string }) =>
|
||||
request<Tutor>('/admin/tutors', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(tutor)
|
||||
}),
|
||||
@@ -81,27 +82,27 @@ export const api = {
|
||||
},
|
||||
students: {
|
||||
delete: (id: number) => request<void>(`/admin/students/${id}`, { method: 'DELETE' }),
|
||||
getAttendance: (id: number) => request<any[]>(`/admin/students/${id}/attendance`),
|
||||
getNotes: (id: number) => request<any[]>(`/admin/students/${id}/notes`),
|
||||
getAttendance: (id: number) => request<Attendance[]>(`/admin/students/${id}/attendance`),
|
||||
getNotes: (id: number) => request<Note[]>(`/admin/students/${id}/notes`),
|
||||
},
|
||||
rooms: {
|
||||
list: () => request<any[]>('/admin/rooms'),
|
||||
list: () => request<Room[]>('/admin/rooms'),
|
||||
create: (name: string, layout: any[]) =>
|
||||
request<any>('/admin/rooms', {
|
||||
request<Room>('/admin/rooms', {
|
||||
method: 'POST',
|
||||
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[]) =>
|
||||
request<any>(`/admin/rooms/${id}/layout`, {
|
||||
request<Room>(`/admin/rooms/${id}/layout`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(layout)
|
||||
}),
|
||||
},
|
||||
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) =>
|
||||
request<any>('/admin/sessions', {
|
||||
request<Session>('/admin/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ course_id, week_nr, date })
|
||||
}),
|
||||
@@ -109,12 +110,12 @@ export const api = {
|
||||
},
|
||||
slots: {
|
||||
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',
|
||||
body: JSON.stringify({ session_id, tutor_id, start_time, end_time, room_id })
|
||||
}),
|
||||
updateStatus: (id: number, status: string) =>
|
||||
request<any>(`/admin/slots/${id}/status`, {
|
||||
request<Slot>(`/admin/slots/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status })
|
||||
}),
|
||||
@@ -126,7 +127,7 @@ export const api = {
|
||||
}),
|
||||
deleteAttendance: (slot_id: number, student_id: number) =>
|
||||
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) =>
|
||||
request<void>(`/admin/slots/${slot_id}/notes/${student_id}`, {
|
||||
method: 'PUT',
|
||||
@@ -143,7 +144,7 @@ export const api = {
|
||||
},
|
||||
checkin: {
|
||||
getInfo: (code: string) => request<any>(`/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) =>
|
||||
request<any>('/checkin', {
|
||||
method: 'POST',
|
||||
|
||||
50
frontend/src/lib/auth.svelte.ts
Normal file
50
frontend/src/lib/auth.svelte.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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 {
|
||||
const 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) {}
|
||||
_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);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { isSuperadmin } from '$lib/auth';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
const {
|
||||
activePath,
|
||||
@@ -68,7 +68,7 @@
|
||||
<nav style="display:flex;flex-direction:column;gap:1px">
|
||||
<div class="eyebrow" style="margin-bottom:6px">Navigation</div>
|
||||
{#each navItems as item}
|
||||
{#if !item.superadmin || $isSuperadmin}
|
||||
{#if !item.superadmin || auth.isSuperadmin}
|
||||
{@const active = isActive(item)}
|
||||
<a
|
||||
href={item.href}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { token } from '$lib/auth';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
goto($token ? '/admin' : '/admin/login');
|
||||
onMount(async () => {
|
||||
await auth.init();
|
||||
goto(auth.authenticated ? '/admin' : '/admin/login');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { token, logout } from '$lib/auth';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -13,7 +13,8 @@
|
||||
let course = $state<Course | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$token) {
|
||||
await auth.init();
|
||||
if (!auth.authenticated) {
|
||||
goto('/admin/login');
|
||||
return;
|
||||
}
|
||||
@@ -24,27 +25,28 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!$token) goto('/admin/login');
|
||||
if (auth.initialized && !auth.authenticated) goto('/admin/login');
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
goto('/admin/login');
|
||||
}
|
||||
|
||||
const activePath = $derived($page.url.pathname);
|
||||
</script>
|
||||
|
||||
{#if $token}
|
||||
<TutorShell
|
||||
{activePath}
|
||||
courseName={course?.name ?? ''}
|
||||
semester={course?.semester ?? ''}
|
||||
>
|
||||
{#snippet children()}
|
||||
{@render children()}
|
||||
{/snippet}
|
||||
</TutorShell>
|
||||
{#if auth.initialized}
|
||||
{#if auth.authenticated}
|
||||
<TutorShell
|
||||
{activePath}
|
||||
courseName={course?.name ?? ''}
|
||||
semester={course?.semester ?? ''}
|
||||
>
|
||||
{#snippet children()}
|
||||
{@render children()}
|
||||
{/snippet}
|
||||
</TutorShell>
|
||||
{:else}
|
||||
<div style="padding: 2rem; text-align: center;">Redirecting to login...</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{@render children()}
|
||||
<div style="padding: 2rem; text-align: center; font-family: var(--serif);">
|
||||
Wird geladen...
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { isSuperadmin } from '$lib/auth';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import type { Course, Tutor } from '$lib/types';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
async function loadData() {
|
||||
courses = await api.admin.courses.list();
|
||||
if ($isSuperadmin) {
|
||||
if (auth.isSuperadmin) {
|
||||
allTutors = await api.admin.tutors.list();
|
||||
for (const course of courses) {
|
||||
assignedTutors[course.id] = await api.admin.courses.listTutors(course.id);
|
||||
@@ -64,7 +64,7 @@
|
||||
</header>
|
||||
|
||||
<!-- Create course form (Superadmin only) -->
|
||||
{#if $isSuperadmin}
|
||||
{#if auth.isSuperadmin}
|
||||
<section class="card" style="overflow:hidden">
|
||||
<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>
|
||||
@@ -95,7 +95,7 @@
|
||||
<thead>
|
||||
<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>
|
||||
{#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>
|
||||
{/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>
|
||||
@@ -109,7 +109,7 @@
|
||||
<div class="tiny" style="color:var(--ink-4);font-family:var(--mono)">{course.semester}</div>
|
||||
</td>
|
||||
|
||||
{#if $isSuperadmin}
|
||||
{#if auth.isSuperadmin}
|
||||
<td style="padding:12px 14px">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;max-width:300px">
|
||||
{#each assignedTutors[course.id] ?? [] as tutor}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { token, isSuperadmin } from '$lib/auth';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
error = '';
|
||||
try {
|
||||
const res = await api.auth.login(email, password);
|
||||
token.set(res.token);
|
||||
isSuperadmin.set(res.is_superadmin);
|
||||
auth.setAuthenticated(res.is_superadmin);
|
||||
goto('/admin');
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Ungültige Anmeldedaten';
|
||||
|
||||
@@ -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 path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -16,7 +16,7 @@ function getBaseURL(): string {
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const baseURL = getBaseURL();
|
||||
const res = await page.request.post(`${baseURL}/__test__/reset`);
|
||||
|
||||
@@ -6,7 +6,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function globalSetup() {
|
||||
// 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)) {
|
||||
throw new Error('data/test/.env not found — run "make test-up" first');
|
||||
}
|
||||
@@ -24,20 +24,33 @@ async function globalSetup() {
|
||||
body: JSON.stringify({ email: 'admin@tutortool.com', password: 'admin' }),
|
||||
});
|
||||
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 };
|
||||
|
||||
// Extract token from Set-Cookie header
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
const tokenMatch = setCookie?.match(/token=([^;]+)/);
|
||||
const token = tokenMatch ? tokenMatch[1] : '';
|
||||
|
||||
// Write Playwright storage state with localStorage pre-populated
|
||||
const { is_superadmin } = await res.json() as { is_superadmin: boolean };
|
||||
|
||||
// Write Playwright storage state with cookies pre-populated
|
||||
const authDir = path.resolve(__dirname, '.auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
const storageState = {
|
||||
cookies: [],
|
||||
cookies: [
|
||||
{
|
||||
name: 'token',
|
||||
value: token,
|
||||
domain: new URL(baseURL).hostname,
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: baseURL.startsWith('https'),
|
||||
sameSite: 'Strict' as const,
|
||||
}
|
||||
],
|
||||
origins: [
|
||||
{
|
||||
origin: baseURL,
|
||||
localStorage: [
|
||||
{ name: 'token', value: token },
|
||||
{ name: 'is_superadmin', value: String(is_superadmin) },
|
||||
],
|
||||
localStorage: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ test.describe('Superadmin CRUD & UI Consistency', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user