Skip to content

Latest commit

 

History

74 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Terminuler

Terminuler is a full-stack appointment booking system. Customers pick a weekday, choose a free one-hour slot, enter their contact details and get a confirmation email. An administrator signs in to a protected dashboard to see upcoming appointments, search and filter them, and cancel bookings.

I built it as a portfolio project. It has a Next.js frontend, a Go REST API and a PostgreSQL database. Authentication is handled by Clerk and email by Resend. The project is tested with Go unit and integration tests and Playwright end-to-end tests, and it runs in GitHub Actions.

Live demo: terminuler.vercel.app · API: terminuler-api.onrender.com

CI Go Next.js React PostgreSQL License: MIT

Terminuler: choosing a time slot and entering customer details


Contents


Demo and hosting

Component Platform URL
Frontend (Next.js) Vercel terminuler.vercel.app
Backend API (Go) Render terminuler-api.onrender.com
Database PostgreSQL, used only by the API private
Admin authentication Clerk (development instance) –
Email delivery Resend –

The public deployment is a demo. The admin dashboard only lets in one configured Clerk user, so visitors can try the booking flow but not the admin area. The API runs on Render's free tier, so after a period without traffic the first request can take up to about a minute while the service wakes up.


Screenshots

These screenshots show the public booking flow. They were taken before the Admin link was added to the page header, and the admin dashboard is not pictured yet.

Choose a date Available times
Booking start screen with the date picker Available one-hour slots for a Monday, with 10:00 already booked
Customer details Confirmation
Selected slot summary and the contact form Confirmation screen with date, time and email address

On small screens the time grid switches from four columns to two. When a slot is selected, the contact form scrolls into view.

Mobile layout with a two-column time grid    Mobile layout after selecting a slot: the form is scrolled into view


Features

Public booking

  • Pick a date. The date picker blocks past dates.
  • See the free one-hour slots, Monday to Friday, 08:00–16:00. That is eight slots per day, the last one starting at 15:00. Booked slots and slots that have already started are hidden. Bookings can be made up to 60 days ahead.
  • Enter name, phone and email, then confirm. The confirmation screen shows the date, time and email address.
  • Double booking is handled. If someone else takes the slot first, the API answers 409. The page explains this, reloads the availability and keeps the details the customer already typed.
  • Loading, error and success states are shown for every step. Validation errors show the API's message. A rate-limited booking (429) asks the customer to wait. Other errors show a generic message.
  • Accessible, keyboard-friendly controls (native inputs, labelled fields, aria-pressed slots, focus management) and a responsive layout.

Admin dashboard (/admin)

  • Protected by Clerk sign-in. Only the user configured in ADMIN_CLERK_USER_ID can load data.
  • Lists the appointments from today onwards, ordered by date and time, with their status (Confirmed or Cancelled).
  • Search by customer name, email or phone, filter by status, and clear the filters.
  • Cancel a confirmed appointment after a confirmation dialog. The customer gets a cancellation email and the slot becomes bookable again.
  • Cancelled appointments stay in the list (soft cancellation), so the dashboard keeps a history of what was cancelled.
  • Table layout on desktop, stacked cards on mobile, plus a Logout button.
  • Clear messages for an expired session (401) and for a signed-in user who is not the admin (403).

Email (Resend)

  • A confirmation email is sent after each booking, and a cancellation email after each admin cancellation.
  • With EMAIL_PROVIDER=log, emails are written to the API log instead of being sent. Local development and the E2E tests use this mode.

Backend

  • Go REST API on the standard library net/http router, layered as handlers → services → repositories.
  • All business rules are enforced on the server. The frontend only reflects them.
  • PostgreSQL with embedded migrations, a partial unique index against double booking, and soft cancellation.
  • Per-client rate limiting on appointment creation.
  • /health checks that the database answers, request access logs, security headers, explicit server timeouts and graceful shutdown.

Tech stack

