Req 1

Database: MongoDB to PostgreSQL

Evaluate migrating from MongoDB to PostgreSQL.

What WorkSpaceMan Does

MongoDB with Mongoose ODM. Heavy use of pre/post hooks for business logic, denormalised schema with computed fields, and no ACID transactions across documents. The Ledger/Wallet system is the most complex data model and would benefit most from relational integrity.

Recommendation

Strongly recommended. PostgreSQL with an ORM like Drizzle or Prisma provides:

  • ACID transactions — critical for the wallet/ledger system where partial writes can corrupt balances
  • Referential integrity — foreign keys enforce the relationships that WSM currently validates in hooks
  • JSON columns — for the few truly schemaless fields (config, Mixed types), Postgres jsonb gives the flexibility of Mongo where needed
  • Better tooling — migrations, query analysis, and reporting are more mature

The WSM models map cleanly to relational tables. The main work is replacing Mongoose hook chains with explicit service-layer functions.

Req 2

Auth Layer

Support all auth methods. Google Auth (OAuth 2.0). Act as an auth authority for SALTOK tokens.

What WorkSpaceMan Does

Three inconsistent mechanisms: Express sessions (Redis), API keys (x-api-key header), and JWT tokens in query params. JWT tokens are decoded but never verified (critical vulnerability). Social OAuth for Facebook, Google, LinkedIn, Twitter. No centralised auth middleware.

Recommendation

CapabilityApproach
Primary authOAuth 2.0 / OIDC provider (self-hosted or managed like Auth0/Clerk)
Google AuthStandard OIDC integration via the auth provider
SALTOK tokensPlatform acts as token issuer — custom grant type or JWT minting endpoint with proper jwt.verify()
API accessOAuth 2.0 client credentials flow for service-to-service, bearer tokens for users
Session managementStateless JWT with short expiry + refresh tokens (stored in DB)

The auth layer should be a single middleware that all routes pass through, extracting the authenticated identity regardless of the token type (user JWT, SALTOK, API key).

Req 3

API Layer

Clean, robust, versioned API surface. Support complex database views as first-class API endpoints.

What WorkSpaceMan Does

JXP custom framework auto-generates CRUD endpoints from Mongoose models. No versioning, no OpenAPI spec, no input validation. Routes are a mix of auto-generated and hand-written.

Recommendation

  • Versioned REST API/api/v1/ prefix with clear deprecation policy
  • OpenAPI 3.1 spec — auto-generated from route definitions (e.g. via zod-to-openapi or @hono/zod-openapi)
  • Input validation — Zod schemas on every endpoint, validated before hitting the service layer
  • Consistent error responses — standard error envelope with codes, messages, and field-level details
  • Rate limiting — enforced per-API-key with configurable tiers
  • Database views as endpoints — PostgreSQL views (e.g. member_summary, invoice_with_lineitems, occupancy_by_location) exposed as read-only API endpoints. Complex joins computed in the DB, served as simple GET requests.
  • Framework options: Hono (lightweight, edge-compatible), Fastify (performance), or Express 5 (familiarity)
// Example: typed, validated, versioned endpoint
app.post('/api/v1/bookings',
  auth(),
  validate(CreateBookingSchema),
  async (c) => {
    const booking = await bookingService.create(c.get('user'), c.req.valid('json'));
    return c.json(booking, 201);
  }
);
Req 4

Permissions (RBAC)

Role-based access control (RBAC) with fine-grained, configurable rules. API access governed by the same permission model as UI access.

Required Permission Tiers

RoleCapability Example
FinanceCan modify pricing
Location ManagersCan apply discounts between 10%–20%
HostsNo discount capability
BDMsUnlimited discount authority

This shows the system needs parameterised permissions, not just boolean role checks. A Location Manager's discount authority is bounded (10–20%), not all-or-nothing.

What WorkSpaceMan Does

Models define a perms object with CRUD flags per role (admin, owner, primary_member, user, member, all, api, setup, finance, pos, super_user, line_manager, manager). Roles are checked in model hooks but not consistently enforced across routes. No support for parameterised constraints like discount caps.

Recommendation

Implement a centralised RBAC system with parameterised rules:

