Files
slotline/README.md
T

263 lines
13 KiB
Markdown

# 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.