Skip to content
Works

Enterprise Groupware

An internal portal that replaced a stack of separate SaaS subscriptions for a 1,000-person staffing company, with a permission model built around how the organisation actually delegates authority.

Period
May 2023 – Jun 2026
Team
3–5 developers at a time (22 people over three years)
Role
Project manager and code lead — requirements, technical direction, task breakdown, code review
Scale
62 tables · 72 web screens · 15 mobile screens · 171k lines of TypeScript
Stack
TypeScript · NestJS · TypeORM · MySQL · Socket.io · Next.js · React Native · Google Cloud Run · Cloud SQL · Cloud Storage · Cloud Build

Problem

A staffing company of roughly 1,000 people was running its internal operations across a stack of separate SaaS subscriptions — one product for chat, another for attendance, others for the rest. At that headcount the arrangement carried two costs. The visible one was licensing, which scaled linearly across a workforce that mixed permanent staff with a large contractor population. The less visible one was administrative: every tool had its own account lifecycle, so joining, moving or leaving meant touching all of them.

The company also wanted capabilities the off-the-shelf products would not give them. Event management and a company-wide noticeboard needed to sit next to chat, not in yet another subscription. More significantly, the generic permission models did not fit. With people split across departments and job grades, working a mix of on-site and remote, and with contractors alongside employees, access had to follow where someone sat in the organisation — not which channel they happened to be invited to.

Two vendor constraints were doing real damage. Message and image retention was capped by the subscription tiers, so conversations and shared files expired while the work they documented was still running.

Five separate subscriptions, each with its own accounts and permissions, replaced by one system with a single permission model BEFORE — five subscriptions, five account lifecycles Chat own accounts Attendance own accounts Events own accounts Documents own accounts Noticeboard own accounts 1,000 people ×5 AFTER — one system, one permission model Chat Attendance Events Wiki Noticeboard one identity · one permission model · web + mobile 1,000 people ×1
Consolidating five subscriptions into one system, and with them five sets of accounts into one.

Approach

The system is TypeScript end to end — server, web and mobile. On a product where the same domain concepts (a shift, a role, a chat membership) have to be expressed in three places, sharing one language kept those definitions from drifting apart. Node on the server followed from that choice rather than the other way round.

What that language covers is wider than it sounds. The system is three applications over one domain — a NestJS server, a Next.js web client and a React Native app — against sixty-two tables that span attendance and shift patterns, paid and special leave, travel expenses, chat with its albums and reactions, events with ticketing, a wiki, a noticeboard, and the permission model that governs all of it. Chat and notifications run over Socket.io. The breadth is the reason a shared vocabulary mattered: a "section" means the same thing to the attendance module and the permission model because there is one definition of it.

The whole product sits on Google Cloud: Cloud Run for the web and API tiers, Cloud SQL beneath them, and Cloud Storage holding every file the product touches, from chat attachments to album images to wiki uploads, under one storage policy instead of one per type. Deployment is a push to main, with Cloud Build installing, building, running the production migrations and rolling out, so a schema change and the code that needs it land together. Mobile releases go through fastlane.

The mobile app was not a convenience wrapper. Two requirements ruled out a responsive web build. Push notifications were the only reliable way to reach a workforce that was largely off-site, and chat needed a local cache so that opening a conversation did not mean refetching its history over a mobile connection. Beyond that, the product is interactive enough — chat, reactions, calendars, image handling — that native rendering was the difference between usable and merely working. React Native meant both platforms stayed in the same language as the rest of the system, maintained by the same people.

Trade-offs

The permission model was rebuilt in late 2025, two and a half years into the project. It is the design decision I would defend most readily.

The system had started out largely open: content was published company-wide and access questions rarely came up. That stopped holding as the contractor population grew — contractors needed some features and not others, and departments wanted information sharing that stayed closed to the rest of the company. The original model could express neither.

The obvious answer was roles in the Discord sense — define a role, attach permissions, assign a person to as many roles as the situation needs. I ruled it out for two reasons. It does not match how authority is actually delegated in a Japanese company, where permission follows from the pairing of department and job grade rather than from either alone; a role-only model pushes that pair into a naming convention. And expressing anything complex means stacking roles on one person, which leaves an effective permission set that nobody can predict by looking at it.

