An event management system for SomNOG, built as NestJS microservices over RabbitMQ with PostgreSQL and Prisma. Modelled on what SomaliREN runs today at events.somaliren.org.so.
Three reference services are implemented here. Student teams build the rest against the same contracts — see docs/ADDING-A-SERVICE.md.
| App | Queue | Owns |
|---|---|---|
gateway |
— | HTTP under /api, JWT verification, validation, Swagger |
auth-service |
auth_queue |
Users, credentials, roles, tokens |
events-service |
events_queue |
Categories, events, workshop tracks, registrations |
notification-service |
notification_queue |
Templates, delivery, retries |
Requires Node 22.19+, npm 10+ and Docker. Prisma 7 needs TypeScript 5.4+.
git clone <this repo> && cd somnog-ems
cp .env.example .env
npm install
npm run infra:up # postgres, rabbitmq, redis, mailhog
npm run prisma:generate # one client per service
npm run prisma:migrate # one migration history per service
npm run seed # SomNOG9 conference, 4 tracks, one user per role
npm run dev # all four apps, watch modeThen open:
| API base | http://localhost:3000/api |
| Swagger | http://localhost:3000/api/docs |
| Health | http://localhost:3000/api/health |
| RabbitMQ management | http://localhost:15672 (guest / guest) |
| MailHog inbox | http://localhost:8025 |
Every HTTP route sits under /api — the gateway sets it once with
app.setGlobalPrefix('api'), so controllers still declare plain paths like
@Get('events/:slug') and nobody repeats the prefix. /events on its own now
returns 404.
Seeded accounts — local development only:
| Role | Password | |
|---|---|---|
| ADMIN | admin@somnog.so | Admin12345 |
| ORGANIZER | organizer@somnog.so | Organizer12345 |
| SPEAKER | speaker@somnog.so | Speaker12345 |
| ATTENDEE | attendee@somnog.so | Attendee12345 |
Clients only ever speak HTTP, and only to the gateway. Behind it everything is RabbitMQ, in one of two shapes:
| RPC (request/response) | Event (publish/subscribe) | |
|---|---|---|
| Nest call | client.send() → @MessagePattern() |
client.emit() → @EventPattern() |
| Caller | Waits for a return value | Fires and forgets |
| Naming | auth.user.find_by_id |
registration.confirmed |
| Means | "Tell me X" | "X happened" |
| Listeners | Exactly one | Zero, one, or many |
| Consumer down | Caller times out | Message waits in the queue |
All of it flows through one topic exchange, somnog.events. Each service owns
a durable queue that Nest binds to the exchange using the patterns that service
declares. So an RPC pattern reaches exactly the one queue that handles it,
while an event reaches every queue bound to that routing key — which is how a
new student service starts receiving registration.confirmed without a single
line changing in the events service.
Registering for a workshop touches all three:
POST /api/events/:id/registrations
gateway verify JWT locally, no call to auth
gateway -> events RPC registration.create (waits)
events check capacity in a transaction, write the row
gateway <- events { id, status: CONFIRMED }
gateway -> client 201 Created <- user is done here
events -> rabbitmq event registration.confirmed (does not wait)
notify <- rabbitmq render template, queue the email
Stop the notification service and register anyway: the registration still succeeds, and the email arrives when the service comes back. That is the demo worth running in front of the students.
Two levels, and a registration answers both:
| Event form | Track form | |
|---|---|---|
| Belongs to | The event (/api/events/:id/registration-form) |
One workshop track (/api/sessions/:id/registration-form) |
| Asks | What the conference needs from everyone — organisation, t-shirt size | Only what that track needs — "will you bring a laptop?" |
| Controls opening times, approval, max per user | Yes | No, those are ignored |
| Required? | Usually | Optional; most tracks have none |
Registering for a track validates the event's required fields plus that
track's, and stores the replies in one answers object. Because they share
that object, a track question may not reuse a key from the event form —
upsertSessionForm() returns 409 CONFLICT if you try, rather than letting
one answer quietly overwrite the other.
GET /api/events/:slug returns the event form and every track's form in one
response, so a client can render the whole thing without extra round trips.
Both routes accept an event slug or UUID:
/api/events/somnog9-conference/registration-form works as well as the id.
Sending fields on a PUT replaces the whole set — the client posts the
complete list every time, and omitting fields leaves the existing questions
alone while still updating the opening times and approval settings.
- Services never call each other over HTTP. The only HTTP hop is browser → gateway.
- No service reads another service's schema.
userIdcolumns in the events schema are plain strings, not foreign keys. Need user data? Ask over RPC. - RPC when the caller is waiting; event when it is not. Sending an email over RPC couples your response time to a mail server.
apps/
gateway/ HTTP under /api, guards, Swagger — no business logic
+ Dockerfile
auth-service/ prisma schema "auth" + Dockerfile
events-service/ prisma schema "events" + Dockerfile
notification-service/ prisma schema "notify" + Dockerfile
libs/
contracts/ patterns, event names, DTOs, error codes — no logic
common/ filters, interceptors, rpc helpers, utils
config/ typed env validation + RabbitMQ transport options
docker/
docker-compose.yml postgres, rabbitmq, redis, mailhog
Dockerfile.migrate one-shot: migrations + seeds, for deploys
migrate.sh what that image runs
Each service generates its own Prisma client into src/generated/prisma/
(gitignored). Prisma 7 requires an explicit output and emits TypeScript into
your source tree rather than dropping a package into node_modules — which
also avoids three schemas fighting over npm's single hoisted copy.
Each service has a prisma.config.ts too. Prisma 7 removed the
package.json#prisma block and no longer reads .env on its own, so that file
loads the root .env and tells the CLI where the schema, migrations and seed
live.
One Postgres instance, three schemas, three independent migration histories.
AUTH_DATABASE_URL ends in ?schema=auth, and so on.
| What | Where |
|---|---|
| Capacity and waitlist under concurrency | events-service/src/registrations/registrations.service.ts → create() |
| Idempotent consumers | notification-service/src/notifications/notifications.service.ts → enqueue() |
| Retry with backoff | notification-service/src/queue/delivery.processor.ts |
| Errors crossing a service boundary | libs/common/src/rpc/service-exception.filter.ts and libs/common/src/http/http-exception.filter.ts |
| Message envelope and correlation ids | libs/contracts/src/messaging/envelope.ts |
| Per-event vs platform permissions | events-service EventRole vs auth-service UserRole |
| Driver adapter + non-public schema | */src/prisma/prisma.service.ts |
| Event form + per-track questions | events-service/src/events/events.service.ts → upsertSessionForm() |
This is an npm workspaces monorepo: npm install once at the root installs
everything and links @somnog/contracts, @somnog/common and @somnog/config
into each app. Use -w <package> to run a script in one workspace.
After the first migration, later ones are made inside the service that changed:
cd apps/events-service
npx prisma migrate dev --name add_speaker_bioLocally you run npm run prisma:migrate and npm run seed yourself. On a
server nobody does - so CI builds a fifth image next to the four services,
ghcr.io/<owner>/somnog-ems-migrate, from docker/Dockerfile.migrate. The
deploy (see the config repo) runs it to completion before any service starts:
prisma migrate deployfor auth, events and notify, in that order.- If
SEED_DEMO_DATA=true,prisma db seedfor auth, then events.
It is a separate image rather than something the service images do at startup
because the service images ship production dependencies only: the prisma CLI
is a devDependency, and the seeds need ts-node plus the generated client's
TypeScript source. It carries the same tag as the services, so the schema
applied always matches the code being deployed.
Re-running it is safe - migrate deploy applies only what is missing, and
every row the seeds write is an upsert.
The seeds are ordered because they reference each other across a boundary they
cannot join across: the events seed credits the conference to the organiser's
user id, which lives in the auth service's database. The auth seed pins the
demo accounts' ids (...0001 to ...0004) so that reference resolves;
SEED_ORGANIZER_USER_ID overrides it if you seed against different accounts.
The client is Rust-free: no engine binary is downloaded for it, queries go
through @prisma/adapter-pg (node-postgres), and the generated client is
TypeScript you can read. Three consequences to know about:
urlis no longer allowed in thedatasourceblock — it lives inprisma.config.tsfor the CLI, and reaches the running service through the adapter.- The adapter does not understand
?schema=; see Troubleshooting below. - Seeds run through
tsconfig.seed.jsonso they compile as CommonJS.
The prisma CLI still downloads a schema engine for migrate and
introspect. That is a one-time download and unrelated to the client.
Every route returns 404, including ones that worked yesterday — the gateway
sets app.setGlobalPrefix('api'), so it is /api/events, not /events. Old
Postman collections and bookmarks need the prefix added. Two things are not
prefixed and should not be: the Swagger UI, mounted directly at /api/docs by
SwaggerModule.setup() and never /api/api/docs, and the RabbitMQ patterns,
which are not HTTP at all.
Swagger's "Try it out" 404s — setGlobalPrefix() must be called before
SwaggerModule.createDocument(). Call it after, and the document lists the
unprefixed paths while the server only answers prefixed ones. In
apps/gateway/src/main.ts the call sits above the DocumentBuilder block for
exactly that reason — keep it there when you edit bootstrap.
ECONNREFUSED on startup — the containers are not up yet. npm run infra:up,
wait for Postgres to report healthy, then npm run dev.
Cannot find module '../generated/prisma/client' — run
npm run prisma:generate. Each service generates into
apps/<service>/src/generated/prisma and imports it through its own
src/prisma/prisma-client.ts.
Queries hit the wrong schema / "table does not exist" — with a driver
adapter, ?schema=auth in the connection string is a Prisma-only parameter
that node-postgres ignores. The schema name is passed explicitly in each
PrismaService:
new PrismaPg({ connectionString }, { schema: 'auth' })Miss that in a new service and every query silently runs against public.
Cannot find module '@somnog/contracts' — build the libraries first:
npm run build. npm run dev does this for you via Nx dependsOn.
Requests work, then start timing out with "Service did not respond in time"
— check messages_unacknowledged at http://localhost:15672. If it climbs with
every request, something is consuming messages without acking them, and once it
reaches prefetchCount (10) RabbitMQ stops delivering and the service goes
silent. In a new service, the usual cause is registering global filters and
interceptors AFTER connectMicroservice() in main.ts: inheritAppConfig
copies them at creation time, so later registrations never reach the
microservice and RmqAckInterceptor never runs. Register them first.
TypeScript deprecation warnings in the editor (moduleResolution=node10,
baseUrl) — both are gone from tsconfig.base.json; no option it still sets is
on TypeScript 7's removal list. baseUrl only matters alongside paths, and
this repo has none: @somnog/contracts and friends resolve through npm
workspace symlinks instead.
Note ignoreDeprecations goes inside compilerOptions, not next to it —
at the top level it is silently ignored. You should not need it here. test
On the resolution mode: tsconfig.base.json uses
module/moduleResolution: node16, which is the supported setting for a
CommonJS Node project and does not disappear in TypeScript 7. No package.json
here declares "type": "module", so node16 resolution still emits and loads
CommonJS — nothing about how the services run changed. Note your editor may
bundle a newer TypeScript than the repo's (5.9), so it can flag deprecations
npm run build stays silent about.
Editor underlines every decorator: "TS1241: the runtime will invoke the
decorator with 2 arguments, but the decorator expects 3" — while
npm run build passes. The TypeScript server attaches to the nearest file
named exactly tsconfig.json; with none, it uses its own defaults, where
experimentalDecorators is off and decorators are read as the ES standard
kind. Every app and lib now has one for that purpose (builds still use
tsconfig.app.json / tsconfig.lib.json). A new service needs one too — copy
it along with the rest. If the squiggles linger after adding it, restart the
TS server: Ctrl+Shift+P → "TypeScript: Restart TS Server".
A message is stuck in a queue — open http://localhost:15672, find the queue, and look at whether anything is consuming it. A service that is down is the usual answer, and that is the system working as designed.
Emails do not arrive — they are not meant to leave your machine. Check http://localhost:8025.
