diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8b7d37f..210e5c3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import type { ErrorResponse } from "@internal-tool/shared"; import { handleError, handleNotFound } from "./lib/errors"; import type { ApiErrorStatus } from "./lib/errors"; import { health } from "./routes/health"; +import { semesters } from "./routes/semesters"; // All API routes live under /api/v1 (TDD 3.2). Better Auth will mount its own // routes under /api/auth when auth is added. @@ -14,7 +15,8 @@ export function createApp() { .onError(handleError) .notFound(handleNotFound) .basePath("/api/v1") - .route("/health", health); + .route("/health", health) + .route("/semesters", semesters); } export const app = createApp(); diff --git a/apps/api/src/db/schema/semesters.ts b/apps/api/src/db/schema/semesters.ts index db4b198..f4a567a 100644 --- a/apps/api/src/db/schema/semesters.ts +++ b/apps/api/src/db/schema/semesters.ts @@ -1,3 +1,4 @@ +import { SEMESTER_TERMS } from "@internal-tool/shared"; import { sql } from "drizzle-orm"; import { boolean, @@ -13,7 +14,8 @@ import { // Example table to show the conventions. Matches `semesters` in TDD 3.1, // minus the recruiting columns, which come with the recruiting tickets. -export const semesterTerm = pgEnum("semester_term", ["fall", "spring"]); +// Values come from packages/shared so the API's schema and the database can't drift apart. +export const semesterTerm = pgEnum("semester_term", SEMESTER_TERMS); export const semesters = pgTable( "semesters", diff --git a/apps/api/src/routes/semesters.ts b/apps/api/src/routes/semesters.ts new file mode 100644 index 0000000..4508aa1 --- /dev/null +++ b/apps/api/src/routes/semesters.ts @@ -0,0 +1,9 @@ +import { Hono } from "hono"; +import { pageQuerySchema } from "@internal-tool/shared"; +import { validate } from "../lib/validation"; +import { listSemesters } from "../services/semesters"; + +export const semesters = new Hono().get("/", validate("query", pageQuerySchema), async (c) => { + // TODO(auth): minimum access is `member` (TDD 3.2); check it here once auth exists. + return c.json(await listSemesters(c.req.valid("query")), 200); +}); diff --git a/apps/api/src/services/semesters.ts b/apps/api/src/services/semesters.ts new file mode 100644 index 0000000..f5e6a89 --- /dev/null +++ b/apps/api/src/services/semesters.ts @@ -0,0 +1,37 @@ +import { asc, count, desc } from "drizzle-orm"; +import type { PageQuery, SemesterList } from "@internal-tool/shared"; +import type { db } from "../db"; +import { semesters } from "../db/schema"; + +// Anything that can run a select: the shared connection, or a transaction in tests. +type Database = Pick; + +// One page of semesters, newest first (TDD 3.2 Lists). Name breaks ties so the order, +// and so the pages, are stable. total counts every semester, not just this page. +export async function listSemesters( + { page, pageSize }: PageQuery, + database?: Database +): Promise { + // Loaded on first use rather than at the top: src/db throws on import when + // DATABASE_URL is missing, and the app (and its tests in CI) must load without one. + const conn = database ?? (await import("../db")).db; + + const items = await conn + .select({ + id: semesters.id, + name: semesters.name, + term: semesters.term, + year: semesters.year, + startsOn: semesters.startsOn, + endsOn: semesters.endsOn, + isCurrent: semesters.isCurrent, + }) + .from(semesters) + .orderBy(desc(semesters.startsOn), asc(semesters.name)) + .limit(pageSize) + .offset((page - 1) * pageSize); + + const [totals] = await conn.select({ total: count() }).from(semesters); + + return { items, total: totals?.total ?? 0 }; +} diff --git a/apps/api/tests/semesters.test.ts b/apps/api/tests/semesters.test.ts new file mode 100644 index 0000000..374e710 --- /dev/null +++ b/apps/api/tests/semesters.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { TransactionRollbackError } from "drizzle-orm"; +import { semesterListSchema } from "@internal-tool/shared"; +import type { ErrorResponse, SemesterList } from "@internal-tool/shared"; +import { app } from "../src/app"; +import type { db } from "../src/db"; +import { semesters } from "../src/db/schema"; +import { env } from "../src/env"; +import * as service from "../src/services/semesters"; + +// Fake data, newest first, the way the service returns it. Stubbing the service +// keeps these route tests running without a database (CI has none). +const list: SemesterList = { + items: [ + { + id: "0b6f4f1e-8a7e-4c55-9d3a-2f1b6c9e0a01", + name: "Fall 2026", + term: "fall", + year: 2026, + startsOn: "2026-08-17", + endsOn: "2026-12-11", + isCurrent: true, + }, + { + id: "0b6f4f1e-8a7e-4c55-9d3a-2f1b6c9e0a02", + name: "Spring 2026", + term: "spring", + year: 2026, + startsOn: "2026-01-12", + endsOn: "2026-05-08", + isCurrent: false, + }, + ], + total: 2, +}; + +describe("GET /api/v1/semesters", () => { + afterEach(() => { + mock.restore(); + }); + + it("returns the first page of 50 by default, as { items, total }", async () => { + const listSemesters = spyOn(service, "listSemesters").mockResolvedValue(list); + + const res = await app.request("/api/v1/semesters"); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(semesterListSchema.safeParse(body).success).toBe(true); + expect(body).toEqual(list); + expect(listSemesters).toHaveBeenCalledWith({ page: 1, pageSize: 50 }); + }); + + it("passes page and pageSize from the query string to the service", async () => { + const listSemesters = spyOn(service, "listSemesters").mockResolvedValue(list); + + const res = await app.request("/api/v1/semesters?page=3&pageSize=200"); + + expect(res.status).toBe(200); + expect(listSemesters).toHaveBeenCalledWith({ page: 3, pageSize: 200 }); + }); + + it("returns an empty list when there are no semesters", async () => { + spyOn(service, "listSemesters").mockResolvedValue({ items: [], total: 0 }); + + const res = await app.request("/api/v1/semesters"); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ items: [], total: 0 }); + }); + + it.each([ + ["pageSize", "201"], + ["pageSize", "0"], + ["page", "0"], + ["page", "abc"], + ["page", "1.5"], + ])("rejects ?%s=%s with a VALIDATION error", async (field, value) => { + const listSemesters = spyOn(service, "listSemesters"); + + const res = await app.request(`/api/v1/semesters?${field}=${value}`); + + expect(res.status).toBe(400); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("VALIDATION"); + expect(Object.keys(body.error.fields ?? {})).toEqual([field]); + expect(listSemesters).not.toHaveBeenCalled(); + }); +}); + +type Transaction = Parameters[0]>[0]; + +// Runs the real query, so it needs a database: it runs in the devcontainer and is +// skipped in CI. Everything happens in a transaction that is rolled back, so local +// data is never changed. Test semesters are far in the future so they sort ahead of +// any real ones. +describe.skipIf(!env.DATABASE_URL)("listSemesters against the database", () => { + async function withTestSemesters(run: (tx: Transaction) => Promise) { + const { db: database } = await import("../src/db"); + try { + await database.transaction(async (tx) => { + // Inserted out of order on purpose. The two fall semesters start on the same day. + await tx.insert(semesters).values([ + { + name: "Test Fall 2991 B", + term: "fall", + year: 2991, + startsOn: "2991-08-20", + endsOn: "2991-12-10", + }, + { + name: "Test Spring 2992", + term: "spring", + year: 2992, + startsOn: "2992-01-12", + endsOn: "2992-05-08", + }, + { + name: "Test Fall 2991 A", + term: "fall", + year: 2991, + startsOn: "2991-08-20", + endsOn: "2991-12-10", + }, + ]); + await run(tx); + tx.rollback(); + }); + } catch (error) { + if (!(error instanceof TransactionRollbackError)) throw error; + } + } + + it("sorts newest first, breaks ties by name, and pages with the full total", async () => { + await withTestSemesters(async (tx) => { + const firstPage = await service.listSemesters({ page: 1, pageSize: 2 }, tx); + const secondPage = await service.listSemesters({ page: 2, pageSize: 2 }, tx); + const everything = await service.listSemesters({ page: 1, pageSize: 200 }, tx); + + expect(firstPage.items.map((s) => s.name)).toEqual(["Test Spring 2992", "Test Fall 2991 A"]); + expect(secondPage.items[0]?.name).toBe("Test Fall 2991 B"); + // total is every row (test rows plus any local ones), not the page length of 2. + expect(firstPage.total).toBe(everything.items.length); + expect(firstPage.total).toBeGreaterThanOrEqual(3); + expect(semesterListSchema.safeParse(firstPage).success).toBe(true); + }); + }); +}); diff --git a/apps/web/src/app/semesters/page.tsx b/apps/web/src/app/semesters/page.tsx new file mode 100644 index 0000000..fda260d --- /dev/null +++ b/apps/web/src/app/semesters/page.tsx @@ -0,0 +1,92 @@ +"use client"; + +import type { Semester } from "@internal-tool/shared"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +type State = + | { status: "loading" } + | { status: "error"; message: string } + | { status: "loaded"; semesters: Semester[] }; + +// Used when there's no API message to show, e.g. the API is down and the proxy answers. +const FALLBACK_ERROR = "Couldn't load semesters."; + +// Dates arrive as "YYYY-MM-DD", which JavaScript reads as midnight UTC. Formatting +// in UTC keeps that calendar day; local time would show Aug 17 as Aug 16 in Eastern. +const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" }); + +function formatDate(isoDate: string) { + return dateFormat.format(new Date(isoDate)); +} + +// Client component: the typed client uses relative /api URLs, which only the +// browser can resolve (Next.js forwards them to the API). +export default function SemestersPage() { + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let ignore = false; + + async function load() { + try { + // First page (up to 50, newest first) covers every semester for years to come. + const res = await api.api.v1.semesters.$get({ query: {} }); + if (res.ok) { + const { items } = await res.json(); + if (!ignore) setState({ status: "loaded", semesters: items }); + } else { + // Errors use the standard envelope: { error: { code, message } }. + const { error } = await res.json(); + if (!ignore) setState({ status: "error", message: error.message }); + } + } catch { + if (!ignore) setState({ status: "error", message: FALLBACK_ERROR }); + } + } + + void load(); + return () => { + ignore = true; + }; + }, []); + + return ( +
+