Layer Technology Version (from the project files)
Frontend Next.js (App Router) 16.3.8
React 19.2.8
TypeScript 5.9
Tailwind CSS 4.3 (via @tailwindcss/postcss)
Clerk (@clerk/nextjs) 7.9
ESLint (eslint-config-next) 9
Backend Go, standard library net/http 1.26.2 (go.mod)
Clerk Go SDK (clerk-sdk-go/v2) v2.7.0
pgx (through database/sql) v5.11.0
golang-migrate v4.20.1
resend-go v4.7.0
godotenv v1.5.1
Database PostgreSQL 18 (Docker image and CI)
Testing Go testing, Playwright (Chromium) Playwright 1.63
Infrastructure Docker, Docker Compose, GitHub Actions, Vercel, Render Node 22 in CI

Architecture

Browser
   │  same-origin requests only: /api/appointments/*, /api/admin/*
   ▼
Next.js on Vercel
   │  pages + route handlers (the API proxy)
   │  adds Clerk session token (admin) or X-Real-IP + X-Proxy-Secret (booking)
   ▼
Go API on Render
   ├── PostgreSQL   appointments (business data)
   ├── Clerk        verifies admin session tokens
   └── Resend       confirmation and cancellation emails
  • The browser never calls the Go API directly. It calls Next.js route handlers on its own origin, and they forward the request to the API at API_URL, which is a server-only variable. The API therefore needs no CORS configuration.
  • Secrets stay on the server. API_PROXY_SECRET, CLERK_SECRET_KEY and the API URL are only read by server code. The only value exposed to the browser is the Clerk publishable key (NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY), which is public by design.
  • For admin requests, the route handler reads the Clerk session and forwards its token as Authorization: Bearer …. The Go API verifies the token and checks the user ID itself.
  • For bookings, the route handler forwards the client IP in X-Real-IP together with X-Proxy-Secret, so the API's rate limiter can tell customers apart.
  • The Go API owns the business rules: slot validation, availability, double-booking protection, cancellation and email.
  • PostgreSQL stores the appointments. It is only reachable by the API.

The proxy is not the security boundary: the Go API is publicly reachable and does all validation, authorization and rate limiting itself.

Naming note: frontend/src/proxy.ts is the Next.js 16 proxy file (formerly middleware.ts). It only runs clerkMiddleware() so the session is available to pages and route handlers. The API proxy is the set of route handlers under frontend/src/app/api.


Project structure

terminuler/
├── .github/workflows/ci.yml        # CI: Go checks/tests, frontend checks, Playwright E2E
├── docker-compose.yml              # Local PostgreSQL 18 (host port 5433)
├── .env.example                    # Template for the repository-root .env
├── docs/images/                    # README screenshots
├── LICENSE
│
├── backend/                        # Go API (module github.com/pitercoding/terminuler)
│   ├── Dockerfile                  # Multi-stage image with the api and migrate binaries
│   ├── cmd/
│   │   ├── api/                    # HTTP server: wiring, timeouts, graceful shutdown
│   │   ├── migrate/                # Applies pending migrations and exits
│   │   └── e2edb/                  # Creates, migrates and empties the *_test database for E2E
│   └── internal/
│       ├── auth/                   # Clerk token verification, admin-only middleware
│       ├── config/                 # Environment variables, defaults and validation
│       ├── database/               # Connection pool, migration runner
│       │   └── migrations/         # Embedded SQL migrations
│       ├── email/                  # Sender interface, Resend and log implementations
│       ├── handlers/               # HTTP handlers, JSON decoding, error mapping
│       ├── middleware/             # Security headers, access log
│       ├── models/                 # Appointment model and status
│       ├── ratelimit/              # Sliding-window limiter, client IP resolver
│       ├── repositories/           # SQL queries
│       ├── routes/                 # Route registration
│       └── services/               # Business rules
│
└── frontend/                       # Next.js application
    ├── next.config.ts              # Security headers, x-powered-by disabled
    ├── playwright.config.ts        # Starts the API and Next.js for E2E runs
    ├── src/
    │   ├── proxy.ts                # Clerk middleware (Next.js 16 "proxy" file)
    │   ├── app/
    │   │   ├── page.tsx            # Public booking flow
    │   │   ├── admin/              # Admin dashboard (layout requires a session)
    │   │   └── api/                # Route handlers proxying to the Go API
    │   ├── components/             # DateSelector, TimeSlotGrid, AppointmentForm, ...
    │   ├── lib/                    # API proxy helpers, date formatting, scrolling
    │   └── services/               # Typed fetch client for the route handlers
    └── tests/e2e/                  # Playwright specs

Getting started

Prerequisites

Tool Version / notes
Git any recent version
Go 1.26.2 or newer (backend/go.mod)
Node.js + npm Node 22 (the version used in CI)
Docker with Compose Runs PostgreSQL. You don't need a local PostgreSQL installation.
Clerk account Free. A development instance provides the keys the frontend needs to start.
Resend account Optional. Not needed if you use EMAIL_PROVIDER=log.

1. Clone

git clone https://github.com/pitercoding/terminuler.git
cd terminuler

2. Configure environment variables

The project uses two env files, both git-ignored:

File Read by
.env (repository root) Docker Compose, the Go API and the Playwright config
frontend/.env.local Next.js

Create the root .env from the template:

cp .env.example .env        # PowerShell: Copy-Item .env.example .env

Then edit .env:

  • Replace change_me in both POSTGRES_PASSWORD and DATABASE_URL with the same password.
  • Set EMAIL_PROVIDER=log to run without Resend. Emails will then appear in the API log.
  • Set CLERK_SECRET_KEY to the secret key of your Clerk development instance (Clerk Dashboard → API keys).
  • Leave ADMIN_CLERK_USER_ID=change_me for now. You will set it in step 6.

Create frontend/.env.local:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
# Optional locally: defaults to http://localhost:8080 outside production
API_URL=http://localhost:8080

CLERK_SECRET_KEY must be the same key in both files. Next.js does not read the root .env.

3. Start PostgreSQL

docker compose up -d

This starts the postgres service (container terminuler-postgres, image postgres:18) on localhost:5433, so it doesn't collide with a local PostgreSQL on 5432. Data is stored in the terminuler-postgres-data volume. The database, user and password come from POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD in .env.

4. Run migrations and start the API

cd backend
go run ./cmd/migrate
go run ./cmd/api

go run ./cmd/api does not run migrations, so run cmd/migrate first and again after pulling new migrations. Only the Docker image runs migrations automatically. The API loads the root .env (it looks in ./.env, then ../.env). It refuses to start if a required variable is missing or invalid.

curl http://localhost:8080/health
# {"status":"ok"}

5. Start the frontend

In a second terminal:

cd frontend
npm ci
npm run dev

6. Set up the admin user

  1. Open http://localhost:3000/admin (or click Admin). You are redirected to Clerk's hosted sign-in page for your development instance. Sign up or sign in.
  2. Until the admin user is configured, the dashboard says your account does not have access (403).
  3. Copy your user ID (user_...) from Clerk Dashboard → Users, put it in ADMIN_CLERK_USER_ID in the root .env, and restart the API.
  4. Reload /admin. Bookings you make on the home page now appear there.

Local URLs

Service URL
Frontend (Next.js dev server) http://localhost:3000
Admin dashboard http://localhost:3000/admin
Go API http://localhost:8080
PostgreSQL localhost:5433
E2E API / web (started by Playwright) http://localhost:8081 / http://localhost:3001

Environment variables

Never commit real values. .env and .env.* files are git-ignored (only .env.example is committed), and backend/.dockerignore keeps them out of the Docker build context.

Backend (Go API): root .env locally, Render in production

Variable Required Default Purpose
DATABASE_URL Yes – PostgreSQL connection string
CLERK_SECRET_KEY Yes – Verifies admin session tokens. Must be from the same Clerk instance as the frontend keys.
ADMIN_CLERK_USER_ID Yes – Clerk user ID (user_...) of the only user allowed on /admin endpoints
PORT No 8080 HTTP port (Render sets it)
APP_TIMEZONE No UTC IANA timezone for business hours and "today", e.g. Europe/Berlin
APPOINTMENT_RATE_LIMIT No 5 Bookings allowed per client per minute
TRUSTED_PROXIES No empty Comma-separated CIDRs/IPs allowed to send X-Real-IP (the template sets localhost)
API_PROXY_SECRET No (needed in production) empty Shared secret sent by the Next.js proxy in X-Proxy-Secret. At least 32 characters, e.g. openssl rand -hex 32.
EMAIL_PROVIDER No resend resend sends emails, log only logs them
RESEND_API_KEY When EMAIL_PROVIDER=resend – Resend API key
RESEND_FROM_EMAIL No onboarding@resend.dev Sender address (see Email)

Docker Compose and tests: root .env

Variable Purpose
POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD Initialize the local PostgreSQL container. They must match DATABASE_URL.
TEST_DATABASE_URL Go integration tests. Must be exported in the shell, because the tests do not read .env.
E2E_DATABASE_URL Optional. Database for Playwright. Defaults to DATABASE_URL with the database renamed to terminuler_test. The name must end in _test.

Frontend (Next.js): frontend/.env.local locally, Vercel in production

Variable Exposure Purpose
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY Public (bundled into the browser) Clerk publishable key
CLERK_SECRET_KEY Server-only Clerk secret key. Same instance, and the same value as the API's.
API_URL Server-only Base URL of the Go API. Defaults to http://localhost:8080 outside production. Required in production, where a missing value makes the route handlers answer 503.
API_PROXY_SECRET Server-only Must equal the API's API_PROXY_SECRET. Never prefix it with NEXT_PUBLIC_.

Shared between Vercel and Render: CLERK_SECRET_KEY (same Clerk instance) and API_PROXY_SECRET (same value).


Database

The API uses a single appointments table, created and changed by two migrations in backend/internal/database/migrations:

  1. 000001_create_appointments: the date, start and end times, customer name, phone and email, and created_at.
  2. 000002_add_appointment_status: adds status (confirmed or cancelled) and cancelled_at, with check constraints that keep them consistent.

Soft cancellation. Cancelling an appointment sets status = 'cancelled' and cancelled_at. The row is never deleted.

Double-booking protection. A partial unique index on (appointment_date, start_time) WHERE status = 'confirmed' allows at most one confirmed appointment per slot. A cancelled appointment therefore frees its slot for a new booking. The API does not check-then-insert, which would race. It inserts directly, and PostgreSQL rejects a concurrent duplicate with a unique violation, which the API maps to 409 Conflict. Cancelling is a single conditional UPDATE, so cancelling the same appointment twice succeeds only once.

Migrations are embedded in the Go binaries and applied with golang-migrate, which records the applied version and takes a database lock. Running migrations repeatedly or concurrently is therefore safe.

How When
go run ./cmd/migrate (from backend/) Locally, before starting the API
./migrate && exec ./api in the Docker image On every container start (production)
go run ./cmd/e2edb Before each E2E run. It also empties the test database.

Authentication and admin dashboard

Clerk handles sign-in. The app has no user table of its own, and there is a single admin, identified by Clerk user ID.

Protection is checked at three levels:

  1. Admin page. frontend/src/app/admin/layout.tsx calls auth.protect(), so a signed-out visitor is redirected to Clerk's sign-in page before anything is rendered.
  2. Admin route handlers. /api/admin/* answer 401 when there is no Clerk session. Otherwise they forward the session token to the API.
  3. Go API. /admin/* routes verify the token with the Clerk Go SDK (401 if it is missing or invalid) and compare its subject with ADMIN_CLERK_USER_ID (403 for any other user).

The Go API is the authority: any signed-in Clerk user can open the page, but only the configured admin can read or cancel appointments.

Logout uses Clerk's SignOutButton and returns to /admin, which shows the sign-in page again.


Email

Emails are sent through an email.Sender interface. EMAIL_PROVIDER selects the implementation at startup.

EMAIL_PROVIDER Behavior
resend (default) Sends "Appointment confirmation" and "Appointment cancelled" HTML emails through the Resend API. Requires RESEND_API_KEY, otherwise the API does not start.
log Writes the email details to the API log and never fails. Used in local development and E2E tests.
  • Emails are sent after the booking or cancellation is stored. A send failure is logged and the request still succeeds, so a flaky email provider cannot make a stored booking look failed. Emails are not retried.
  • Each Resend request has a 10-second timeout, shorter than the server's 20-second write timeout.
  • The customer name is HTML-escaped before it goes into the email body.
  • Sender address. Without RESEND_FROM_EMAIL, the API uses Resend's testing sender onboarding@resend.dev. No custom domain is configured in this project. Resend only delivers mail from that testing sender to the Resend account owner's own address. Sending to any customer requires a sender on a domain verified in Resend.

Testing

Backend tests

cd backend
go test ./...                                    # unit tests
go vet ./...                                     # static checks
go vet -tags=integration ./...
gofmt -l .                                       # lists unformatted files (CI fails if any)

The unit tests cover configuration parsing, booking rules (business hours, horizon, past slots, timezones, phone/email validation), handler status codes and JSON handling, the JSON content-type check, routes and methods, admin authorization (401/403), the rate limiter and client IP resolution, security headers and access logging, the health check, the log email sender and the E2E database guard. The clock is injected, so time-dependent rules are deterministic.

Integration tests run against a real PostgreSQL behind the integration build tag. Each run creates its own schema, applies the migrations and drops the schema at the end. The tests cover the repository and the service → repository flow, including concurrent bookings of the same slot and soft cancellation. TEST_DATABASE_URL must be set in the shell:

# bash
TEST_DATABASE_URL="postgres://terminuler:<password>@localhost:5433/terminuler" \
  go test -count=1 -tags=integration ./...
# PowerShell
$env:TEST_DATABASE_URL = "postgres://terminuler:<password>@localhost:5433/terminuler"
go test -count=1 -tags=integration ./...

CI runs both suites with -race. The race detector needs cgo and a C compiler, so on Windows without a 64-bit GCC toolchain, run the tests without -race.

Frontend checks

cd frontend
npx next typegen          # generates next-env.d.ts and route types (not committed)
npx tsc --noEmit          # type check
npm run lint              # ESLint
npm run build             # production build (needs NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY)

End-to-end (Playwright)

cd frontend
npx playwright install chromium     # first time only
npm run test:e2e                    # headless
npm run test:e2e:ui                 # Playwright UI mode

Requirements: PostgreSQL running (docker compose up -d), Go installed, and the Clerk keys in frontend/.env.local or the environment.

playwright.config.ts starts its own servers, so the tests can run while the dev servers are up:

  • API on port 8081. go run ./cmd/e2edb && go run ./cmd/api, with EMAIL_PROVIDER=log and the rate limit raised to 1000/min. e2edb creates, migrates and empties terminuler_test, and refuses any database whose name does not end in _test.
  • Frontend on port 3001. A production build (npm run build && npm run start).
Spec What it covers
booking.spec.ts A full booking from date selection to the confirmation screen. The booked slot is no longer offered afterwards.
booking-conflict.spec.ts Two browsers pick the same slot. The second booking gets 409, sees the conflict message, keeps its details and books another slot.
security.spec.ts Security headers and no x-powered-by; /admin redirects without a session; /api/admin/* return 401 without a session; a non-JSON booking gets 415; invalid JSON gets the API's 400.
api-proxy.spec.ts Proxy helper behavior: status and Retry-After forwarding, no-store, 502/504 when the API is unreachable or slow, 204 pass-through, the JSON content-type check.

On failure, traces and screenshots are kept in frontend/test-results/.


CI

.github/workflows/ci.yml runs on every push to main and every pull request targeting main. A newer push to the same ref cancels the run in progress, and the workflow has read-only repository permissions.

Job Steps
Backend (Go) gofmt -l check, go vet (with and without the integration tag), go test -race ./..., go test -race -tags=integration ./... against a PostgreSQL 18 service container
Frontend (Next.js) Node 22, npm ci, next typegen + tsc --noEmit, npm run lint, npm run build
E2E (Playwright) Runs after both jobs pass. Installs Chromium, checks the format of the Clerk keys, then runs npm run test:e2e against a PostgreSQL 18 service container. Uploads frontend/test-results/ on failure (kept 7 days).

The workflow needs two repository secrets: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY. The E2E tests do not sign in, and the API they start uses placeholder Clerk values. Deployment is not part of the workflow.


Production deployment

Backend on Render

The API is deployed as a container built from backend/Dockerfile. The multi-stage build produces two static binaries (api and migrate) on alpine:3.22, which runs as a non-root user. The start command is:

./migrate && exec ./api

Migrations run on every start because Render's free tier has no pre-deploy step. exec makes the API PID 1, so it receives SIGTERM and shuts down gracefully.

Environment variables set in Render:

  • DATABASE_URL: the PostgreSQL connection string
  • CLERK_SECRET_KEY, ADMIN_CLERK_USER_ID
  • API_PROXY_SECRET: the same value as on Vercel
  • APP_TIMEZONE
  • EMAIL_PROVIDER=resend, RESEND_API_KEY, optionally RESEND_FROM_EMAIL
  • Optionally APPOINTMENT_RATE_LIMIT. PORT is provided by Render.

Use /health as the health check path. It returns 200 {"status":"ok"} when the database answers a ping and 503 {"status":"unavailable"} otherwise.

TRUSTED_PROXIES is not useful on Render, because Vercel's outgoing IPs are not fixed. API_PROXY_SECRET is what lets the API trust the client IP sent by the proxy. Without it, all bookings through the proxy share one rate limit, and the API logs a warning at startup.

Frontend on Vercel

Vercel builds the Next.js app from frontend/ (next build). Environment variables:

  • API_URL=https://terminuler-api.onrender.com
  • NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY
  • API_PROXY_SECRET: the same value as on Render

Clerk

The public deployment uses a Clerk development instance (pk_test_ / sk_test_ keys) and its hosted sign-in pages. That is a deliberate choice for a portfolio demo with a single admin. Development instances have usage limits and show Clerk's development branding. A real production deployment would use a Clerk production instance on its own domain. All three Clerk values (publishable key, secret key, admin user ID) must come from the same instance, because a token from another instance is rejected with 401.

Resend

Emails go through the Resend API with RESEND_API_KEY. See Email for the sender-address limitation: without a verified domain, only the Resend account owner receives emails. Bookings and cancellations still succeed when an email cannot be delivered.


Security

These measures are implemented. They are a reasonable baseline for a small public demo, not a complete security program.

  • Authentication and authorization. Clerk sessions are verified by the Go API on every admin request, and only the configured admin user ID is allowed (401 / 403). The admin page also requires a session on the server.
  • Server-side secrets. The Clerk secret key, API URL and proxy secret are only available to server code. Env files are git-ignored and excluded from the Docker build context.
  • API proxy secret. X-Real-IP is only trusted when the request comes from TRUSTED_PROXIES or carries X-Proxy-Secret, which is compared in constant time. The client IP cannot be spoofed to bypass the rate limit.
  • Rate limiting. A sliding window per client on POST /appointments (default 5/min) returns 429 with Retry-After. IPv6 clients are grouped by /64.
  • JSON Content-Type enforcement. Bookings must be application/json (415 otherwise), both in the proxy and in the API, so another site cannot submit bookings through a visitor's browser without a CORS preflight.
  • Strict input handling. 1 MiB body limit, unknown JSON fields rejected, server-side validation of every field (phone format, plain email address, control characters, lengths) and of every business rule.
  • Parameterized SQL for all queries.
  • Database-level integrity. A partial unique index prevents double booking. Cancellation is a single conditional update.
  • Security headers. Next.js sends CSP frame-ancestors 'none', X-Frame-Options: DENY, nosniff, Referrer-Policy and Permissions-Policy, and hides x-powered-by. The API sends a restrictive CSP, nosniff, no-referrer and Cache-Control: no-store.
  • Controlled errors. Unexpected errors return a generic internal server error, and details only go to the server log. The access log records method, path, status and duration, but no query strings or headers.
  • Runtime hardening. Explicit HTTP timeouts, a capped database connection pool, graceful shutdown, and a non-root container.

API

Base URLs: https://terminuler-api.onrender.com (production) · http://localhost:8080 (local).

Errors use one shape: {"error": "message"}. A wrong method gets 405 with an Allow header.

Method Path Auth Purpose
GET /health – Liveness and database check: 200 {"status":"ok"} or 503
GET /appointments/availability?date=YYYY-MM-DD – Free slots for a date: {"date": "...", "available_slots": [{"start_time": "08:00", "end_time": "09:00"}, ...]}. Empty for weekends, past days and dates beyond 60 days.
POST /appointments – (rate limited) Creates a booking. Returns 201 with the appointment. Errors: 400 validation, 409 slot taken, 413 body too large, 415 not JSON, 429 rate limited.
GET /admin/appointments Clerk token, admin Appointments from today onwards (confirmed and cancelled), ordered by date and time
DELETE /admin/appointments/{id} Clerk token, admin Soft-cancels a confirmed appointment and emails the customer. Returns 204, 400 for an invalid id, or 404 if the appointment is missing or already cancelled.

Example booking request:

curl -X POST http://localhost:8080/appointments \
  -H "Content-Type: application/json" \
  -d '{
    "appointment_date": "2026-10-05",
    "start_time": "13:00",
    "end_time": "14:00",
    "customer_name": "Jane Doe",
    "customer_phone": "+49 151 23456789",
    "customer_email": "jane@example.com"
  }'

Appointment responses include id, appointment_date (plain YYYY-MM-DD), start_time, end_time, the customer fields, status, created_at and cancelled_at (null unless cancelled).

Next.js route handlers (used by the browser)

Route Forwards to
GET /api/appointments/availability?date=… GET /appointments/availability
POST /api/appointments POST /appointments, adding X-Real-IP and X-Proxy-Secret
GET /api/admin/appointments GET /admin/appointments with the Clerk session token
DELETE /api/admin/appointments/{id} DELETE /admin/appointments/{id} with the Clerk session token

The route handlers return the API's status and JSON body, including Retry-After, and mark every response no-store. If the API cannot be reached they answer 502. If it doesn't answer within 60 seconds they answer 504. If API_URL is missing in production they answer 503.


Technical decisions

  • Go with the standard library. Go 1.22+ routing patterns give method matching, 405 and HEAD support, which is enough for five endpoints without a framework. Middlewares are plain func(http.Handler) http.Handler.
  • Next.js. One project serves the React UI and the server-side route handlers that keep secrets and the API URL away from the browser.
  • PostgreSQL owns booking integrity. A partial unique index is safer than an application-level availability check, which can race. Concurrent integration tests and a two-browser E2E test verify it.
  • API proxy. Same-origin requests mean no CORS. The proxy also attaches the Clerk token for admin calls and the client IP plus proxy secret for rate limiting.
  • Soft cancellation. Keeping cancelled rows preserves history for the admin and makes cancellation idempotent, while the partial index still frees the slot.
  • Clerk. Sign-in, sessions and token verification come from a dedicated service instead of custom password handling. With a single admin, authorization is a user ID comparison in the API.
  • Playwright. Tests run against the real stack (browser → Next.js → Go → PostgreSQL) with nothing mocked, using a production build and a separate, self-guarding test database.
  • Business rules in a service with an injected clock. Rules such as "no past slots" and "today in APP_TIMEZONE" are tested deterministically, including timezone edge cases. Dates travel as plain YYYY-MM-DD to avoid off-by-one-day bugs in browsers.
  • No Redis, queue or ORM. The API runs as a single instance with one table. An in-memory rate limiter and synchronous, best-effort emails are enough. The Sender interface, the repository interface and the rate-limit middleware are clear points to extend if that changes.

Known limitations

  • One admin, configured by Clerk user ID. There are no roles or multiple staff members.
  • Fixed schedule. Hours, weekdays and the 60-day horizon are constants in code. Holidays and breaks are not supported.
  • The admin dashboard lists appointments from today onwards. Past appointments are not shown, and appointments cannot be rescheduled.
  • Rate limiting is in memory, per process. It resets on restart and is not shared between instances.
  • Emails are not retried or tracked. Without a verified Resend domain, only the account owner receives them.
  • The public demo uses a Clerk development instance, and the API runs on Render's free tier with cold starts.
  • UI is English only, with a light theme only.

Project status

Portfolio project, V2 completed. The application is functional, tested and deployed. V1 delivered the public booking flow. V2 added Clerk authentication, the admin dashboard, cancellation emails and soft cancellation, plus production hardening (proxy secret, content-type enforcement, security headers, database health check, access logs). It is not a commercial product.


License

MIT © 2026 Piter Gomes

About

[EN] Full-stack appointment booking system with real-time availability, booking conflict handling, confirmation emails, and automated E2E testing. [PT-BR] Sistema full-stack de agendamento de consultas com disponibilidade em tempo real, tratamento de conflitos, emails de confirmação e testes E2E automatizados.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages