diff --git a/backend/cntr/analytics-snapshot.job.spec.ts b/backend/cntr/analytics-snapshot.job.spec.ts new file mode 100644 index 0000000..25def81 --- /dev/null +++ b/backend/cntr/analytics-snapshot.job.spec.ts @@ -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); + }); +}); diff --git a/backend/cntr/analytics-snapshot.job.ts b/backend/cntr/analytics-snapshot.job.ts new file mode 100644 index 0000000..716659c --- /dev/null +++ b/backend/cntr/analytics-snapshot.job.ts @@ -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}`, + }; +} diff --git a/backend/cntr/capacity-alert.service.spec.ts b/backend/cntr/capacity-alert.service.spec.ts new file mode 100644 index 0000000..3a323d1 --- /dev/null +++ b/backend/cntr/capacity-alert.service.spec.ts @@ -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'); + }); +}); diff --git a/backend/cntr/capacity-alert.service.ts b/backend/cntr/capacity-alert.service.ts new file mode 100644 index 0000000..69283a3 --- /dev/null +++ b/backend/cntr/capacity-alert.service.ts @@ -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, + }; +} diff --git a/backend/cntr/onboarding-checklist.service.spec.ts b/backend/cntr/onboarding-checklist.service.spec.ts new file mode 100644 index 0000000..8f5a0ca --- /dev/null +++ b/backend/cntr/onboarding-checklist.service.spec.ts @@ -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); + }); +}); diff --git a/backend/cntr/onboarding-checklist.service.ts b/backend/cntr/onboarding-checklist.service.ts new file mode 100644 index 0000000..c60a62c --- /dev/null +++ b/backend/cntr/onboarding-checklist.service.ts @@ -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, + }; +} diff --git a/backend/cntr/resource-usage.service.spec.ts b/backend/cntr/resource-usage.service.spec.ts new file mode 100644 index 0000000..3c0ae23 --- /dev/null +++ b/backend/cntr/resource-usage.service.spec.ts @@ -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'); + }); +}); diff --git a/backend/cntr/resource-usage.service.ts b/backend/cntr/resource-usage.service.ts new file mode 100644 index 0000000..3f69584 --- /dev/null +++ b/backend/cntr/resource-usage.service.ts @@ -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(), + }; +}