Platform Requirements
Architecture requirements for the new Proximity Green platform, mapped against findings from the WorkSpaceMan review.
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
jsonbgives 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.
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
| Capability | Approach |
|---|---|
| Primary auth | OAuth 2.0 / OIDC provider (self-hosted or managed like Auth0/Clerk) |
| Google Auth | Standard OIDC integration via the auth provider |
| SALTOK tokens | Platform acts as token issuer — custom grant type or JWT minting endpoint with proper jwt.verify() |
| API access | OAuth 2.0 client credentials flow for service-to-service, bearer tokens for users |
| Session management | Stateless 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).
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-openapior@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);
}
);
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
| Role | Capability Example |
|---|---|
| Finance | Can modify pricing |
| Location Managers | Can apply discounts between 10%–20% |
| Hosts | No discount capability |
| BDMs | Unlimited 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:
| Layer | What It Does |
|---|---|
| Roles | Named sets of permissions: admin, finance, location_manager, host, bdm, member, api_client |
| Permissions | Granular actions: booking:create, invoice:read, discount:apply, pricing:modify |
| Constraints | Parameterised rules: discount:apply { min: 0, max: 20 } per role. Evaluated at runtime. |
| Scoping | Permissions scoped to organisation and/or location (a manager at Rosebank can't see Sandton data) |
| Middleware | Single 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.
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:
- 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
BookingCreatedevent that triggers notifications, calendar sync, etc.
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
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:parkingscope 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.
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
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
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 { ... }
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:
| Concept | Design |
|---|---|
| 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). |
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
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
referencefield — 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.)
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;
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
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
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_preferencestable:{ 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
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:
Type Example Implementation Required Name must be present Zod .min(1)+ DBNOT NULLFormat Email, mobile, VAT number Zod .email(), regex patternsUnique Email must be unique DB unique index + Zod .refine()Range Discount 0–100% Zod .min(0).max(100)Referential location_id must exist DB foreign key constraint Cross-field end_date > start_date Zod .refine()on the schemaAsync Company reg number valid Custom validator calling external API - Configurable rules — store validation rules in a
field_rulestable 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
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
| Source | Type | Volume | Access |
|---|---|---|---|
| Gallery | High-res artwork images (primary, detail, in-situ, process) | 300+ and growing | Public, read-heavy |
| Members | Profile avatars | ~800+ | Authenticated, small |
| Rooms/Spaces | Photos, floor plans | ~100-200 | Public, rarely changed |
| Invoices/PDFs | Generated documents | Growing monthly | Authenticated, write-once |
| CRM | Attachments, proposals | Moderate | Authenticated |
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:
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
| Role | Purpose | AI Usage |
|---|---|---|
primary | Main exhibition image | Used in search results, cards, recommendations |
detail | Close-up of texture, technique | "Here's the cyanotype technique Dixie used" |
in_situ | Work installed in the space | "This is how it looks on a 2m wall" |
process | Artist working, behind the scenes | "Fred Clarke's studio process" |
Cost
| Service | Pricing | Est. 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_mediarecords with roles - No need to migrate thumbnails — they're generated on-the-fly
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
ParkingUserrecords 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:
| Capability | How 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
| Capability | Description |
|---|---|
| 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,ParkingUserrecords migrate to PostgreSQL tables with Admyt remote IDs preserved - Existing
Notificationrecords (entry/exit events) migrate toparking_sessions - Admyt API integration moves behind
ParkingAccessProvideradapter — 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
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
Integration Points
| System | On Check-In | On 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
| Capability | Description |
|---|---|
| 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
Guestrecords migrate togueststable with IDs preserved - Existing guest check-in records (from
Checkinmodel) migrate tovisitswith status set tochecked_out Contactmodel's guest aggregation continues —guest_idforeign key on the unified contact view- New: full lifecycle, integrations, and AI capabilities layered on top of migrated data
Requirements vs Current State
| # | Requirement | WSM Status | New 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 |