Registration, teams, payments, rooming and workshop booking
for the third edition of the IEEE Tunisian RAS Student & Young Professional Congress.
Live site Β· How it works Β· Domain model Β· Architecture Β· Getting started
- About
- Features
- A quick tour
- How it works: the participant journey and the rules behind each step
- Domain model: class diagram of the database
- Key flows: sequence diagrams for registration, payment and booking
- Architecture: deployment, authentication, modules, events
- Getting started Β· Deployment Β· Engineering notes Β· Team
TRSYP 3.0 took place on 16 to 18 October 2026 at Le Royal, Yasmine Hammamet, under the theme Human-Robot Symbiosis. It was organised by the IEEE INSAT Student Branch with the IEEE RAS Tunisia Section.
This repository is the congress's production web platform. It started as the event's public site and grew, between February and October 2026, into the tool participants used to sign up, form teams, pay, pick a roommate and book their workshops. The organising committee ran the other side of it (payment review, bans, visa letters, IEEE checks) from a separate admin portal that shares the same database.
| 400+ | 3 | 8 | 250+ |
|---|---|---|---|
| people used the platform | team activities | months of development | commits |
| Feature | What it does | |
|---|---|---|
| π | Public site | Hero video, three-day program, speakers, workshops, venue, visa help and the three competition tracks with their spec books. |
| π | Accounts | Email/password or Google sign-in through Supabase Auth, with email verification and password reset. |
| πͺͺ | IEEE membership check | Membership and RAS status are read from IEEE's member API, not from the form, and they set the fee tier. |
| π€ | Hybrid avatar | Every participant builds a half-human, half-robot avatar that represents them across the site. |
| π₯ | Teams | Teams for the Competition, the Technical Challenge and the Adwya Γ Orange Fablab Challenge, joined with a 6-character code. |
| π³ | Payments | A fee computed from role and membership, receipt upload, and review by the treasurer. |
| π | Rooming | Double rooms formed with a join code, between two participants of the same gender. |
| π§βπ« | Workshop booking | One seat per session, capacity enforced under concurrent load, and a duty check for challenge teams. |
| π | Phase switches | Each feature opens and closes on its own soon / open / closed window, set from environment variables. |
Public pages are captured from the running app. Signed-in pages use a demo account and sample data.
The landing page opens on the congress's hero video, dates and a countdown.
Everything a participant needs on one page: profile and avatar, fee and payment status, team and invite code.
The full fee table with the participant's own fee highlighted, the bank details and the receipt upload.
Live seat counts, one seat per session, and a note telling challenge teams whether they are covered.
Create a double room and send its code to a roommate, or join theirs.
A participant goes through the same steps in order. Each step after the profile is optional and has its own registration window.
flowchart LR
A["Create account<br/>email or Google"] --> B["Verify email"]
B --> C["Fill participant profile<br/>phone, gender, career stage,<br/>student branch, IEEE number"]
C --> D["IEEE check<br/>runs in the background"]
C --> E["Build avatar"]
E --> F{{"Dashboard"}}
F --> G["Create or join teams"]
F --> H["Pay the fee"]
F --> I["Form a room"]
H -- "payment approved" --> J["Book workshops"]
The profile is the only required step. Once it exists, the participant lands on the dashboard and everything else is done from there. The avatar is required before the dashboard opens, because it is how participants appear to each other in teams and rooms.
Participants don't declare their own membership. They give their IEEE member number (if they have one), and the backend asks IEEE's member API who they are.
- Lookup key. The member number is used first. The account email is the fallback, and only once the participant has verified that email.
- Standing. IEEE's status is mapped to four standings:
Activeis a member,Applicantalso counts as a member (usually waiting for payment), any other past status is lapsed, and no record is not a member. - RAS. A participant counts as RAS only if they are an active member and IEEE lists the RAS society
(
MEMRA024) among their societies. - Student or Young Professional. IEEE's grade is ignored on purpose: this is the participant's own answer.
- When it runs. Right after registration, whenever the member number changes, on request from the dashboard, and in a background sweep every 5 minutes that rechecks unverified or outdated participants, 20 at a time. A failed IEEE call writes nothing and is retried later.
- Until the answer arrives, a new participant is priced as a non-member.
The admin portal writes to the same ieee_verifications table. When it records a result, the backend applies it to
the participant on its next pass, so both sides always agree on how someone is priced.
The fee depends on two things: the participant's role (what they take part in) and their tier (their IEEE membership).
| Role \ Tier | IEEE RAS member | IEEE member | Non-IEEE |
|---|---|---|---|
| Visitor, attending without a team | 170 TND | 175 TND | 185 TND |
| Challenger, on a Competition or Technical Challenge team | 175 TND | 180 TND | 190 TND |
| Fablab participant, on a Fablab team | 100 TND | 100 TND | 100 TND |
- Role precedence. Fablab wins over Challenger, which wins over Visitor. A participant on both a Fablab team and a Competition team pays the Fablab fee.
- Selection. Only teams that passed the selection phase (
Team.selected) count. A team that was not selected no longer makes its members challengers. - Override. An agreed exception (
Participant.feeOverride) replaces the table for that one person. - Snapshot. When a receipt is submitted, the fee at that moment is stored with it (
amountSnapshot), so the treasurer reviews the amount the participant actually saw, even if their role or tier changed afterwards.
The table lives in backend/.../registration/domain/fees.ts and is
mirrored in lib/fees.ts, so the dashboard can update the fee the moment someone joins a team,
without waiting for the server.
| Activity | Team size | Who can enter |
|---|---|---|
| Competition | 3 to 6 | Everyone |
| Technical Challenge | 2 to 6 | Everyone |
| Adwya Γ Orange Fablab Challenge | 2 to 4 | IEEE members who are also RAS members |
- The creator becomes the leader and gets a unique 6-character join code to share.
- A participant can be in at most one team per activity, but can be in one team of each. This is a unique index on
(participant, activity)inteam_memberships, not just a check in the code. - A leader leads at most one team per activity, also enforced by a unique index.
- Members can leave. The leader cannot leave: they can remove members or disband the team, so a team is never left without a leader.
- A team cannot grow past the size its leader chose.
- Fablab teams also pick an axis (sample preparation, or weighing and dosing) and the leader submits the concept dossier from the dashboard, during its own submission window.
Payment is by bank transfer. The participant transfers the fee and uploads the receipt. A committee member reviews it.
stateDiagram-v2
direction LR
[*] --> NotPaid
NotPaid --> Pending: receipt uploaded
Pending --> Approved: treasurer approves
Pending --> Rejected: treasurer rejects, with a reason
Rejected --> Pending: new receipt uploaded
NotPaid --> Approved: cash recorded by an admin
Approved --> [*]
Participant.paidis the single source of truth. Only approving a proof sets it, and rejecting a proof always clears it, so a participant can never see "paid" next to a rejected receipt.- Only one proof can be pending at a time, and none can be sent once the payment is confirmed.
- Rejected proofs are kept, so the next submission can be compared with the one that was turned down.
- Cash has no receipt: the admin who receives it records it, and it is stored already approved.
- After approval, the treasurer records how the money actually arrived (bank transfer, internal fund or cash).
- Once a participant has paid, their profile is locked and their account cannot be deleted without a refund first.
- Every room is a double. The creator becomes the owner and gets a 6-character code to send to their roommate.
- The room takes the owner's gender, and only a participant of the same gender can join. A participant cannot change their gender while they are in a room, so this never goes out of date.
- A participant is in at most one room.
- Joining locks the room row for the transaction, so two people racing for the last spot cannot both get it.
- The owner can remove the roommate or disband the room. Deleting the room frees the roommate automatically.
Day 3 has two workshop sessions. Workshops in the same session run in parallel.
- A participant gets one seat per session, enforced by a unique index on
(participant, session). - Bookings are final: no cancelling, no switching.
- Booking needs a confirmed payment (or a payment waiver granted by the committee). Banned participants cannot book.
- Team duty. Each selected Technical Challenge team needs at least one member in a workshop, in either session,
or its student branch loses points (
WORKSHOP_SB_PENALTY). The team sees whether it is covered. This rule is reported to the organisers, not enforced by the booking. - How capacity holds under a rush is explained in Key flows.
Every feature reads its own window from an environment variable, so the committee could open and close each step without a code change.
stateDiagram-v2
direction LR
[*] --> soon
soon --> open
open --> closed
closed --> open: reopened
soonandclosedboth block creating and joining. They only change the message shown.- In
closed, existing teams stay visible and manageable. Nobody can create or join new ones.
The database is PostgreSQL on Supabase, described in backend/prisma/schema.prisma and
versioned with Prisma migrations. Participant is the centre of the model: everything a participant does hangs off it.
The two diagrams below show the models behind the features above, with their main fields.
Who someone is: their account, their participant profile, what IEEE says about them, and, for international
participants, their travel and visa details. Admin is separate: committee members are not participants.
classDiagram
class User {
+String id
+String email
+String name
+String lastName
+String supabaseId
+Json avatar
}
class Participant {
+String id
+Int ieeeId
+String phone
+String gender
+ParticipantType participantType
+CareerStage careerStage
+Boolean isRas
+SB sb
+Country country
+Boolean paid
+Boolean banned
+Int feeOverride
}
class IeeeVerification {
+IeeeCheckStatus status
+String memberStatus
+String[] societies
+String matchedBy
+DateTime checkedAt
+String checkedBy
}
class InternationalInfo {
+String countryOfResidence
+String affiliation
+DateTime expectedArrivalDate
+Boolean requiresVisaLetter
}
class VisaApplication {
+String passportNumber
+VisaStatus status
}
class Admin {
+String email
+Position position
+String supabaseId
}
User "1" -- "0..1" Participant : has profile
Participant "1" -- "0..1" IeeeVerification : checked by IEEE
Participant "1" -- "0..1" InternationalInfo : travels from abroad
InternationalInfo "1" -- "0..1" VisaApplication : needs a visa
What a participant takes part in: teams, payment, room and workshop seats.
classDiagram
class Participant {
+String id
+Boolean paid
+Boolean banned
}
class Team {
+String code
+String name
+Int size
+TeamActivity activity
+FablabAxis axis
+String submissionUrl
+Boolean selected
}
class TeamMembership {
+TeamActivity activity
+DateTime createdAt
}
class PaymentProof {
+PaymentMethod method
+PaymentProofStatus status
+Int amountSnapshot
+String storagePath
+PaymentType paymentType
+String rejectionReason
}
class Room {
+String code
+String gender
}
class Workshop {
+String id
+String title
+Int session
+String startTime
+Int capacity
}
class WorkshopBooking {
+Int session
+DateTime createdAt
}
class WorkshopPaymentWaiver
Participant "1" -- "*" TeamMembership : joins
TeamMembership "*" -- "1" Team
Participant "1" -- "*" Team : leads
Participant "1" -- "*" PaymentProof : submits
Participant "1..2" -- "0..1" Room : lives in
Participant "1" -- "0..1" Room : owns
Participant "1" -- "*" WorkshopBooking : books
WorkshopBooking "*" -- "1" Workshop
Participant "1" -- "0..1" WorkshopPaymentWaiver : may book unpaid
Enumerations
| Enum | Values |
|---|---|
ParticipantType |
NonIEEE, Student, YoungProfessional |
CareerStage |
Student, YoungProfessional |
TeamActivity |
COMPETITION, CHALLENGE, FABLAB |
FablabAxis |
SAMPLE_PREPARATION, WEIGHING_DOSING |
IeeeCheckStatus |
VERIFIED, MISMATCH, INACTIVE, NOT_FOUND |
PaymentMethod |
BANK_TRANSFER, D17, FLOUCI, CASH (only bank transfer is offered in the UI) |
PaymentType |
BANK_TRANSFER, INTERNAL_FUND, CASH |
PaymentProofStatus |
PENDING, APPROVED, REJECTED |
VisaStatus |
Pending, Approved, Rejected, LetterSent |
Rules held by the database itself, so they hold even under concurrent requests:
| Constraint | Rule |
|---|---|
team_memberships (participant_id, activity) unique |
One team per activity per participant |
teams (leader_id, activity) unique |
One led team per activity |
workshop_bookings (participant_id, session) unique |
One workshop seat per session |
rooms.owner_id unique, participants.room_id single column |
One owned room, one room per participant |
participants.ieee_id, participants.phone unique |
No two profiles with the same IEEE number or phone |
TeamMembership.activity copied from Team.activity |
Makes the first rule a plain unique index (a team's activity never changes) |
WorkshopBooking.session copied from Workshop.session |
Same idea for one seat per session |
sequenceDiagram
autonumber
actor P as Participant
participant W as Next.js app
participant SA as Supabase Auth
participant API as NestJS API
participant DB as PostgreSQL
participant IEEE as IEEE member API
P->>W: Sign up (email or Google)
W->>SA: signUp / OAuth
SA->>DB: auth.users insert
Note over DB: trigger on_auth_user_created<br/>creates the public users row
SA-->>P: verification email
P->>W: Fill participant profile
W->>API: POST /registration (Bearer JWT)
API->>API: verify JWT, resolve users row
API->>DB: create participant, priced as NonIEEE
API-->>W: 201 participant
API-)API: event participant.registered
API->>IEEE: look up member number, else verified email
IEEE-->>API: status and societies
API->>DB: save ieee_verification, set participantType and isRas
W->>API: GET /registration/profile
API-->>W: profile with fee and IEEE standing
The IEEE check happens after the response, through an internal event, so registration never waits on IEEE's API and never fails because of it.
sequenceDiagram
autonumber
actor P as Participant
participant API as NestJS API
participant ST as Supabase Storage<br/>(private bucket)
participant DB as PostgreSQL
actor T as Treasurer<br/>(admin portal)
P->>API: POST /payment/proof (receipt)
API->>API: window open? not banned, not paid,<br/>no other proof pending?
API->>DB: insert proof PENDING with amountSnapshot
API->>ST: upload receipt, keyed by proof id
Note over API,ST: failed upload rolls the row back
API-->>P: proof PENDING
T->>API: GET /payment/admin/proofs (oldest first)
T->>API: GET /payment/proof/:id/file
API->>ST: create signed URL (60 s)
API-->>T: short-lived link
alt approve
T->>API: POST /payment/admin/proofs/:id/approve
API->>DB: proof APPROVED, participant.paid = true
else reject
T->>API: POST /payment/admin/proofs/:id/reject (reason)
API->>DB: proof REJECTED, participant.paid = false
end
P->>API: GET /payment/proof/me
API-->>P: status, and the reason if rejected
When booking opens, many people try to book the same popular workshop within seconds.
sequenceDiagram
autonumber
actor A as Participant A
actor B as Participant B
participant API as NestJS API
participant DB as PostgreSQL
par
A->>API: POST /workshops/pitching/booking
and
B->>API: POST /workshops/pitching/booking
end
API->>DB: BEGIN, SELECT workshop FOR UPDATE (A)
API->>DB: BEGIN, SELECT workshop FOR UPDATE (B)
Note over DB: B waits for A's lock
API->>DB: count seats, insert booking A, COMMIT
API-->>A: 201 seat booked
Note over DB: lock released, B reads the final count
API->>DB: count seats: full, ROLLBACK
API-->>B: 403 This workshop is full
- Every booking locks exactly one row, the workshop, so bookings cannot deadlock.
- The unique
(participant, session)index catches a double booking even if two requests from the same person arrive at once. - Transaction timeouts are raised above Prisma's defaults so the queue drains during a rush. If it still times out, the participant is asked to try again in a moment instead of getting an error.
One Docker image runs both applications in a single Render web service. Next.js listens on the public port and
proxies /api/* and /health to NestJS, so the browser only ever talks to one origin.
flowchart LR
B([Browser])
subgraph R["Render web service Β· one Docker container"]
N["Next.js 16<br/>public PORT"]
A["NestJS API<br/>:3001"]
N -- "/api/*, /health" --> A
end
SA[("Supabase Auth")]
DB[("Supabase PostgreSQL")]
ST[("Supabase Storage<br/>receipts")]
IEEE["IEEE member API"]
AP["Admin portal<br/>(separate app)"]
B --> N
B -. "sign-in, session JWT" .-> SA
A -- "verifies JWT" --> SA
A -- "Prisma 7" --> DB
A -- "service role" --> ST
A -- "membership checks" --> IEEE
AP -.-> DB
- Sign-in is Supabase's job. The browser signs in with Supabase Auth and sends the session's access token as a
Bearerheader on every API call. - The API verifies the token itself. ES256 tokens are checked against Supabase's public keys (JWKS, cached), and legacy HS256 tokens against the project secret. Expired tokens are refused.
- From Supabase user to database user. A database trigger creates the
usersrow when someone signs up. The JWT strategy then finds that row from the token's subject, and recreates it if it is ever missing, so a valid token is never locked out. - Admins are whoever is listed in the
adminstable. Admin routes check that table, not a claim in the token. - Receipts are in a private storage bucket that only the API can reach, with the service-role key. Reviewers get a signed link that expires after 60 seconds.
- Input is validated with
class-validatorDTOs; unknown fields are stripped before they reach a service. - Abuse. Helmet sets security headers, and a global rate limit allows 50 requests per minute per client.
The API is a modular monolith: one NestJS app, one folder per domain, each with its own controller, service, DTOs and domain rules.
| Module | Responsibility |
|---|---|
auth |
Supabase JWT strategy, user sync, profile and avatar, admin guard |
registration |
Participant profile, fee rules, teams for the three activities, Fablab submission, bans |
ieee |
IEEE member API client, membership verification and the background sweep |
payment |
Receipt upload and storage, review queue, cash payments |
rooming |
Double rooms with join codes |
workshop |
Day-3 workshop booking and team workshop duty |
visa |
Visa applications for international participants |
admin |
Admin accounts |
Modules react to each other through domain events (@nestjs/event-emitter) instead of calling each other for
side effects. The registration module announces what happened, and the ieee module listens:
| Event | Emitted when | Listener |
|---|---|---|
participant.registered |
A participant profile is created | ieee runs the first membership check |
participant.ieee_details_changed |
The member number changes | ieee checks again |
The payment module also emits payment.proof.submitted and payment.status.updated, and registration emits
participant.paid. Nothing listens to them yet: they are the hook for notifications.
- Next.js App Router. The dashboard talks to the API through a typed client in
lib/api/, one service per backend module, with types that mirror the API's responses. - State lives in small Zustand stores (
lib/store/): the auth session, the participant's registration, and their teams. - Windows are read in
lib/config.tsfromNEXT_PUBLIC_*variables, so a closed feature is hidden in the UI as well as refused by the API.
| Layer | Tools |
|---|---|
| Frontend | Next.js 16 (App Router), React 19, TypeScript, Zustand, Motion, Avataaars |
| Backend | NestJS 10, Prisma 7 with the pg adapter, class-validator, Joi config validation, Passport JWT, @nestjs/event-emitter |
| Data and auth | Supabase PostgreSQL, Supabase Auth (email and Google), Supabase Storage |
| Security | Helmet, @nestjs/throttler, JWKS verification, admin guard |
| API docs | Swagger at /api/docs outside production |
| Tests | Jest, with unit tests for fees, IEEE membership, payments, registration, rooming and workshops |
| Delivery | Multi-stage Dockerfile, Render, GitHub Actions deploy hook |
.
βββ app/ Next.js routes (public pages, /register, /dashboard)
βββ components/ Page and section components
βββ lib/
β βββ api/ Typed client for every backend endpoint
β βββ store/ Zustand stores (auth, registration, teams)
β βββ supabase/ Browser Supabase client and email callbacks
β βββ fees.ts Fee table, mirrored from the backend
β βββ config.ts Runtime config and registration windows
βββ backend/
β βββ prisma/ Schema, migrations, seed
β βββ src/
β βββ common/ Guards, decorators, pipes, event names
β βββ modules/ One folder per domain
β βββ <module>/
β βββ controller/ HTTP routes
β βββ service/ Use cases and transactions
β βββ domain/ Pure rules (fees, IEEE mapping)
β βββ dto/ Validated request and response shapes
βββ public/ Images, hero video, partner and speaker photos
βββ scripts/ Image optimisation, production start script
βββ Dockerfile Builds both apps into one image
βββ render.yaml Render service definition
Requires Node.js 20 or newer and a Supabase project.
git clone https://github.com/IEEE-INSAT/TRSYP3.0.git
cd TRSYP3.0
cp .env.example .env # fill in the Supabase valuesStart the API:
npm --prefix backend ci
cd backend
npx prisma generate
npm run start:devStart the frontend in another terminal:
npm ci
npm run devThe frontend runs at http://localhost:3000 and the API at http://localhost:3001. Both applications and Prisma
read the single root .env file.
.env.example documents every variable. The ones you will touch most are the windows:
| Variable | Values | Controls |
|---|---|---|
NEXT_PUBLIC_PARTICIPANT_REGISTRATION_OPEN |
true / false |
Participant sign-up |
NEXT_PUBLIC_COMPETITION_PHASE, COMPETITION_REGISTRATION_PHASE |
soon / open / closed |
Competition teams |
NEXT_PUBLIC_CHALLENGE_PHASE, CHALLENGE_REGISTRATION_PHASE |
soon / open / closed |
Technical Challenge teams |
NEXT_PUBLIC_FABLAB_PHASE, FABLAB_REGISTRATION_PHASE |
soon / open / closed |
Fablab teams |
NEXT_PUBLIC_FABLAB_SUBMISSION_PHASE, FABLAB_SUBMISSION_PHASE |
soon / open / closed |
Fablab concept submission |
NEXT_PUBLIC_ROOMING_PHASE, ROOMING_PHASE |
soon / open / closed |
Rooming |
WORKSHOP_BOOKING_PHASE |
soon / open / closed |
Workshop booking |
WORKSHOP_SB_PENALTY |
number | Points a student branch loses when its team skips workshops |
PAYMENT_PROOF_OPEN |
true / false |
Receipt upload (read per request, no rebuild) |
IEEE_CLIENT_ID, IEEE_CLIENT_SECRET |
secret | IEEE membership checks (without them, everyone is priced as a non-member) |
Keep each NEXT_PUBLIC_* window aligned with its backend twin. NEXT_PUBLIC_* values are compiled into the browser
bundle, so changing one needs a frontend rebuild.
cd backend
npm run build
npm test -- --runInBand
cd ..
npm run typecheck
npm run lint
npm run buildThe root Dockerfile builds both applications. Next.js listens on Render's public PORT; NestJS
listens internally on port 3001. docker-compose.yml gives the same topology for local
container testing:
docker compose up --build # http://localhost:3000render.yaml defines the single Docker service. Provide every variable marked sync: false before
deploying. Every push to main triggers the Render deploy hook through
.github/workflows/deploy-render.yml.
Supabase authentication emails are configured in the Supabase dashboard, not in Render. Set the public frontend as the Auth Site URL and allow:
https://rtc.ieee.tn/verify-email/
https://rtc.ieee.tn/reset-password/
For local development, also add this under Supabase Dashboard β Authentication β URL Configuration β Redirect URLs:
http://localhost:3000/**
Without it, Supabase ignores the localhost redirectTo the app asks for and falls back to the production Site URL.
Decisions that mattered once real traffic arrived:
- Let the database hold the rules. "One team per activity", "one seat per session" and "one room per person" are
unique indexes, not
ifstatements. Two requests arriving at the same millisecond cannot both pass. - Lock one row, never two. Workshop booking and room joining each lock a single row, which makes them safe under concurrency without any risk of deadlock.
- Fees from the source. The tier comes from IEEE's records, so nobody gets the cheaper fee by ticking a box, and the admin portal and the API price everyone the same way.
- Slow dependencies off the request path. The IEEE check runs from an event and a background sweep, so IEEE being slow or down never blocks a registration.
- One source of truth for "paid". Receipts are an audit trail. Only
Participant.paidsays whether someone has paid, and only approval changes it. - Switches without a rebuild. Backend windows such as
PAYMENT_PROOF_OPENare read per request. - One origin. Next proxies the API, so there is no CORS to manage in production and one Render service hosts everything.
- Images ready ahead of time. On-the-fly image optimisation was too slow on Render's free instance, so images are
converted to WebP with
scripts/optimize-images.mjsbefore they are committed.
Built by the IEEE INSAT Student Branch web team:
![]() Makki Aloulou |
![]() Mohamed Amine Achour |
![]() Yassine Kolsi |










