Technical Architecture
System Overview
The CSM Dashboard is built as a Next.js application that reads directly from Firebase Realtime Database and displays customer health metrics in real-time.
┌─────────────────────────────────────────────────────────────┐
│ CSM Dashboard (Next.js) │
│ ┌────────────┐ ┌────────────┐ ┌──────────────────────┐ │
│ │ Customer │ │ Metrics │ │ Alerts & Filters │ │
│ │ List │ │ Details │ │ & Export │ │
│ └────────────┘ └────────────┘ └──────────────────────┘ │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ Firebase Admin SDK (Node.js) │
│ - Read CustomerHealthMetrics│
│ - Read Companies │
│ - Read Employees │
└───────────────┬───────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Firebase Realtime Database │
│ (pivot-inc project) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ CustomerHealthMetrics/{companyId}/ │ │
│ │ - numberofemployees │ │
│ │ - last_login_date_master_account │ │
│ │ - quarts_de_travail_ouverts_10_ │ │
│ │ - quarts_de_travail_attribues_hors_dispo_10 │ │
│ │ - quarts_problematiques_50 │ │
│ │ - history/{date}/... │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Companies/{companyId}/ │ │
│ │ - name │ │
│ │ - email (master account) │ │
│ │ - city, phone, etc. │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Employees/{companyId}/{employeeId}/ │ │
│ │ - archived │ │
│ │ - name, position, etc. │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ PresenceStatus/{userId}/ │ │
│ │ - lastActive │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Schedules/{companyId}/{weekId}/shifts/ │ │
│ │ Timecards/{companyId}/ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▲
│
│
┌─────────────────────────┴───────────────────────────────────┐
│ Firebase Cloud Functions (Metric Calculators) │
│ ┌────────────────────┐ ┌─────────────────────────────┐ │
│ │ Real-time Updates │ │ Scheduled Cron Jobs │ │
│ │ │ │ │ │
│ │ onEmployeeCreate │ │ calculateDailyMetrics() │ │
│ │ onEmployeeArchive │ │ - Open shifts │ │
│ │ onPresenceUpdate │ │ - Unavailable shifts │ │
│ │ onCompanyEmailChg │ │ - Conflicting timecards │ │
│ │ │ │ │ │
│ │ → Update metrics │ │ Daily at 6:00 AM Toronto │ │
│ │ immediately │ │ → Update batch metrics │ │
│ └────────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Technology Stack
Frontend Dashboard
- Framework: Next.js 14 (App Router)
- Styling: Tailwind CSS
- Charts: Recharts
- Data Grid: AG Grid Community
- State Management: React Hooks (useState, useEffect)
- Authentication: Google OAuth (@pivotapp.ca domain only)
Backend / Data Layer
- Database: Firebase Realtime Database (pivot-inc project)
- Functions: Firebase Cloud Functions (Node.js)
- Authentication: Firebase Admin SDK
- Scheduler: Firebase PubSub Cron (6:00 AM Toronto daily)
Infrastructure
- Hosting: GCP Cloud Run (via GitHub Actions)
- Domain: csm.pivotdev.ca (or similar)
- SSL: Google Certificate Manager
- Load Balancer: GCP HTTPS Load Balancer
Component Architecture
Dashboard Application Structure
pivot-csm-dashboard/
├── app/
│ ├── page.tsx # Main customer list view
│ ├── customer/[id]/page.tsx # Individual customer detail
│ ├── alerts/page.tsx # Alert management
│ ├── api/
│ │ ├── customers/route.ts # GET /api/customers
│ │ ├── customer/[id]/route.ts # GET /api/customer/:id
│ │ └── metrics/route.ts # GET /api/metrics
│ └── layout.tsx
├── components/
│ ├── dashboard/
│ │ ├── CustomerList.tsx # AG Grid customer table
│ │ ├── MetricCard.tsx # Individual metric display
│ │ ├── TrendChart.tsx # Sparkline/trend visualization
│ │ ├── AlertBadge.tsx # Color-coded status indicator
│ │ └── FilterPanel.tsx # Search/filter controls
│ ├── customer/
│ │ ├── CustomerHeader.tsx # Company info header
│ │ ├── MetricsGrid.tsx # All 5 metrics displayed
│ │ ├── HistoricalChart.tsx # 90-day trend charts
│ │ └── ActivityTimeline.tsx # Recent activity log
│ └── GoogleAuth.tsx # Authentication wrapper
├── lib/
│ ├── firebase-admin.ts # Firebase Admin SDK setup
│ ├── metrics.ts # Metric calculation helpers
│ ├── alerts.ts # Alert logic
│ └── utils.ts # Shared utilities
├── types/
│ ├── customer.ts
│ ├── metrics.ts
│ └── alerts.ts
└── public/
API Routes
GET /api/customers
Returns list of all companies with current health metrics.
Response:
[
{
"companyId": "-MCMchoX-9hNe0jRvC1H",
"name": "Bloom Sushi QDS",
"email": "contact@bloomsushi.com",
"metrics": {
"numberofemployees": 127,
"last_login_date_master_account": "2025-11-23T14:30:00Z",
"quarts_de_travail_ouverts_10_": "No",
"quarts_de_travail_attribues_hors_dispo_10": "Yes",
"quarts_problematiques_50": "No"
},
"alertLevel": "warning",
"lastUpdated": "2025-11-24T06:00:00Z"
}
]
Query Parameters:
?alertLevel=critical- Filter by alert level?sortBy=lastLogin- Sort options?search=bloom- Search by company name
GET /api/customer/:id
Returns detailed information for a single company including historical data.
Response:
{
"companyId": "-MCMchoX-9hNe0jRvC1H",
"name": "Bloom Sushi QDS",
"email": "contact@bloomsushi.com",
"phone": "514-555-1234",
"city": "Montreal, QC",
"metrics": {
"current": { ... },
"history": [
{
"date": "2025-11-24",
"numberofemployees": 127,
...
}
]
},
"trends": {
"employeeCount": {
"7day": "+2",
"30day": "-5",
"90day": "+12"
}
}
}
GET /api/metrics
Returns aggregated metrics across all companies (for dashboard overview).
Response:
{
"totalCompanies": 350,
"alertCounts": {
"critical": 12,
"warning": 45,
"healthy": 293
},
"metricBreakdown": {
"inactiveAccounts": 8,
"highOpenShifts": 15,
"unavailableAssignments": 23,
"conflictingTimecards": 10
}
}
Firebase Cloud Functions
Real-time Metric Updates
onEmployeeCreate
export const onEmployeeCreate = functions.database
.ref('/Employees/{companyId}/{employeeId}')
.onCreate(async (snapshot, context) => {
const companyId = context.params.companyId;
// Count active employees
const employeeCount = await countActiveEmployees(companyId);
// Update metric
await admin.database()
.ref(`CustomerHealthMetrics/${companyId}`)
.update({
numberofemployees: employeeCount,
lastUpdated: admin.database.ServerValue.TIMESTAMP
});
});
onEmployeeArchive
export const onEmployeeUpdate = functions.database
.ref('/Employees/{companyId}/{employeeId}')
.onUpdate(async (change, context) => {
const before = change.before.val();
const after = change.after.val();
// Check if archived status changed
if (before.archived !== after.archived) {
const companyId = context.params.companyId;
const employeeCount = await countActiveEmployees(companyId);
await admin.database()
.ref(`CustomerHealthMetrics/${companyId}`)
.update({
numberofemployees: employeeCount,
lastUpdated: admin.database.ServerValue.TIMESTAMP
});
}
});
onPresenceStatusUpdate
export const onPresenceStatusUpdate = functions.database
.ref('/PresenceStatus/{userId}/lastActive')
.onWrite(async (change, context) => {
const userId = context.params.userId;
// Get user's company and check if master account
const user = await admin.database()
.ref(`Users/${userId}`)
.once('value');
const userData = user.val();
const companyId = userData.currentCompany;
// Get company to check master account email
const company = await admin.database()
.ref(`Companies/${companyId}`)
.once('value');
if (company.val().email === userData.email) {
// This is master account
const lastActive = change.after.val();
await admin.database()
.ref(`CustomerHealthMetrics/${companyId}`)
.update({
last_login_date_master_account: lastActive,
lastUpdated: admin.database.ServerValue.TIMESTAMP
});
}
});
Scheduled Batch Metrics
calculateDailyMetrics (Cron: 0 6 * * * America/Toronto)
export const calculateDailyMetrics = functions.pubsub
.schedule('0 6 * * *')
.timeZone('America/Toronto')
.onRun(async (context) => {
// Get all companies
const companiesSnapshot = await admin.database()
.ref('Companies')
.once('value');
const companies = companiesSnapshot.val();
for (const [companyId, companyData] of Object.entries(companies)) {
try {
// Calculate each metric
const openShifts = await calculateOpenShiftsMetric(companyId);
const unavailableShifts = await calculateUnavailableShiftsMetric(companyId);
const conflictingTimecards = await calculateConflictingTimecardsMetric(companyId);
// Update metrics
await admin.database()
.ref(`CustomerHealthMetrics/${companyId}`)
.update({
quarts_de_travail_ouverts_10_: openShifts,
quarts_de_travail_attribues_hors_dispo_10: unavailableShifts,
quarts_problematiques_50: conflictingTimecards,
lastUpdated: admin.database.ServerValue.TIMESTAMP
});
// Store historical snapshot
const today = new Date().toISOString().split('T')[0];
await admin.database()
.ref(`CustomerHealthMetrics/${companyId}/history/${today}`)
.set({
numberofemployees: companyData.employeeCount,
quarts_de_travail_ouverts_10_: openShifts,
quarts_de_travail_attribues_hors_dispo_10: unavailableShifts,
quarts_problematiques_50: conflictingTimecards,
timestamp: admin.database.ServerValue.TIMESTAMP
});
} catch (error) {
console.error(`Error calculating metrics for company ${companyId}:`, error);
// Log to Slack #support-logs
await sendSlackAlert(`Metric calculation failed for ${companyId}: ${error.message}`);
}
}
console.log(`Daily metrics calculated for ${Object.keys(companies).length} companies`);
});
Metric Calculation Helpers
async function calculateOpenShiftsMetric(companyId: string): Promise<string> {
const last14Days = getLast14Days();
const dailyPercentages = [];
for (const date of last14Days) {
const weekId = getWeekIdForDate(date);
// Get draft schedules for this week
const schedules = await admin.database()
.ref(`Schedules/${companyId}/${weekId}/shifts`)
.orderByChild('date')
.equalTo(date)
.once('value');
if (!schedules.exists()) {
continue; // No schedule for this day
}
const shifts = schedules.val();
let regularShifts = 0;
let openShifts = 0;
for (const shift of Object.values(shifts)) {
if (shift.isRegular) regularShifts++;
if (!shift.employeeId) openShifts++;
}
if (regularShifts > 0) {
const percentage = (openShifts / regularShifts) * 100;
dailyPercentages.push(percentage);
}
}
if (dailyPercentages.length === 0) {
return "None"; // No schedules in last 14 days
}
const average = dailyPercentages.reduce((a, b) => a + b) / dailyPercentages.length;
return average > 10 ? "Yes" : "No";
}
Authentication & Security
Google OAuth
- Restrict to @pivotapp.ca domain
- Use same OAuth client as pivot-kpi dashboard
- Session management via Firebase Auth
Firebase Admin SDK
- Service account key stored in
/home/chipdev/pivot-meta/pivot-devops/github/secrets/pivot-inc-firebase-sa.json - Database URL:
https://pivot-inc.firebaseio.com - Read-only access to customer data (no write permissions from dashboard)
API Security
- All API routes protected by authentication middleware
- Rate limiting on API endpoints
- CORS restricted to dashboard domain only
Deployment
GitHub Actions CI/CD
name: Deploy CSM Dashboard
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build Docker image
run: docker build -t gcr.io/pivot-dev-59310/pivot-csm-dashboard .
- name: Push to GCR
run: docker push gcr.io/pivot-dev-59310/pivot-csm-dashboard
- name: Deploy to Cloud Run
run: |
gcloud run deploy pivot-csm-dashboard \
--image gcr.io/pivot-dev-59310/pivot-csm-dashboard \
--platform managed \
--region us-central1 \
--allow-unauthenticated
Environment Variables
NEXT_PUBLIC_GOOGLE_CLIENT_ID=594226458087-...
GOOGLE_CLOUD_PROJECT_ID=pivot-inc
FIREBASE_DATABASE_URL=https://pivot-inc.firebaseio.com
FIREBASE_SERVICE_ACCOUNT=/path/to/service-account.json
NEXT_PUBLIC_BASE_URL=https://csm.pivotdev.ca
Monitoring & Logging
Application Logs
- Cloud Run logs for API requests
- Firebase Cloud Functions logs for metric calculations
- Error tracking via Sentry (optional)
Slack Alerts
Send notifications to #support-logs for:
- Failed metric calculations
- Company matching errors
- Critical alert thresholds exceeded
Performance Metrics
- API response times
- Metric calculation duration
- Database query performance
Scalability Considerations
Current Scale
- ~350 active companies
- 5 metrics per company
- Daily batch processing
- Low query volume (support team only)
Future Scale
- Firebase Realtime Database can handle thousands of companies
- Consider sharding if >10,000 companies
- Add caching layer (Redis) if API response time degrades
- Implement pagination for customer list (100 per page)