Skip to content
Open
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
4 changes: 3 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/db/schema/semesters.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SEMESTER_TERMS } from "@internal-tool/shared";
import { sql } from "drizzle-orm";
import {
boolean,
Expand All @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/routes/semesters.ts
Original file line number Diff line number Diff line change
@@ -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);
});
37 changes: 37 additions & 0 deletions apps/api/src/services/semesters.ts
Original file line number Diff line number Diff line change
@@ -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<typeof db, "select">;

// 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<SemesterList> {
// 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 };
}
148 changes: 148 additions & 0 deletions apps/api/tests/semesters.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof db.transaction>[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<void>) {
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);
});
});
});
92 changes: 92 additions & 0 deletions apps/web/src/app/semesters/page.tsx
Original file line number Diff line number Diff line change
@@ -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<State>({ 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 (
<main className="mx-auto flex max-w-3xl flex-col gap-6 p-8">
<h1 className="text-2xl font-semibold">Semesters</h1>

{state.status === "loading" && <p role="status">Loading semesters…</p>}

{state.status === "error" && (
<p role="alert" className="text-red-700">
{state.message} Refresh the page to try again.
</p>
)}

{state.status === "loaded" && state.semesters.length === 0 && <p>No semesters yet.</p>}

{state.status === "loaded" && state.semesters.length > 0 && (
<table className="w-full text-left">
<thead>
<tr className="border-b border-neutral-300">
<th className="py-2">Name</th>
<th className="py-2">Starts</th>
<th className="py-2">Ends</th>
<th className="py-2">Status</th>
</tr>
</thead>
<tbody>
{state.semesters.map((semester) => (
<tr key={semester.id} className="border-b border-neutral-200">
<td className="py-2">{semester.name}</td>
<td className="py-2">{formatDate(semester.startsOn)}</td>
<td className="py-2">{formatDate(semester.endsOn)}</td>
<td className="py-2">{semester.isCurrent ? "Current" : ""}</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
2 changes: 2 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
export * from "./enums";
export * from "./errors";
export * from "./forms/definition";
export * from "./lists";
export * from "./semesters";
10 changes: 10 additions & 0 deletions packages/shared/src/lists.ts
Original file line number Diff line number Diff line change
@@ -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<typeof pageQuerySchema>;
26 changes: 26 additions & 0 deletions packages/shared/src/semesters.ts
Original file line number Diff line number Diff line change
@@ -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<typeof semesterSchema>;
export type SemesterList = z.infer<typeof semesterListSchema>;
Loading