Semesters

+ + {state.status === "loading" &&

Loading semesters…

} + + {state.status === "error" && ( +

+ {state.message} Refresh the page to try again. +

+ )} + + {state.status === "loaded" && state.semesters.length === 0 &&

No semesters yet.

} + + {state.status === "loaded" && state.semesters.length > 0 && ( + + + + + + + + + + + {state.semesters.map((semester) => ( + + + + + + + ))} + +
NameStartsEndsStatus
{semester.name}{formatDate(semester.startsOn)}{formatDate(semester.endsOn)}{semester.isCurrent ? "Current" : ""}
+ )} +
+ ); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9cb6538..8041fed 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,3 +4,5 @@ export * from "./enums"; export * from "./errors"; export * from "./access"; export * from "./forms/definition"; +export * from "./lists"; +export * from "./semesters"; diff --git a/packages/shared/src/lists.ts b/packages/shared/src/lists.ts new file mode 100644 index 0000000..c05f39c --- /dev/null +++ b/packages/shared/src/lists.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +// Every list endpoint pages the same way (TDD 3.2 Lists): ?page=1&pageSize=50, max 200. +// Query strings arrive as text, so the numbers are coerced. +export const pageQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(200).default(50), +}); + +export type PageQuery = z.output; diff --git a/packages/shared/src/semesters.ts b/packages/shared/src/semesters.ts new file mode 100644 index 0000000..cab8504 --- /dev/null +++ b/packages/shared/src/semesters.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const SEMESTER_TERMS = ["fall", "spring"] as const; +export type SemesterTerm = (typeof SEMESTER_TERMS)[number]; + +// One semester as the API returns it. Dates are "YYYY-MM-DD" strings rather than +// timestamps, so they mean the same calendar day in every time zone. +export const semesterSchema = z.object({ + id: z.uuid(), + name: z.string(), + term: z.enum(SEMESTER_TERMS), + year: z.number().int(), + startsOn: z.iso.date(), + endsOn: z.iso.date(), + isCurrent: z.boolean(), +}); + +// GET /api/v1/semesters: one page, newest semester first (TDD 3.2 Lists). +// total counts every semester, not just the ones on this page. +export const semesterListSchema = z.object({ + items: z.array(semesterSchema), + total: z.number().int().nonnegative(), +}); + +export type Semester = z.infer; +export type SemesterList = z.infer;