LayerWhat It Does
RolesNamed sets of permissions: admin, finance, location_manager, host, bdm, member, api_client
PermissionsGranular actions: booking:create, invoice:read, discount:apply, pricing:modify
ConstraintsParameterised rules: discount:apply { min: 0, max: 20 } per role. Evaluated at runtime.
ScopingPermissions scoped to organisation and/or location (a manager at Rosebank can't see Sandton data)
MiddlewareSingle authorize('discount:apply', { amount: 15 }) middleware that checks roles, scopes, AND constraints
// Example: parameterised permission check
authorize('discount:apply', { amount: req.body.discount })
// Finance: always passes
// Location Manager: passes if amount >= 10 && amount <= 20
// Host: always fails
// BDM: always passes

This is the same permission model for both API and UI — the frontend checks permissions to show/hide controls, the backend enforces them.

Req 5

Business Logic Separation

Centralised in the backend, clearly separated from frontend.

What WorkSpaceMan Does

Business logic is spread across three layers: Mongoose model hooks (heaviest), job handlers (Pub/Sub), and Express route handlers. Some logic also lives in the Handlebars templates. This makes it untestable and hard to reason about.

Recommendation

Three-layer architecture with strict boundaries:

Routes / Controllers | (validate input, call service, return response) v Service Layer | (ALL business logic lives here, testable independently) v Repository / Data Layer | (database queries only, no business rules)
  • Services are pure functions or classes that take validated data and return results
  • No business logic in the database layer — no model hooks, no triggers
  • No business logic in the frontend — the API is the single source of truth
  • Events for side effects — after a booking is created, emit a BookingCreated event that triggers notifications, calendar sync, etc.
Req 6

Frontend / Backend Separation

Backend exposes mature, stable APIs. Any frontend framework can consume them.

What WorkSpaceMan Does

Server-rendered Handlebars templates (OpenMembers, CRM) tightly coupled to Express routes. Some newer modules use Svelte and Vue but still share models directly. No API contract between frontend and backend.

Recommendation

  • API-first design — the OpenAPI spec IS the contract. Frontend teams develop against the spec, not the implementation.
  • Auto-generated client SDKs — generate TypeScript types from the OpenAPI spec so frontend code is type-safe
  • CORS configuration — explicit allowed origins for web clients
  • No server-rendered HTML — the API returns JSON only. Frontend is a separate deployment (SPA or SSR framework)
  • Framework-agnostic — React/Next.js for web, Flutter for mobile, any client for third-party integrations — all consume the same API
Req 7

Third-Party API Access

Ability to grant scoped API access to external parties.

What WorkSpaceMan Does

API keys with no scoping — a key either has access or doesn't. No way to restrict a third party to specific operations or resources.

Recommendation

  • OAuth 2.0 client credentials — third parties register as API clients with specific scopes
  • Scoped tokens — e.g. a parking provider gets wallet:deduct:parking scope only
  • Per-client rate limits — configurable burst/rate per API client
  • Audit logging — every API call from a third party is logged with client ID, action, and affected resources
  • API key rotation — keys can be rotated without downtime (grace period for old keys)

This directly enables Req 12 (third-party wallet deductions) with proper security controls.

Req 8

FreeRADIUS Integration

What WorkSpaceMan Does

Integrates via OpenRadius REST API (packages/openmembers/libs/openradius.js). User provisioning with auto-generated passwords (happypass), service/profile management, and login testing. Credentials sent in welcome emails.

Recommendation

  • Abstract behind a NetworkAccessProvider interface (same pattern as accounting adapter)
  • Operations: createUser(), deleteUser(), testLogin(), setServiceProfile()
  • Auto-provisioning on member onboarding, auto-removal on offboarding
  • Store credentials encrypted at rest, expose via member portal (not in plain text emails)
  • Consider direct FreeRADIUS SQL backend integration instead of REST wrapper for lower latency
Req 9

PaperCut API Integration

What WorkSpaceMan Does

SSH-based integration (packages/openmembers/libs/papercut.js) executing server commands on the PaperCut host. Manages users, balances, groups, and suspension. Has a command injection vulnerability due to unescaped parameters.

Recommendation

  • Use the PaperCut REST API (available in newer versions) instead of SSH command execution
  • If SSH is still needed, use parameterised command execution (never string concatenation)
  • Abstract behind a PrintProvider interface
  • Operations: createUser(), adjustBalance(), getBalance(), setGroup()
  • Sync balance as a read-through cache — PaperCut is the source of truth for print credits
Req 10

Accounting System

10a. Monthly recurring invoices (subscriptions). 10b. Once-off invoices.

What WorkSpaceMan Does

Deep Xero coupling throughout the codebase. Monthly invoice run: calculate from licenses → approve → send → pay → fulfil. Adhoc charges for one-off items. Pro-rata calculations, price escalation, invoice holidays, commitment discounts. Xero IDs stored on almost every model.

Recommendation

Build an internal invoicing engine with an accounting adapter for external sync:

10a. Subscriptions

  • Subscription model — each license generates a recurring billing item with start/end dates, frequency, and price
  • Invoice generation job — scheduled monthly, collects all active subscriptions for each org, applies discounts and pro-rata
  • Approval workflow — draft → approved → sent → paid (same as WSM but with explicit state machine)
  • Price escalation — scheduled job applies percentage increases on configured dates

10b. Once-off Invoices

  • Adhoc invoice creation — admin or system can create invoices with arbitrary line items
  • Self-service purchases — product purchases generate and auto-fulfil once-off invoices
  • Booking invoicing — admin can convert bookings to invoice line items

Accounting Adapter

interface AccountingProvider {
  createInvoice(data): Promise<ExternalInvoice>
  recordPayment(data): Promise<ExternalPayment>
  syncContact(data): Promise<ExternalContact>
  createCreditNote(data): Promise<ExternalCreditNote>
}

// Swap providers without changing business logic
class XeroProvider implements AccountingProvider { ... }
class SageProvider implements AccountingProvider { ... }
Req 11

Wallet Architecture

Multi-currency. Multi-frequency (monthly, weekly, daily, evergreen). Multi-type: Quota vs Evergreen.

What WorkSpaceMan Does

The most complex subsystem. Wallets per user per currency with priority-based deduction, overflow support, quota top-ups (daily/weekly/monthly/annually), and reserve-to-debit conversion. Balance cached in a separate model. All logic in Mongoose hooks (untestable).

Recommendation

Redesign with double-entry accounting principles and explicit wallet types:

ConceptDesign
Currency First-class entity: Space, Stuff, Printing, Parking, etc. Each wallet belongs to exactly one currency.
Wallet Types Quota wallets reset on a schedule (monthly/weekly/daily). Evergreen wallets carry forward indefinitely.
Frequency Quota wallets have a reset_frequency (monthly/weekly/daily) and quota_amount. A cron job resets balances per frequency.
Ledger Immutable append-only ledger. Every transaction creates a record. Balance is always SUM(ledger WHERE wallet_id = ?).
Deduction priority Each wallet has a priority field. Debits drain highest-priority wallets first (see Req 12).
Wallet Table +----+----------+----------+-----------+----------+-------+ | id | user_id | currency | type | frequency| quota | +----+----------+----------+-----------+----------+-------+ | 1 | user_123 | space | quota | monthly | 20 | | 2 | user_123 | space | quota | weekly | 5 | | 3 | user_123 | space | evergreen | null | null | | 4 | user_123 | stuff | quota | monthly | 500 | | 5 | user_123 | parking | quota | monthly | 5 | +----+----------+----------+-----------+----------+-------+ Ledger Table (append-only) +----+-----------+--------+-------------+---------------------+ | id | wallet_id | amount | description | created_at | +----+-----------+--------+-------------+---------------------+ | 1 | 1 | +20 | Monthly top | 2026-03-01 00:00:00 | | 2 | 1 | -2 | Booking #42 | 2026-03-15 14:00:00 | | 3 | 3 | +10 | Purchase | 2026-03-20 09:30:00 | +----+-----------+--------+-------------+---------------------+ Balance = SUM(amount) per wallet (no cached Balance table needed)
Req 12

Payment API (Third-Party Deductions)

Third parties can deduct credits via API. Consolidated balance view per currency. Deduction priority: monthly quota → weekly quota → daily quota → evergreen.

What WorkSpaceMan Does

Priority-based multi-wallet deduction in Mongoose hooks. Overflow wallets. No external API for third-party deductions — all deductions go through internal code paths.

Recommendation

Expose a clean Deduction API for third parties (e.g. parking provider, PaperCut, POS):

POST /api/v1/wallets/deduct
Authorization: Bearer {third_party_token}  // scoped to wallet:deduct

{
  "user_id": "user_123",
  "currency": "parking",
  "amount": 1,
  "description": "Parking entry - Rosebank",
  "reference": "admyt_txn_456"
}

Deduction Algorithm

function deduct(userId, currency, amount): wallets = getWallets(userId, currency) .orderBy(priority) // monthly=1, weekly=2, daily=3, evergreen=4 remaining = amount splits = [] for wallet in wallets: balance = getBalance(wallet.id) if balance <= 0: continue take = min(remaining, balance) splits.push({ wallet_id: wallet.id, amount: -take }) remaining -= take if remaining == 0: break if remaining > 0: throw InsufficientFundsError(currency, amount, remaining) // Atomic transaction: create all ledger entries transaction: for split in splits: ledger.insert(split) return { success: true, splits }

Consolidated Balance Endpoint

GET /api/v1/wallets/balance?user_id=user_123

{
  "space":   { "total": 18, "breakdown": [
    { "type": "monthly_quota", "balance": 12, "resets": "2026-04-01" },
    { "type": "evergreen", "balance": 6 }
  ]},
  "stuff":   { "total": 450, "breakdown": [...] },
  "parking": { "total": 3, "breakdown": [...] },
  "printing":{ "total": 89, "breakdown": [...] }
}

Key design decisions:

  • Deductions are atomic — PostgreSQL transactions ensure all-or-nothing across wallet splits
  • Idempotency via reference field — prevents duplicate charges if the third party retries
  • Audit trail — every deduction logged with source client ID, IP, and timestamp
  • Insufficient funds handled gracefully — returns how much is missing so the third party can decide (deny access, allow overdraft, etc.)
Req 13

Audit Logging

Log all interactions at the database level. Capture before and after values for every change. Record who made the change and when.

What WorkSpaceMan Does

Post-validate hooks on key models use deep-diff to compute changes and write to the Log model. Coverage is inconsistent — not all models have audit hooks, and the log structure varies. The white paper proposes an append-only event ledger to formalise this.

Recommendation

  • Append-only audit table in PostgreSQL — immutable, no UPDATE or DELETE allowed
  • Every write operation (INSERT, UPDATE, DELETE) creates an audit record with: entity, entity_id, action, before (jsonb), after (jsonb), changed_by, timestamp
  • Implement via database triggers (guaranteed capture) or service-layer middleware (more flexible)
  • Queryable: "show me all changes to this invoice" or "who modified this user's role?"
  • Integrates with Req 14 (PDF export) — audit history exportable per record
-- Audit table (append-only)
CREATE TABLE audit_log (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    entity VARCHAR NOT NULL,       -- 'invoice', 'user', 'booking'
    entity_id UUID NOT NULL,
    action VARCHAR NOT NULL,       -- 'create', 'update', 'delete'
    before_data JSONB,
    after_data JSONB,
    changed_by UUID REFERENCES users(id),
    changed_at TIMESTAMPTZ DEFAULT now()
);

-- No UPDATE or DELETE grants on this table
REVOKE UPDATE, DELETE ON audit_log FROM app_user;
Req 14

PDF Export

Generate a PDF from any list, form, or activity view. PDFs must include full audit log / history where applicable.

What WorkSpaceMan Does

Uses PhantomJS 2.1.16 (deprecated 2018) for PDF generation. Invoice PDFs are generated via HTML templates rendered to PDF. No generic PDF export for lists or audit trails. The white paper identifies this as Phase 5 work.

Recommendation

  • Replace PhantomJS with a modern PDF engine: Puppeteer (headless Chrome), @react-pdf/renderer (React components to PDF), or Typst (fast, programmable document engine)
  • Generic PDF export endpoint: GET /api/v1/{entity}/export/pdf?filter=...&include_audit=true
  • Three PDF types:
    • List PDF — table of records with current filters/sort applied
    • Detail PDF — single record with all fields, formatted as a form
    • Audit PDF — record detail + full change history from audit log (Req 13)
  • Brand-consistent templates using the Proximity Green design system
  • Generated server-side (Cloudflare Worker or background job) — not in the browser
Req 15

Reporting

Live reporting against the production database. Data warehouse for complex or heavy reporting — only if live reporting proves insufficient.

What WorkSpaceMan Does

Ad-hoc reporting via MongoDB aggregation pipelines (POST /aggregate/{model}). Google BigQuery for analytics. The packages/reports module uses jsonexport for CSV export. No dashboarding or self-service reporting.

Recommendation

  • PostgreSQL as the primary reporting engine — Postgres handles analytical queries far better than MongoDB. Views and materialised views provide real-time dashboards without a separate data warehouse.
  • Read replicas for heavy reports — Neon supports read replicas that don't impact production write performance
  • Reporting API — pre-built report endpoints for common queries:
    • Revenue by location/month
    • Occupancy trends
    • Member growth & churn
    • Wallet usage by currency
    • Booking utilisation by room type
  • Data warehouse only if needed — if PostgreSQL can't handle a specific report within 5 seconds, push that query to a materialised view or BigQuery. Don't pre-optimise.
  • AI-assisted reporting — "Show me revenue trend for Rosebank this quarter" via the admin AI assistant
Req 16

UI: Personalised Views & Advanced Filtering

Users can save personal view preferences: visible fields, column order, sort order. List views with Looker-style filtering and pivoting. Cascading dropdowns throughout all forms.

What WorkSpaceMan Does

Handlebars server-rendered templates with hardcoded column layouts. No saved views, no user-configurable filtering, no cascading dropdowns. The white paper's Generic UI Engine (Phase 4) addresses this with model-driven list/form rendering.

Recommendation

  • Saved views per user — store as JSON in a user_preferences table: { entity: 'members', columns: [...], sort: '...', filters: {...} }
  • Looker-style filter builder — field + operator + value, combinable with AND/OR. Translates to API query params:
    // Frontend filter state
    { field: 'status', op: 'eq', value: 'active' },
    { field: 'location', op: 'eq', value: 'rosebank' },
    { field: 'balance', op: 'gte', value: 100 }
    
    // Translates to API call
    GET /api/v1/users?status=active&location=rosebank&balance_min=100
  • Cascading dropdowns — driven by API: selecting a Location populates Spaces, selecting a Space populates Rooms. Each dropdown calls the API with the parent's value as a filter.
  • Column pivoting — for reporting views, allow users to group by any categorical field (location, membership type, status) with aggregation (count, sum, average)
  • Use a component like TanStack Table (React) or AG Grid for the list view engine
Req 17

Data Validation

Simple and complex validation rules configurable per field. Unique key enforcement. Format validation: email, mobile number, and other standard formats.

What WorkSpaceMan Does

Mongoose schema-level validation (required, type, enum) plus ad-hoc validation in pre-save hooks. No express-validator or Zod on API routes — req.body goes directly to the database. The white paper's Governance Engine (Phase 2) proposes declarative validation rules and a uniqueness registry.

Recommendation

  • Zod schemas for every API endpoint — validates at the API boundary before reaching the service layer
  • Database constraints for critical rules: unique indexes, check constraints, foreign keys (PostgreSQL enforces these at the DB level, unlike MongoDB)
  • Validation types:
    TypeExampleImplementation
    RequiredName must be presentZod .min(1) + DB NOT NULL
    FormatEmail, mobile, VAT numberZod .email(), regex patterns
    UniqueEmail must be uniqueDB unique index + Zod .refine()
    RangeDiscount 0–100%Zod .min(0).max(100)
    Referentiallocation_id must existDB foreign key constraint
    Cross-fieldend_date > start_dateZod .refine() on the schema
    AsyncCompany reg number validCustom validator calling external API
  • Configurable rules — store validation rules in a field_rules table so admins can add format patterns or uniqueness constraints without code changes
  • Same validation on frontend and backend — Zod schemas shared via the auto-generated TypeScript SDK
Req 18

Media & Image Management

Store, manage, and serve images and documents across the platform: artwork for the gallery, member avatars, room/space photos, generated PDFs, and CRM attachments. On-the-fly resizing and format conversion for responsive delivery.

Media Types Across the Platform

SourceTypeVolumeAccess
GalleryHigh-res artwork images (primary, detail, in-situ, process)300+ and growingPublic, read-heavy
MembersProfile avatars~800+Authenticated, small
Rooms/SpacesPhotos, floor plans~100-200Public, rarely changed
Invoices/PDFsGenerated documentsGrowing monthlyAuthenticated, write-once
CRMAttachments, proposalsModerateAuthenticated

What WorkSpaceMan Does

User avatars stored as base64 in the user.img field (in MongoDB documents). Room and space images stored similarly. The gallery uses CatalogIt (hub.catalogit.app) as an external platform with its own image hosting and multiple resolution derivatives. No unified media storage, no on-the-fly transforms, no CDN.

Recommendation: Cloudflare R2 + Image Transformations

Since the platform runs on Cloudflare, use R2 (S3-compatible object storage with zero egress fees) and Image Transformations for on-the-fly resizing:

Upload Serve | | v v [R2 Bucket] --> [Image Transform] --> [CDN Edge Cache] (originals) (resize/format on (global: JHB, Nairobi, the fly: webp, Lagos, Accra) avif, thumbnails)

How It Works

// Upload endpoint
POST /api/v1/media/upload
-> Validates file (type, size, dimensions)
-> Generates UUID filename
-> Stores original in R2: /gallery/originals/{uuid}.jpg
-> Creates media record in PostgreSQL
-> Returns media URLs

// Serving with on-the-fly transforms (no pre-generated thumbnails)
Original:    /media/{uuid}/original
Thumbnail:   /media/{uuid}/w=300,h=300,fit=cover
Exhibition:  /media/{uuid}/w=800,q=85,f=webp
Full screen: /media/{uuid}/w=1920,q=90,f=avif

Database Schema

CREATE TABLE media (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    entity_type VARCHAR NOT NULL,  -- 'artwork','avatar','room','document'
    entity_id UUID NOT NULL,
    filename VARCHAR NOT NULL,
    content_type VARCHAR NOT NULL,
    size_bytes INTEGER NOT NULL,
    width INTEGER,
    height INTEGER,
    r2_key VARCHAR NOT NULL,       -- path in R2 bucket
    uploaded_by UUID REFERENCES users(id),
    created_at TIMESTAMPTZ DEFAULT now()
);

-- Gallery-specific: multiple images per artwork with roles
CREATE TABLE artwork_media (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    artwork_id UUID REFERENCES artworks(id),
    media_id UUID REFERENCES media(id),
    role VARCHAR NOT NULL,         -- 'primary','detail','in_situ','process'
    caption TEXT,
    sort_order INTEGER DEFAULT 0
);

Gallery Media Roles

RolePurposeAI Usage
primaryMain exhibition imageUsed in search results, cards, recommendations
detailClose-up of texture, technique"Here's the cyanotype technique Dixie used"
in_situWork installed in the space"This is how it looks on a 2m wall"
processArtist working, behind the scenes"Fred Clarke's studio process"

Cost

ServicePricingEst. Monthly
R2 Storage$0.015/GB/month~$1-3
R2 Reads$0.36/million requests~$1
Image Transforms$0.50/1,000 unique transforms~$2 (cached after first request)
Total~$4-6/month

R2 has zero egress fees — critical for an image-heavy gallery served across Africa.

CatalogIt Migration

  • Export originals from CatalogIt (283 items with multiple resolution derivatives)
  • Upload originals to R2 — Cloudflare handles all resizing from there
  • Map CatalogIt item IDs to new artwork_media records with roles
  • No need to migrate thumbnails — they're generated on-the-fly
Req 19

Parking Management

Unified parking management across locations — member bays, visitor parking, Admyt integration, wallet-based billing, and AI-powered occupancy intelligence.

What WorkSpaceMan Does

Three-model parking system (ParkingCenter, ParkingTenant, ParkingUser) tightly coupled to the Admyt API. Entry/exit events come via POST /checkin/admyt and are stored as Notification records. Billing uses the wallet system with a dedicated parking currency and a separate bays currency for allocated spaces. Bay allocation is validated in model hooks with assertCanAllocate() guards to prevent over-allocation.

Current gaps:

  • No visitor/guest parking — only registered ParkingUser records with Admyt accounts can use the system
  • No real-time availability dashboard — occupancy is computed from notifications, not surfaced proactively
  • No cross-location overflow — if one center is full, no suggestion to redirect to nearby locations
  • Manual exit process required when barriers fail — admin must manually create Transaction notification
  • No pre-booking — all parking is first-come-first-served
  • Car management is per-user via Admyt API sync, no self-service

Recommendation

Retain the Admyt integration as the physical access layer (barriers, ANPR, hardware) but build a richer platform layer on top:

Data Model

-- Parking infrastructure
CREATE TABLE parking_centers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    location_id UUID NOT NULL REFERENCES locations(id),
    name VARCHAR NOT NULL,
    admyt_center_id INTEGER,             -- Admyt remote reference
    total_bays INTEGER NOT NULL,
    member_bays INTEGER NOT NULL,        -- Reserved for members
    visitor_bays INTEGER NOT NULL,       -- Reserved for visitor/guest use
    flex_bays INTEGER DEFAULT 0,         -- Can swing member ↔ visitor based on demand
    billing_interval_minutes INTEGER DEFAULT 60,
    cost_per_interval DECIMAL(10,2),
    grace_period_minutes INTEGER DEFAULT 30,
    status VARCHAR DEFAULT 'active',
    created_at TIMESTAMPTZ DEFAULT now()
);

