Business Platform

Internal documentation for the multi-tenant SaaS business management platform.

Live app: d2efo184qf3zrs.cloudfront.net
GitHub: mirrorhead-uk/supplier-platform
AWS account: 559401928829 · eu-west-1 (Ireland)

What is this?

Business Platform is a fully multi-tenant SaaS tool for small businesses. Each tenant gets a completely isolated workspace with their own data, users, and role-based access. It started as a supplier management tool and is expanding into a full business management suite.

Modules

Suppliers Live
Supply chain management with document uploads
Clients Live
Pipeline from lead to active with documents
People Live
HR records, salary, org chart, documents
Settings Live
Team management, roles, invitations
Invoicing Planned
Client invoicing with PDF generation
Expenses Planned
Expense tracking with receipt uploads

Tech stack

LayerTechnology
FrontendReact 18, Vite, React Router, CSS Modules
AuthAWS Cognito — User Pool + JWT
APIAWS API Gateway HTTP API
ComputeAWS Lambda Node.js 22.x (32 functions)
DatabaseDynamoDB single-table (supplier-platform-v2)
StorageS3 — frontend bundle + document uploads
CDNCloudFront with OAC + security headers

Architecture

All infrastructure lives in eu-west-1 (Ireland), AWS account 559401928829.

Request flow

Browser → CloudFront → S3          (React app — static files)
Browser → API Gateway → Lambda     (all REST endpoints)
Browser → Cognito                  (auth / JWT issuance)
Lambda  → DynamoDB                 (entity data)
Lambda  → S3                       (presigned document URLs)

AWS resources

ServiceResource IDPurpose
CloudFrontE1FIHLCB86M2CFCDN, security headers, OAC
S3 frontendsupplier-platform-frontend-559401928829React bundle (private)
API Gatewayycn03ustd3HTTP API — all endpoints
Lambda32 functionsAll business logic
DynamoDBsupplier-platform-v2All entity data
S3 documentssupplier-platform-documents-559401928829File uploads
Cognitoeu-west-1_hGgmpHBHQAuth, user pools, groups
IAMsupplier-platform-lambda-roleLambda execution role

Multi-tenancy

Tenant isolation is enforced at three independent layers:

  • Cognito — users belong to a group named tenant-{id}. The tenantId is embedded in the JWT as custom:tenantId.
  • Lambdashared/auth.js extracts tenantId from the JWT on every request. No request can claim another tenant's ID.
  • DynamoDB — every record has tenantId as its partition key. All queries are scoped to tenantId = :tid. Cross-tenant reads are structurally impossible.

Tenant roles

Cognito groupAccess level
tenant-id:adminFull access — manage team and all data
tenant-id:contributorCreate and edit records
tenant-id:readonlyView only
platform-adminPlatform back-office — all tenants

Database — single-table design

Table: supplier-platform-v2  ·  PK: tenantId  ·  SK: entityType#entityId

EntitySK prefixExample
SupplierSUPPLIER#SUPPLIER#sup-abc123
ClientCLIENT#CLIENT#cli-xyz789
EmployeeEMPLOYEE#EMPLOYEE#emp-def456
InvoiceINVOICE#INVOICE#inv-ghi012 (planned)
ExpenseEXPENSE#EXPENSE#exp-jkl345 (planned)
Note: sk is a DynamoDB reserved word. Always alias it as #sk using ExpressionAttributeNames: { '#sk': 'sk' } in any expression.

Security: Deletion protection ON  ·  PITR (point-in-time recovery) ON  ·  AES-256 encryption

Document storage

S3 path structure:

s3://supplier-platform-documents-559401928829/
  {tenantId}/{entityId}/{category}/{fileId}__{fileName}

Upload flow: Frontend calls Lambda to get a presigned PUT URL (5 min TTL) → browser uploads directly to S3 → Lambda never touches file bytes. Download links are presigned GET URLs with 1-hour TTL, generated at list time.

