WayClub
Group management for teams, clubs and communities, from meetings to handover.
- Role
- Sole developer: product, PostgreSQL schema and security, NestJS API, Next.js web app, Flutter mobile app, deployment
- Dates
- Aug 2026 – present
- Status
- In development · no live site
- Stack
- Next.js, NestJS, PostgreSQL · 784 API tests
In short
One record per event
Proposal, conditional approval, registration, attendance, the report and the committee handover live on one record that moves through fifteen server-owned states.
Tenancy in the database
PostgreSQL row-level security keeps each club’s data apart. Requests run as a role that cannot bypass the policies, so a forgotten filter returns nothing.
Safe under concurrency
Stale edits get a 409 conflict instead of overwriting, waitlists promote inside one transaction, and the audit log is hash-chained.
PostgreSQL keeps each club’s rows apart
-
On the device
- Web app: Next.js 15, React 19
- Local guided demo
-
On my infrastructure
- API: NestJS 11
- PostgreSQL 17
-
Outside the system
- Storage and email
Engineering challenges
-
Tenant isolation that a forgotten check cannot break
PostgreSQL enforces tenant isolation, so no endpoint has to remember it. A standalone club cannot reference an institution, so it cannot hold a university’s reviewer role or approve with its authority.
-
Concurrent edits that never silently overwrite each other
A stale save is refused with a clear message and a field-by-field comparison. A waitlist promotion that checked only tenancy was found and now allows only the organising committee or the person who cancelled.
-
An audit log that shows when it has been changed
Any edit to a past decision breaks the hash chain. The concurrency bug is fixed and covered by a test that overlaps two real transactions.
Technical decisions
| Decision | What I chose | Instead of |
|---|---|---|
| The workflow engine lives in database rows | Versioned workflow definitions as rows, each instance pinned to its version | An external engine such as Temporal or Camunda |
| A club does not need a university | The account as the tenant root, with the institution optional | Making the institution the tenant |
| Row-level security for tenancy | PostgreSQL row-level security, with a request role that owns nothing | Tenant filters in application code, or a database per institution |
Tested against real PostgreSQL
- database tests
- 109
- API tests
- 784
- Flutter tests
- 95
- database migrations
- 59
Test figures are from the 20 October 2026 local runs; the migration count is the 21 October inventory. Newer development is described separately. Screenshots use fictional data.
Working with AI tools
Define: Club registrations and event capacity cannot oversell under high concurrency. Decide: Enforce database-level transactions with row-level locks instead of optimistic application-level checks. Investigate: Agents implemented single-record updates that passed sequential unit tests but failed race-condition checks. Verify: Validated with concurrent registration tests across overlapping database transactions, backed by 784 passing API tests.
Full engineering notes
Summary
A club runs on a few people giving up their evenings, and when the committee changes the next one inherits a tangle of chats, sheets and forms. WayClub is group management for teams, clubs and communities: organisers run meetings, tasks, events and handover in one workspace, and members find what is on, register with one button and keep their plans in one place. Each event is one durable record that moves through server-owned states from draft to archived, with conditional approval where a group needs it. Groups can run inside an institution or stand alone. I designed and built the PostgreSQL schema, the NestJS API, the Next.js web app and, since mid-October, a Flutter mobile app. The engineering centres on tenant isolation enforced by the database, writes that cannot silently overwrite each other, and an audit log that shows if it has been edited. The 26 October mobile build adds frosted event surfaces, registration forms, tickets, What’s on and Your plans, with a door check-in presentation for committees. The product remains in active development and currently has no public live site.
The problem
A group’s events need several people to agree before anything happens, and the evidence of that agreement lives in places nobody can audit afterwards. The product problem is one record everyone can trust; the technical problem is keeping that record honest when many groups share one database.
- A proposal, its approvals, the poster, registration, attendance and the report are usually separate documents, so information is re-keyed and lost between them.
- Approval routes depend on the event: an off-campus, higher-risk or higher-budget event needs more reviewers, and the route must change if the event changes during revision.
- Many clubs and institutions share one deployment, so a forgotten check in one endpoint must not expose another tenant’s data.
- Committees change every year. Authority, open tasks and access to external tools have to pass to the next team without anyone handing over a password.
My role
What I owned
- The PostgreSQL data model for tenancy, clubs and roles, events and workflow, registrations, audit, committee continuity, files, notifications and account rights.
- The tenancy boundary: row-level security policies keyed on the account, a request role that owns nothing and cannot bypass RLS, and composite foreign keys that bind each row’s institution to its own account.
- The NestJS 11 API: a per-request transaction that carries the caller’s verified claims, the versioned workflow engine, optimistic concurrency, race-safe registration and waitlist promotion, and a transactional email outbox.
- The Next.js 15 and React 19 web app, including the event proposal form with autosave and conflict recovery, the reviewer queue and the committee handover flow.
- The design token package: OKLCH colour recipes, per-club theming and a build-time contrast gate.
- The test suites, GitHub Actions workflow, web deployment work and Flutter member and committee interfaces.
- The latest mobile event, registration, ticket and planning screens, with a frosted visual system tied to the event poster.
What I did not own
- PostgreSQL, NestJS, Next.js, React and the other open-source frameworks the product is built on.
- No design collaborator, QA team or external code review. The adversarial review described in the showcase was a structured self-review, not an outside audit.
- No institution has adopted or endorsed WayClub. The demo tenant and every person in the seed data are fictional.
Product flow
Propose
A committee member writes a multi-step proposal that autosaves, with a poster that requires alt text.
Route
Risk, venue and budget answers choose the approval route. A revision re-evaluates the route against the event as it now is.
Approve
Reviewers work a queue sorted by due date, see a field-level diff since the last submission, and approve, request changes or reject.
Register
Members register up to capacity, then join a waitlist. A cancellation promotes the next eligible person in the same transaction.
Attend
Check-in uses an eight-character attendance code. The post-event report is pre-filled from real attendance rows.
Hand over
Verified attendees receive involvement records. At the end of a term, authority transfers to the next committee on an effective date.
Architecture in detail
A Next.js 15 and React 19 web app calls a NestJS 11 API. Each API request opens one PostgreSQL transaction, sets the caller’s verified claims as a transaction-local setting and runs as a role that owns nothing and cannot bypass row-level security. PostgreSQL 17 enforces tenant isolation with RLS policies keyed on the account, and writes an append-only, per-account hash-chained audit log through triggers. The API stores files through a storage facade with S3-compatible and Google Cloud Storage drivers and sends email through SMTP from a transactional outbox. A separate local guided demo uses the same web pages with fictional data and no network access. WayClub has no current public live site.
| Part | What it holds |
|---|---|
| On the device | |
| Web app: Next.js 15, React 19 |
|
| Local guided demo |
|
| On my infrastructure | |
| API: NestJS 11 |
|
| PostgreSQL 17 |
|
| Outside the system | |
| Storage and email |
|
Connections between the parts
- Web app: Next.js 15, React 19 to API: NestJS 11HTTPS with a signed session; writes carry expectedVersion
- API: NestJS 11 to PostgreSQL 17One transaction per request as the request role, claims set transaction-locally
- PostgreSQL 17 to API: NestJS 11Only rows the policies admit; another tenant’s row reads as not found
- API: NestJS 11 to Storage and emailFile bytes through the storage facade; email from the outbox
- Local guided demo to Web app: Next.js 15, React 19Built from the same pages, with no network connection
How each challenge was solved
Tenant isolation that a forgotten check cannot break
- Why it was hard
Cross-tenant disclosure is the most serious risk in a shared database: one endpoint that forgets a WHERE clause would show one club’s members or proposals to another. The tenant also could not simply be the university, because a club must be able to use WayClub without its institution being a customer. And connections are pooled, so identity set for one request must never survive into the next.
- What I built
The tenant root is the account. Every tenant-scoped row carries account_id, which is never null, and institution_id, which is null for a standalone club. Each request opens one transaction as a role that owns nothing and holds no RLS bypass, then runs select set_config('request.jwt.claims', $1, true). The third argument makes the setting transaction-local, so it resets at COMMIT or ROLLBACK and cannot leak across pooled connections. The auth guard resolves those claims from the database rather than copying them from the token. Policies read the account through app.current_account_id(), so a request without a tenant reads nothing. A composite foreign key binds each row’s (account_id, institution_id) pair to the institution’s own account. Because that is a constraint, not a policy, it applies to the table owner too.
- Options considered
- Filter by tenant in every query in application code, which fails as soon as one query forgets
- A separate schema or database per tenant, which makes cross-club features and migrations heavier
- Row-level security in PostgreSQL, keyed on a tenant id that each request sets inside its own transaction
- Trade-off
Every query pays for the policy checks, and a missing policy fails closed, so an unfinished feature denies access instead of degrading gracefully. RLS is deliberately not forced on the table owner, because forcing it broke the SECURITY DEFINER helpers the policies rely on. The boundary rests on the request role having no ownership and no bypass, so tests read the catalogue to check that this still holds.
- Validation
API tests show that a request for another tenant’s record returns 404, the same response as a record that never existed. A neighbouring test shows a same-tenant outsider gets 403. Database tests run against real PostgreSQL in CI.
Concurrent edits that never silently overwrite each other
- Why it was hard
A proposal is edited by several committee members and moved through states by reviewers, often at the same time. Two people saving from stale copies would silently overwrite each other’s changes, and two state transitions could both succeed from the same starting state. Registration has the same problem: two students taking the last place at once must not both get it.
- What I built
Mutable records carry a version column. Every write sends the version the client last read, and the API updates with WHERE id = $1 AND version = $2, incrementing the version in the same statement. If no row matches, the API re-reads the record and returns HTTP 409 with the code version_conflict. In the proposal form, a conflict shows a banner that lists the fields that differ, theirs and mine, with an option to load their version. Registration takes a per-event transaction advisory lock (pg_advisory_xact_lock) before checking capacity, and cancellation and waitlist promotion take the same lock, so the check and the insert cannot interleave.
- Options considered
- Last write wins, which loses work without anyone noticing
- Pessimistic locks held while a person edits, which do not survive the gaps between HTTP requests
- A version column checked on every write, with a lock only around the capacity check
- Trade-off
Clients have to carry versions and handle a conflict path. The registration lock serialises sign-ups for a single event, which is acceptable at club scale but has not been load tested.
- Validation
A database test shows that an update with a stale version affects zero rows while the correct version succeeds. An API test sends a lifecycle transition with an old version and expects 409 version_conflict. A web test checks that the conflict banner shows both versions. Registration tests cover waitlisting at capacity and transactional promotion on cancellation.
An audit log that shows when it has been changed
- Why it was hard
Approvals only matter if nobody can later rewrite who approved what. Blocking edits in application code is not enough, because anyone with database access could still edit a row. The first hash-chain design also had a concurrency bug that a test suite running one transaction at a time could never reveal.
- What I built
Triggers write audit rows for key changes such as role assignments, workflow stages, approvals and event status, so a code path cannot forget them. No role has UPDATE or DELETE on audit_event, and a BEFORE UPDATE OR DELETE trigger raises an exception even for the owner, so changing history needs a schema change rather than a quiet edit. Each row stores a SHA-256 hash of the previous row’s hash and its own fields, chained per account. The first version allocated chain position before the trigger took its lock, so two overlapping transactions could fork the chain. The fix moved chaining into a deferred constraint trigger that runs at COMMIT and allocates chain_index inside the per-account advisory lock, with a unique index on chain_index as a backstop.
- Options considered
- Write audit rows from application code and trust that nothing edits them
- Ship events to an external log service, which adds infrastructure and still needs ordering guarantees
- Keep the log in PostgreSQL, block changes with grants and triggers, and chain each row to the previous one by hash
- Trade-off
The log is tamper-evident, not tamper-proof. Someone who can run DDL could disable the trigger and rewrite the chain, and the hash shows that only if a copy of the chain head is kept somewhere else. Audited writes within one account are serialised at commit.
- Validation
A regression test opens two real connections, overlaps their transactions and checks that the chain does not fork. Another test checks that the request role cannot update or delete audit rows. In the same reproduction, moving the lock to commit time cut same-tenant audited writes from about 4.8 seconds to about 50 milliseconds.
Why I made each decision
The workflow engine lives in database rows
- Because
Human approvals take days and need explicit states, conditional routing, deadlines, idempotent decisions and an audit trail, all of which fit naturally in rows. One decision per stage is a unique constraint, a decided stage matches no update policy, and a rejection without a comment violates a constraint. Resubmission appends new stage rows with a higher attempt number instead of overwriting, and the current stage is derived rather than stored, so there is no pointer to race over.
- Trade-off
Routing rules live in this codebase and its tests, not in an established engine. An early version did not re-evaluate routing after a revision, so an event could become high-risk during revision and keep its low-risk route. Migration 010 fixed this, and a test now covers it.
A club does not need a university
- Because
A club should be able to adopt WayClub on its own. Standalone clubs get an internal review route, recorded as CLUB_INTERNAL and ADVISOR_REVIEW, never INSTITUTION_OFFICIAL, because an official approval is impossible without an institution row the account owns.
- Trade-off
Two columns on every tenant-scoped table, and policies that handle both modes.
Row-level security for tenancy
- Because
Filtering tenants with a WHERE clause in every query fails open: one forgotten predicate is a breach. With row-level security a missed filter returns an empty result instead of another club’s data. An external authorisation service was over-engineering for a pilot, and a database per institution stays an option for tenants with strict data-residency needs.
- Trade-off
Policies have to be written and reviewed per table, every query pays a policy check, and support work must go through the same restricted path. Forcing row-level security on the table owner was measured to break the security-definer helpers, so it is enabled but not forced.
Testing
Dated local runs on 20 October 2026 passed 109 database, 784 API and 95 Flutter tests. The 25 October handoff additionally records 85 email-related checks and 24 account-rights API checks passing. These are separate recorded runs, not a combined fresh total for the 26 October mobile work.
- Database tests (RLS, isolation, integrity)
- 109
- API integration tests against real PostgreSQL
- 784
- Migration files
- 59
- Flutter app tests
- 95
Representative cases
Cross-tenant reads: a request for another tenant’s event or club returns 404, indistinguishable from a record that does not exist, while a same-tenant outsider gets 403.
Chain integrity under concurrency: two real connections overlap their transactions and the audit chain must not fork.
Stale writes: an update with an old version affects zero rows in the database, and the API returns 409 version_conflict.
Revision re-routing: an event edited during revision into a higher-risk event must pick up the longer approval route.
Waitlist promotion: cancelling frees the place and promotes the next eligible entry in the same transaction, and only the organising committee or the cancelling registrant may trigger it.
No stored secrets in handover: an information-schema sweep checks that no continuity table has a credential-shaped column.
Results in detail
New member screens, with no live site currently
The 26 October Flutter build adds frosted event pages, registration forms, tickets, What’s on and Your plans. The case study shows the latest direction while development continues; WayClub currently has no public live site.
A workspace for the committee
Meetings, tasks, event stages and handover share a committee workspace. Recent mobile work includes the door and check-in presentation. A separate interactive prototype explores the refined workspace, joining and account flows; prototype screens are labelled as design work.
Email and account rights behind the interface
Recent backend work adds rendered registration, waitlist and cancellation emails, plus account rights and a scheduled deletion job. These are implementation updates; the hosted service is currently paused.
Launch work still ahead
Billing, Wallet passes and remaining guest and onboarding flows still need implementation or complete integration and validation. The mobile app is not in a store, and the product has not launched publicly.
Stack and release record
Platforms
Web (desktop and phone width), Mobile (Flutter, iOS and Android): member side and a committee subset, not in a store yet
Built with
TypeScript, Next.js, React 19, TanStack Query, NestJS, PostgreSQL with row-level security, Flutter, Vitest, Playwright, OKLCH design tokens with a contrast gate, GitHub Actions, Google Cloud (Singapore), Firebase Hosting
Testing and delivery
The 20 October local runs recorded 109 database, 784 API and 95 Flutter tests; 25 October work added verified email and account-rights changes. The 26 October mobile build develops event, registration, ticket and planning screens. WayClub is in active development, with no current public live site or store release.
Status in full
In active development, with no public live site currently. The latest Flutter work includes redesigned event pages, registration forms, tickets, What’s on, Your plans and door check-in screens. The committee workspace and joining flows are also being refined in interactive prototypes. Store releases, billing, Wallet passes and complete launch validation remain ahead.