What replaced it keys permissions to the (department, job title) pair directly. Each pair owns a set of rows of subject × action × scope, where scope is a bitmask over all / section / title / branch / own, so one row expresses what would otherwise need several stacked roles. A key of zero acts as a wildcard, which covers cases like "this grade, any department".

Permissions hang off a department and job-title pair. Each pair owns rows of subject, action and a scope bitmask. ORGANISATION Department key 0 = any Job title key 0 = any role_section_title unique on the pair GRANTS — one row each subject action scope (bitmask) attendance read section | own board create section users approve title | own 8 subjects × 5 actions × 5 scope bits ENFORCED, NOT DOCUMENTED CHECK constraints in the database valid subject · valid action · scope ⊆ 0b11111 Rejected when the role is saved “any department” cannot carry department scope editing a subject requires reading it An invalid grant cannot be written at all
Permissions key to the (department, job title) pair. Scope is a bitmask, so one row expresses what would otherwise be several stacked roles.

Where two rows both apply their permissions union, and there are deliberately no deny rules: the schema has room for them, but they would reintroduce the very problem that ruled out stacked roles. Scope stays inside the pair it was granted on, so a wildcard row never quietly widens a grant belonging elsewhere.

Combinations that cannot mean anything are refused when the role is saved rather than when it is used, and the refusal comes back in the administrator's language, not the developer's, because the person configuring the role is the one who has to fix it. That and the database constraints are the same principle twice: make invalid states unrepresentable, and fail early enough that someone can act on it.

The cost is rigidity: the model assumes the organisation is describable as department × grade, and an access axis that does not fit that shape would need a schema change, not configuration.

The second trade-off is one I would make differently now. Events were designed around a simple model: someone creates an event, and whoever wants to attend opts in. Then the requirement arrived that people also wanted to manage their own schedules. Events already leaned heavily on the calendar, and putting personal scheduling into the same surface would have left two competing notions of "what is on my calendar" in one view, on a data model that had never been built to separate them. Rebuilding it properly was costed and rejected — too much work for the value at that point in the project.

We integrated Google Calendar instead and let personal scheduling live there. It worked, and given the budget it was the right call. But for a groupware product, "I want to see my own schedule" is not an exotic request. It was foreseeable, and a model that had anticipated it would not have needed the workaround. The integration closed the gap without removing it, and that is the kind of decision worth being honest about: it was a reasonable response to a problem I should not have had.

My scope

I ran this engagement as project manager and code lead, from the contract in 2023 through to the most recent release in mid-2026.

The team was small and rarely the same twice. A typical month had three to five developers on it; the busiest had seven. Twenty-two people committed to the project across three years, most of them contractors on short engagements at a small consultancy. Continuity had to sit somewhere other than the team roster, so it sat with me: I handled requirements and the client relationship, broke the work into tasks, set the technical direction, and reviewed and approved every merge.

I worked across all three codebases instead of owning a single layer. By volume the weight fell on the web frontend and the mobile app, with the NestJS server behind them — which matches a product whose hard problems were mostly about what users could see and do, not about throughput. In the final stretch the team wound down to one or two people and I maintained the system on my own.

Outcome

The system went into production and is still running. It replaced the separate subscriptions it was meant to consolidate, and the permission model holds up against the access rules the company actually needed — which was the part most at risk of not landing.

The saving is plain arithmetic. Per-seat licensing ran at roughly ¥1,000 a head, so retiring chat and attendance alone came to more than ¥2 million a month across a workforce of over a thousand. The real figure was higher than that, because per-seat pricing sat badly on a workforce with heavy contractor turnover: every arrival and departure meant a seat to add or reclaim across several vendors, and any seat nobody reclaimed was simply paid for.

The part that was not in the brief is that the features nobody had been paying for separately — events and the noticeboard — are the ones that changed how the company used it. Internal sharing that previously had nowhere to live now had somewhere, which is the kind of result consolidation produces and a per-seat subscription cannot.

It has kept changing since launch, arriving as steady customisation rather than a big second phase — the shape you would expect when a product is genuinely embedded in how a company works, not sitting alongside it.