diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 3b438a9..fd16fde 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -7,10 +7,15 @@ from fastapi import APIRouter -from .auth import endpoints +from .auth import endpoints as auth_endpoints +from .scheduling import endpoints as scheduling_endpoints from . import health api_router = APIRouter() -api_router.include_router(endpoints.router, prefix="/auth", tags=["auth"]) +api_router.include_router(auth_endpoints.router, prefix="/auth", tags=["auth"]) + +api_router.include_router( + scheduling_endpoints.router, prefix="/scheduling", tags=["scheduling"] +) api_router.include_router(health.router, prefix="/health", tags=["health"]) diff --git a/backend/app/api/v1/auth/endpoints.py b/backend/app/api/v1/auth/endpoints.py index 0deabcc..2c6efaa 100644 --- a/backend/app/api/v1/auth/endpoints.py +++ b/backend/app/api/v1/auth/endpoints.py @@ -43,7 +43,7 @@ async def login_for_access_token( token_type: string ("bearer") Errors: - 401 Unauthorized: If the username or password is incorrect + 401: if the username or password is incorrect """ user = authenticate_user(session, form_data.username, form_data.password) if not user: @@ -73,7 +73,7 @@ def signup(user: UserCreate, session: SessionDep) -> User: created_at: timestamp Errors: - 409 Conflict: If the username already exists + 409: if the username already exists """ existing = session.exec( diff --git a/backend/app/api/v1/models.py b/backend/app/api/v1/models.py index f4199d4..f874344 100644 --- a/backend/app/api/v1/models.py +++ b/backend/app/api/v1/models.py @@ -71,6 +71,10 @@ class AvailabilityDB(SQLModel, table=True): user_id: int = Field(foreign_key="users.id", nullable=False, index=True) day_of_week: int = Field(nullable=False) available_minutes: int | None = None + created_at: datetime | None = Field( + default=None, + sa_column=Column(DateTime(timezone=True), server_default=func.now(), nullable=False), + ) class SessionDB(SQLModel, table=True): diff --git a/backend/app/api/v1/scheduling/config.py b/backend/app/api/v1/scheduling/config.py new file mode 100644 index 0000000..cffa2d7 --- /dev/null +++ b/backend/app/api/v1/scheduling/config.py @@ -0,0 +1,35 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# app/api/v1/scheduling/config.py +# Manages scheduling-specific env variables & constants + +from pydantic import BaseModel, Field, field_validator + + +class ScheduleForm(BaseModel): + """ + Represents the post for a scheduling entry in the system + with a dictionary of int 0-6 (weekdays) to int (minutes available) + """ + + availability: dict[int, int] = Field( + ..., description="Map of weekday (0=Sun..6=Sat) to available minutes (0..1440)" + ) + + # because scheduling has a relatively complex data structure, + # and browsers cannot be trusted, the data will be validated at + # the API level + @field_validator("availability") + @classmethod + def validate_availability(cls, value: dict[int, int]) -> dict[int, int]: + if not isinstance(value, dict): + raise ValueError("availability must be a dictionary") + + for day, minutes in value.items(): + if day not in range(7): + raise ValueError("weekday must be an integer 0-6") + if minutes < 0 or minutes > 1440: + raise ValueError("minutes must be between 0 and 1440") + + return value diff --git a/backend/app/api/v1/scheduling/endpoints.py b/backend/app/api/v1/scheduling/endpoints.py new file mode 100644 index 0000000..58170c7 --- /dev/null +++ b/backend/app/api/v1/scheduling/endpoints.py @@ -0,0 +1,98 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# app/api/v1/scheduling/endpoints.py +# Handles scheduling endpoints + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.api.v1.auth.auth import get_current_active_user +from app.api.v1.auth.config import User +from app.api.v1.scheduling.config import ScheduleForm +from app.api.v1.db import SessionDep +from app.api.v1.models import AvailabilityDB + +from sqlalchemy import delete +from sqlmodel import select + + +router = APIRouter() + + +@router.get("/schedule", response_model=ScheduleForm) +async def get_schedule( + current_user: Annotated[User, Depends(get_current_active_user)], + session: SessionDep, +) -> ScheduleForm: + """ + return the authenticated user's current availability schedule + + Response: + availability: Dictionary with keys as week days (int 0-6; 0 is Sunday) + and values as integers for minutes available + i.e. { 0: 30, 1: 40, 2: 0 ... } + + Errors: + 500: unexpected error + """ + + rows = session.exec( + select(AvailabilityDB).where(AvailabilityDB.user_id == current_user.id) + ).all() + + availability: dict[int, int] = {day: 0 for day in range(7)} + for row in rows: + # Defensive: ignore any malformed DB rows + if row.day_of_week in availability and isinstance(row.available_minutes, int): + availability[row.day_of_week] = row.available_minutes + + return ScheduleForm(availability=availability) + + +@router.post("/schedule", response_model=ScheduleForm) +async def submit_schedule( + current_user: Annotated[User, Depends(get_current_active_user)], + schedule: ScheduleForm, + session: SessionDep, +) -> ScheduleForm: + """ + submit weekday availability schedule for the authenticated user, + and adds it as separate entries to the `availability` table in + the database + + Requires: + schedule: Dictionary with keys as week days (int 0-6; 0 is Sunday) + and values as integers for minutes available + i.e. { 0: 30, 1: 40, 2: 0 ... } + + Errors: + 400: schedule format is invalid + 500: unexpected error + + """ + + try: + session.exec( + delete(AvailabilityDB).where(AvailabilityDB.user_id == current_user.id) + ) + + # always store a full week; missing keys default to 0. + entries = [ + AvailabilityDB( + user_id=current_user.id, + day_of_week=day, + available_minutes=schedule.availability.get(day, 0), + ) + for day in range(7) + ] + session.add_all(entries) + session.commit() + except Exception as exc: + session.rollback() + raise HTTPException( + status_code=500, detail="Failed to update schedule" + ) from exc + + return schedule diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index 5d7b246..1f329c5 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -8,6 +8,7 @@ and managing auth cookies */ import type { Cookies } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; import { getBackendApiV1BaseUrl } from './backend'; @@ -51,6 +52,75 @@ export function clearAccessTokenCookie(cookies: Cookies): void { cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' }); } +// Server-side auth helpers +// ------------------------ + +/** + * Returns the access token cookie or redirects to login. + * + * @param cookies - the cookies object from the load function or actions context + * @param params - redirectTo is the url to return to after login + * @returns the access token if it exists + */ +export function requireAccessToken(cookies: Cookies, params: { redirectTo: string }): string { + const accessToken = getAccessTokenFromCookies(cookies); + if (!accessToken) { + throw redirect(303, `/auth/login?redirectTo=${encodeURIComponent(params.redirectTo)}`); + } + return accessToken; +} + +/** + * Calls the backend API using the JWT from cookies and parses json response + * + * - if no token exists, redirects to login + * - if backend returns 401/403, clears cookie and redirects to login + * + * @param fetchFn - pass in fetch from the load function or actions context + * @param params - cookies, redirect url, API path, method, and body + * @returns the parsed JSON response from the backend on success + */ +export async function backendAuthedJson( + fetchFn: typeof fetch, + params: { + cookies: Cookies; + redirectTo: string; + path: string; + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + } +): Promise { + const accessToken = requireAccessToken(params.cookies, { redirectTo: params.redirectTo }); + + const res = await fetchFn(`${getBackendApiV1BaseUrl()}${params.path}`, { + method: params.method ?? 'GET', + headers: { + ...(params.body ? { 'content-type': 'application/json' } : {}), + authorization: `Bearer ${accessToken}` + }, + body: params.body ? JSON.stringify(params.body) : undefined + }); + + if (res.status === 401 || res.status === 403) { + clearAccessTokenCookie(params.cookies); + throw redirect(303, `/auth/login?redirectTo=${encodeURIComponent(params.redirectTo)}`); + } + + if (!res.ok) { + let detail = 'Request failed'; + try { + const body = await res.json(); + if (typeof body?.detail === 'string') detail = body.detail; + if (Array.isArray(body?.detail)) detail = 'Invalid request'; + } catch { + // ignore + } + throw new Error(detail); + } + + return (await res.json()) as T; +} + // Primary auth functions // ---------------------- diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts index 2b9ffa6..c80d546 100644 --- a/frontend/src/routes/+layout.server.ts +++ b/frontend/src/routes/+layout.server.ts @@ -25,9 +25,10 @@ export const load: LayoutServerLoad = async ({ url, cookies }) => { throw redirect(303, `/auth/login?redirectTo=${encodeURIComponent(redirectTo)}`); } - if (isAuthed && (url.pathname === '/auth/login' || url.pathname === '/auth/signup')) { + if (isAuthed && isAuthRoute(url.pathname)) { throw redirect(303, url.searchParams.get('redirectTo') || '/'); } return {}; }; + diff --git a/frontend/src/routes/schedule/+page.server.ts b/frontend/src/routes/schedule/+page.server.ts new file mode 100644 index 0000000..ffd2ca9 --- /dev/null +++ b/frontend/src/routes/schedule/+page.server.ts @@ -0,0 +1,64 @@ +/* +Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +This project is licensed under Apache 2.0 + +src/routes/schedule/+page.server.ts +server-side logic for the schedule page +*/ + +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +import { backendAuthedJson } from '$lib/server/auth'; + +type ScheduleForm = { + availability: Record; +}; + +function clampMinutes(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1440, Math.max(0, value)); +} + +/** + * reads a "minutes" field from the form. names are the weekday numbers 0-6 + * empty/invalid values become 0; clamped to 0..1440. + */ +function minutesFromForm(form: FormData, dayKey: string): number { + const raw = String(form.get(dayKey) ?? '').trim(); + if (!raw) return 0; + const parsed = Number.parseInt(raw, 10); + return clampMinutes(Number.isNaN(parsed) ? 0 : parsed); +} + +export const load: PageServerLoad = async ({ cookies, fetch }) => { + const schedule = await backendAuthedJson(fetch, { + cookies, + redirectTo: '/schedule', + path: '/scheduling/schedule' + }); + return { schedule }; +}; + +export const actions: Actions = { + default: async ({ request, cookies, fetch }) => { + const form = await request.formData(); + const availability: Record = Object.fromEntries( + Array.from({ length: 7 }, (_, day) => [String(day), minutesFromForm(form, String(day))]) + ); + + try { + const schedule = await backendAuthedJson(fetch, { + cookies, + redirectTo: '/schedule', + path: '/scheduling/schedule', + method: 'POST', + body: { availability } + }); + return { success: true, schedule }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update schedule.'; + return fail(400, { error: message }); + } + } +}; diff --git a/frontend/src/routes/schedule/+page.svelte b/frontend/src/routes/schedule/+page.svelte index 6b13eda..dd4781d 100644 --- a/frontend/src/routes/schedule/+page.svelte +++ b/frontend/src/routes/schedule/+page.svelte @@ -8,8 +8,20 @@ src/routes/schedule/+page.svelte
@@ -115,7 +127,17 @@ src/routes/schedule/+page.svelte

Set Your Weekly Schedule

-
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + {#if form?.success} +
+ Schedule updated. +
+ {/if}
@@ -135,9 +160,12 @@ src/routes/schedule/+page.svelte
@@ -147,9 +175,12 @@ src/routes/schedule/+page.svelte @@ -159,9 +190,12 @@ src/routes/schedule/+page.svelte @@ -171,9 +205,12 @@ src/routes/schedule/+page.svelte @@ -183,9 +220,12 @@ src/routes/schedule/+page.svelte @@ -195,9 +235,12 @@ src/routes/schedule/+page.svelte @@ -205,7 +248,7 @@ src/routes/schedule/+page.svelte

minutes per day