A multi-tenant, client-scoped talent intelligence platform. Each client organisation gets an isolated workspace containing their knowledge base, SPOC contacts, domain matrix, and BU planning data.
- Tech Stack
- Project Structure
- Features
- Multi-Tenancy & User Roles
- Authentication — Microsoft SSO
- Environment Variables
- Local Development Setup
- Production Deployment
- Database Schema & Migrations
- API Reference
- First-Time Production Setup
- Updating in Production
- Troubleshooting
| Layer | Technology |
|---|---|
| Frontend | React 18, Vite, React Router v6 |
| Backend | Node.js 20, Express 4 |
| Database | PostgreSQL 16 |
| Authentication | Microsoft Entra ID (Azure AD) SSO via MSAL Node |
| Session tokens | JWT (jsonwebtoken) — issued after SSO validation |
| Process Manager | PM2 (cluster mode) |
| Reverse Proxy | Caddy (auto HTTPS via Let's Encrypt) |
prism/
├── server/ # Express API (port 4000)
│ ├── index.js # Entry point, route registration
│ ├── db.js # PostgreSQL connection pool
│ ├── .env # Environment variables (not committed)
│ ├── db/
│ │ ├── schema.sql # Full database schema
│ │ ├── seed.js # Initial data seed
│ │ ├── seed-knowledge.js # Knowledge base seed data
│ │ └── migrate-multi-tenant.js # One-time migration: adds clients + client_users
│ ├── middleware/
│ │ └── auth.js # JWT verify, requireClientAccess, requireSuperAdmin
│ └── routes/
│ ├── auth.js # Microsoft SSO + /me
│ ├── clients.js # Workspace CRUD + user management
│ ├── knowledge.js # Knowledge base (client-scoped)
│ └── users.js # Platform-level user management
│
└── client/ # React + Vite frontend
├── vite.config.js # Dev proxy: /api → localhost:4000
└── src/
├── App.jsx # Routes + auth guards + WorkspaceLayout
├── api/index.js # Axios client (attaches JWT + X-Client-ID)
├── context/
│ ├── AuthContext.jsx # User, clients list, isSuperAdmin
│ └── ClientContext.jsx # Active workspace, canEdit
└── pages/
├── LoginPage.jsx # Microsoft SSO sign-in button
├── AuthCallbackPage.jsx # Handles /auth/callback after SSO
├── WorkspaceSelectorPage.jsx
├── SuperAdminPage.jsx # Workspace + user management
├── SettingsPage.jsx # Workspace member management
└── knowledge/
├── KnowledgePage.jsx # Tab shell with URL-based routing
├── CompanyProfile.jsx
├── Locations.jsx
├── DomainMatrix.jsx
└── BUPlanning.jsx
Each client workspace has four knowledge modules with individual URLs:
| Tab | URL path |
|---|---|
| Company Profile | /w/:clientSlug/company-profile |
| Capability Report | /w/:clientSlug/capability-report |
| Domain Matrix | /w/:clientSlug/domain-matrix |
| BU Planning | /w/:clientSlug/bu-planning |
| Custom Tabs* | /w/:clientSlug/custom_:slug |
*Custom tabs have auto-generated URL slugs based on their names
- Rich-text sections and subsections with inline editing
- Admins can reorder sections/subsections (▲▼), add/remove them
- Block sizing: each section can be set to Full, Half, or 1/3 width — sizes persist to the database
- 2-second debounced autosave
- Structured capability data per domain
- Section-based layout, editable by workspace admins
- Business domain landscape table with configurable columns
- Smart column defaults:
- Client SPOC / Woven SPOC → pre-fills
Name: \nDesignation/ Role: - SPOC Contacts / Woven SPOC Contacts → pre-fills
Mobile: \nEmail:
- Client SPOC / Woven SPOC → pre-fills
- Add/remove rows and columns; rename column headers inline
white-space: pre-wraprendering preserves multi-line contact details
- 5-year workforce planning per Business Unit
- Per-BU: leader, domains, planning dimensions × year grid
- Add/remove BUs, dimensions, and year columns
- Year columns are center-aligned
- Workspace admins can create custom tabs via the "Manage Tabs" panel
- Tab names are converted to user-friendly URL slugs (e.g.,
Sales Pipeline→custom_sales-pipeline) - Slugs are regenerated automatically when tab names change
- Special characters are removed, spaces become hyphens, max 50 chars per slug
- Custom tabs reuse the Company Profile component for flexible content storage
- Up to 10 total tabs (built-in + custom) per workspace
- Tab order is fully customizable via drag controls in Manage Tabs
- URL pattern:
/w/:clientSlug/custom_:slug(e.g.,/w/acme/custom_sales-pipeline)
- Download Section — exports the active tab as a PDF
- Download All — exports all four sections as a combined PDF
- Create, edit, and delete client workspaces (name, slug, description)
- Manage users across all workspaces
- Enter any workspace as admin
Prism uses a three-tier role model:
| Role | Scope | Capabilities |
|---|---|---|
| Super Admin | Platform | Create/edit/delete workspaces, manage all users, enter any workspace |
| Workspace Admin | Client | View + edit all knowledge base content, manage workspace members |
| Workspace Member | Client | View-only access to the workspace knowledge base |
- Every API request includes an
X-Client-IDheader (set automatically fromlocalStorage) - Server middleware scopes all knowledge base reads/writes to
req.clientId - Users can belong to multiple workspaces with different roles in each
- Super Admins bypass workspace access checks and can enter any workspace
/workspaces → workspace selector (after login)
/super-admin → super admin panel
/w/:clientSlug/company-profile → Company Profile
/w/:clientSlug/capability-report → Capability Report
/w/:clientSlug/domain-matrix → Domain Matrix
/w/:clientSlug/bu-planning → BU Planning
/w/:clientSlug/custom_:slug → Custom Tab (e.g., custom_sales-pipeline)
/w/:clientSlug/settings → workspace settings (member management)
Prism uses Microsoft Entra ID (Azure AD) for authentication. Username/password login is not supported.
1. User clicks "Sign in with Microsoft"
2. Browser navigates to GET /api/auth/microsoft
3. Server generates an auth URL and redirects to Microsoft
4. User authenticates with their Microsoft account (restricted to the configured tenant/domain)
5. Microsoft redirects to GET /api/auth/microsoft/callback?code=...
6. Server validates:
- Tenant ID must match AZURE_TENANT_ID
- Email domain must match AZURE_ALLOWED_DOMAIN
7. Server finds or creates the user in the DB (matched by email)
8. Server issues a Prism JWT, redirects to /auth/callback?payload=<base64>
9. Frontend stores the JWT, navigates to workspace selector or workspace
In Azure Portal → App registrations → your app:
- Redirect URI (Web):
https://your-domain.com/api/auth/microsoft/callback - Client Secret: create one, note the value and secret ID
- API Permissions:
openid,profile,email(Microsoft Graph — delegated)
For local development, also add: http://localhost:4000/api/auth/microsoft/callback
Create server/.env with the following:
# Server
PORT=4000
DATABASE_URL=postgresql://rcc_user:YOUR_DB_PASSWORD@localhost:5432/rcc_db
JWT_SECRET=your_long_random_secret_here
JWT_EXPIRES_IN=7d
CLIENT_ORIGIN=https://your-domain.com
# Microsoft Entra ID SSO
AZURE_CLIENT_ID=your_azure_app_client_id
AZURE_CLIENT_SECRET=your_azure_client_secret_value
AZURE_TENANT_ID=your_azure_tenant_id
AZURE_REDIRECT_URI=https://your-domain.com/api/auth/microsoft/callback
AZURE_ALLOWED_DOMAIN=your-domain.com| Variable | Required | Description |
|---|---|---|
PORT |
No | Express API port (default: 4000) |
DATABASE_URL |
Yes | Full PostgreSQL connection string |
JWT_SECRET |
Yes | Long random string for signing JWTs |
JWT_EXPIRES_IN |
No | Token lifetime (default: 7d) |
CLIENT_ORIGIN |
Yes | Frontend origin for CORS + SSO redirect target |
AZURE_CLIENT_ID |
No* | Azure AD app (client) ID (*required if SSO enabled) |
AZURE_CLIENT_SECRET |
No* | Azure AD client secret value (*required if SSO enabled) |
AZURE_TENANT_ID |
No* | Azure AD tenant ID (*required if SSO enabled) |
AZURE_REDIRECT_URI |
No | Must exactly match the redirect URI in Azure Portal |
AZURE_ALLOWED_DOMAIN |
No | Only this email domain is permitted to sign in |
- Server will fail to start if
DATABASE_URL,JWT_SECRET, orCLIENT_ORIGINare missing - If any Azure SSO variable is partially configured, a warning is logged but startup continues
- For production, ensure all required variables are set in your
.envfile or deployment environment
⚠️ Never commitserver/.envto version control.
Generate a secure JWT secret:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"- Node.js 20+
- PostgreSQL 16 running locally (or via Docker)
git clone <your-repo-url>
cd prismdocker run -d \
--name prism_postgres \
-e POSTGRES_DB=rcc_db \
-e POSTGRES_USER=rcc_user \
-e POSTGRES_PASSWORD=rcc_pass \
-p 5432:5432 \
postgres:16-alpinePORT=4000
DATABASE_URL=postgresql://rcc_user:rcc_pass@localhost:5432/rcc_db
JWT_SECRET=dev_secret_change_in_production
JWT_EXPIRES_IN=7d
CLIENT_ORIGIN=http://localhost:5173
AZURE_CLIENT_ID=your_azure_app_client_id
AZURE_CLIENT_SECRET=your_azure_client_secret
AZURE_TENANT_ID=your_azure_tenant_id
AZURE_REDIRECT_URI=http://localhost:4000/api/auth/microsoft/callback
AZURE_ALLOWED_DOMAIN=your-allowed-domain.comcd server && npm install
cd ../client && npm installcd server && npm run seed
node db/migrate-multi-tenant.js # adds multi-tenant tables# Terminal 1 — API
cd server && npm run dev
# Terminal 2 — Frontend
cd client && npm run devOpen → http://localhost:5173
Note: SSO will redirect back to
http://localhost:4000/api/auth/microsoft/callback. Make sure this URI is registered in Azure Portal for local testing.
The production server runs on a VPS with:
- PM2 managing the Node.js API process
- Caddy (running in Docker) as the reverse proxy with automatic HTTPS
ssh user@YOUR_SERVER_IP
cd /path/to/prism
# Pull latest code
git pull origin master
# Install any new server dependencies
cd server && npm install
# Rebuild frontend
cd ../client && npm run build
# Restart API with updated env vars
pm2 restart rcc-api --update-envCaddy runs inside a Docker container. Example Caddyfile:
your-domain.com {
reverse_proxy 172.18.0.1:4000
}After editing the Caddyfile:
docker restart <caddy-container-name>Key env var in your ecosystem.config.js:
CLIENT_ORIGIN: 'https://your-domain.com'pm2 status # view all processes
pm2 logs rcc-api # tail API logs
pm2 restart rcc-api # restart
pm2 restart rcc-api --update-env # restart + pick up .env changes| Table | Purpose |
|---|---|
users |
Platform users (name, email, role, is_super_admin) |
clients |
Client workspaces (name, slug, description) |
client_users |
Workspace memberships (client_id, user_id, role) |
knowledge_base |
JSONB knowledge data, scoped by (client_id, section) |
Initial schema:
cd server && npm run seedMulti-tenant migration (run once — idempotent):
node server/db/migrate-multi-tenant.jsThis migration:
- Creates
clientsandclient_userstables - Adds
is_super_admintousers - Adds
client_idtoknowledge_base - Migrates existing data to the default workspace
- Promotes the first admin user to Super Admin
# Backup
pg_dump $DATABASE_URL > backup_$(date +%Y%m%d).sql
# Restore
psql $DATABASE_URL < backup_20240101.sql
# Connect directly
psql $DATABASE_URLAll routes except /api/auth/microsoft and /api/auth/microsoft/callback require:
Authorization: Bearer <jwt>headerX-Client-ID: <clientId>header for workspace-scoped routes
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/auth/microsoft |
None | Initiates Microsoft OAuth redirect |
| GET | /api/auth/microsoft/callback |
None | OAuth callback — issues JWT, redirects to frontend |
| GET | /api/auth/me |
JWT | Returns current user + their workspaces |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/clients |
JWT | Super Admin: all clients. Others: own workspaces |
| POST | /api/clients |
Super Admin | Create a workspace |
| PUT | /api/clients/:id |
Super Admin | Update workspace name/slug/description |
| DELETE | /api/clients/:id |
Super Admin | Delete workspace |
| GET | /api/clients/:id/users |
Admin / Super Admin | List workspace members |
| POST | /api/clients/:id/users |
Admin / Super Admin | Add user to workspace (creates if new) |
| PATCH | /api/clients/:id/users/:uid |
Admin / Super Admin | Change workspace role |
| DELETE | /api/clients/:id/users/:uid |
Admin / Super Admin | Remove user from workspace |
Requires X-Client-ID header. Reads/writes are scoped to the active workspace.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/knowledge/:section |
Member+ | Get section data |
| PUT | /api/knowledge/:section |
Workspace Admin | Save section data (upsert) |
Sections: company_profile, capability_report, domain_matrix, bu_planning
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/users |
Super Admin | List all platform users |
| POST | /api/users |
Super Admin | Create user |
| PATCH | /api/users/:id |
Super Admin | Update user |
| DELETE | /api/users/:id |
Super Admin | Delete user |
GET /api/health → { "status": "ok", "ts": "..." }
The server validates all required environment variables on startup:
- Critical:
DATABASE_URL,JWT_SECRET,CLIENT_ORIGIN— server fails to start if missing - Optional: Azure SSO variables — server logs a warning if partially configured but still starts
This prevents silent failures in production due to misconfiguration.
- Section Parameter: Only valid sections are accepted (
company-profile,capability-report,domain-matrix,bu-planning). Invalid sections return400 Bad Request. - PUT Endpoint:
- Request body must be a valid JSON object (not null, string, or array)
- Maximum payload size is 1MB to prevent storage and memory issues
- Invalid payloads return
400 Bad Requestwith descriptive error messages
CLIENT_ORIGINenvironment variable is required and strictly enforced- No wildcard (
*) CORS origin in production — prevents unsafe credential sharing across origins credentials: trueallows authentication headers in cross-origin requests only for the configured origin
- All user input in queries is parameterized (
$1,$2, etc.) to prevent SQL injection - Knowledge base data is stored in PostgreSQL's
JSONBtype with size limits enforced at the application level - Client access is scoped at the database middleware level — users can only access their own workspace data
After deploying for the first time:
ssh user@YOUR_SERVER_IP
cd /path/to/prism && node server/db/migrate-multi-tenant.jsThe first user to sign in via SSO can be promoted via SQL:
psql $DATABASE_URL << 'SQL'
-- Promote user to Super Admin
UPDATE users SET is_super_admin = TRUE WHERE email = 'you@your-domain.com';
-- Add to a workspace as Admin
INSERT INTO client_users (client_id, user_id, role)
SELECT c.id, u.id, 'admin'
FROM clients c, users u
WHERE c.slug = 'your-workspace-slug' AND u.email = 'you@your-domain.com'
ON CONFLICT (client_id, user_id) DO UPDATE SET role = 'admin';
SQLOnce logged in as Super Admin, use the Super Admin panel (/super-admin) to:
- Create additional client workspaces
- Edit workspace names, slugs, and descriptions
- Add users to workspaces via the "Manage Users" button
ssh user@YOUR_SERVER_IP
cd /path/to/prism
# Pull latest
git pull origin master
# Install new server dependencies (if any)
cd server && npm install
# Rebuild frontend
cd ../client && npm run build
# Restart API (picks up new code + updated .env)
pm2 restart rcc-api --update-envIf the database schema changed, also run:
node /path/to/prism/server/db/migrate-multi-tenant.js- The signed-in Microsoft account's email domain doesn't match
AZURE_ALLOWED_DOMAIN - Check
AZURE_ALLOWED_DOMAINinserver/.env
- The Microsoft account belongs to a different Azure AD tenant
- Verify
AZURE_TENANT_IDin.envmatches your organisation's tenant
- The OAuth state parameter expired (10-minute window)
- Try signing in again — this is normal if the tab was left idle
- Azure Portal must have the exact URI registered:
https://your-domain.com/api/auth/microsoft/callback - For local dev:
http://localhost:4000/api/auth/microsoft/callback
- JWT may be expired (default: 7 days)
- Sign out and sign back in via Microsoft SSO
- Check that
X-Client-IDis being sent (inspect network tab — should be the client's UUID) - Verify the user is a member of the workspace:
SELECT * FROM client_users WHERE user_id = <id>;
sed -ichanges the inode; Docker bind mount still points to the old inode- Fix:
docker restart <caddy-container-name>
- Ensure
npm run buildcompleted without errors - The Express server serves
client/dist— PM2 restart not needed for frontend-only changes
pm2 logs rcc-api --lines 50 # check for startup errors
# Common cause: missing or malformed server/.envpg_dump $DATABASE_URL > prism_backup_$(date +%Y%m%d).sql