-- Tenant (organisation) bay allocations
CREATE TABLE parking_tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    parking_center_id UUID NOT NULL REFERENCES parking_centers(id),
    organisation_id UUID NOT NULL REFERENCES organisations(id),
    admyt_tenant_id VARCHAR,             -- Admyt remote reference
    allocated_bays INTEGER NOT NULL DEFAULT 0,
    status VARCHAR DEFAULT 'active',
    CHECK (allocated_bays >= 0)
);

-- Registered parking users (member cars)
CREATE TABLE parking_users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id),
    admyt_user_id VARCHAR,               -- Admyt remote reference
    status VARCHAR DEFAULT 'active'
);

CREATE TABLE parking_user_cars (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    parking_user_id UUID NOT NULL REFERENCES parking_users(id),
    plate VARCHAR NOT NULL,
    make VARCHAR,
    model VARCHAR,
    is_primary BOOLEAN DEFAULT false,
    UNIQUE(plate)
);

-- NEW: Parking sessions (replaces Notification model)
CREATE TABLE parking_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    parking_center_id UUID NOT NULL REFERENCES parking_centers(id),
    parking_user_id UUID REFERENCES parking_users(id),  -- NULL for visitors
    guest_id UUID REFERENCES guests(id),                -- For visitor parking
    plate VARCHAR NOT NULL,
    entry_at TIMESTAMPTZ NOT NULL,
    exit_at TIMESTAMPTZ,
    duration_minutes INTEGER,
    session_type VARCHAR NOT NULL,        -- 'member', 'visitor', 'pre_booked'
    billing_amount DECIMAL(10,2),
    billing_status VARCHAR DEFAULT 'pending', -- 'pending', 'charged', 'waived', 'free'
    admyt_event_id VARCHAR,              -- Admyt reference
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_sessions_active ON parking_sessions(parking_center_id, exit_at) WHERE exit_at IS NULL;

