A full-stack reservation system for busy airports and malls that lets drivers browse parking zones (including limited EV charging spots), reserve a spot in real time, and lets admins manage the catalog, pricing, and all reservations. The platform is built with a strict Go backend following clean architecture, and an Astro + React frontend with Tailwind UI components.
SpotSync solves a real-world problem: airports and malls have limited high-demand parking and EV charging spots, but drivers discover they are full only on arrival. SpotSync centralizes the catalog, exposes live availability, and lets drivers lock in a spot with a license plate before they arrive.
The platform supports two first-class personas:
- Driver — browse the zone catalog, reserve a spot, view and cancel their own reservations.
- Admin — same as driver, plus create / update / delete zones, view all reservations, and manage users.
- 40 zone catalog auto-seeded on first boot, split into 60% available / 40% full by a background rotator.
- Live
available_spotscounter on every zone (computed fromtotal_capacity - active_reservations - rotation_hold). - JWT-based authentication with role-aware middleware (driver vs admin).
- Public zone browsing, so the marketing surface of the home page never forces a login.
- Reservation flow with concurrency safety (zone-full returns 409 Conflict).
- Full admin console: KPIs, drivers, users, reservations, and zones grid.
- Server-side rendering on the home page with first-paint populated from the Go API.
- React islands for interactive surfaces (admin tables, reservation modal, profile settings).
- CORS hardened to the Astro frontend origin (never wildcard) so credentials and Bearer tokens are accepted.
- Graceful health endpoint (
/health) independent of the database, safe for Vercel / load balancers.
| Layer | Technology |
|---|---|
| Language | Go 1.25 |
| Web framework | Echo v4 (github.com/labstack/echo/v4) |
| ORM | GORM with PostgreSQL driver |
| Database | PostgreSQL (NeonDB in production) |
| Validation | go-playground/validator/v10 |
| Auth | golang-jwt/jwt/v5 |
| Password hashing | golang.org/x/crypto/bcrypt (cost 10) |
| Config | joho/godotenv |
| Hot reload (dev) | air (.air.toml) |
| Layer | Technology |
|---|---|
| Framework | Astro 4 (static output, selective hydration) |
| UI islands | React 18 via @astrojs/react |
| Styling | Tailwind CSS 3 + tailwindcss-animate |
| Primitives | Radix UI (Dialog, Progress, Slot) |
| State | nanostores + @nanostores/persistent |
| Icons | lucide-react |
| Charts | recharts (admin dashboard) |
| Helpers | clsx, tailwind-merge, class-variance-authority |
| Types | TypeScript 5 |
Spotsync/
├── backend/
│ ├── api/
│ │ └── index.go # Vercel serverless entry point
│ ├── cmd/api/
│ │ └── main.go # Local `go run` entry point
│ ├── internal/
│ │ ├── config/ # Env loading + validation
│ │ ├── database/ # GORM connection + auto-migration
│ │ ├── dto/ # Request/response structs
│ │ ├── handler/ # HTTP handlers (Echo)
│ │ ├── middleware/ # JWT auth + admin guard
│ │ ├── models/ # GORM models
│ │ ├── repository/ # Data access layer
│ │ ├── seeder/ # Zone + user seeders, rotator
│ │ ├── server/ # Echo wiring (sync.Once)
│ │ └── service/ # Business logic
│ ├── pkg/utils/ # JWT, password, validator, response helpers
│ ├── .air.toml # Hot reload config
│ ├── go.mod / go.sum
│ └── .env
├── frontend/
│ ├── src/
│ │ ├── components/ # Astro + React components
│ │ ├── data/ # Static seed data
│ │ ├── layouts/ # Layout shell
│ │ ├── lib/ # Constants, formatting, toasts
│ │ ├── pages/ # Routes (index, login, register, admin/*)
│ │ ├── scripts/ # Client-side glue
│ │ ├── services/ # Fetch wrappers (api.ts, auth, etc.)
│ │ ├── store/ # Nano Stores (auth, theme)
│ │ ├── styles/ # globals.css
│ │ └── types/ # TypeScript contracts
│ ├── public/ # favicon
│ ├── astro.config.mjs
│ ├── tailwind.config.mjs
│ ├── components.json
│ ├── package.json
│ └── .env
├── Project_Req.MD # Original assignment spec
├── Backend.MD # Backend build notes
├── Frontend.MD # Frontend build notes
└── README.md # You are here
┌──────────────────────────────────────────────────────────────────────────────┐
│ Astro Frontend (port 4321) │
│ Static SSR + React islands · Tailwind · Nano Stores · Fetch wrapper │
│ │
│ Pages: │
│ / Home + zone catalog (SSR catalog render) │
│ /login Driver/admin login (React) │
│ /register Self-service registration (React) │
│ /my-reservations Driver booking history (Astro) │
│ /admin/* Admin console (React): overview, drivers, users, │
│ reservations, zones │
└──────────────────────────────────────────────────────────────────────────────┘
│ HTTPS · Bearer JWT · JSON
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ Go Backend (Echo · port 8080) │
│ Clean Architecture: │
│ Handler ──► Service ──► Repository ──► GORM ──► PostgreSQL │
│ │ │
│ └── Echo middleware: RequestLogger · Recover · CORS · JWT · Admin │
│ │
│ Background goroutines: │
│ • Auto-migration (once) │
│ • Zone seeder (40 zones, idempotent) │
│ • User seeder (admin + 2 drivers, idempotent) │
│ • ZoneRotator (every 1 hour, 60/40 split) │
└──────────────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────┐
│ PostgreSQL (Neon) │
│ users · parking_zones│
│ reservations │
└────────────────────────┘
The backend is dual-deployable: cmd/api/main.go is the long-running local server (used with go run or air), and api/index.go is the Vercel serverless shim that exports Handler and reuses the same Echo instance built once via sync.Once.
The backend enforces a strict five-layer separation. Handlers never touch the database; Services never write SQL; Repositories never return JSON.
| Layer | Directory | Responsibility |
|---|---|---|
| DTO | internal/dto/ |
Request payloads and response structures. Validate incoming JSON. Never expose GORM models directly. |
| Handler | internal/handler/ |
HTTP layer. Binds/validates DTOs, extracts JWT claims from Echo context, calls Service, returns JSON. |
| Service | internal/service/ |
Business logic. Hashes passwords, signs JWTs, enforces capacity rules, calls Repository. |
| Repository | internal/repository/ |
All GORM database operations (CRUD, transactions, row locks). |
| Model | internal/models/ |
GORM structs representing database tables. |
Manual dependency injection is performed in internal/server/server.go: Repository → Service → Handler, in that order. The Handler only ever talks to the Service, and the Service only ever talks to the Repository.
Middleware chain (request lifecycle):
echomw.Logger()— structured request/response log.echomw.Recover()— panic-to-500 fallback.echomw.CORSWithConfig(...)— restricted toFRONTEND_URL, never wildcard.AuthMiddleware(cfg.JWTSecret)— validatesAuthorization: Bearer <jwt>and populatesauth_user_id,auth_role,auth_is_adminon the Echo context.AdminOnly()— rejects 403 for non-admin users on protected routes.
A central CustomHTTPErrorHandler wraps every error into a uniform JSON envelope ({ "status": "error", "message": ..., "errors": ... }) so the frontend can react predictably.
The frontend uses Astro's "static output + selective hydration" model:
- Static Astro pages handle the home page, layout, and admin frame. The home page server-renders the zone catalog into the first paint so visitors see real availability before any JS executes.
- React islands (
client:load/client:visible) handle stateful surfaces: login, register, reservation modal, admin tables, profile settings, and the admin overview dashboard. - Nano Stores (with
@nanostores/persistent) hold the JWT token and the authenticated user in localStorage. The fetch wrapper reads the token on every request, so login/pull-token is automatic. - Service layer (
src/services/*.ts) wraps the API:api.ts— genericapiFetchwith token injection, envelope parsing, andApiError.authService.ts,userService.ts,zoneService.ts,reservationService.ts,adminData.ts— typed wrappers that mirror the backend DTOs.
- Tailwind + Radix power the UI primitives (Button, Card, Dialog, Input, Progress, Avatar, Badge, Table).
- Theme store (
themeStore.ts) handles light/dark mode.
The schema is owned by GORM models in backend/internal/models/. Auto-migration runs on every boot, so the tables and their constraints are always in sync with the structs.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
bigserial |
PK, auto-increment | Unique account identifier |
name |
varchar(100) |
NOT NULL | Display name |
email |
varchar(150) |
UNIQUE, NOT NULL | Login / contact address |
password |
varchar(255) |
NOT NULL | bcrypt hash (excluded from JSON) |
role |
varchar(20) |
NOT NULL, default 'driver' |
One of driver, admin |
created_at |
timestamptz |
auto | Account creation timestamp |
updated_at |
timestamptz |
auto | Last profile update timestamp |
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
bigserial |
PK, auto-increment | Unique zone identifier |
name |
varchar(100) |
NOT NULL | Descriptive name (e.g., "Terminal 1 EV Charging") |
type |
varchar(30) |
NOT NULL | One of general, ev_charging, covered |
total_capacity |
integer |
NOT NULL | Maximum vehicles in this zone |
price_per_hour |
numeric(10,2) |
NOT NULL | Cost per hour |
rotation_hold |
integer |
NOT NULL, default 0 |
Synthetic occupancy used by the ZoneRotator |
created_at |
timestamptz |
auto | Zone creation timestamp |
updated_at |
timestamptz |
auto | Last zone update timestamp |
available_spots is not a stored column. It is computed by the repository on every read:
available_spots = total_capacity - active_reservations - rotation_hold
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
bigserial |
PK, auto-increment | Unique reservation identifier |
user_id |
bigint |
NOT NULL, FK → users(id) ON DELETE CASCADE, INDEX |
Booking driver |
zone_id |
bigint |
NOT NULL, FK → parking_zones(id) ON DELETE CASCADE, INDEX |
Booked zone |
license_plate |
varchar(15) |
NOT NULL | Vehicle plate |
status |
varchar(20) |
NOT NULL, default 'active' |
One of active, completed, cancelled |
created_at |
timestamptz |
auto | Booking timestamp |
updated_at |
timestamptz |
auto | Last status update timestamp |
users (1) ──< (N) reservations (N) >── (1) parking_zones
Both foreign keys cascade on delete, so removing a user or a zone cleans up its reservations automatically.
Base URL: /api/v1. All endpoints accept and return JSON. Protected endpoints require Authorization: Bearer <jwt>.
| Method | Path | Body | Returns |
|---|---|---|---|
POST |
/auth/register |
{ name, email, password, role? } |
{ token, user } |
POST |
/auth/login |
{ email, password } |
{ token, user } |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/zones |
none | List all zones with live available_spots |
GET |
/zones/:id |
none | Fetch one zone |
POST |
/zones |
admin | Create a zone |
PUT |
/zones/:id |
admin | Update a zone (partial fields allowed) |
DELETE |
/zones/:id |
admin | Remove a zone |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/reservations/mine |
any user | List the caller's reservations |
GET |
/reservations |
admin | List all reservations in the system |
GET |
/reservations/:id |
any user | Fetch one reservation |
POST |
/reservations |
any user | Create a reservation. Returns 409 Conflict if the zone is full. |
PATCH |
/reservations/:id/status |
any user | Update status to active, completed, or cancelled |
DELETE |
/reservations/:id |
any user | Delete a reservation |
| Method | Path | Description |
|---|---|---|
GET |
/auth/users |
List users |
GET |
/auth/users/count |
Count users grouped by role |
DELETE |
/auth/users/:id |
Delete a user |
| Method | Path | Description |
|---|---|---|
GET |
/health |
Returns { "status": "UP" } (always available, even when DB is down) |
To keep the catalog feeling alive in the demo and in production, a ZoneRotator goroutine runs every hour and enforces a 60/40 available/full split across the 40 zones:
- It does not touch the
reservationstable. - It mutates
parking_zones.rotation_holdinstead, soavailable_spotsis recomputed naturally. - On each tick it (1) flips enough zones to hit the target split, and (2) when the split already matches, swaps a few zones between buckets so the visible state changes every hour.
- The first rotation runs immediately on boot so a fresh server doesn't show all-100% availability.
- The goroutine is cancelled via the server's lifecycle context, so it exits cleanly on shutdown.
- JWT Flow — credentials are POSTed to
/auth/login, bcrypt-hashed passwords are compared, and on success a signed JWT is returned. The client persists it inlocalStorage(spotsync_token) and attaches it asAuthorization: Bearer <token>on every subsequent request. - Token claims —
user_idandroleare injected into the Echo context viaAuthMiddleware. - Security rules —
- Passwords are never exposed in JSON (
json:"-"on theUsermodel). JWT_SECRETis required at boot; the server refuses to start without it.- Protected routes return
401 Unauthorizedon missing/expired/invalid tokens. - Admin-only routes return
403 Forbiddenfor non-admin tokens. - CORS is restricted to
FRONTEND_URL(never wildcard), so credentials are accepted.
- Passwords are never exposed in JSON (
On a fresh database the user seeder inserts (and self-heals) the following accounts:
| Role | Password | |
|---|---|---|
| Admin | admin@spotsync.com |
adminpassword |
| Driver | john@spotsync.com |
password123 |
| Driver | jane@spotsync.com |
password123 |
The login page exposes quick-fill buttons for these credentials so the demo works out of the box. The zone seeder also pre-populates 40 zones across the three types (general, ev_charging, covered) so the catalog never starts empty.