diff --git a/k8s/user-preferences-deployment.yaml b/k8s/user-preferences-deployment.yaml new file mode 100644 index 00000000..0986971e --- /dev/null +++ b/k8s/user-preferences-deployment.yaml @@ -0,0 +1,38 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: teachlink-user-preferences + namespace: production + labels: + app: teachlink + component: user-preferences +spec: + replicas: 2 + selector: + matchLabels: + app: teachlink + component: user-preferences + template: + metadata: + labels: + app: teachlink + component: user-preferences + spec: + containers: + - name: user-preferences + image: rinafcode/teachlink-user-preferences:latest + imagePullPolicy: Always + ports: + - containerPort: 3000 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + env: + - name: NODE_ENV + value: "production" + - name: PORT + value: "3000" diff --git a/k8s/user-preferences-hpa.yaml b/k8s/user-preferences-hpa.yaml new file mode 100644 index 00000000..50c75637 --- /dev/null +++ b/k8s/user-preferences-hpa.yaml @@ -0,0 +1,28 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: teachlink-user-preferences-hpa + namespace: production + labels: + app: teachlink + component: user-preferences +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: teachlink-user-preferences + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1edc7c2..d6e32145 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: dompurify: specifier: ^3.2.4 version: 3.4.7 + ethers: + specifier: ^6.12.0 + version: 6.16.0 framer-motion: specifier: ^12.23.0 version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -311,6 +314,9 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} @@ -1770,6 +1776,9 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/curves@1.8.0': resolution: {integrity: sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==} engines: {node: ^14.21.3 || >=16} @@ -1789,6 +1798,10 @@ packages: '@noble/hashes@1.1.5': resolution: {integrity: sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ==} + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@noble/hashes@1.7.0': resolution: {integrity: sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==} engines: {node: ^14.21.3 || >=16} @@ -3025,6 +3038,9 @@ packages: '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -3539,6 +3555,9 @@ packages: aes-decrypter@4.0.2: resolution: {integrity: sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==} + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -4635,6 +4654,10 @@ packages: resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} engines: {node: '>=20'} + ethers@6.16.0: + resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} + engines: {node: '>=14.0.0'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} @@ -7179,6 +7202,9 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -7258,6 +7284,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -7733,6 +7762,18 @@ packages: utf-8-validate: optional: true + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.18.2: resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} engines: {node: '>=10.0.0'} @@ -7894,6 +7935,8 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@adraffy/ens-normalize@1.10.1': {} + '@adraffy/ens-normalize@1.11.1': {} '@alloc/quick-lru@5.2.0': {} @@ -9420,6 +9463,10 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + '@noble/curves@1.8.0': dependencies: '@noble/hashes': 1.7.0 @@ -9435,10 +9482,11 @@ snapshots: '@noble/curves@1.9.7': dependencies: '@noble/hashes': 1.8.0 - optional: true '@noble/hashes@1.1.5': {} + '@noble/hashes@1.3.2': {} + '@noble/hashes@1.7.0': {} '@noble/hashes@1.8.0': {} @@ -10313,7 +10361,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -11000,6 +11048,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@types/pg@8.20.0': dependencies: '@types/node': 20.19.41 @@ -11748,6 +11800,8 @@ snapshots: global: 4.4.0 pkcs7: 1.0.4 + aes-js@4.0.0-beta.5: {} + agent-base@7.1.4: {} ajv-formats@2.1.1(ajv@8.20.0): @@ -12189,7 +12243,7 @@ snapshots: c32check@2.0.0: dependencies: - '@noble/hashes': 1.1.5 + '@noble/hashes': 1.8.0 base-x: 4.0.1 cac@6.7.14: {} @@ -13162,6 +13216,19 @@ snapshots: eta@4.6.0: {} + ethers@6.16.0: + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + eventemitter3@4.0.7: {} eventemitter3@5.0.1: {} @@ -14698,11 +14765,11 @@ snapshots: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -14713,11 +14780,11 @@ snapshots: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -16156,6 +16223,8 @@ snapshots: tslib@1.14.1: {} + tslib@2.7.0: {} + tslib@2.8.1: {} tsx@4.22.3: @@ -16246,6 +16315,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.19.8: {} + undici-types@6.21.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -16875,6 +16946,8 @@ snapshots: ws@7.5.11: {} + ws@8.17.1: {} + ws@8.18.2: {} ws@8.20.1: {} diff --git a/src/lib/auth/SessionPredictor.ts b/src/lib/auth/SessionPredictor.ts new file mode 100644 index 00000000..d84dca78 --- /dev/null +++ b/src/lib/auth/SessionPredictor.ts @@ -0,0 +1,160 @@ +// src/lib/auth/SessionPredictor.ts + +export interface SessionPredictorOptions { + /** The total length of a session in milliseconds */ + maxSessionLength?: number; + /** Activity threshold in milliseconds to consider the user idle */ + idleThreshold?: number; + /** Callback when the model predicts the user is about to abandon the session */ + onPredictiveAbandonment?: () => void; + /** Callback when the session is predicted to expire soon and should be refreshed */ + onPredictiveRefresh?: () => void; +} + +/** + * Predictive Analytics for Session Management. + * Analyzes user activity patterns to predict idle times and proactively manage session state. + */ +export class SessionPredictor { + private activityTimestamps: number[] = []; + private maxSessionLength: number; + private idleThreshold: number; + private isTracking = false; + + private onPredictiveAbandonment?: () => void; + private onPredictiveRefresh?: () => void; + + private trackingInterval: ReturnType | null = null; + private sessionStartTime: number; + + constructor(options: SessionPredictorOptions = {}) { + this.maxSessionLength = options.maxSessionLength || 60 * 60 * 1000; // 1 hour + this.idleThreshold = options.idleThreshold || 15 * 60 * 1000; // 15 minutes + this.onPredictiveAbandonment = options.onPredictiveAbandonment; + this.onPredictiveRefresh = options.onPredictiveRefresh; + this.sessionStartTime = Date.now(); + } + + /** + * Starts tracking user activity to build predictive models. + */ + public startTracking(): void { + if (this.isTracking) return; + this.isTracking = true; + this.sessionStartTime = Date.now(); + + if (typeof window !== 'undefined') { + window.addEventListener('mousemove', this.trackActivity); + window.addEventListener('keydown', this.trackActivity); + window.addEventListener('click', this.trackActivity); + window.addEventListener('scroll', this.trackActivity); + } + + // Run prediction evaluation every 30 seconds + this.trackingInterval = setInterval(() => this.evaluatePredictions(), 30000); + } + + /** + * Stops tracking and cleans up event listeners. + */ + public stopTracking(): void { + this.isTracking = false; + + if (typeof window !== 'undefined') { + window.removeEventListener('mousemove', this.trackActivity); + window.removeEventListener('keydown', this.trackActivity); + window.removeEventListener('click', this.trackActivity); + window.removeEventListener('scroll', this.trackActivity); + } + + if (this.trackingInterval) { + clearInterval(this.trackingInterval); + this.trackingInterval = null; + } + } + + /** + * Log an activity timestamp. Debounced to avoid excessive array growth. + */ + private trackActivity = (): void => { + const now = Date.now(); + const lastActivity = this.activityTimestamps[this.activityTimestamps.length - 1]; + + // Only record activity if it's been more than 500ms since the last one + if (!lastActivity || now - lastActivity > 500) { + this.activityTimestamps.push(now); + + // Keep only the last 1000 timestamps to prevent memory leaks + if (this.activityTimestamps.length > 1000) { + this.activityTimestamps.shift(); + } + } + }; + + /** + * Evaluates current predictive metrics and triggers callbacks if thresholds are met. + */ + public evaluatePredictions(): void { + const now = Date.now(); + const probabilityIdle = this.predictIdleProbability(now); + + if (probabilityIdle > 0.8 && this.onPredictiveAbandonment) { + this.onPredictiveAbandonment(); + } + + const sessionProgress = (now - this.sessionStartTime) / this.maxSessionLength; + const shouldRefresh = this.predictSessionRefresh(sessionProgress, probabilityIdle); + + if (shouldRefresh && this.onPredictiveRefresh) { + this.onPredictiveRefresh(); + } + } + + /** + * Predicts the probability (0.0 to 1.0) that the user is currently idle or about to go idle + * based on the frequency and recency of their activity. + */ + public predictIdleProbability(currentTime: number = Date.now()): number { + if (this.activityTimestamps.length === 0) { + const timeSinceStart = currentTime - this.sessionStartTime; + return Math.min(timeSinceStart / this.idleThreshold, 1.0); + } + + const lastActivity = this.activityTimestamps[this.activityTimestamps.length - 1]; + const timeSinceLastActivity = currentTime - lastActivity; + + // Linear increase in probability of being idle + let probability = timeSinceLastActivity / this.idleThreshold; + + // Analyze pattern: if frequency of events in the last 5 minutes is very low, increase probability + const fiveMinutesAgo = currentTime - 5 * 60 * 1000; + const recentActivities = this.activityTimestamps.filter(t => t > fiveMinutesAgo).length; + + if (recentActivities < 5 && timeSinceLastActivity > 60000) { // less than 5 actions in 5 mins, and none in 1 min + probability += 0.2; + } + + return Math.max(0, Math.min(probability, 1.0)); + } + + /** + * Predicts whether the session should be proactively refreshed. + * Based on session progress and user engagement (inverse of idle probability). + */ + public predictSessionRefresh(sessionProgress: number, idleProbability: number): boolean { + // If the session is more than 75% complete and the user is highly engaged (< 30% idle probability), + // predict that they will need a session refresh soon to avoid disruption. + if (sessionProgress > 0.75 && idleProbability < 0.3) { + return true; + } + return false; + } + + /** + * Reset session tracking manually (e.g. after a refresh) + */ + public resetSession(): void { + this.sessionStartTime = Date.now(); + this.activityTimestamps = []; + } +} diff --git a/src/lib/auth/__tests__/SessionPredictor.test.ts b/src/lib/auth/__tests__/SessionPredictor.test.ts new file mode 100644 index 00000000..5e209ce8 --- /dev/null +++ b/src/lib/auth/__tests__/SessionPredictor.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SessionPredictor } from '../SessionPredictor'; + +describe('SessionPredictor', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should initialize with default values', () => { + const predictor = new SessionPredictor(); + expect(predictor).toBeDefined(); + // Initially probability should be 0 since time hasn't passed + expect(predictor.predictIdleProbability()).toBe(0); + }); + + it('should increase idle probability over time if no activity', () => { + const predictor = new SessionPredictor({ idleThreshold: 10000 }); + + // Advance time by 5 seconds (50% of threshold) + vi.advanceTimersByTime(5000); + expect(predictor.predictIdleProbability(Date.now())).toBe(0.5); + + // Advance to 10 seconds (100% of threshold) + vi.advanceTimersByTime(5000); + expect(predictor.predictIdleProbability(Date.now())).toBe(1.0); + }); + + it('should predict session refresh correctly', () => { + const predictor = new SessionPredictor(); + + // Session is 80% complete, highly engaged (idle probability 0.1) + expect(predictor.predictSessionRefresh(0.8, 0.1)).toBe(true); + + // Session is 50% complete, highly engaged + expect(predictor.predictSessionRefresh(0.5, 0.1)).toBe(false); + + // Session is 80% complete, completely idle (idle probability 0.9) + expect(predictor.predictSessionRefresh(0.8, 0.9)).toBe(false); + }); + + it('should trigger predictive callbacks when thresholds are met during evaluation', () => { + const onAbandonment = vi.fn(); + const onRefresh = vi.fn(); + + const predictor = new SessionPredictor({ + maxSessionLength: 60000, // 1 minute + idleThreshold: 10000, // 10 seconds + onPredictiveAbandonment: onAbandonment, + onPredictiveRefresh: onRefresh, + }); + + predictor.startTracking(); + + // Advance time by 9 seconds, probability should be 0.9 + // Since probability > 0.8, onPredictiveAbandonment should trigger + vi.advanceTimersByTime(9000); + predictor.evaluatePredictions(); + expect(onAbandonment).toHaveBeenCalled(); + + // Advance to 50 seconds (83% session completion) + // Add activity to reduce idle probability + vi.advanceTimersByTime(41000); + + // Simulate activity to reset idle probability + window.dispatchEvent(new Event('mousemove')); + + // After activity, probability is low, but session is > 75% complete + predictor.evaluatePredictions(); + expect(onRefresh).toHaveBeenCalled(); + + predictor.stopTracking(); + }); + + it('should reset session tracking manually', () => { + const predictor = new SessionPredictor({ idleThreshold: 10000 }); + + vi.advanceTimersByTime(10000); + expect(predictor.predictIdleProbability(Date.now())).toBe(1.0); + + predictor.resetSession(); + expect(predictor.predictIdleProbability(Date.now())).toBe(0); + }); +});