Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions backend/app/api/v1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
4 changes: 2 additions & 2 deletions backend/app/api/v1/auth/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions backend/app/api/v1/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
35 changes: 35 additions & 0 deletions backend/app/api/v1/scheduling/config.py
Original file line number Diff line number Diff line change
@@ -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
98 changes: 98 additions & 0 deletions backend/app/api/v1/scheduling/endpoints.py
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions frontend/src/lib/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and managing auth cookies
*/

import type { Cookies } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';

import { getBackendApiV1BaseUrl } from './backend';

Expand Down Expand Up @@ -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<T>(
fetchFn: typeof fetch,
params: {
cookies: Cookies;
redirectTo: string;
path: string;
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
}
): Promise<T> {
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
// ----------------------

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/routes/+layout.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
};

64 changes: 64 additions & 0 deletions frontend/src/routes/schedule/+page.server.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
};

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<ScheduleForm>(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<string, number> = Object.fromEntries(
Array.from({ length: 7 }, (_, day) => [String(day), minutesFromForm(form, String(day))])
);

try {
const schedule = await backendAuthedJson<ScheduleForm>(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 });
}
}
};
Loading
Loading