Skip to content

mars-alien/nexus

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Nexus

Geospatial social meetup platform — post events, discover nearby activities, join with a request workflow, and get promoted off the waitlist automatically.

Java Spring Boot React PostgreSQL


Contents


Overview

Nexus lets users post events, discover what is happening nearby, and send join requests. The system enforces per-event capacity, promotes waitlisted users automatically when a slot opens, and notifies participants in real time. The backend is a single Spring Boot monolith; the frontend is a React SPA with Leaflet maps and geolocation.


Architecture

React SPA (Vite · Zustand · Leaflet)
         │ HTTP :8080
 ┌───────▼──────────────────────────────────────────┐
 │               Spring Boot Monolith               │
 │                                                  │
 │  JWT filter → auth / event / group / notification│
 │                                                  │
 │  • PostGIS spatial queries (ST_DWithin)          │
 │  • Caffeine in-memory cache                      │
 │  • Direct in-process calls (no Kafka/HTTP)       │
 │  • Flyway migrations                             │
 └──────────────────────────┬───────────────────────┘
                            │
                   ┌────────▼────────┐
                   │  PostgreSQL 15  │
                   │  + PostGIS 3.3  │
                   └─────────────────┘

Key flows

  1. DiscoveryST_DWithin on a PostGIS spatial index, Caffeine-cached by radius + category.
  2. Join request — capacity check via direct service call; full events go to WAITLISTED; leaving a member triggers promoteFromWaitlist in the same @Transactional method.
  3. Notifications — created by direct method call from EventService / GroupService / JoinRequestService inside the same transaction, no messaging broker needed.
  4. Auth — stateless JWT (15 min) validated by a OncePerRequestFilter; refresh tokens stored hashed (SHA-256), rotated on every use.

Tech stack

Layer Technology
Backend Java 21 · Spring Boot 3.2 · Maven
Frontend React 18 · Vite · Zustand · React Query v5
Database PostgreSQL 15 · PostGIS 3.3 (single DB)
Cache Caffeine (in-memory, no external server)
Auth JWT (access 15 min · refresh 7 days, rotated on each use)
ORM JPA / Hibernate Spatial · Flyway migrations · MapStruct
Maps Leaflet · OpenStreetMap · Nominatim geocoding
Hosting Railway (app + PostgreSQL) · Vercel (frontend)

Local setup

Prerequisites

  • Java 21 and Maven 3.9+
  • Docker Desktop
  • Node.js 20+

Backend

git clone https://github.com/mars-alien/nexus.git
cd nexus/backend

# Start PostgreSQL + the Spring Boot app
docker compose up --build

The API is available at http://localhost:8080.

To run the app without Docker (requires a local PostgreSQL with PostGIS):

mvn spring-boot:run

Copy .env.example to .env and adjust values if needed.

Frontend

cd nexus/frontend
npm install
npm run dev    # http://localhost:5173

Set VITE_API_URL=http://localhost:8080 in frontend/.env.development if not already set.


How it works

1) Geospatial discovery

Event locations are stored as geometry(Point, 4326) with a PostGIS GiST spatial index. The query uses ST_DWithin against a geography cast for accurate metre-level radius filtering without a full table scan. Results are paged and cached in Caffeine keyed by lat · lng · radiusKm · category · page with a 5-minute TTL.

2) Join request and waitlist
  • A user sends POST /api/v1/events/{id}/join-requests.
  • JoinRequestService calls EventService.getCapacity() directly (in-process, same JVM) to check current capacity.
  • If full, the request is saved as WAITLISTED with a queue position.
  • When any member leaves (DELETE /api/v1/events/{id}/members/me), promoteFromWaitlist runs inside a @Transactional method: it computes available slots, fetches the earliest WAITLISTED requests, promotes them to APPROVED, and updates currentMemberCount — all in one DB transaction.
3) Notifications

Instead of a Kafka consumer, NotificationService.createNotification() is called directly by EventService, GroupService, and JoinRequestService within the same transaction that triggers the relevant state change. This guarantees notifications are never lost without requiring a message broker or outbox pattern.

4) JWT auth and token rotation

JwtAuthenticationFilter validates the JWT on every request and populates the SecurityContext with a UserPrincipal record. Access tokens are stateless and expire in 15 minutes. Refresh tokens are hashed with SHA-256 before storage and rotated on every use: the old token is revoked atomically before the new pair is issued.


