Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sapling-api

Go backend for Sapling, a personal, single-tenant job application tracker:

  • Applications — cards with custom, user-defined statuses and a full status-change history.
  • Calendar — personal events, recurring/one-time availability windows, and an inbox of pending interview invitations.
  • Public calendar — a 6-character personal link (/{token}) where interviewers see only Busy/Tentative/Available slots and can submit an interview invitation. No accounts, no titles exposed.

Companion repos: sapling-web (React + Vite frontend) and sapling-deploy (Docker Compose stack).

Tech stack

  • Go 1.25, chi v5 router
  • PostgreSQL 16 via pgx/v5 + sqlc (typed queries, no ORM)
  • goose v3 migrations, embedded and applied automatically on startup
  • JWT auth (access + rotating refresh tokens) with golang-jwt/jwt/v5, bcrypt passwords
  • Background auto-archive job (moves stale applications to the system "Lost" status every 6 h)

Prerequisites

  • Go 1.25+
  • PostgreSQL 16 (or Docker to run one)
  • sqlc — only if you change SQL queries (internal/db/queries/); generated code is committed under internal/db/query/

Setup

cp .env.example .env
# then edit .env:
#   DATABASE_URL          postgres connection string
#   ACCESS_TOKEN_SECRET   openssl rand -hex 32
#   REFRESH_TOKEN_SECRET  openssl rand -hex 32
#   CORS_ORIGIN           frontend origin, e.g. http://localhost:5173

SMTP variables are optional. Without SMTP_HOST, password-reset links are printed to stdout instead of emailed.

Run locally

# disposable database
docker run -d --name jt-pg -p 5432:5432 \
  -e POSTGRES_USER=sapling -e POSTGRES_PASSWORD=sapling -e POSTGRES_DB=sapling \
  postgres:16-alpine

# run the API (migrations apply automatically)
set -a; source .env; set +a
go run ./cmd/api

Health check: curl http://localhost:8080/healthz

Develop

go build ./...   # build
go vet ./...     # lint
go test ./...    # tests (token + slot-computation logic)
sqlc generate    # regenerate internal/db/query after editing queries

API overview

Area Base path Auth
Auth (register/login/refresh/logout/password reset) /api/auth/*
Profile + calendar link /api/user/* Bearer
Applications + status history /api/applications* Bearer
Custom statuses /api/statuses* Bearer
Events, availability, invitations /api/calendar/* Bearer
Public calendar + invite submission /api/c/{token}* none (rate-limited)

Notable behaviors:

  • Registration seeds five statuses (Applied, Interview, Accepted, Rejected, and the undeletable system status Lost) and generates the 6-char public calendar token.
  • Refresh tokens rotate on every /api/auth/refresh; they are stored hashed and also set as an HttpOnly cookie.
  • Password reset is a link: POST /api/auth/forgot-password mails a one-hour JWT to /reset-password?token=…, and POST /api/auth/reset-password spends it. The response to the first is identical whether or not the address is registered. The token carries a fingerprint of the password hash it was issued against, so it is single-use without any server-side state — resetting changes the hash, the fingerprint stops matching, and the link is dead. The same holds if the password moves by any other route.
  • Password change (POST /api/user/password, authenticated) takes current_password and new_password, drops every refresh token, and returns a fresh token pair so the caller stays signed in on this device and nowhere else. A wrong current password is a 400, not a 401, so clients don't mistake it for an expired session.
  • Deleting a status that applications still use returns 409 with {affected_count, lost_status_id}; retry with ?confirm=true to move those applications to Lost.
  • The public calendar returns the owner's display_name and timezone alongside the slots, so whoever follows the link can see they are booking with the right person. Nothing else about the owner is exposed — slots carry only times and a type (busy / tentative / available), never titles. Available slots are 30-minute granules computed from availability windows minus busy/tentative overlap.
  • Published slots are 30-minute granules, but a submitted invitation only has to fit one open availability window and clash with nothing already booked — it may last any positive multiple of 5 minutes and need not align to a granule boundary. A proposal spanning two adjacent windows is refused.
  • Availability windows support PATCH /api/calendar/availability/{id}: every field is optional and an omitted one keeps its current value, so editing a window's hours preserves its identity. Flipping is_recurring clears whichever of day_of_week / specific_date no longer applies.

Deploy

docker build -t sapling-api .
docker run --env-file .env -p 8080:8080 sapling-api

The full deployment stack (Traefik, Postgres, frontend) lives in sapling-deploy. CI (.github/workflows/ci.yml) runs tests/vet/golangci-lint, builds a multi-arch image, scans it with Trivy, and pushes to GHCR on version tags.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages