A small multi-tenant marina management application built with TypeScript, Fastify, React, and Vite. It supports customer and employee workflows for managing customers, vessels, slips, and transient or weekly recurring reservations.
The project is intentionally scoped as an interview-sized solution: business rules are fully enforced by the API, while persistence and authentication use simple demo implementations.
- Customer and employee role-based workflows
- Strict marina tenant isolation
- Customer, vessel, and slip management
- Transient and weekly recurring reservations
- DST-aware recurrence in each marina's local time zone
- Automatic deterministic best-fit slip assignment
- Conflict detection across an entire recurring series
- Whole-series reservation editing and cancellation
- Validation, deletion guards, and consistent API errors
- Seeded data for two marinas and multiple demo users
- Each marina is an independent tenant.
- Customers can manage only their own profile, vessels, and reservations.
- Employees can manage all records in their marina.
- Slip compatibility is based on vessel length only.
- Reservations are assigned to a slip immediately; drafts and waitlists are out of scope.
- Recurring reservations repeat weekly and contain at most 52 occurrences.
- Reservation ranges use
[start, end)semantics, so back-to-back bookings do not conflict. - Updating or canceling a recurring reservation affects the entire series.
- Customer emails are normalized with
trim().toLowerCase()and must be unique within a marina. - Reservation duration is intentionally unrestricted; availability and the 52-occurrence recurrence cap are the current limits.
- The public user picker is demo authentication. The API derives role and marina from the selected seeded user rather than trusting client-provided claims.
web/ React + Vite client
server/
src/app.ts Fastify routes and HTTP result mapping
src/services/ Business operations and authorization
src/domain/ Recurrence, availability, and domain types
src/store.ts Seeded in-memory data store
test/ Backend unit and integration tests
shared/ Type-only API contract shared with the client
docs/ Requirements, design, and implementation notes
The API follows a small route -> service -> domain -> store structure:
- Routes parse identity and validate request bodies with Zod.
- Services enforce ownership, tenant boundaries, and entity invariants.
- Pure domain helpers handle overlap checks, recurrence expansion, and slip selection.
- Expected failures use a typed
Result<T>rather than exceptions. - The React client remains thin and treats the API as the source of truth.
Slip selection considers every occurrence before committing a reservation. Compatible slips are ordered by smallest sufficient capacity and then by label, making assignment predictable. The in-memory check-and-insert path is synchronous, which prevents interleaving within one Node.js process.
Customer reservation ownership is captured when a reservation is created, so historical and canceled reservations remain visible to the original customer after a vessel is deleted. User-facing customers, vessels, slips, and reservations are sorted by stable domain-friendly keys.
- Node.js 22 or later
- npm
Install all workspace dependencies from the repository root:
npm installStart the API in one terminal:
cd server
npm run devStart the client in another terminal:
cd web
npm run devOpen http://localhost:5173. The client proxies /api requests to the API at http://localhost:3000.
The API port can be changed with the PORT environment variable:
$env:PORT=4000
npm run devChanging the API port also requires updating the proxy target in web/vite.config.ts.
The sign-in screen loads seeded identities from GET /api/users. It includes customers and employees from:
- Lakeside Marina (
America/Chicago) - Island Harbor (
Pacific/Honolulu)
Select different users to exercise role permissions and tenant isolation. All data is stored in memory and returns to its seeded state whenever the API restarts.
The matched GET /api/users route is public, including requests with a query string. All other
routes require an x-user-id header containing a seeded user ID.
| Resource | Endpoints |
|---|---|
| Users | GET /api/users |
| Customers | GET/POST /api/customers, GET/PUT/DELETE /api/customers/:id |
| Vessels | GET/POST /api/vessels, GET/PUT/DELETE /api/vessels/:id |
| Slips | GET/POST /api/slips, GET/PUT/DELETE /api/slips/:id |
| Reservations | GET/POST /api/reservations, GET/PUT /api/reservations/:id |
| Cancellation | POST /api/reservations/:id/cancel |
Example transient reservation:
curl -X POST http://localhost:3000/api/reservations \
-H "Content-Type: application/json" \
-H "x-user-id: cust-1" \
-d '{
"vesselId": "vessel-1",
"type": "transient",
"start": "2026-07-10T10:00:00-05:00",
"end": "2026-07-10T12:00:00-05:00"
}'Expected business errors use one response shape:
{
"code": "CONFLICT",
"message": "Every compatible slip is already reserved for part of the requested time",
"details": {
"occurrence": {
"start": "2026-07-10T15:00:00.000Z",
"end": "2026-07-10T17:00:00.000Z"
}
}
}Supported error codes are VALIDATION_FAILED, FORBIDDEN, NOT_FOUND, STATE_CONFLICT, CONFLICT, NO_AVAILABILITY, and INTERNAL_SERVER_ERROR.
Run the same quality gates used by CI from the repository root:
npm run verifyThe root workspace also exposes each gate separately:
npm run format:check
npm run lint
npm run typecheck
npm test
npm run test:coverage
npm run build
npm run auditThe test suites cover:
- Input schemas and API status/error mapping
- Role, ownership, and cross-tenant authorization
- Best-fit selection and overlap boundaries
- Weekly recurrence, DST transitions, and the 52-occurrence limit
- Conflict and no-availability outcomes
- Atomic recurring reservation creation
- Simultaneous duplicate booking requests against one API process
- Reservation update and cancellation
- Entity deletion and compatibility guards
- Frontend booking, editing, cancellation, management pages, and role gating
- In-memory persistence: keeps the solution focused, but data is not durable. Within one Node.js process, all requests share one store and there is no async boundary between the availability check and mutation, so duplicate attempts cannot interleave on that path. This does not protect multiple API processes.
- Demo identity picker: useful for exercising roles and tenants, but unsuitable as production authentication.
- Marina-local time entry: booking fields are interpreted and displayed in the selected marina's IANA time zone. UTC instants remain the storage and API representation.
- Embedded occurrences: makes all-or-nothing series operations straightforward, but conflict checks are linear scans.
- Length-only compatibility: satisfies the current rule without prematurely modeling beam, draft, power, or amenities.
- Minimal frontend state: page-local React state avoids unnecessary dependencies; it does not provide caching, routing, or optimistic updates.
- Unrestricted duration: transient reservations have no maximum duration by design. A product policy can add one later if long-lived slip holds become undesirable.
- Replace the in-memory store with a transactional database.
- Enforce reservation overlap guarantees across processes with a database constraint or transaction.
- Integrate a real identity provider and remove the public demo user list.
- Add pagination, audit history, observability, and production configuration.
- Extend slip compatibility to beam, draft, power, and amenity requirements.
- Add browser-level end-to-end smoke tests.
More detailed analysis and design decisions are available in docs/.