Modules

Feature documentation for each module in the platform.

Suppliers  Live

Routes: /suppliers, /suppliers/:id

Manage the company's supply chain. Each supplier record tracks contact info, category, relationship owner, goods supplied, and status.

Statuses

StatusMeaning
ActiveCurrent, trusted supplier
PendingUnder review / onboarding
InactiveNo longer used
RejectedFailed vetting

Documents

Five categories: Certifications, Contracts & Agreements, Quotes, Invoices, Other. Drag-and-drop upload directly to S3 via presigned URL.

Clients  Live

Routes: /clients, /clients/:id

Manage client relationships with a sales pipeline model. Notes are inline-editable (click to edit, ⌘↵ to save).

Pipeline

Lead Prospect Proposal Active Inactive

Documents

Three categories: Contracts & Agreements, Quotes, Invoices.

People (HR)  Live

Routes: /people, /people/:id

Employee records. Separate from platform users — an employee does not need a platform login. Optionally linked to a Cognito user via userId.

Key fields

firstName, lastName, fullName, jobTitle, department, employmentType, status, startDate, workEmail, personalEmail, phone, salary (annual GBP), managerId (links to another employee), userId (optional Cognito link), notes

Statuses

StatusMeaning
ActiveCurrently employed
On leaveTemporarily absent
TerminatedNo longer employed

Documents

Four categories: Contracts, Right to Work, Qualifications, Other Docs.

Settings  Live

Route: /settings (tenant admins only)

RolePermissions
AdminFull access — manage team and all data
ContributorCreate and edit records
Read onlyView only

Admin back-office  Live

Route: /admin (platform-admin group only)

Separate area with distinct dark sidebar. Inaccessible to tenant users. Manage all tenants — create, list, and permanently delete tenants with all their data.

API Reference

Base URL: https://ycn03ustd3.execute-api.eu-west-1.amazonaws.com
All endpoints require Authorization: Bearer <cognito-id-token>. The tenant is derived from the JWT.

Suppliers

MethodPathLambdaDescription
GET/supplierslistSuppliersList all suppliers
POST/supplierscreateSupplierCreate a supplier
GET/suppliers/{id}getSupplierGet one supplier
PUT/suppliers/{id}updateSupplierUpdate supplier
DELETE/suppliers/{id}deleteSupplierDelete supplier
GET/suppliers/{id}/documentslistDocumentsList documents grouped by category
POST/suppliers/{id}/documents/upload-urlgetUploadUrlGet presigned S3 upload URL
DELETE/suppliers/{id}/documentsdeleteDocumentDelete a document (body: { s3Key })

Clients

MethodPathLambdaDescription
GET/clientslistClientsList all clients
POST/clientscreateClientCreate a client
GET/clients/{id}getClientGet one client
PUT/clients/{id}updateClientUpdate client
DELETE/clients/{id}deleteClientDelete client
GET/clients/{id}/documentslistClientDocumentsList documents grouped by category
POST/clients/{id}/documents/upload-urlgetClientUploadUrlGet presigned S3 upload URL
DELETE/clients/{id}/documentsdeleteClientDocumentDelete a document

Employees

MethodPathLambdaDescription
GET/employeeslistEmployeesList all employees
POST/employeescreateEmployeeCreate an employee
GET/employees/{id}getEmployeeGet one employee
PUT/employees/{id}updateEmployeeUpdate employee
DELETE/employees/{id}deleteEmployeeDelete employee
GET/employees/{id}/documentslistEmployeeDocumentsList documents grouped by category
POST/employees/{id}/documents/upload-urlgetEmployeeUploadUrlGet presigned S3 upload URL
DELETE/employees/{id}/documentsdeleteEmployeeDocumentDelete a document

Team management

MethodPathDescription
GET/teamList all users in the tenant
POST/team/inviteInvite a new user
PUT/team/{username}/roleChange a user's role
DELETE/team/{username}Remove a user (must be suspended first)
POST/team/{username}/suspendSuspend a user
POST/team/{username}/unsuspendUnsuspend a user