-- NEW: Pre-booked parking
CREATE TABLE parking_bookings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    parking_center_id UUID NOT NULL REFERENCES parking_centers(id),
    user_id UUID REFERENCES users(id),
    guest_id UUID REFERENCES guests(id),
    plate VARCHAR,
    booked_date DATE NOT NULL,
    bay_type VARCHAR NOT NULL,            -- 'member', 'visitor'
    status VARCHAR DEFAULT 'confirmed',   -- 'confirmed', 'used', 'cancelled', 'no_show'
    booked_by UUID NOT NULL REFERENCES users(id),
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_bookings_date ON parking_bookings(parking_center_id, booked_date, status);

Admyt Integration (Adapter Pattern)

// ParkingAccessProvider — adapter interface
interface ParkingAccessProvider {
  // Tenant management
  createTenant(centerId: string, data: TenantData): Promise<RemoteTenant>;
  updateTenant(centerId: string, tenantId: string, data: TenantData): Promise<void>;
  deleteTenant(centerId: string, tenantId: string): Promise<void>;

  // User management
  createUser(centerId: string, tenantId: string, data: UserData): Promise<RemoteUser>;
  deleteUser(centerId: string, tenantId: string, userId: string): Promise<void>;

  // Car management
  listCars(userId: string): Promise<Car[]>;
  addCar(userId: string, car: Car): Promise<void>;
  removeCar(userId: string, plate: string): Promise<void>;

