Business Platform

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

Live app: d2efo184qf3zrs.cloudfront.net
GitLab: mirrorhead-group/business-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
Contacts Live
Cross-entity contact records linked to suppliers and clients
Assets Live
Asset tracking for equipment, devices, and more
Invoicing Live
Client invoicing with PDF generation and company branding
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 (55 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
Lambda55 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
ContactCONTACT#CONTACT#con-mno789
AssetASSET#ASSET#ast-pqr456
Asset HistoryASSET_HISTORY#ASSET_HISTORY#ast-pqr456#2026-04-08T12:00:00Z
InvoiceINVOICE#INVOICE#inv-ghi012
Employee EventEMPLOYEE_EVENT#EMPLOYEE_EVENT#emp-def456#evt-abc123
Company SettingsCOMPANY_SETTINGS#COMPANY_SETTINGS#main
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.

Contacts  Live

Route: /contacts, /contacts/:id

A cross-entity contact book. Each contact record represents a named person or company and can be linked to any number of suppliers and/or clients. Contacts are created automatically when a supplier or client is added (using the entity name as the contact name and the "Contact details" field as free-text contact info), and can also be created manually from the Contacts page.

Key fields

name (required), contactDetails (free-text — email, phone, address, etc.), linkedSuppliers[] (supplier IDs), linkedClients[] (client IDs), isPrimary (boolean — marks the primary contact for a supplier or client), notes, sourceType (manual | supplier | client), sourceId

Primary contacts

Each supplier and client detail page shows a "Primary contact" section (the contact with isPrimary: true linked to that entity) and an "Additional contacts" section for all others. The primary contact can be changed from the supplier or client detail page — the change is applied optimistically in the UI and two PATCH calls are fired in sequence to promote the new primary and demote the old one.

Linking

Linked suppliers and clients are shown in the contact detail modal and are clickable — they navigate directly to the supplier or client detail page. Additional supplier and client links can be added inline from the contact modal using the link picker.

Assets  Live

Routes: /assets, /assets/:id

Track company assets such as laptops, desktops, phones, equipment, vehicles, and furniture. Each asset has a name, type (from a predefined dropdown), owner (selected from the People list), location, and a free-text asset tag for internal IDs or serial numbers.

Key fields

name (required), assetType (Laptop, Desktop, Monitor, Phone, Tablet, Printer, Networking, Server, Peripheral, Vehicle, Furniture, Tool, Other), assetOwner (free-text, searchable from People list), assetLocation (free-text), assetTag (free-text — serial number, barcode, etc.), status, notes

Statuses

StatusMeaning
ActiveIn use and operational
In repairBeing serviced or repaired
RetiredDecommissioned, no longer in use
LostMissing or unaccounted for

Change history

Every asset has a full audit trail. The detail page shows a timeline of all changes — each entry records the action (created or updated), which fields changed, and the before/after values. History entries are stored as separate DynamoDB records using the ASSET_HISTORY#assetId#timestamp SK pattern and are written as best-effort side effects during create and update operations.

People integration

The People (Employee) detail page includes an "Assigned assets" card that shows all assets whose assetOwner matches the employee's full name. Clicking an asset navigates to the asset detail page.

Invoicing  Live

Routes: /invoices, /invoices/:id

Create and manage client invoices with automatic invoice numbering (INV-0001, INV-0002, …) and server-side PDF generation with company branding. Invoices are linked to clients and contain line items with descriptions, quantities, and unit prices.

Key fields

invoiceNumber (auto-generated), clientId, clientName, lineItems[] (each with description, quantity, unitPrice), subtotal (computed), tax (computed), total (computed), issueDate, dueDate, paymentTerms, notes, status

Statuses

StatusMeaning
DraftBeing prepared, not yet sent
SentSent to the client
PaidPayment received
VoidCancelled / voided

PDF export

Each invoice can be exported as a branded PDF. The PDF is generated server-side by the generateInvoicePdf Lambda using pdfkit, and includes the company name, address, accent colour (from Company Settings), a line items table with alternating row colours, totals, and optional notes. The PDF is returned as a binary response and downloaded directly in the browser.

Settings  Live

Route: /settings (tenant admins only)

Team management

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

Company branding

The Workspace tab lets admins configure company branding used across the platform and on exported PDFs. Fields: companyName, companyAddress, companyPhone, companyEmail, companyWebsite, accentColor (hex colour picker, defaults to #4F46E5). Stored as a single DynamoDB record with SK COMPANY_SETTINGS#main.

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

Contacts

MethodPathLambdaDescription
GET/contactslistContactsList all contacts
POST/contactscreateContactCreate a contact
GET/contacts/{id}getContactGet one contact
PUT/contacts/{id}updateContactUpdate contact fields
DELETE/contacts/{id}deleteContactDelete a contact

Assets

MethodPathLambdaDescription
GET/assetslistAssetsList all assets
POST/assetscreateAssetCreate an asset
GET/assets/{id}getAssetGet one asset
PUT/assets/{id}updateAssetUpdate asset fields
DELETE/assets/{id}deleteAssetDelete an asset
GET/assets/{id}/historylistAssetHistoryList change history for an asset

Invoices

MethodPathLambdaDescription
GET/invoiceslistInvoicesList all invoices
POST/invoicescreateInvoiceCreate an invoice (auto-generates invoice number)
GET/invoices/{id}getInvoiceGet one invoice
PUT/invoices/{id}updateInvoiceUpdate invoice
DELETE/invoices/{id}deleteInvoiceDelete invoice
GET/invoices/{id}/pdfgenerateInvoicePdfGenerate branded PDF (binary download)

Employee events (calendar)

MethodPathLambdaDescription
GET/employees/{id}/eventslistEmployeeEventsList events for one employee
POST/employees/{id}/eventscreateEmployeeEventCreate a holiday/sickness event
DELETE/employees/{id}/events/{eventId}deleteEmployeeEventDelete an event
GET/employees/eventslistAllEmployeeEventsList all events across all employees (calendar view)

Company settings

MethodPathLambdaDescription
GET/settings/companygetCompanySettingsGet company branding settings
PUT/settings/companyupdateCompanySettingsUpdate company branding settings

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 55 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, category, relationshipOwner, goods). Accepts optional contactDetails (free-text contact info). Generates a sup-{uuid} ID and writes to DynamoDB with attribute_not_exists(#sk). If contactDetails is provided, the frontend fires a best-effort POST /contacts to create a linked primary contact using the supplier name and contact details — this is handled client-side after the supplier is created.
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, contactDetails, website, status, relationshipOwner, industry, notes. Verifies ownership before updating. Used by the Edit drawer and the inline notes editor on the client detail page. After a successful edit, the frontend also patches contactDetails on the primary linked contact via PUT /contacts/{id}.
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
Contacts — 5 functions
listContacts GET /contacts
Returns all contact records for the authenticated tenant.
Queries DynamoDB with begins_with(#sk, 'CONTACT#'). Each record includes linkedSuppliers[] and linkedClients[] arrays so the frontend can resolve linked entity names without additional requests.
DynamoDB QueryTenant-scoped
getContact GET /contacts/{id}
Fetches a single contact record by ID.
GetItem using tenantId + CONTACT#{id}. Returns 404 if not found.
DynamoDB GetItem
createContact POST /contacts
Creates a new contact record for the tenant.
Required: name. Optional: contactDetails (free-text — email, phone, address, etc.), linkedSuppliers[], linkedClients[], isPrimary (boolean), notes, sourceType (manual | supplier | client), sourceId. Generates a con-{uuid} ID. Contacts created automatically on supplier/client creation use sourceType: 'supplier' or 'client' and set isPrimary: true.
DynamoDB PutItemValidation
updateContact PUT /contacts/{id}
Updates allowed fields on an existing contact record.
Allowed fields: name, contactDetails, linkedSuppliers, linkedClients, isPrimary, notes. Verifies ownership before updating. Used by the contact detail modal (notes editing, link picker), the EditSupplier/EditClient drawers (syncing contactDetails on the primary contact), and the "Make primary" button on supplier/client detail pages (promotes/demotes isPrimary).
DynamoDB UpdateItemPartial update
deleteContact DELETE /contacts/{id}
Permanently deletes a contact record.
Verifies the record exists and belongs to the tenant before deleting. Does not cascade-update linkedSuppliers or linkedClients on the deleted contact's linked entities.
DynamoDB DeleteItemOwnership check
Assets — 6 functions
listAssets GET /assets
Returns all asset records for the authenticated tenant.
Queries DynamoDB with begins_with(#sk, 'ASSET#'). Used by the Assets list page and also loaded by the Employee detail page to match assetOwner for the "Assigned assets" card.
DynamoDB QueryTenant-scoped
getAsset GET /assets/{id}
Fetches a single asset record by ID.
GetItem using tenantId + ASSET#{id}. Returns 404 if not found.
DynamoDB GetItem
createAsset POST /assets
Creates a new asset record for the tenant.
Required: name, assetType (validated against a predefined list: Laptop, Desktop, Monitor, Phone, Tablet, Printer, Networking, Server, Peripheral, Vehicle, Furniture, Tool, Other). Optional: assetOwner (person name — searchable from People list in the frontend), assetLocation, assetTag (serial number / barcode), status (active, in-repair, retired, lost — defaults to active), notes. Generates an ast-{uuid} ID.
DynamoDB PutItemValidation
updateAsset PUT /assets/{id}
Updates allowed fields on an existing asset record.
Allowed fields: name, assetType, assetOwner, assetLocation, assetTag, status, notes. Validates assetType and status against their predefined lists. Verifies ownership before updating. Used by the Edit drawer and the inline notes editor on the asset detail page.
DynamoDB UpdateItemPartial update
deleteAsset DELETE /assets/{id}
Permanently deletes an asset record.
Verifies the record exists and belongs to the tenant before deleting.
DynamoDB DeleteItemOwnership check
listAssetHistory GET /assets/{id}/history
Returns the full change history for an asset, newest first.
Queries DynamoDB with begins_with(#sk, 'ASSET_HISTORY#{assetId}#') and ScanIndexForward: false to return entries in reverse chronological order. Each entry includes an action (created or updated), a changes[] array of { field, label, from, to } objects, and a human-readable summary. History entries are written as best-effort side effects by createAsset and updateAsset — update history only records fields that actually changed.
DynamoDB QueryReverse chronological
Employee events — 4 functions
listEmployeeEvents GET /employees/{id}/events
Returns all calendar events (holidays, sickness, etc.) for a single employee.
Queries DynamoDB with begins_with(#sk, 'EMPLOYEE_EVENT#{employeeId}#'). Each event has a type, start/end dates, and optional notes.
DynamoDB QueryTenant-scoped
createEmployeeEvent POST /employees/{id}/events
Creates a new calendar event for an employee (holiday, sickness, etc.).
Generates an evt-{uuid} ID. Stored with SK EMPLOYEE_EVENT#{employeeId}#{eventId}. Validates required date fields.
DynamoDB PutItemValidation
deleteEmployeeEvent DELETE /employees/{id}/events/{eventId}
Deletes a calendar event from an employee's record.
Verifies the event exists and belongs to the tenant before deleting.
DynamoDB DeleteItemOwnership check
listAllEmployeeEvents GET /employees/events
Returns all employee events across the entire tenant — used by the team calendar view.
Queries DynamoDB with begins_with(#sk, 'EMPLOYEE_EVENT#') to get all events regardless of employee. Used by the People page Calendar tab to show a combined team calendar.
DynamoDB QueryTenant-scopedCross-employee
Invoices — 6 functions
listInvoices GET /invoices
Returns all invoice records for the authenticated tenant.
Queries DynamoDB with begins_with(#sk, 'INVOICE#'). Used by the Invoices list page.
DynamoDB QueryTenant-scoped
getInvoice GET /invoices/{id}
Fetches a single invoice record by ID.
GetItem using tenantId + INVOICE#{id}. Returns 404 if not found.
DynamoDB GetItem
createInvoice POST /invoices
Creates a new invoice with auto-generated sequential numbering (INV-0001, INV-0002, …).
Required: clientId, clientName, issueDate, dueDate. Optional: lineItems[] (each with description, quantity, unitPrice), paymentTerms, notes, status (draft/sent/paid/void, defaults to draft). Automatically computes subtotal, tax, and total from line items. Queries existing invoices to determine the next sequential number.
DynamoDB PutItemAuto-numberingComputed totals
updateInvoice PUT /invoices/{id}
Updates allowed fields on an existing invoice.
Recomputes subtotal, tax, and total if lineItems change. Validates status against the allowed values. Verifies ownership before updating.
DynamoDB UpdateItemComputed totalsPartial update
deleteInvoice DELETE /invoices/{id}
Permanently deletes an invoice record.
Verifies the record exists and belongs to the tenant before deleting.
DynamoDB DeleteItemOwnership check
generateInvoicePdf GET /invoices/{id}/pdf
Generates a branded PDF for an invoice and returns it as a binary download.
Fetches the invoice and company settings in parallel from DynamoDB. Uses pdfkit to render an A4 PDF with: company name in the configured accent colour, company address, invoice number and dates, "Bill To" section, a line items table with alternating row colours, subtotal/tax/total, optional notes, and a footer. Returns isBase64Encoded: true so API Gateway delivers the binary PDF. Configured with 256MB memory and 30s timeout.
pdfkitBinary response256MB / 30s
Company settings — 2 functions
getCompanySettings GET /settings/company
Returns the tenant's company branding settings, or sensible defaults if not yet configured.
Reads a single DynamoDB record with SK COMPANY_SETTINGS#main. If the record doesn't exist, returns defaults: empty strings for all fields except accentColor which defaults to #4F46E5. Used by the Settings page and by generateInvoicePdf to apply branding to exported PDFs.
DynamoDB GetItemDefaults
updateCompanySettings PUT /settings/company
Saves the tenant's company branding settings.
Validates accentColor is a valid hex colour. Performs a full PutItem overwrite on COMPANY_SETTINGS#main. Fields: companyName, companyAddress, companyPhone, companyEmail, companyWebsite, accentColor. Admin only.
DynamoDB PutItemAdmin onlyHex validation
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.

Master deploy script

# Full deploy (backend + frontend)
./deploy-all.sh

# Frontend only (most common after UI changes)
./deploy-all.sh --frontend

# Specific Lambda functions only
./deploy-all.sh --lambdas "supplier-platform-createInvoice supplier-platform-updateInvoice"

# Skip npm bundle rebuild (re-use existing zip)
./deploy-all.sh --backend --skip-build

# Dry run — prints plan without making changes
./deploy-all.sh --dry-run

All Lambda code lives in one zip (backend/functions.zip). Every Lambda points into the same zip via its handler path (e.g. backend/functions/createEmployee.handler).

Frontend deploy

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

Backend deploy

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.

CI/CD (GitLab)

Pushes to main trigger the GitLab CI pipeline defined in .gitlab-ci.yml. The pipeline runs security scanning (SAST + Secret Detection) on every push and merge request, then builds the frontend and deploys both the frontend (S3 + CloudFront) and backend (Lambda) on main branch only.

Adding a new Lambda

  1. Write the handler in backend/functions/yourFunction.js
  2. Add the function name to DEFAULT_FUNCTIONS in deploy-all.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-all.sh or GitLab CI 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 to main. Cloudflare Pages (connected to GitLab) 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.