diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 66c2c26..d9b8565 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -9,6 +9,22 @@ permissions: jobs: quality: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17.6-alpine + env: + POSTGRES_USER: betterticket + POSTGRES_PASSWORD: betterticket_ci + POSTGRES_DB: betterticket + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U betterticket -d betterticket" + --health-interval 5s + --health-timeout 3s + --health-retries 12 + env: + DATABASE_URL: postgresql://betterticket:betterticket_ci@localhost:5432/betterticket steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 @@ -22,13 +38,17 @@ jobs: - run: pnpm format:check - run: pnpm lint - run: pnpm typecheck + - run: pnpm db:migrate - run: pnpm test - run: pnpm coverage + - run: pnpm build - uses: actions/upload-artifact@v7 if: always() with: name: coverage - path: coverage/ + path: | + coverage/ + apps/*/coverage/ if-no-files-found: ignore infrastructure-smoke: diff --git a/AGENTS.md b/AGENTS.md index 3e3a81c..45194e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,19 +2,19 @@ ## Status and source of truth -BetterTicket currently has infrastructure only. `infrastructure_plan.md` is the approved source of truth; it records Fastify, a pnpm workspace, Drizzle migrations, a 50% future coverage floor, and deferred provider/release choices. Do not change those decisions without revising the plan through `planning-and-task-breakdown` or `spec-driven-development` first. +BetterTicket has infrastructure plus an anonymous ticket-creation baseline. `infrastructure_plan.md` remains the approved infrastructure source of truth; `docs/specs/`, `docs/plans/`, and `docs/decisions/` record approved product scope and architecture. Do not change those decisions without revising the relevant plan/spec through `planning-and-task-breakdown` or `spec-driven-development` first. ## Repository map | Area | Location | Status | | -------------- | ----------------------------------------------------------------- | ------------------------------------ | -| Frontend | `apps/web` | Not created yet; planned React/Vite. | -| API | `apps/api` | Not created yet; planned Fastify. | +| Frontend | `apps/web` | React/Vite ticket creation. | +| API | `apps/api` | Fastify/Drizzle ticket creation. | | Infrastructure | `compose.yml`, `docker/`, `.env.example` | Present. | | Scripts | `scripts/smoke.sh` | Present; infrastructure-only. | -| Tests | Application test directories | Not created yet. | +| Tests | Colocated `*.test.ts(x)` files | Unit, component, and DB integration. | | CI | `.github/workflows/pr-checks.yml`, `.github/workflows/codeql.yml` | Present. | -| Docs | `README.md`, `infrastructure_plan.md` | Present. | +| Docs | `README.md`, `infrastructure_plan.md`, `docs/` | Present. | | Agent skills | `.agents/skills/` | Present. | ## Required reading and boundaries @@ -38,8 +38,10 @@ docker compose --profile tools run --rm toolchain pnpm install --frozen-lockfile docker compose --profile tools run --rm toolchain pnpm format:check docker compose --profile tools run --rm toolchain pnpm lint docker compose --profile tools run --rm toolchain pnpm typecheck +docker compose --profile tools run --rm toolchain pnpm db:migrate docker compose --profile tools run --rm toolchain pnpm test docker compose --profile tools run --rm toolchain pnpm coverage +docker compose --profile tools run --rm toolchain pnpm build bash scripts/smoke.sh ``` diff --git a/README.md b/README.md index 6c08f04..1dfe81e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ # BetterTicket -BetterTicket is a future public, multi-user web application. This repository currently provides its development infrastructure only; no production frontend, API, authentication, database schema, or product workflows have been implemented. +BetterTicket is a public ticketing web application. The first product slice supports anonymous ticket creation through a React frontend and Fastify API, with tickets stored in PostgreSQL. Authentication and technician workflows are not implemented yet. ## Repository map | Location | Purpose | | --------------------------- | -------------------------------------------------------------------- | -| `apps/web` | Planned React/Vite frontend; not created yet. | -| `apps/api` | Planned Fastify API; not created yet. | +| `apps/web` | React/Vite ticket-creation frontend and component tests. | +| `apps/api` | Fastify API, Drizzle schema/migrations, and API/database tests. | | `docker/` and `compose.yml` | Pinned Docker toolchain and local PostgreSQL/S3-compatible services. | | `scripts/smoke.sh` | Disposable infrastructure smoke test. | | `.github/workflows/` | Pull-request checks and scheduled CodeQL analysis. | | `.agents/skills/` | Repository-specific Codex skills. | +| `docs/` | Product specs, implementation plans, and architecture decisions. | | `infrastructure_plan.md` | Approved infrastructure decisions and deferred product decisions. | ## Getting started @@ -38,7 +39,14 @@ BetterTicket is a future public, multi-user web application. This repository cur docker compose up --detach postgres object-storage ``` -5. Run the available quality checks in the toolchain container, then the host Docker smoke test: +5. Apply the versioned PostgreSQL migrations, then start the frontend and API in the pinned toolchain. Vite serves the application at `http://localhost:5173` and proxies `/api` to Fastify on port 3000: + + ```sh + docker compose --profile tools run --rm toolchain pnpm db:migrate + docker compose --profile tools run --rm --service-ports toolchain pnpm dev + ``` + +6. Run the quality checks in the toolchain container, then the host Docker smoke test. PostgreSQL must be running because the API suite includes a real persistence test: ```sh docker compose --profile tools run --rm toolchain pnpm format:check @@ -46,12 +54,11 @@ BetterTicket is a future public, multi-user web application. This repository cur docker compose --profile tools run --rm toolchain pnpm typecheck docker compose --profile tools run --rm toolchain pnpm test docker compose --profile tools run --rm toolchain pnpm coverage + docker compose --profile tools run --rm toolchain pnpm build bash scripts/smoke.sh ``` - With no application tests yet, Vitest validates the configured harness and exits successfully with no test files. Coverage thresholds take effect once application source is added. - -6. Stop the long-lived local services when finished: +7. Stop the long-lived local services when finished: ```sh docker compose down @@ -59,11 +66,15 @@ BetterTicket is a future public, multi-user web application. This repository cur To discard their local data as well, use `docker compose down --volumes`. +## Ticket creation baseline + +Anonymous visitors can submit a title, issue description, setup details, and optional additional information. `POST /api/tickets` validates the request, rejects unknown fields, and stores the ticket with a generated UUID, `OPEN` status, and timestamps. See [the feature spec](docs/specs/ticket-creation.md) and [ADR-001](docs/decisions/001-anonymous-ticket-creation.md) for scope and rationale. + ## Tooling and CI -The root package is a pnpm workspace reserved for future `apps/web` and `apps/api` packages. React/Vite and Fastify are recorded in the workspace catalog but no application packages or entrypoints exist yet. Drizzle ORM/Kit is available for the future PostgreSQL migration workflow; no business schema or migrations exist. +The pnpm workspace contains React/Vite and Fastify packages. Drizzle schema files are the database source of truth, and generated SQL migrations are committed under `apps/api/drizzle`. -Pull requests run locked installation, formatting, linting, type checking, the empty test harness, coverage, Compose validation, the infrastructure smoke test, and Gitleaks. CodeQL runs weekly and on manual dispatch. Dependabot tracks npm, Docker, and GitHub Actions updates. Playwright, API integration, application builds, Trivy scanning, deployment, and release workflows begin only after application code and provider choices exist. +Pull requests run locked installation, formatting, linting, type checking, migrations against PostgreSQL, unit/component/integration tests, coverage, application builds, Compose validation, the infrastructure smoke test, and Gitleaks. CodeQL runs weekly and on manual dispatch. Dependabot tracks npm, Docker, and GitHub Actions updates. Playwright, Trivy scanning, deployment, and release workflows remain deferred. Docker image tags are deliberately pinned. Dependabot proposes updates; review and test them before merging. @@ -76,4 +87,6 @@ Before enabling a release workflow, select the static host, API container host, - If Docker cannot connect, start Docker Desktop and rerun `docker compose config --quiet`. - If ports 5432 or 8333 are occupied, stop the conflicting local service or change the loopback mapping in `compose.yml`. - If Docker reports a bind-mount permission issue, grant Docker Desktop access to this repository and rerun the command. +- If tests report that the `tickets` relation does not exist, run `docker compose --profile tools run --rm toolchain pnpm db:migrate`. +- If ports 3000 or 5173 are occupied, stop the conflicting application before running the development command. - If a locked install fails after changing dependencies, regenerate `pnpm-lock.yaml` from the toolchain with `docker compose --profile tools run --rm toolchain pnpm install`, review the diff, then rerun with `--frozen-lockfile`. diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..80f4f9f --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "drizzle-kit"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required to run database commands"); +} + +export default defineConfig({ + dialect: "postgresql", + out: "./drizzle", + schema: "./src/database/schema.ts", + dbCredentials: { url: databaseUrl } +}); diff --git a/apps/api/drizzle/0000_yielding_blizzard.sql b/apps/api/drizzle/0000_yielding_blizzard.sql new file mode 100644 index 0000000..2011d98 --- /dev/null +++ b/apps/api/drizzle/0000_yielding_blizzard.sql @@ -0,0 +1,11 @@ +CREATE TYPE "public"."ticket_status" AS ENUM('OPEN');--> statement-breakpoint +CREATE TABLE "tickets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "title" varchar(160) NOT NULL, + "description" text NOT NULL, + "setup" text NOT NULL, + "additional_information" text, + "status" "ticket_status" DEFAULT 'OPEN' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..94bba4b --- /dev/null +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -0,0 +1,91 @@ +{ + "id": "a78acd5c-2d65-420e-aada-4126037ca948", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup": { + "name": "setup", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "additional_information": { + "name": "additional_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ticket_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.ticket_status": { + "name": "ticket_status", + "schema": "public", + "values": ["OPEN"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json new file mode 100644 index 0000000..d3153da --- /dev/null +++ b/apps/api/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1790627527624, + "tag": "0000_yielding_blizzard", + "breakpoints": true + } + ] +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..a45ead6 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,29 @@ +{ + "name": "@betterticket/api", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "coverage": "vitest run --coverage", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "dev": "tsx watch src/server.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "drizzle-orm": "^0.45.2", + "fastify": "catalog:", + "pg": "^8.0.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/pg": "^8.0.0", + "@vitest/coverage-v8": "^4.0.0", + "drizzle-kit": "^0.31.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0", + "vitest": "^4.0.0" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..2f37db1 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,53 @@ +import Fastify, { type FastifyServerOptions } from "fastify"; + +import { ticketRoutes } from "./tickets/ticket-route.js"; +import type { TicketRepository } from "./tickets/ticket-repository.js"; + +interface BuildAppOptions { + ticketRepository: TicketRepository; + logger?: FastifyServerOptions["logger"]; +} + +export function buildApp({ ticketRepository, logger = true }: BuildAppOptions) { + const app = Fastify({ + logger, + ajv: { + customOptions: { + removeAdditional: false + } + } + }); + + app.addHook("onSend", async (_request, reply) => { + void reply + .header("x-content-type-options", "nosniff") + .header("x-frame-options", "DENY") + .header("referrer-policy", "no-referrer"); + }); + + app.setErrorHandler((error, request, reply) => { + if (typeof error === "object" && error !== null && "validation" in error && error.validation) { + return reply.status(400).send({ + error: { + code: "VALIDATION_ERROR", + message: "Ticket details are invalid" + } + }); + } + + request.log.error( + { errorType: error instanceof Error ? error.name : "UnknownError" }, + "ticket request failed" + ); + return reply.status(500).send({ + error: { + code: "INTERNAL_ERROR", + message: "The ticket could not be created" + } + }); + }); + + void app.register(ticketRoutes, { ticketRepository }); + + return app; +} diff --git a/apps/api/src/database/client.ts b/apps/api/src/database/client.ts new file mode 100644 index 0000000..a7733bd --- /dev/null +++ b/apps/api/src/database/client.ts @@ -0,0 +1,11 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +export function createDatabase(databaseUrl: string) { + const pool = new Pool({ connectionString: databaseUrl }); + + return { + database: drizzle(pool), + close: () => pool.end() + }; +} diff --git a/apps/api/src/database/postgres-ticket-repository.integration.test.ts b/apps/api/src/database/postgres-ticket-repository.integration.test.ts new file mode 100644 index 0000000..4090963 --- /dev/null +++ b/apps/api/src/database/postgres-ticket-repository.integration.test.ts @@ -0,0 +1,47 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "./client.js"; +import { PostgresTicketRepository } from "./postgres-ticket-repository.js"; +import { tickets } from "./schema.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for PostgreSQL integration tests"); +} + +const { database, close } = createDatabase(databaseUrl); + +describe("PostgresTicketRepository", () => { + beforeEach(async () => { + await database.delete(tickets); + }); + + afterAll(close); + + it("persists and returns a ticket with database-generated fields", async () => { + const repository = new PostgresTicketRepository(database); + + const created = await repository.create({ + title: "Laptop will not start", + description: "The power light flashes once.", + setup: "Framework Laptop 13, Fedora 42" + }); + + const storedTickets = await database.select().from(tickets); + + expect(storedTickets).toEqual([created]); + expect(created).toMatchObject({ + title: "Laptop will not start", + description: "The power light flashes once.", + setup: "Framework Laptop 13, Fedora 42", + additionalInformation: null, + status: "OPEN" + }); + expect(created.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + expect(created.createdAt).toBeInstanceOf(Date); + expect(created.updatedAt).toBeInstanceOf(Date); + }); +}); diff --git a/apps/api/src/database/postgres-ticket-repository.ts b/apps/api/src/database/postgres-ticket-repository.ts new file mode 100644 index 0000000..7c1bcca --- /dev/null +++ b/apps/api/src/database/postgres-ticket-repository.ts @@ -0,0 +1,30 @@ +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; + +import { tickets } from "./schema.js"; +import type { + CreateTicketInput, + TicketRecord, + TicketRepository +} from "../tickets/ticket-repository.js"; + +export class PostgresTicketRepository implements TicketRepository { + constructor(private readonly database: NodePgDatabase) {} + + async create(input: CreateTicketInput): Promise { + const [ticket] = await this.database + .insert(tickets) + .values({ + title: input.title, + description: input.description, + setup: input.setup, + additionalInformation: input.additionalInformation + }) + .returning(); + + if (!ticket) { + throw new Error("Ticket insert did not return a row"); + } + + return ticket; + } +} diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts new file mode 100644 index 0000000..40e8cd1 --- /dev/null +++ b/apps/api/src/database/schema.ts @@ -0,0 +1,14 @@ +import { pgEnum, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +export const ticketStatus = pgEnum("ticket_status", ["OPEN"]); + +export const tickets = pgTable("tickets", { + id: uuid().defaultRandom().primaryKey(), + title: varchar({ length: 160 }).notNull(), + description: text().notNull(), + setup: text().notNull(), + additionalInformation: text("additional_information"), + status: ticketStatus().default("OPEN").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull() +}); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..7ed3834 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,21 @@ +import { buildApp } from "./app.js"; +import { createDatabase } from "./database/client.js"; +import { PostgresTicketRepository } from "./database/postgres-ticket-repository.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required to start the API"); +} + +const { database, close } = createDatabase(databaseUrl); +const app = buildApp({ + ticketRepository: new PostgresTicketRepository(database) +}); + +app.addHook("onClose", close); + +await app.listen({ + host: "0.0.0.0", + port: Number(process.env.PORT ?? 3000) +}); diff --git a/apps/api/src/tickets/ticket-contract.ts b/apps/api/src/tickets/ticket-contract.ts new file mode 100644 index 0000000..b407224 --- /dev/null +++ b/apps/api/src/tickets/ticket-contract.ts @@ -0,0 +1,93 @@ +export const ticketStatuses = ["OPEN"] as const; + +export type TicketStatus = (typeof ticketStatuses)[number]; + +export interface CreateTicketInput { + title: string; + description: string; + setup: string; + additionalInformation?: string; +} + +export interface Ticket { + id: string; + title: string; + description: string; + setup: string; + additionalInformation: string | null; + status: TicketStatus; + createdAt: string; + updatedAt: string; +} + +const requiredText = (maxLength: number) => ({ + type: "string", + minLength: 1, + maxLength, + pattern: "\\S" +}); + +export const createTicketBodySchema = { + type: "object", + additionalProperties: false, + required: ["title", "description", "setup"], + properties: { + title: requiredText(160), + description: requiredText(5_000), + setup: requiredText(3_000), + additionalInformation: requiredText(3_000) + } +} as const; + +export const ticketResponseSchema = { + type: "object", + additionalProperties: false, + required: [ + "id", + "title", + "description", + "setup", + "additionalInformation", + "status", + "createdAt", + "updatedAt" + ], + properties: { + id: { type: "string", format: "uuid" }, + title: { type: "string" }, + description: { type: "string" }, + setup: { type: "string" }, + additionalInformation: { type: ["string", "null"] }, + status: { type: "string", enum: ticketStatuses }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" } + } +} as const; + +export const apiErrorResponseSchema = { + type: "object", + additionalProperties: false, + required: ["error"], + properties: { + error: { + type: "object", + additionalProperties: false, + required: ["code", "message"], + properties: { + code: { type: "string" }, + message: { type: "string" } + } + } + } +} as const; + +export function normalizeCreateTicketInput(input: CreateTicketInput): CreateTicketInput { + return { + title: input.title.trim(), + description: input.description.trim(), + setup: input.setup.trim(), + ...(input.additionalInformation === undefined + ? {} + : { additionalInformation: input.additionalInformation.trim() }) + }; +} diff --git a/apps/api/src/tickets/ticket-repository.ts b/apps/api/src/tickets/ticket-repository.ts new file mode 100644 index 0000000..5bb7f1b --- /dev/null +++ b/apps/api/src/tickets/ticket-repository.ts @@ -0,0 +1,18 @@ +import type { CreateTicketInput, TicketStatus } from "./ticket-contract.js"; + +export type { CreateTicketInput } from "./ticket-contract.js"; + +export interface TicketRecord { + id: string; + title: string; + description: string; + setup: string; + additionalInformation: string | null; + status: TicketStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface TicketRepository { + create(input: CreateTicketInput): Promise; +} diff --git a/apps/api/src/tickets/ticket-route.test.ts b/apps/api/src/tickets/ticket-route.test.ts new file mode 100644 index 0000000..4387295 --- /dev/null +++ b/apps/api/src/tickets/ticket-route.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { buildApp } from "../app.js"; +import type { CreateTicketInput, TicketRecord, TicketRepository } from "./ticket-repository.js"; + +class FakeTicketRepository implements TicketRepository { + readonly created: CreateTicketInput[] = []; + + async create(input: CreateTicketInput): Promise { + this.created.push(input); + + return { + id: "cc04d84c-9aee-4d35-8af3-999d861aaed6", + ...input, + additionalInformation: input.additionalInformation ?? null, + status: "OPEN", + createdAt: new Date("2026-09-28T18:00:00.000Z"), + updatedAt: new Date("2026-09-28T18:00:00.000Z") + }; + } +} + +describe("POST /api/tickets", () => { + const apps: Array> = []; + + afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); + }); + + it("creates an anonymous ticket with normalized input", async () => { + const repository = new FakeTicketRepository(); + const app = buildApp({ ticketRepository: repository, logger: false }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/api/tickets", + payload: { + title: " Laptop will not start ", + description: " The power light flashes once. ", + setup: " Framework Laptop 13, Fedora 42 ", + additionalInformation: " Started after an update. " + } + }); + + expect(response.statusCode).toBe(201); + expect(repository.created).toEqual([ + { + title: "Laptop will not start", + description: "The power light flashes once.", + setup: "Framework Laptop 13, Fedora 42", + additionalInformation: "Started after an update." + } + ]); + expect(response.json()).toEqual({ + id: "cc04d84c-9aee-4d35-8af3-999d861aaed6", + title: "Laptop will not start", + description: "The power light flashes once.", + setup: "Framework Laptop 13, Fedora 42", + additionalInformation: "Started after an update.", + status: "OPEN", + createdAt: "2026-09-28T18:00:00.000Z", + updatedAt: "2026-09-28T18:00:00.000Z" + }); + }); + + it.each([ + ["blank title", { title: " ", description: "Issue", setup: "Setup" }], + [ + "unknown property", + { + title: "Issue", + description: "Description", + setup: "Setup", + status: "CLOSED" + } + ], + ["oversized description", { title: "Issue", description: "x".repeat(5_001), setup: "Setup" }] + ])("rejects %s without writing", async (_name, payload) => { + const repository = new FakeTicketRepository(); + const app = buildApp({ ticketRepository: repository, logger: false }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/api/tickets", + payload + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: { + code: "VALIDATION_ERROR", + message: "Ticket details are invalid" + } + }); + expect(repository.created).toHaveLength(0); + }); + + it("stores omitted additional information as null", async () => { + const repository = new FakeTicketRepository(); + const app = buildApp({ ticketRepository: repository, logger: false }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/api/tickets", + payload: { + title: "Laptop will not start", + description: "The power light flashes once.", + setup: "Framework Laptop 13, Fedora 42" + } + }); + + expect(response.statusCode).toBe(201); + expect(response.json()).toMatchObject({ additionalInformation: null }); + }); + + it("does not expose persistence errors", async () => { + const repository: TicketRepository = { + create: vi.fn().mockRejectedValue(new Error("password=database-secret")) + }; + const app = buildApp({ ticketRepository: repository, logger: false }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/api/tickets", + payload: { + title: "Laptop will not start", + description: "The power light flashes once.", + setup: "Framework Laptop 13, Fedora 42" + } + }); + + expect(response.statusCode).toBe(500); + expect(response.body).not.toContain("database-secret"); + expect(response.json()).toEqual({ + error: { + code: "INTERNAL_ERROR", + message: "The ticket could not be created" + } + }); + }); +}); diff --git a/apps/api/src/tickets/ticket-route.ts b/apps/api/src/tickets/ticket-route.ts new file mode 100644 index 0000000..ee9dc32 --- /dev/null +++ b/apps/api/src/tickets/ticket-route.ts @@ -0,0 +1,43 @@ +import type { FastifyPluginAsync } from "fastify"; + +import { + apiErrorResponseSchema, + createTicketBodySchema, + normalizeCreateTicketInput, + ticketResponseSchema, + type CreateTicketInput, + type Ticket +} from "./ticket-contract.js"; +import type { TicketRepository } from "./ticket-repository.js"; + +interface TicketRoutesOptions { + ticketRepository: TicketRepository; +} + +export const ticketRoutes: FastifyPluginAsync = async ( + app, + { ticketRepository } +) => { + app.post<{ Body: CreateTicketInput; Reply: Ticket }>( + "/api/tickets", + { + schema: { + body: createTicketBodySchema, + response: { + 201: ticketResponseSchema, + 400: apiErrorResponseSchema, + 500: apiErrorResponseSchema + } + } + }, + async (request, reply) => { + const ticket = await ticketRepository.create(normalizeCreateTicketInput(request.body)); + + return reply.status(201).send({ + ...ticket, + createdAt: ticket.createdAt.toISOString(), + updatedAt: ticket.updatedAt.toISOString() + }); + } + ); +}; diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..aef82a9 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "sourceMap": true, + "types": ["node"] + }, + "exclude": ["src/**/*.test.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..17b8d04 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..fd33321 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "lcov"], + thresholds: { + branches: 50, + functions: 50, + lines: 50, + statements: 50 + } + } + } +}); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..6732fd8 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Create a ticket | BetterTicket + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..9458ca4 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,29 @@ +{ + "name": "@betterticket/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --noEmit && vite build", + "coverage": "vitest run --coverage", + "dev": "vite --host 0.0.0.0", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "catalog:", + "@vitest/coverage-v8": "^4.0.0", + "jsdom": "^27.0.0", + "typescript": "^5.0.0", + "vite": "catalog:", + "vitest": "^4.0.0" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..31d47c2 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,35 @@ +import { TicketForm } from "./tickets/TicketForm.js"; + +export function App() { + return ( + <> +
+ + + BetterTicket + +
+ +
+
+

Support request

+

Tell us what went wrong.

+

+ Share enough context for a technician to start investigating. You can create this ticket + without an account. +

+
+ +
+ +
+
+ +
+

Do not include passwords, access keys, or other sensitive information.

+
+ + ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..b039329 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App.js"; +import "./styles.css"; + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Application root was not found"); +} + +createRoot(root).render( + + + +); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..0630ab1 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,252 @@ +:root { + color: #17212b; + background: #f2f4f3; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-synthesis: none; + line-height: 1.5; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 20rem; + min-height: 100vh; +} + +button, +input, +textarea { + font: inherit; +} + +.site-header { + border-bottom: 1px solid #d6dcda; + background: #ffffff; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.75rem; + padding: 1rem clamp(1rem, 5vw, 4rem); + color: #17212b; + font-weight: 750; + letter-spacing: -0.02em; + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border-radius: 0.25rem; + background: #0b5967; + color: #ffffff; + font-size: 0.75rem; + letter-spacing: 0.05em; +} + +main { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(30rem, 1.2fr); + gap: clamp(2rem, 6vw, 6rem); + width: min(76rem, calc(100% - 2rem)); + margin: 0 auto; + padding: clamp(3rem, 8vw, 7rem) 0 4rem; +} + +.intro { + align-self: start; + position: sticky; + top: 2rem; +} + +.eyebrow { + margin: 0 0 1rem; + color: #9b4a12; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +h1 { + max-width: 12ch; + margin: 0; + color: #17212b; + font-size: clamp(2.5rem, 6vw, 5rem); + line-height: 0.98; + letter-spacing: -0.055em; +} + +.intro > p:last-child { + max-width: 34rem; + margin: 1.5rem 0 0; + color: #53616a; + font-size: 1.05rem; +} + +.form-panel { + border-top: 0.25rem solid #0b5967; + background: #ffffff; + padding: clamp(1.25rem, 4vw, 2.5rem); + box-shadow: 0 1rem 2.5rem rgb(23 33 43 / 8%); +} + +fieldset { + display: grid; + gap: 1.5rem; + margin: 0; + padding: 0; + border: 0; +} + +legend { + margin-bottom: 1.5rem; + color: #17212b; + font-size: 1.25rem; + font-weight: 750; +} + +.field { + display: grid; + gap: 0.4rem; +} + +label { + font-weight: 700; +} + +.field-hint { + margin: 0; + color: #64727a; + font-size: 0.875rem; +} + +input, +textarea { + width: 100%; + border: 1px solid #9aa6aa; + border-radius: 0.25rem; + background: #ffffff; + color: #17212b; + padding: 0.75rem; +} + +textarea { + resize: vertical; +} + +input:focus-visible, +textarea:focus-visible, +button:focus-visible, +a:focus-visible { + outline: 0.2rem solid #e9a23b; + outline-offset: 0.15rem; +} + +.submit-button { + justify-self: start; + min-width: 10rem; + border: 0; + border-radius: 0.25rem; + background: #0b5967; + color: #ffffff; + padding: 0.8rem 1.25rem; + font-weight: 750; + cursor: pointer; +} + +.submit-button:hover:not(:disabled) { + background: #074753; +} + +.submit-button:disabled { + cursor: wait; + opacity: 0.7; +} + +.submission-message:empty { + display: none; +} + +.success-message, +.error-message { + margin: 1.5rem 0 0; + border-left: 0.25rem solid; + padding: 0.9rem 1rem; +} + +.success-message { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5rem 1rem; + border-color: #26734d; + background: #eef8f2; + color: #174c34; +} + +.error-message { + border-color: #a33c30; + background: #fff1ef; + color: #7d281f; +} + +code { + border: 1px solid #b7d5c3; + border-radius: 0.2rem; + background: #ffffff; + padding: 0.1rem 0.35rem; +} + +footer { + width: min(76rem, calc(100% - 2rem)); + margin: 0 auto; + border-top: 1px solid #d6dcda; + padding: 1.5rem 0 3rem; + color: #64727a; + font-size: 0.875rem; +} + +footer p { + margin: 0; +} + +@media (max-width: 48rem) { + main { + grid-template-columns: 1fr; + gap: 2.5rem; + padding-top: 3rem; + } + + .intro { + position: static; + } + + h1 { + font-size: clamp(2.75rem, 14vw, 4.5rem); + } +} + +@media (max-width: 24rem) { + .form-panel { + margin-inline: -0.5rem; + padding-inline: 1rem; + } + + .success-message { + display: grid; + } +} diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/apps/web/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/apps/web/src/tickets/TicketForm.test.tsx b/apps/web/src/tickets/TicketForm.test.tsx new file mode 100644 index 0000000..7f3266f --- /dev/null +++ b/apps/web/src/tickets/TicketForm.test.tsx @@ -0,0 +1,123 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { TicketForm } from "./TicketForm.js"; + +const ticketResponse = { + id: "cc04d84c-9aee-4d35-8af3-999d861aaed6", + title: "Laptop will not start", + description: "The power light flashes once.", + setup: "Framework Laptop 13, Fedora 42", + additionalInformation: "Started after an update.", + status: "OPEN", + createdAt: "2026-09-28T18:00:00.000Z", + updatedAt: "2026-09-28T18:00:00.000Z" +}; + +function fillRequiredFields() { + fireEvent.change(screen.getByLabelText("Title"), { + target: { value: "Laptop will not start" } + }); + fireEvent.change(screen.getByLabelText("What is happening?"), { + target: { value: "The power light flashes once." } + }); + fireEvent.change(screen.getByLabelText("Your setup"), { + target: { value: "Framework Laptop 13, Fedora 42" } + }); +} + +describe("TicketForm", () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("submits the approved ticket fields and shows confirmation", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(ticketResponse), { + status: 201, + headers: { "content-type": "application/json" } + }) + ); + vi.stubGlobal("fetch", fetchMock); + render(); + + fillRequiredFields(); + fireEvent.change(screen.getByLabelText("Additional information"), { + target: { value: "Started after an update." } + }); + fireEvent.click(screen.getByRole("button", { name: "Create ticket" })); + + expect(await screen.findByText("Your ticket has been created.")).toBeInTheDocument(); + expect(screen.getByText("cc04d84c")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith("/api/tickets", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: "Laptop will not start", + description: "The power light flashes once.", + setup: "Framework Laptop 13, Fedora 42", + additionalInformation: "Started after an update." + }) + }); + }); + + it("disables duplicate submission while the request is pending", async () => { + let resolveRequest: ((response: Response) => void) | undefined; + const fetchMock = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }) + ); + vi.stubGlobal("fetch", fetchMock); + render(); + + fillRequiredFields(); + fireEvent.click(screen.getByRole("button", { name: "Create ticket" })); + + const pendingButton = await screen.findByRole("button", { + name: "Creating ticket…" + }); + expect(pendingButton).toBeDisabled(); + fireEvent.click(pendingButton); + expect(fetchMock).toHaveBeenCalledTimes(1); + + resolveRequest?.( + new Response(JSON.stringify(ticketResponse), { + status: 201, + headers: { "content-type": "application/json" } + }) + ); + await screen.findByText("Your ticket has been created."); + }); + + it("shows an accessible error and keeps the entered values", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: "INTERNAL_ERROR", + message: "The ticket could not be created" + } + }), + { status: 500, headers: { "content-type": "application/json" } } + ) + ) + ); + render(); + + fillRequiredFields(); + fireEvent.click(screen.getByRole("button", { name: "Create ticket" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "We couldn't create your ticket. Please try again." + ); + expect(screen.getByLabelText("Title")).toHaveValue("Laptop will not start"); + await waitFor(() => + expect(screen.getByRole("button", { name: "Create ticket" })).toBeEnabled() + ); + }); +}); diff --git a/apps/web/src/tickets/TicketForm.tsx b/apps/web/src/tickets/TicketForm.tsx new file mode 100644 index 0000000..24dd5b4 --- /dev/null +++ b/apps/web/src/tickets/TicketForm.tsx @@ -0,0 +1,131 @@ +import { useState, type FormEvent } from "react"; + +import { createTicket, type CreateTicketInput } from "./ticket-api.js"; + +type SubmissionState = + | { type: "idle" } + | { type: "submitting" } + | { type: "success"; ticketId: string } + | { type: "error" }; + +const initialState: SubmissionState = { type: "idle" }; + +function readFormInput(form: HTMLFormElement): CreateTicketInput { + const formData = new FormData(form); + const additionalInformation = String(formData.get("additionalInformation") ?? "").trim(); + + return { + title: String(formData.get("title") ?? "").trim(), + description: String(formData.get("description") ?? "").trim(), + setup: String(formData.get("setup") ?? "").trim(), + ...(additionalInformation ? { additionalInformation } : {}) + }; +} + +export function TicketForm() { + const [submission, setSubmission] = useState(initialState); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + const form = event.currentTarget; + setSubmission({ type: "submitting" }); + + try { + const ticket = await createTicket(readFormInput(form)); + form.reset(); + setSubmission({ type: "success", ticketId: ticket.id }); + } catch { + setSubmission({ type: "error" }); + } + } + + const isSubmitting = submission.type === "submitting"; + + return ( +
+
+ Issue details + +
+ +

+ A short summary that helps a technician scan the queue. +

+ +
+ +
+ +

+ Include what you expected, what happened, and any messages you saw. +

+