  // Webhook handler
  handleEvent(payload: AdmytEvent): Promise<ParkingSession>;
}

// AdmytProvider implements ParkingAccessProvider
class AdmytProvider implements ParkingAccessProvider {
  private client: AxiosInstance;
  // Retry wrapper, error mapping, plate normalisation
  // Existing WSM logic preserved but behind clean interface
}

Visitor Parking

Visitors currently have no parking access. The new platform should support:

CapabilityHow It Works
Pre-booked visitor bays Host books a visitor bay when registering the guest. Plate added to Admyt whitelist for the day. Bay deducted from visitor_bays pool.
Walk-in visitor parking Reception checks visitor in, assigns a bay from available visitor pool. Barrier opened via Admyt API or manual override.
Flex bay allocation flex_bays can swing between member and visitor pools based on demand. AI recommends reallocation when one pool is consistently underused.
Billing Visitor parking can be free (host's org covers it), charged to the visitor, or billed to the host org's wallet. Configurable per location.
Time-limited access Visitor parking auto-expires. If vehicle hasn't exited by the limit, alert sent to host and facility team.

Wallet Integration

// Parking wallet types
Currency: parking     → Time-based charges (cost × intervals)
Currency: bays        → Allocated bay count per tenant

// Deduction flow (existing pattern, cleaned up)
POST /api/v1/wallets/deduct
{
  "user_id": "user_123",
  "currency": "parking",
  "amount": 3,                          // 3 intervals
  "description": "Parking: Rosebank Hub, 2h 15m",
  "reference": "session_abc123",
  "source": "admyt"
}

// Visitor parking deduction (new)
POST /api/v1/wallets/deduct
{
  "organisation_id": "org_456",         // Host's org pays
  "currency": "parking",
  "amount": 0,                          // Free for visitors (configurable)
  "description": "Visitor parking: Sarah van der Merwe, Rosebank Hub",
  "reference": "session_def789",
  "source": "visitor_parking"
}

AI Capabilities

CapabilityDescription
Occupancy prediction Predict parking demand by day/hour using historical session data. Alert facilities when a center is expected to hit >90% by mid-morning.
Overflow routing When a center is full, suggest nearby alternatives. Push notifications to members with real-time availability across locations.
Anomaly detection Flag vehicles parked >24 hours, sessions without exit events, unusual entry patterns (e.g. same plate entering multiple centers simultaneously).
Bay rebalancing Recommend flex bay reallocation based on utilisation trends. "Rosebank visitor bays are 95% utilised Tue–Thu but member bays are only 60% — recommend shifting 5 flex bays to visitor pool on those days."
Natural language queries "Is there parking at Sandton today?" "Book me a bay at Rosebank for tomorrow." "How many visitor bays are left?" — via Teams, mobile app, or admin chat.
Cost optimisation Analyse parking spend per org. Suggest plan changes if an org consistently exceeds their bay allocation (paying overflow rates).

API Endpoints

// Parking centers
GET    /api/v1/parking/centers                    -- List all centers with live occupancy
GET    /api/v1/parking/centers/:id                -- Single center detail
GET    /api/v1/parking/centers/:id/availability   -- Real-time bay counts by type

// Parking sessions
GET    /api/v1/parking/sessions                   -- List/filter sessions (active, historical)
POST   /api/v1/parking/sessions/manual-exit       -- Admin: force session close

// Parking bookings (new)
POST   /api/v1/parking/bookings                   -- Pre-book a bay (member or visitor)
GET    /api/v1/parking/bookings?date=2026-03-30   -- Bookings for a date
DELETE /api/v1/parking/bookings/:id               -- Cancel booking

// Parking users & cars
GET    /api/v1/parking/users/:userId/cars         -- List registered vehicles
POST   /api/v1/parking/users/:userId/cars         -- Add vehicle (syncs to Admyt)
DELETE /api/v1/parking/users/:userId/cars/:plate   -- Remove vehicle

// Visitor parking
POST   /api/v1/parking/visitor-access             -- Grant temporary visitor parking access
DELETE /api/v1/parking/visitor-access/:id          -- Revoke visitor parking

// Admyt webhook
POST   /api/v1/webhooks/admyt                     -- Entry/exit events from Admyt hardware

Admyt Migration Path

  • Existing ParkingCenter, ParkingTenant, ParkingUser records migrate to PostgreSQL tables with Admyt remote IDs preserved
  • Existing Notification records (entry/exit events) migrate to parking_sessions
  • Admyt API integration moves behind ParkingAccessProvider adapter — same HTTP calls, cleaner interface
  • Wallet currencies (parking, bays) carry over unchanged
  • New: self-service car management in member app (currently admin-only)
  • New: visitor parking, pre-booking, and AI occupancy features layered on top
Req 20

Guest & Visitor Management

End-to-end visitor lifecycle — pre-registration, check-in, host notification, access provisioning, facility services, and check-out — replacing the current stub Guest model with a full visitor management system.

What WorkSpaceMan Does

Minimal Guest model with basic fields: name, email, mobile, organisation, location_id, visiting_date, source, invited_by, and accepted_terms. Post-save hook auto-populates the Contact model if an email exists. Guest WiFi access uses mac and ip fields. No NDA handling, no host notifications, no visitor badge generation, no check-out tracking, no facility service integration.

Current gaps:

  • No visitor lifecycle — no state machine (invited → checked-in → in-meeting → checked-out)
  • No host notification — hosts aren't alerted when their visitor arrives
  • No pre-registration workflow — no self-service visitor form, no advance NDA signing
  • No access control integration — visitors can't get temporary door/lift access via Clay
  • No parking tie-in — visitor parking is completely separate (see Req 19)
  • No facility services — can't order coffee, book a meeting room, or arrange AV for a visitor meeting
  • No visitor analytics — no reporting on visitor volume, peak times, or frequent visitors
  • No compliance — no NDA/waiver management, no emergency evacuation roster inclusion

Recommendation

Build a complete visitor management system that integrates with parking (Admyt), access control (Clay), WiFi (FreeRADIUS), the wallet system, and facility operations.

Data Model

-- Guest/visitor record
CREATE TABLE guests (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR NOT NULL,
    email VARCHAR,
    mobile VARCHAR,
    company VARCHAR,                      -- Visitor's company
    photo_url VARCHAR,                    -- Optional ID photo
    id_number VARCHAR,                    -- Optional: national ID for compliance
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_guests_email ON guests(email) WHERE email IS NOT NULL;

-- Visit record (one per visit — a guest can visit many times)
CREATE TABLE visits (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    guest_id UUID NOT NULL REFERENCES guests(id),
    location_id UUID NOT NULL REFERENCES locations(id),
    host_user_id UUID NOT NULL REFERENCES users(id),
    host_organisation_id UUID NOT NULL REFERENCES organisations(id),

    -- Scheduling
    expected_date DATE NOT NULL,
    expected_arrival TIME,
    expected_departure TIME,
    purpose VARCHAR,                      -- 'meeting', 'interview', 'delivery', 'maintenance', 'event'

    -- Lifecycle state machine
    status VARCHAR NOT NULL DEFAULT 'invited',
    -- States: invited → pre_registered → checked_in → in_meeting → checked_out → no_show
    invited_at TIMESTAMPTZ DEFAULT now(),
    pre_registered_at TIMESTAMPTZ,        -- Self-service form completed
    checked_in_at TIMESTAMPTZ,
    meeting_started_at TIMESTAMPTZ,
    checked_out_at TIMESTAMPTZ,
    checked_in_by VARCHAR,                -- 'self_kiosk', 'reception', 'qr_code', 'host'

    -- Compliance
    nda_signed BOOLEAN DEFAULT false,
    nda_signed_at TIMESTAMPTZ,
    nda_document_id UUID REFERENCES media(id),
    waiver_signed BOOLEAN DEFAULT false,
    terms_accepted BOOLEAN DEFAULT false,
    emergency_contact_name VARCHAR,
    emergency_contact_phone VARCHAR,

    -- Access provisioning
    wifi_provisioned BOOLEAN DEFAULT false,
    wifi_credentials JSONB,               -- {ssid, password, expires_at}
    access_badge_id VARCHAR,              -- Temporary badge number
    clay_access_granted BOOLEAN DEFAULT false,
    clay_access_group_id UUID,            -- Temporary Clay access group
    clay_access_expires_at TIMESTAMPTZ,

    -- Parking (links to Req 19)
    parking_booked BOOLEAN DEFAULT false,
    parking_booking_id UUID REFERENCES parking_bookings(id),
    vehicle_plate VARCHAR,

    -- Facility services
    meeting_room_booking_id UUID REFERENCES bookings(id),
    catering_order_id UUID,
    notes TEXT,                           -- Internal notes (not shown to visitor)

    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_visits_date ON visits(location_id, expected_date, status);
CREATE INDEX idx_visits_host ON visits(host_user_id, expected_date);
CREATE INDEX idx_visits_active ON visits(location_id, status) WHERE status IN ('checked_in', 'in_meeting');

-- Visitor check-in log (audit trail)
CREATE TABLE visit_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    visit_id UUID NOT NULL REFERENCES visits(id),
    event_type VARCHAR NOT NULL,          -- 'invited', 'pre_registered', 'checked_in', 'nda_signed', etc.
    actor_type VARCHAR NOT NULL,          -- 'system', 'host', 'visitor', 'reception', 'ai'
    actor_id VARCHAR,
    metadata JSONB,
    created_at TIMESTAMPTZ DEFAULT now()
);

-- Frequent visitors (auto-detected for fast-track)
CREATE VIEW frequent_visitors AS
SELECT guest_id, location_id, COUNT(*) as visit_count,
       MAX(checked_in_at) as last_visit
FROM visits
WHERE status = 'checked_out' AND checked_in_at > now() - interval '6 months'
GROUP BY guest_id, location_id
HAVING COUNT(*) >= 3;

Visitor Lifecycle

invited | (host creates visit or sends invite link) v pre_registered | (visitor fills form: name, company, NDA, emergency contact, vehicle plate) v checked_in | (reception/kiosk/QR — triggers: host notification, WiFi, badge, parking, access) v in_meeting | (host confirms visitor collected — optional state) v checked_out | (visitor leaves — triggers: revoke access, close parking session, debrief prompt) no_show ← (auto-set if not checked in by end of expected_date)

Integration Points

SystemOn Check-InOn Check-Out
Host notification Teams message / push notification / SMS to host: "Your visitor has arrived" Optional: "Your visitor has left"
Admyt (Parking) Add visitor plate to whitelist for the day. Assign visitor bay. Remove plate from whitelist. Close parking session.
Clay (Access) Add visitor to temporary access group (reception → meeting room floor). Time-limited. Remove from access group. Deactivate temporary badge.
FreeRADIUS (WiFi) Provision guest WiFi credentials. SMS/email to visitor with SSID + password. Deprovision WiFi account.
Wallet (Billing) Reserve parking cost from host org's wallet (if applicable). Convert reserve to charge. Bill catering/services to host org.
Bookings Link visitor to meeting room booking. Screen shows "Guest arriving" on room display. Release room if visitor was last attendee.
Emergency roster Add visitor to live building occupancy roster (evacuation list). Remove from roster.

Self-Service Pre-Registration

// Host creates visit → system sends invite link to visitor
POST /api/v1/visits
{
  "guest": { "name": "Sarah van der Merwe", "email": "sarah@deloitte.com", "company": "Deloitte SA" },
  "location_id": "loc_rosebank",
  "expected_date": "2026-03-30",
  "expected_arrival": "09:00",
  "purpose": "meeting",
  "parking_required": true,
  "meeting_room_booking_id": "booking_abc"
}

// → System sends email/SMS to visitor with pre-registration link
// → Visitor completes: emergency contact, NDA signature, vehicle plate
// → Status moves: invited → pre_registered
// → On arrival day: visitor scans QR at kiosk → instant check-in (all pre-completed)

AI Capabilities

CapabilityDescription
Proactive host prep "You have a visitor at 10am. Protea room is booked, parking bay reserved, NDA pre-signed. Coffee preference from last visit: flat white."
Frequent visitor fast-track Auto-detect repeat visitors (≥3 visits in 6 months). Skip NDA re-signing, pre-fill details, issue standing WiFi credentials.
Visitor analytics "Rosebank averages 23 visitors/day, peaking Tue–Thu 09:00–11:00. Recommend staffing 2 receptionists on those mornings."
No-show prediction Flag visits likely to no-show based on patterns (e.g. visitor has cancelled 3 of last 5). Release pre-booked resources early.
Natural language "Register Sarah from Deloitte visiting me tomorrow at 10, she'll need parking" — via Teams, app, or admin chat. AI creates visit, books room, reserves bay.
Facility awareness On check-in: "The lift to 4th floor is under maintenance today. Direct visitor to the south stairwell or service lift." Context-aware wayfinding.
Compliance reporting Audit-ready reports: who visited, when, NDA status, time on premises. Export for security reviews or insurance requirements.

API Endpoints

// Visits
POST   /api/v1/visits                             -- Create visit (host registers visitor)
GET    /api/v1/visits?date=2026-03-30&location=X  -- Visits for a date/location
GET    /api/v1/visits/:id                          -- Visit detail with full event log
PATCH  /api/v1/visits/:id                          -- Update visit (reschedule, add services)
DELETE /api/v1/visits/:id                          -- Cancel visit

// Check-in / check-out
POST   /api/v1/visits/:id/check-in                -- Check in visitor (triggers all integrations)
POST   /api/v1/visits/:id/check-out               -- Check out visitor (revokes all access)

// Self-service (public, token-authenticated)
GET    /api/v1/visits/register/:token              -- Pre-registration form data
POST   /api/v1/visits/register/:token              -- Submit pre-registration
POST   /api/v1/visits/kiosk-checkin                -- QR code scan at kiosk

// Guests (contact records)
GET    /api/v1/guests                              -- Search guests (for repeat visitor lookup)
GET    /api/v1/guests/:id/history                  -- Visit history for a guest

// Analytics
GET    /api/v1/visits/analytics                    -- Visitor volume, peak times, avg duration
GET    /api/v1/visits/active                       -- Currently on-premises visitors (evacuation roster)
GET    /api/v1/visits/compliance-report             -- NDA status, time-on-premises audit

Migration Path

  • Existing Guest records migrate to guests table with IDs preserved
  • Existing guest check-in records (from Checkin model) migrate to visits with status set to checked_out
  • Contact model's guest aggregation continues — guest_id foreign key on the unified contact view
  • New: full lifecycle, integrations, and AI capabilities layered on top of migrated data

Requirements vs Current State

#RequirementWSM StatusNew Platform
1 PostgreSQL MongoDB only PostgreSQL + Drizzle/Prisma
2 Auth + SALTOK JWT not verified OAuth 2.0 + SALTOK issuer
3 Versioned API No versioning REST v1 + OpenAPI spec
4 RBAC Inconsistent Centralised RBAC middleware
5 Business logic separation In model hooks Service layer pattern
6 Frontend/backend split Server-rendered API-first, framework-agnostic
7 Third-party API access No scoping OAuth 2.0 client credentials + scopes
8 FreeRADIUS Exists (OpenRadius) NetworkAccessProvider adapter
9 PaperCut SSH (vulnerable) REST API + PrintProvider adapter
10 Accounting Xero-coupled Internal engine + AccountingProvider adapter
11 Wallet architecture Complex hooks Double-entry ledger, typed wallets, scheduled resets
12 Payment API (deductions) No external API Scoped deduction endpoint with priority ordering
13 Audit logging Partial (deep-diff hooks) Append-only audit table, DB triggers, full before/after
14 PDF export PhantomJS (deprecated) Modern PDF engine, generic export for lists/forms/audit
15 Reporting Ad-hoc aggregation PostgreSQL views + AI-assisted reporting
16 UI: Views & filtering Hardcoded layouts Saved views, Looker-style filters, cascading dropdowns
17 Data validation No API validation Zod schemas + DB constraints + configurable rules
18 Media & image management Base64 in DB + CatalogIt Cloudflare R2 + Image Transforms + CDN
19 Parking management Admyt-coupled, no visitor parking Admyt adapter + visitor bays + pre-booking + AI occupancy
20 Guest & visitor management Stub model only Full lifecycle, self-service, Clay/WiFi/parking integration