263 lines
13 KiB
Markdown
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.
|