Admin (platform-admin only)

MethodPathDescription
GET/admin/tenantsList all tenants on the platform
POST/admin/tenantsCreate a new tenant + first admin user
DELETE/admin/tenants/{tenantId}Delete a tenant and all its data

Document upload pattern

All upload-url endpoints accept the same request body:

{
  "fileName": "contract.pdf",
  "fileType": "application/pdf",
  "fileSize": 102400,
  "category": "contracts"
}

Returns a presigned S3 URL. The browser then PUTs the file directly to S3 — Lambda never handles file bytes. Download URLs in list responses are presigned GET URLs with a 1-hour TTL.

Lambda functions

All 32 Lambda functions. Runtime: nodejs22.x. All share a single deployment bundle at backend/functions.zip. Shared helpers live in backend/functions/shared/.

Shared helpers: shared/auth.js — extracts JWT claims, checks roles · shared/db.js — DynamoDB client + key helpers (sk(), skPrefix(), idFromSk()) · shared/response.jsok() / error() builders, getTenantId() · shared/roles.js — role constants
Suppliers — 8 functions
listSuppliers GET /suppliers
Returns all supplier records for the authenticated tenant.
Queries DynamoDB with begins_with(#sk, 'SUPPLIER#') to return only supplier entities from the single-table design, excluding any other entity types that share the partition key.
DynamoDB QueryTenant-scoped
getSupplier GET /suppliers/{id}
Fetches a single supplier record by its ID.
Performs a DynamoDB GetItem using the composite key tenantId + SUPPLIER#{id}. Returns 404 if not found.
DynamoDB GetItem
createSupplier POST /suppliers
Creates a new supplier record for the tenant.
Validates required fields (name, contact, category, relationshipOwner, goods), generates a sup-{uuid} ID, and writes to DynamoDB with attribute_not_exists(#sk) to prevent accidental overwrites.
DynamoDB PutItemValidation
updateSupplier PUT /suppliers/{id}
Updates allowed fields on an existing supplier record.
Fetches the existing record first to verify ownership, then performs a UpdateItem on only the fields present in the request body. Always updates updatedAt. Returns the full updated record via ReturnValues: ALL_NEW.
DynamoDB UpdateItemOwnership check
deleteSupplier DELETE /suppliers/{id}
Permanently deletes a supplier record.
Fetches the record first to verify it exists and belongs to the tenant before deleting. Does not delete associated S3 documents — those are managed separately.
DynamoDB DeleteItemOwnership check
getUploadUrl POST /suppliers/{id}/documents/upload-url
Generates a presigned S3 PUT URL so the browser can upload a document directly to S3.
Validates the category (certifications, contracts, quotes, invoices, other) and enforces a 50MB file size limit. Stores the file at {tenantId}/{supplierId}/{category}/{uuid}__{fileName}. URL expires in 5 minutes. Lambda never handles file bytes.
S3 presigned URL50MB limit
listDocuments GET /suppliers/{id}/documents
Lists all documents for a supplier, grouped by category with presigned download URLs.
Uses ListObjectsV2 on the S3 prefix {tenantId}/{supplierId}/, then generates a presigned GET URL (1-hour TTL) for each object. Groups results into the five document categories and returns the full grouped structure.
S3 ListObjectsPresigned download URLs
deleteDocument DELETE /suppliers/{id}/documents
Deletes a document from S3. Requires tenant-admin role.
Accepts s3Key in the request body. Validates that the key starts with {tenantId}/{supplierId}/ before deleting — prevents cross-tenant or cross-supplier deletion even if a malicious key is supplied.
S3 DeleteObjectAdmin onlyKey validation
Clients — 8 functions
listClients GET /clients
Returns all client records for the authenticated tenant.
Queries DynamoDB with begins_with(#sk, 'CLIENT#') to return only client entities.
DynamoDB QueryTenant-scoped
getClient GET /clients/{id}
Fetches a single client record by ID.
GetItem using tenantId + CLIENT#{id}. Returns 404 if not found.
DynamoDB GetItem
createClient POST /clients
Creates a new client record for the tenant.
Required fields: name, relationshipOwner. Default status is lead. Generates a cli-{uuid} ID. Validates status against the pipeline values: lead, prospect, proposal, active, inactive.
DynamoDB PutItemPipeline status
updateClient PUT /clients/{id}
Updates allowed fields on an existing client record.
Allowed fields: name, contact, website, status, relationshipOwner, industry, notes. Verifies ownership before updating. Used by both the Edit drawer and the inline notes editor on the client detail page.
DynamoDB UpdateItemPartial update
deleteClient DELETE /clients/{id}
Permanently deletes a client record.
Verifies the record exists and belongs to the tenant before deleting. Does not cascade-delete S3 documents.
DynamoDB DeleteItemOwnership check
getClientUploadUrl POST /clients/{id}/documents/upload-url
Generates a presigned S3 PUT URL for uploading a client document.
Valid categories: contracts, quotes, invoices. 50MB limit. Path: {tenantId}/{clientId}/{category}/{uuid}__{fileName}.
S3 presigned URL50MB limit
listClientDocuments GET /clients/{id}/documents
Lists all documents for a client, grouped by category with presigned download URLs.
Same pattern as listDocuments — lists S3 objects under the client prefix, generates presigned GET URLs (1-hour TTL), groups by the three client categories.
S3 ListObjectsPresigned download URLs
deleteClientDocument DELETE /clients/{id}/documents
Deletes a client document from S3. Requires tenant-admin role.
Validates the s3Key starts with {tenantId}/{clientId}/ before deleting.
S3 DeleteObjectAdmin onlyKey validation
Employees — 8 functions
listEmployees GET /employees
Returns all employee records for the authenticated tenant.
Queries DynamoDB with begins_with(#sk, 'EMPLOYEE#'). Used by the People list page and also loaded by the employee detail page to populate the line-manager search dropdown.
DynamoDB QueryTenant-scoped
getEmployee GET /employees/{id}
Fetches a single employee record by ID.
GetItem using tenantId + EMPLOYEE#{id}. Returns 404 if not found.
DynamoDB GetItem
createEmployee POST /employees
Creates a new employee HR record for the tenant.
Required fields: firstName, lastName. Generates fullName automatically. Generates an emp-{uuid} ID. Key fields: jobTitle, department, employmentType (full-time/part-time/contractor/intern), status (active/on-leave/terminated), startDate, workEmail, personalEmail, phone, salary (number, annual GBP), managerId (optional — another employeeId), userId (optional — a Cognito username to link the HR record to a platform login).
DynamoDB PutItemfullName computed
updateEmployee PUT /employees/{id}
Updates allowed fields on an existing employee record.
Automatically recomputes fullName if firstName or lastName changes. Validates salary is a number. Used by the Edit drawer, the inline notes editor, and the inline line-manager picker on the employee detail page — all three call this same endpoint with different subsets of fields.
DynamoDB UpdateItemfullName syncPartial update
deleteEmployee DELETE /employees/{id}
Permanently deletes an employee HR record.
Verifies ownership before deleting. Does not cascade-delete S3 documents or unlink the managerId on other employees who report to this person.
DynamoDB DeleteItemOwnership check
getEmployeeUploadUrl POST /employees/{id}/documents/upload-url
Generates a presigned S3 PUT URL for uploading an employee document.
Valid categories: contracts, right-to-work, qualifications, other. 50MB limit. Path: {tenantId}/{employeeId}/{category}/{uuid}__{fileName}.
S3 presigned URL50MB limit
listEmployeeDocuments GET /employees/{id}/documents
Lists all documents for an employee, grouped by category with presigned download URLs.
Lists S3 objects under the employee prefix, generates presigned GET URLs (1-hour TTL), and groups into four HR-specific categories.
S3 ListObjectsPresigned download URLs
deleteEmployeeDocument DELETE /employees/{id}/documents
Deletes an employee document from S3. Requires tenant-admin role.
Validates the s3Key starts with {tenantId}/{employeeId}/ before deleting.
S3 DeleteObjectAdmin onlyKey validation
Team management — 5 functions
tenantListUsers GET /team
Lists all users in the current tenant's Cognito group, with their roles and account status.
Queries ListUsersInGroup on the tenant's Cognito group, then calls AdminListGroupsForUser for each user to determine their role sub-group (:admin, :contributor, :readonly). Returns enriched user objects including email, role label, account status, and createdAt.
CognitoRole resolution
tenantInviteUser POST /team/invite
Invites a new user to the tenant by creating a Cognito account and assigning them a role.
Calls AdminCreateUser which triggers Cognito to send an invitation email with a temporary password. Adds the user to the tenant group and the appropriate role sub-group. Only tenant admins can invite users.
CognitoSends invite emailAdmin only
tenantUpdateUserRole PUT /team/{username}/role
Changes a team member's role within the tenant.
Removes the user from all existing role sub-groups (:admin, :contributor, :readonly) then adds them to the new role group. Prevents admins from changing their own role. Only tenant admins can change roles.
CognitoAdmin only
tenantSuspendUser POST /team/{username}/suspend  ·  /team/{username}/unsuspend
Suspends or unsuspends a team member. One function handles both actions.
Suspended users cannot log in but their account and all associated data are preserved. Uses AdminDisableUser / AdminEnableUser in Cognito. The action is determined from the URL path. Only tenant admins can suspend users. A user must be suspended before they can be permanently deleted.
CognitoAdmin onlyDual-action
tenantRemoveUser DELETE /team/{username}
Permanently deletes a user from the tenant. The user must be suspended first.
Calls AdminDeleteUser in Cognito. Enforces the suspension prerequisite as a safety guard against accidental deletion of active accounts. Only tenant admins can delete users. This action cannot be undone.
CognitoAdmin onlyRequires suspension first
Platform admin — 3 functions (platform-admin group only)
adminListTenants GET /admin/tenants
Lists all tenants on the platform with user counts and creation dates.
Calls ListGroups on the Cognito User Pool and filters to top-level tenant groups — those starting with tenant- and containing no colon (which would indicate a role sub-group like tenant-id:admin). For each tenant, calls ListUsersInGroup to get the user count. Uses the group Description field as the display name (set by adminCreateTenant), falling back to deriving a name from the group name if the description is the legacy format.
CognitoPlatform-admin only
adminCreateTenant POST /admin/tenants
Creates a new tenant workspace and its first admin user.
Takes companyName and adminEmail. Generates a URL-safe tenantId from the company name (e.g. tenant-acme-ltd). Creates a Cognito group for the tenant with the company name stored in the Description field. Creates the first user via AdminCreateUser — Cognito sends an invitation email with a temporary password. Adds the user to the tenant group. Sets custom:tenantId and custom:tenantName as Cognito user attributes.
CognitoPlatform-admin onlySends invite email
adminDeleteTenant DELETE /admin/tenants/{tenantId}
Permanently and irreversibly deletes a tenant and all of its data.
Requires the confirmTenantId field in the request body to match the path parameter — a safety check to prevent accidental deletion. Performs three sequential operations: (1) deletes all Cognito users in the tenant group, (2) deletes the tenant group and all role sub-groups (:admin, :contributor, :readonly), (3) scans and deletes all DynamoDB records where tenantId matches — this covers all entity types (suppliers, clients, employees, etc.). Guards against deleting platform-admin or role sub-groups directly.
CognitoDynamoDBPlatform-admin onlyIrreversible

Deployment

How to build and deploy the frontend and backend.

SSO note: AWS SSO sessions expire periodically. If you see UnrecognizedClientException, run aws sso login --profile mirrorhead-dev first.

Frontend deploy

cd /path/to/Supplier-Platform
./scripts/deploy.sh

Builds with Vite → syncs hashed assets to S3 with Cache-Control: immutable → syncs index.html with no-cache → invalidates CloudFront /*.

Backend deploy

./backend/scripts/deploy.sh

Bundles all backend/functions/ into a single zip, then for each Lambda: updates code → waits → updates runtime to nodejs22.x → waits. Takes ~10 minutes. The waits are necessary — Lambda blocks config changes while a code update propagates.

Adding a new Lambda

  1. Write the handler in backend/functions/yourFunction.js
  2. Add to the FUNCTIONS array in backend/scripts/deploy.sh
  3. Copy an existing setup script (e.g. setup-clients-api.sh) and adapt it
  4. Run the setup script once to create the Lambda and API Gateway route
  5. Future deploys via deploy.sh will keep it updated

Environment variables

Frontend (.env)

VariableValue
VITE_API_BASE_URLhttps://ycn03ustd3.execute-api.eu-west-1.amazonaws.com
VITE_DEV_TENANT_IDtenant-dev-001
VITE_COGNITO_REGIONeu-west-1
VITE_COGNITO_USER_POOL_IDeu-west-1_hGgmpHBHQ
VITE_COGNITO_CLIENT_ID7ecs96dmcldrq7856jk67rs0l7

Lambda env vars (per function)

VariableValue
TABLE_NAMEsupplier-platform-v2
USER_POOL_IDeu-west-1_hGgmpHBHQ
DOCUMENTS_BUCKETsupplier-platform-documents-559401928829
CORS_ORIGINhttps://d2efo184qf3zrs.cloudfront.net

Updating these docs

Edit docs/index.html in the repo and push. Cloudflare Pages rebuilds within ~30 seconds — no build step required.

Decision Log

Key architectural decisions, why we made them, and the trade-offs involved.

Single-table DynamoDB design

All entity types live in one table: tenantId (PK) + entityType#entityId (SK).

Why: DynamoDB performs best with access patterns baked into the key structure. One table means one IAM policy, one set of backups, one billing unit. Adding a new entity type is zero-infrastructure — just a new SK prefix.

Trade-offs: Harder to reason about for newcomers. sk is a DynamoDB reserved word — requires ExpressionAttributeNames: { '#sk': 'sk' } in all expressions.

Serverless compute (Lambda only)

Why: Zero infrastructure management. Pay-per-request — effectively free for the first 1M requests/month. Cold starts (~200ms) acceptable for B2B use.

Trade-offs: All functions share one bundle — a bad require() breaks everything. 15-minute execution limit (not a current constraint).

Cognito for auth

Why: Native AWS integration, free up to 10,000 MAU, handles email verification and password reset. Groups map cleanly to tenant roles.

Trade-offs: Single-region only — no native multi-region HA. JWT validation is trust-based (claims read without signature verification) — sufficient for internal use.

Presigned S3 URLs for document upload

Why: Browser uploads directly to S3. Lambda never handles file bytes — no 6MB payload limit, no base64 overhead, no bandwidth costs through Lambda.

Trade-offs: Two round trips instead of one. Download links expire after 1 hour.

Bash scripts instead of Terraform/CDK

Why: Speed of iteration — a new Lambda + route is ~20 lines of bash. No state file drift. Fully transparent.

Trade-offs: Setup scripts are not idempotent. No drift detection.

Mitigation: Named setup-*.sh (run once) vs deploy.sh (idempotent, run every deploy).

eu-west-1 (Ireland) as primary region

Why: Closest AWS region to UK/Europe. GDPR-compliant EU data residency. Full service availability.

Future HA region: eu-central-1 (Frankfurt) — nearest alternative EU region with full service parity.