Directory layout

.
├── .env.example
├── backend/
│   ├── Dockerfile
│   ├── docker-compose.yml          # postgres + app (local dev)
│   ├── pom.xml
│   └── src/main/
│       ├── java/com/nexus/
│       │   ├── NexusApplication.java
│       │   ├── auth/               # User, RefreshToken, JWT, AuthService
│       │   ├── event/              # Event, PostGIS queries, scheduler
│       │   ├── group/              # GroupMember, JoinRequest, waitlist
│       │   ├── notification/       # Notification entity + service
│       │   ├── security/           # JwtFilter, SecurityConfig, UserPrincipal
│       │   ├── common/             # BaseEntity, ApiResponse, PageResponse
│       │   └── exception/          # GlobalExceptionHandler + custom exceptions
│       └── resources/
│           ├── application.yml
│           └── db/migration/
│               └── V1__init.sql    # single Flyway migration for all tables
└── frontend/
    ├── src/
    │   ├── api/                    # axios instance + endpoint constants
    │   ├── components/             # shared UI components
    │   ├── features/
    │   │   ├── auth/               # login / register forms + auth API
    │   │   ├── events/             # event cards, map, form, API
    │   │   └── groups/             # join-request button, members list, API
    │   ├── pages/                  # route-level page components
    │   ├── store/                  # Zustand auth store
    │   ├── styles/                 # CSS custom properties + component styles
    │   └── utils/                  # formatters, validators
    └── index.html

Engineering decisions

Monolith over microservices — five Spring Boot services connected by Kafka, Redis, and Feign clients were collapsed into one. The domain boundaries are preserved as packages (auth, event, group, notification) but services communicate via direct method calls in the same transaction. This eliminates the operational overhead of Kafka, multiple databases, and an API gateway while keeping the code organised.

Caffeine over Redis — geospatial query results are cached in-process with Caffeine (max 500 entries, 5-minute TTL). No external cache server means one less Railway service and zero network latency on cache hits.

Real foreign keys — the monolith uses a single database, so events.creator_id is a proper REFERENCES users(id) constraint. The microservice version stored only UUIDs across service boundaries and could not enforce referential integrity at the DB level.

Denormalised creatorUsername on Event — event listings include the creator's username without a join on every read. Username changes are rare and out of scope; the trade-off is intentional.

Flyway over ddl-auto: createddl-auto: validate is used in all environments. Schema is owned by a single versioned V1__init.sql migration, making every environment reproducible with a clear upgrade path.

Spring Security 6 path orderingGET /api/v1/events/my must be declared before GET /api/v1/events/{id} in the security filter chain. Spring Security 6's {id} pattern matches any literal segment (including "my"), so first-match-wins ordering is required.


What I learned

  • Spatial queries with PostGIS and JTS. ST_DWithin on a geography cast uses the GiST index for accurate radius searches without a full table scan. JTS uses (x=longitude, y=latitude) coordinate ordering — opposite of most geographic APIs — which caused subtle bugs until caught.

  • JWT refresh token rotation. Short-lived access tokens keep the stateless API fast. Storing refresh tokens hashed with SHA-256 and rotating on every use means a stolen token can only be used once before the legitimate client invalidates it.

  • Waitlist auto-promotion. Concurrent member departures could over-promote. Computing available slots and promoting inside a @Transactional method makes the operation atomic without an explicit row lock.

  • Spring Data JPA with ManyToOne traversal. When Event.creator is a @ManyToOne, the derived query becomes findByCreator_IdAndDeletedAtIsNull (underscore traversal), not findByCreatorIdAndDeletedAtIsNull. MapStruct source paths also change: source = "creator.id" instead of source = "creatorId".

  • Monolith vs microservices trade-offs. Microservices give independent deployability and per-service data stores at the cost of network latency, distributed transactions, and operational complexity. Collapsing to a monolith removed Kafka, Redis, four extra databases, and an API gateway — all without losing domain separation.

About

Geospatial social meetup platform built with Spring Boot microservices, React, PostGIS, Kafka, and JWT auth — featuring event discovery, join-request workflow, and automatic waitlist promotion.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages