Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions backend/cntr/analytics-snapshot.job.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { buildDailySnapshot, PlatformStats } from './analytics-snapshot.job';

const sampleStats: PlatformStats = {
totalMembers: 150,
activeBookings: 23,
totalRevenueKobo: 5_000_000,
newRegistrations: 8,
checkInsToday: 12,
};

describe('buildDailySnapshot', () => {
it('formats snapshotDate as YYYY-MM-DD', () => {
const snapshot = buildDailySnapshot(sampleStats, new Date('2026-03-15T10:00:00Z'));
expect(snapshot.snapshotDate).toBe('2026-03-15');
});

it('formats single-digit month and day with padding', () => {
const snapshot = buildDailySnapshot(sampleStats, new Date('2026-01-05T00:00:00Z'));
expect(snapshot.snapshotDate).toBe('2026-01-05');
});

it('includes all PlatformStats fields in output', () => {
const snapshot = buildDailySnapshot(sampleStats, new Date('2026-06-01T00:00:00Z'));
expect(snapshot.totalMembers).toBe(sampleStats.totalMembers);
expect(snapshot.activeBookings).toBe(sampleStats.activeBookings);
expect(snapshot.totalRevenueKobo).toBe(sampleStats.totalRevenueKobo);
expect(snapshot.newRegistrations).toBe(sampleStats.newRegistrations);
expect(snapshot.checkInsToday).toBe(sampleStats.checkInsToday);
});

it('id is a valid UUID', () => {
const snapshot = buildDailySnapshot(sampleStats, new Date());
expect(snapshot.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});

it('output is a plain object', () => {
const snapshot = buildDailySnapshot(sampleStats, new Date());
expect(typeof snapshot).toBe('object');
expect(snapshot.constructor).toBe(Object);
});

it('generates unique ids for each call', () => {
const a = buildDailySnapshot(sampleStats, new Date());
const b = buildDailySnapshot(sampleStats, new Date());
expect(a.id).not.toBe(b.id);
});
});
25 changes: 25 additions & 0 deletions backend/cntr/analytics-snapshot.job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { randomUUID } from 'crypto';

export interface PlatformStats {
totalMembers: number;
activeBookings: number;
totalRevenueKobo: number;
newRegistrations: number;
checkInsToday: number;
}

export interface AnalyticsSnapshot extends PlatformStats {
id: string;
snapshotDate: string;
}

export function buildDailySnapshot(stats: PlatformStats, date: Date): AnalyticsSnapshot {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return {
...stats,
id: randomUUID(),
snapshotDate: `${year}-${month}-${day}`,
};
}
50 changes: 50 additions & 0 deletions backend/cntr/capacity-alert.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { checkCapacityThreshold } from './capacity-alert.service';

describe('checkCapacityThreshold', () => {
it('throws RangeError when capacity <= 0', () => {
expect(() => checkCapacityThreshold(0, 5)).toThrow(RangeError);
expect(() => checkCapacityThreshold(-1, 5)).toThrow(RangeError);
});

it('computes occupancyPercent = Math.round((activeBookings / capacity) * 100)', () => {
const result = checkCapacityThreshold(10, 5);
expect(result.occupancyPercent).toBe(50);
});

it('rounds occupancyPercent correctly', () => {
const result = checkCapacityThreshold(3, 1);
expect(result.occupancyPercent).toBe(33);
});

it('isNearCapacity is true when occupancyPercent >= default threshold of 80', () => {
const result = checkCapacityThreshold(10, 8);
expect(result.occupancyPercent).toBe(80);
expect(result.isNearCapacity).toBe(true);
});

it('isNearCapacity is false just below default threshold', () => {
const result = checkCapacityThreshold(100, 79);
expect(result.isNearCapacity).toBe(false);
});

it('isNearCapacity respects custom thresholdPercent', () => {
const result = checkCapacityThreshold(10, 5, 50);
expect(result.isNearCapacity).toBe(true);
});

it('isFull is true when activeBookingsCount >= capacity', () => {
expect(checkCapacityThreshold(10, 10).isFull).toBe(true);
expect(checkCapacityThreshold(10, 11).isFull).toBe(true);
});

it('isFull is false when activeBookingsCount < capacity', () => {
expect(checkCapacityThreshold(10, 9).isFull).toBe(false);
});

it('exports CapacityStatus shape with all 3 fields', () => {
const result = checkCapacityThreshold(10, 3);
expect(result).toHaveProperty('occupancyPercent');
expect(result).toHaveProperty('isNearCapacity');
expect(result).toHaveProperty('isFull');
});
});
21 changes: 21 additions & 0 deletions backend/cntr/capacity-alert.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export interface CapacityStatus {
occupancyPercent: number;
isNearCapacity: boolean;
isFull: boolean;
}

export function checkCapacityThreshold(
capacity: number,
activeBookingsCount: number,
thresholdPercent = 80,
): CapacityStatus {
if (capacity <= 0) {
throw new RangeError(`capacity must be > 0, got: ${capacity}`);
}
const occupancyPercent = Math.round((activeBookingsCount / capacity) * 100);
return {
occupancyPercent,
isNearCapacity: occupancyPercent >= thresholdPercent,
isFull: activeBookingsCount >= capacity,
};
}
77 changes: 77 additions & 0 deletions backend/cntr/onboarding-checklist.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { computeOnboardingProgress, MemberProfile } from './onboarding-checklist.service';

const allDone: MemberProfile = {
hasProfilePhoto: true,
hasFirstBooking: true,
hasTwoFactorEnabled: true,
isEmailVerified: true,
};

const allPending: MemberProfile = {
hasProfilePhoto: false,
hasFirstBooking: false,
hasTwoFactorEnabled: false,
isEmailVerified: false,
};

describe('computeOnboardingProgress', () => {
it('returns exactly 4 steps', () => {
const result = computeOnboardingProgress(allPending);
expect(result.steps).toHaveLength(4);
});

it('totalCount is always 4', () => {
expect(computeOnboardingProgress(allDone).totalCount).toBe(4);
expect(computeOnboardingProgress(allPending).totalCount).toBe(4);
});

it('completedCount is 4 when all steps are done', () => {
const result = computeOnboardingProgress(allDone);
expect(result.completedCount).toBe(4);
});

it('isComplete is true only when all 4 steps are done', () => {
expect(computeOnboardingProgress(allDone).isComplete).toBe(true);
});

it('isComplete is false when fewer than 4 steps are done', () => {
const partial: MemberProfile = { ...allDone, hasTwoFactorEnabled: false };
expect(computeOnboardingProgress(partial).isComplete).toBe(false);
});

it('completedCount equals the number of completed steps', () => {
const partial: MemberProfile = {
hasProfilePhoto: true,
hasFirstBooking: true,
hasTwoFactorEnabled: false,
isEmailVerified: false,
};
expect(computeOnboardingProgress(partial).completedCount).toBe(2);
});

it('completedCount is 0 when no steps are done', () => {
expect(computeOnboardingProgress(allPending).completedCount).toBe(0);
});

it('each step has key, label, description, and isComplete fields', () => {
const result = computeOnboardingProgress(allPending);
for (const step of result.steps) {
expect(step).toHaveProperty('key');
expect(step).toHaveProperty('label');
expect(step).toHaveProperty('description');
expect(step).toHaveProperty('isComplete');
}
});

it('profile_photo step isComplete reflects hasProfilePhoto', () => {
const result = computeOnboardingProgress({ ...allPending, hasProfilePhoto: true });
const step = result.steps.find((s) => s.key === 'profile_photo');
expect(step?.isComplete).toBe(true);
});

it('two_factor step isComplete reflects hasTwoFactorEnabled', () => {
const result = computeOnboardingProgress({ ...allPending, hasTwoFactorEnabled: true });
const step = result.steps.find((s) => s.key === 'two_factor');
expect(step?.isComplete).toBe(true);
});
});
56 changes: 56 additions & 0 deletions backend/cntr/onboarding-checklist.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
export interface MemberProfile {
hasProfilePhoto: boolean;
hasFirstBooking: boolean;
hasTwoFactorEnabled: boolean;
isEmailVerified: boolean;
}

export interface OnboardingStep {
key: string;
label: string;
description: string;
isComplete: boolean;
}

export interface OnboardingChecklist {
steps: OnboardingStep[];
completedCount: number;
totalCount: number;
isComplete: boolean;
}

export function computeOnboardingProgress(member: MemberProfile): OnboardingChecklist {
const steps: OnboardingStep[] = [
{
key: 'profile_photo',
label: 'Upload Profile Photo',
description: 'Add a profile photo to personalise your account.',
isComplete: member.hasProfilePhoto,
},
{
key: 'first_booking',
label: 'Make Your First Booking',
description: 'Book a workspace to get started.',
isComplete: member.hasFirstBooking,
},
{
key: 'two_factor',
label: 'Enable Two-Factor Authentication',
description: 'Secure your account with 2FA.',
isComplete: member.hasTwoFactorEnabled,
},
{
key: 'email_verified',
label: 'Verify Your Email',
description: 'Confirm your email address.',
isComplete: member.isEmailVerified,
},
];
const completedCount = steps.filter((s) => s.isComplete).length;
return {
steps,
completedCount,
totalCount: steps.length,
isComplete: completedCount === steps.length,
};
}
45 changes: 45 additions & 0 deletions backend/cntr/resource-usage.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createUsageLog, ResourceType } from './resource-usage.service';

describe('createUsageLog', () => {
it('throws RangeError for quantity = 0', () => {
expect(() => createUsageLog('s1', 'm1', 'PRINT', 0, 'pages')).toThrow(RangeError);
});

it('throws RangeError for negative quantity', () => {
expect(() => createUsageLog('s1', 'm1', 'PRINT', -1, 'pages')).toThrow(RangeError);
});

it('does not throw for quantity > 0', () => {
expect(() => createUsageLog('s1', 'm1', 'PRINT', 1, 'pages')).not.toThrow();
});

it('recordedAt is a valid ISO 8601 string', () => {
const log = createUsageLog('s1', 'm1', 'INTERNET', 5, 'MB');
expect(() => new Date(log.recordedAt)).not.toThrow();
expect(log.recordedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});

it('returns all expected fields', () => {
const log = createUsageLog('session-42', 'member-7', 'LOCKER', 1, 'unit');
expect(log.sessionId).toBe('session-42');
expect(log.memberId).toBe('member-7');
expect(log.resourceType).toBe('LOCKER');
expect(log.quantity).toBe(1);
expect(log.unit).toBe('unit');
});

it('works for PRINT resource type', () => {
const log = createUsageLog('s', 'm', 'PRINT', 10, 'pages');
expect(log.resourceType).toBe('PRINT');
});

it('works for INTERNET resource type', () => {
const log = createUsageLog('s', 'm', 'INTERNET', 100, 'MB');
expect(log.resourceType).toBe('INTERNET');
});

it('works for LOCKER resource type', () => {
const log = createUsageLog('s', 'm', 'LOCKER', 1, 'unit');
expect(log.resourceType).toBe('LOCKER');
});
});
30 changes: 30 additions & 0 deletions backend/cntr/resource-usage.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export type ResourceType = 'PRINT' | 'INTERNET' | 'LOCKER';

export interface ResourceUsageLog {
sessionId: string;
memberId: string;
resourceType: ResourceType;
quantity: number;
unit: string;
recordedAt: string;
}

export function createUsageLog(
sessionId: string,
memberId: string,
resourceType: ResourceType,
quantity: number,
unit: string,
): ResourceUsageLog {
if (quantity <= 0) {
throw new RangeError(`quantity must be > 0, got: ${quantity}`);
}
return {
sessionId,
memberId,
resourceType,
quantity,
unit,
recordedAt: new Date().toISOString(),
};
}
Loading