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
WayClub’s 26 October committee design prototype: a live event, membership requests, publishing and meeting approvals for a fictional film society.
  • WayClub’s What’s on design prototype with a film premiere ticket, waiting-list position and upcoming club events.
  • WayClub committee design prototype for a fictional film society: a running event, join requests, publishing, meeting approvals and upcoming events.
  • WayClub’s redesigned Robotics Showcase Night event, with poster, date, venue, details, places remaining and Register action.
  • WayClub’s ticket design prototype showing a fictional film premiere, door code, QR code and calendar and Wallet actions.

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

  1. On the device

    • Web app: Next.js 15, React 19
    • Local guided demo
  2. On my infrastructure

    • API: NestJS 11
    • PostgreSQL 17
  3. Outside the system

    • Storage and email
Where tenant identity is set, where it is enforced, and where history is written. Parts and connections

Engineering challenges

  1. 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.

  2. 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.

  3. 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

DecisionWhat I choseInstead of
The workflow engine lives in database rowsVersioned workflow definitions as rows, each instance pinned to its versionAn external engine such as Temporal or Camunda
A club does not need a universityThe account as the tenant root, with the institution optionalMaking the institution the tenant
Row-level security for tenancyPostgreSQL row-level security, with a request role that owns nothingTenant 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

10 sections, about 12 minutes to read

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

  1. Propose

    A committee member writes a multi-step proposal that autosaves, with a poster that requires alt text.

  2. Route

    Risk, venue and budget answers choose the approval route. A revision re-evaluates the route against the event as it now is.

  3. Approve

    Reviewers work a queue sorted by due date, see a field-level diff since the last submission, and approve, request changes or reject.

  4. Register

    Members register up to capacity, then join a waitlist. A cancellation promotes the next eligible person in the same transaction.

  5. Attend

    Check-in uses an eight-character attendance code. The post-event report is pre-filled from real attendance rows.

  6. Hand over

    Verified attendees receive involvement records. At the end of a term, authority transfers to the next committee on an effective date.

One event record, carried from the first draft to a member’s verified record.

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.

PartWhat it holds
On the device
Web app: Next.js 15, React 19
  • TanStack Query for server state
  • Event proposal form with autosave and conflict recovery
  • Reviewer queue, oversight and committee handover screens
  • OKLCH design tokens with a build-time contrast gate
Local guided demo
  • The real app pages with build-time fictional data adapters
  • Changes held in memory and reset on reload
  • Content Security Policy with connect-src 'none'
On my infrastructure
API: NestJS 11
  • One transaction per request, claims set with set_config(..., true)
  • Claims resolved from the database, never copied from the token
  • Versioned workflow engine with re-evaluated routing
  • expectedVersion compare-and-swap, 409 version_conflict
  • Per-event advisory lock for registration and waitlist
  • Transactional notification outbox
PostgreSQL 17
  • Row-level security keyed on app.current_account_id()
  • Request role with no ownership and no BYPASSRLS
  • Composite foreign key binding institution to account
  • Append-only audit_event with a per-account SHA-256 chain
  • 59 migration files
Outside the system
Storage and email
  • Storage facade: S3-compatible and Google Cloud Storage drivers
  • SMTP driver; production refuses the sandbox driver

Connections between the parts

  1. Web app: Next.js 15, React 19 to API: NestJS 11HTTPS with a signed session; writes carry expectedVersion
  2. API: NestJS 11 to PostgreSQL 17One transaction per request as the request role, claims set transaction-locally
  3. PostgreSQL 17 to API: NestJS 11Only rows the policies admit; another tenant’s row reads as not found
  4. API: NestJS 11 to Storage and emailFile bytes through the storage facade; email from the outbox
  5. 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.

Hiring interns for January 2027?

I’m available full-time from 25 January to 23 April 2027.