diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e140b4 --- /dev/null +++ b/README.md @@ -0,0 +1,262 @@ +# slotline + +A small reservation platform built with Spring Boot. Resources (rooms, courts, +equipment) are booked for time slots, and the core guarantee is that a single +slot can never be double-booked, even under heavy concurrency. + +The project is deliberately focused on the parts that are hard to get right in a +booking system: concurrency, transactions, security, and a documented API. + +## Tech stack + +- Java 21, Spring Boot 3.5 +- Spring Web, Spring Data JPA, Spring Security (stateless JWT) +- Flyway for schema migrations (vendor-specific: H2 for dev/test, PostgreSQL for deploy) +- H2 in-memory (dev/test), PostgreSQL (deploy profile) +- springdoc-openapi for Swagger UI +- Maven Wrapper (`./mvnw`) - no local Maven install required + +## Running it + +Prerequisites: a JDK 21. The build uses the Maven Wrapper, so Maven itself does +not need to be installed. + +```bash +# dev profile (H2 in-memory), starts on http://localhost:8080 +./mvnw spring-boot:run + +# run the test suite (unit + integration + the concurrency test) +./mvnw test +``` + +On startup a default ADMIN account is seeded if none exists (see Configuration). + +- Swagger UI: http://localhost:8080/swagger-ui.html +- OpenAPI JSON: http://localhost:8080/v3/api-docs + +## Architecture + +Layered, one package per bounded slice: + +``` +com.slotline + config/ SecurityConfig, OpenApiConfig, SlotlineProperties, AdminBootstrap + security/ JwtService, JwtAuthenticationFilter, AuthenticatedUser, RestAuthenticationHandlers + user/ User, Role, AuthService, AuthController, dto/ + resource/ Resource, ResourceService, ResourceController, dto/ + reservation/ Reservation, ReservationService, ReservationController, dto/ + common/ domain exceptions + GlobalExceptionHandler (RFC 7807 ProblemDetail) +``` + +- Controllers are thin; all logic and transaction boundaries live in the services. +- Requests and responses are immutable records; entities never leave the service layer. +- Errors are returned as `application/problem+json` (ProblemDetail), including + field-level validation violations. + +## No double-booking under concurrency (the core feature) + +Booking a slot runs inside a single transaction in `ReservationService.create`: + +1. The target resource row is loaded with a pessimistic write lock + (`SELECT ... FOR UPDATE` via `@Lock(PESSIMISTIC_WRITE)`), which serializes all + concurrent bookings for that one resource. +2. An overlap query looks for any active reservation on the resource where + `existing.start < new.end AND existing.end > new.start`. +3. If an overlap exists, the booking is rejected with `409 Conflict`; otherwise the + reservation is inserted. + +Because the resource row is locked for the duration of the check-and-insert, two +concurrent requests for overlapping slots cannot both pass the overlap check. +The lock is released on commit, so the loser sees the committed reservation and +gets a clean `409`. + +Extra layers of defense: + +- `@Version` optimistic locking on `Resource` and `Reservation` protects against + lost updates on other concurrent operations (for example a concurrent cancel). + Any optimistic-lock failure is also mapped to `409`. +- The PostgreSQL migration adds a partial unique index + `(resource_id, start_time, end_time) WHERE status = 'ACTIVE'` as a + database-level backstop in production. + +Why pessimistic locking for the booking path rather than optimistic alone: it is +deterministic across H2 and PostgreSQL and it correctly rejects arbitrary +overlapping slots, not just byte-identical ones. Optimistic `@Version` is kept as +a second layer, and the unique index is the last line of defense in production. + +This is verified by `ReservationConcurrencyTest`: 12 threads are released +simultaneously against the same slot and the test asserts exactly one success, +eleven `409`s, and exactly one active reservation in the database. Removing the +pessimistic lock makes the test fail (all 12 succeed), so the test genuinely +exercises the guarantee. + +## API + +Authentication is a Bearer JWT obtained from `/api/auth/login`. + +| Method | Path | Access | Notes | +|--------|------------------------------------------|-------------|-------------------------------| +| POST | /api/auth/register | public | creates a USER account (201) | +| POST | /api/auth/login | public | returns a JWT | +| GET | /api/resources | USER, ADMIN | list resources | +| GET | /api/resources/{id} | USER, ADMIN | single resource | +| POST | /api/resources | ADMIN | create resource (201) | +| PUT | /api/resources/{id} | ADMIN | update resource | +| DELETE | /api/resources/{id} | ADMIN | delete resource (204) | +| POST | /api/reservations | USER, ADMIN | book a slot (201) or 409 | +| GET | /api/reservations/mine | USER, ADMIN | caller's reservations | +| POST | /api/reservations/{id}/cancel | owner/ADMIN | cancel a reservation | +| GET | /api/resources/{resourceId}/reservations | USER, ADMIN | active reservations | + +## curl examples + +An end-to-end script lives at `scripts/smoke.sh` (happy path plus the 409 +conflict). It expects a running instance: + +```bash +BASE_URL=http://localhost:8080 ./scripts/smoke.sh +``` + +The individual calls: + +```bash +# log in as the seeded admin +ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin12345"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") + +# create a resource (ADMIN only) +curl -s -X POST http://localhost:8080/api/resources \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \ + -d '{"name":"Court 1","description":"Outdoor tennis court"}' + +# register and log in as a normal user +curl -s -X POST http://localhost:8080/api/auth/register \ + -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"password123"}' +USER_TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"password123"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") + +# book a slot -> 201 +curl -s -X POST http://localhost:8080/api/reservations \ + -H "Authorization: Bearer $USER_TOKEN" -H 'Content-Type: application/json' \ + -d '{"resourceId":1,"startTime":"2026-09-01T10:00:00Z","endTime":"2026-09-01T11:00:00Z"}' + +# book the same slot again -> 409 Conflict +curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8080/api/reservations \ + -H "Authorization: Bearer $USER_TOKEN" -H 'Content-Type: application/json' \ + -d '{"resourceId":1,"startTime":"2026-09-01T10:00:00Z","endTime":"2026-09-01T11:00:00Z"}' +``` + +## Testing + +```bash +./mvnw test +``` + +Suite: + +- `JwtServiceTest` - token issue/parse round trip and rejection of tokens signed with another key. +- `ReservationApiTest` - full HTTP flow through Spring Security: register, login, role enforcement (USER cannot create resources), book, 409 on conflict, cancel, and re-book after cancel. +- `ReservationConcurrencyTest` - the concurrency guarantee described above. +- `SlotlineApplicationTests` - context loads. + +## Docker + +Multi-stage build (JDK 21 to build, JRE 21 to run): + +```bash +docker build -t slotline:latest . +docker run --rm -p 8080:8080 \ + -e SPRING_PROFILES_ACTIVE=postgres \ + -e SLOTLINE_DB_URL=jdbc:postgresql://db:5432/slotline \ + -e SLOTLINE_DB_USERNAME=slotline \ + -e SLOTLINE_DB_PASSWORD=secret \ + -e SLOTLINE_JWT_SECRET=please-change-this-to-a-long-random-secret \ + slotline:latest +``` + +Without `SPRING_PROFILES_ACTIVE=postgres` the container runs on the in-memory H2 +database (useful for a quick demo, not for real data). + +## Configuration + +All values are overridable by environment variable: + +| Variable | Default | Purpose | +|---------------------------|------------------------|------------------------------------| +| SLOTLINE_JWT_SECRET | dev-only placeholder | HMAC signing key (set in prod) | +| SLOTLINE_ADMIN_USERNAME | admin | seeded admin username | +| SLOTLINE_ADMIN_PASSWORD | admin12345 | seeded admin password (set in prod)| +| SPRING_PROFILES_ACTIVE | (dev / H2) | set to `postgres` for deploy | +| SLOTLINE_DB_URL | localhost postgres | JDBC url (postgres profile) | +| SLOTLINE_DB_USERNAME | slotline | DB user (postgres profile) | +| SLOTLINE_DB_PASSWORD | slotline | DB password (postgres profile) | + +The dev JWT secret and admin password are for local use only and must be +overridden before any real deployment. + +## Wave 2 (depth) + +Wave 2 is additive: the v1 anti-double-booking core (pessimistic lock plus +overlap check plus the PostgreSQL partial unique index) is untouched and its +concurrency test still passes. New capabilities: + +- Multi-tenant. Every user, resource and reservation belongs to an + `Organization`. The JWT carries an `org` claim and all resource and + reservation queries are scoped to the caller's organization, so a tenant can + neither see nor book another tenant's resources (cross-tenant access is a 404). + Registration accepts an optional `organizationSlug`; without one the account + joins the seeded `default` organization. +- Recurring reservations. `POST /api/reservations/recurring` takes an RRULE + subset (`FREQ=DAILY|WEEKLY`, `INTERVAL`, `COUNT`, `UNTIL`, `BYDAY`), expands it + into concrete slots in the resource time zone, and books the whole series in + one transaction (all-or-nothing: any conflict rolls the series back). +- Availability engine. Per-resource operating hours, blackout date ranges and a + buffer (minutes required between reservations). A booking outside the rules is + rejected with `422 Unprocessable Entity`; a straight slot clash is still `409`. +- Waitlist. `POST /api/reservations/waitlist` records interest in a taken slot. + When a reservation is cancelled the oldest matching waiter is promoted into a + real reservation and notified. +- Notifications. A `NotificationChannel` abstraction (email channel logs; + webhook channel is enabled only when `slotline.notifications.webhook.url` is + set) fans out on booking, cancellation and waitlist promotion. Fully testable + without sending anything by registering a recording channel. +- Resource groups. Nestable `resource-group` hierarchy with resource assignment. +- Admin reports. `GET /api/admin/reports/resources/{id}/occupancy?from=..&until=..` + and `.../history` (ADMIN only, tenant-scoped). +- iCal export. `GET /api/resources/{id}/calendar.ics` and + `GET /api/reservations/calendar.ics` return an RFC 5545 feed of active slots. + +New endpoints (all under the existing JWT security): + +| Method | Path | Access | +|--------|-----------------------------------------------------|-------------| +| GET | /api/organizations | ADMIN | +| POST | /api/organizations | ADMIN | +| POST | /api/reservations/recurring | USER, ADMIN | +| POST | /api/reservations/waitlist | USER, ADMIN | +| GET | /api/reservations/waitlist/mine | USER, ADMIN | +| GET | /api/reservations/calendar.ics | USER, ADMIN | +| GET | /api/resources/{id}/calendar.ics | USER, ADMIN | +| GET/PUT| /api/resources/{id}/operating-hours | GET all, PUT ADMIN | +| GET/POST| /api/resources/{id}/blackouts | GET all, POST ADMIN | +| DELETE | /api/resources/{id}/blackouts/{blackoutId} | ADMIN | +| GET/POST| /api/resource-groups | GET all, POST ADMIN | +| POST | /api/resource-groups/{groupId}/resources/{resourceId}| ADMIN | +| GET | /api/resource-groups/{groupId}/resources | USER, ADMIN | +| GET | /api/admin/reports/resources/{id}/occupancy | ADMIN | +| GET | /api/admin/reports/resources/{id}/history | ADMIN | + +### PostgreSQL migration verification (Testcontainers) + +`PostgresMigrationTest` boots a real PostgreSQL container, applies the +`postgresql` migrations and asserts the partial unique index enforces a single +ACTIVE reservation per slot. It self-skips (JUnit assumption) when Docker is not +reachable, so the H2 suite always runs. + +## Non-goals (v1) + +Payments, a separate frontend (Swagger UI is the console), notifications, and +external calendar sync are intentionally out of scope.