diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..c7ce3f0 --- /dev/null +++ b/firebase.json @@ -0,0 +1,21 @@ +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log" + ], + "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" run build" + ] + } + ] +} diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..2fc32f7 --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,47 @@ +{ + "indexes": [ + { + "collectionGroup": "time_registrations", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "startTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "deviations", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "detectedAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "deviations", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "organizationId", + "order": "ASCENDING" + }, + { + "fieldPath": "detectedAt", + "order": "DESCENDING" + } + ] + } + ], + "fieldOverrides": [] +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..d4bbdb8 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,110 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + + // ===== HJELPEFUNKSJONER ===== + + function isAuthenticated() { + return request.auth != null; + } + + function getUserData() { + return get(/databases/$(database)/documents/users/$(request.auth.uid)).data; + } + + function isAdmin() { + return isAuthenticated() && + getUserData().role in ['admin', 'systemAdmin']; + } + + function isSystemAdmin() { + return isAuthenticated() && + getUserData().role == 'systemAdmin'; + } + + function belongsToSameOrg(userId) { + return getUserData().organizationId == + get(/databases/$(database)/documents/users/$(userId)).data.organizationId; + } + + function isOwner(userId) { + return isAuthenticated() && request.auth.uid == userId; + } + + // ===== BRUKERE ===== + + match /users/{userId} { + // Les: Egen bruker eller admin i samme org + allow read: if isOwner(userId) || + (isAdmin() && belongsToSameOrg(userId)); + + // Opprett: Kun ved registrering (håndteres av auth) + allow create: if isOwner(userId) || isAdmin(); + + // Oppdater: Egen bruker (begrenset) eller admin + allow update: if isOwner(userId) || isAdmin(); + + // Slett: Kun admin + allow delete: if isAdmin(); + } + + // ===== TIMEREGISTRERINGER ===== + + match /time_registrations/{registrationId} { + // Les: Egen registrering eller admin i samme org + allow read: if isAuthenticated() && + (resource.data.userId == request.auth.uid || + (isAdmin() && belongsToSameOrg(resource.data.userId))); + + // Opprett: Kun egne registreringer + allow create: if isAuthenticated() && + request.resource.data.userId == request.auth.uid && + request.resource.data.organizationId == getUserData().organizationId; + + // Oppdater: Egen registrering eller admin + allow update: if isAuthenticated() && + (resource.data.userId == request.auth.uid || isAdmin()); + + // Slett: Kun admin + allow delete: if isAdmin(); + } + + // ===== TARIFFPROFILER ===== + + match /tariff_profiles/{profileId} { + // Les: Alle autentiserte brukere + allow read: if isAuthenticated(); + + // Skriv: Kun admin + allow write: if isAdmin(); + } + + // ===== AVVIK ===== + + match /deviations/{deviationId} { + // Les: Egen avvik eller admin i samme org + allow read: if isAuthenticated() && + (resource.data.userId == request.auth.uid || + (isAdmin() && belongsToSameOrg(resource.data.userId))); + + // Opprett: Kun Cloud Functions + allow create: if false; + + // Oppdater: Admin (for kvittering) + allow update: if isAdmin(); + + // Slett: Admin + allow delete: if isAdmin(); + } + + // ===== AUDIT LOGS ===== + + match /audit_logs/{logId} { + // Les: Kun systemadmin + allow read: if isSystemAdmin(); + + // Skriv: Kun Cloud Functions + allow write: if false; + } + } +} diff --git a/functions/package.json b/functions/package.json new file mode 100644 index 0000000..2ba2a9d --- /dev/null +++ b/functions/package.json @@ -0,0 +1,26 @@ +{ + "name": "timereg-functions", + "version": "1.0.0", + "description": "Cloud Functions for TimeReg - AML compliance rule engine", + "main": "lib/index.js", + "scripts": { + "build": "tsc", + "serve": "npm run build && firebase emulators:start --only functions", + "shell": "npm run build && firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "18" + }, + "dependencies": { + "firebase-admin": "^12.0.0", + "firebase-functions": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "typescript": "^5.3.0" + }, + "private": true +} diff --git a/functions/src/index.ts b/functions/src/index.ts new file mode 100644 index 0000000..a389675 --- /dev/null +++ b/functions/src/index.ts @@ -0,0 +1,315 @@ +import * as functions from 'firebase-functions'; +import * as admin from 'firebase-admin'; + +admin.initializeApp(); + +const db = admin.firestore(); + +// ===== TYPER ===== + +interface TariffRules { + maxDailyHours: number; + maxWeeklyHours: number; + minDailyRest: number; // minutter + minWeeklyRest: number; // minutter + useAverageCalculation: boolean; + averagePeriodWeeks?: number; + maxAverageHours?: number; +} + +interface TimeRegistration { + id: string; + userId: string; + organizationId: string; + startTime: admin.firestore.Timestamp; + endTime: admin.firestore.Timestamp | null; + duration: number; // minutter + breaks: Array<{ + startTime: admin.firestore.Timestamp; + endTime: admin.firestore.Timestamp; + duration: number; + }>; +} + +// ===== CLOUD FUNCTION: Evaluer timeregistrering ===== + +export const evaluateTimeRegistration = functions.firestore + .document('time_registrations/{registrationId}') + .onWrite(async (change, context) => { + // Ignorer sletting + if (!change.after.exists) { + return null; + } + + const registration = change.after.data() as TimeRegistration; + + // Ignorer hvis ikke fullført + if (!registration.endTime) { + return null; + } + + const userId = registration.userId; + + try { + // Hent brukerens tariffprofil + const userDoc = await db.collection('users').doc(userId).get(); + if (!userDoc.exists) { + console.error('Bruker ikke funnet:', userId); + return null; + } + + const userData = userDoc.data()!; + const tariffProfileId = userData.tariffProfileId; + + const profileDoc = await db.collection('tariff_profiles').doc(tariffProfileId).get(); + if (!profileDoc.exists) { + console.error('Tariffprofil ikke funnet:', tariffProfileId); + return null; + } + + const rules = profileDoc.data()!.rules as TariffRules; + + // Kjør evalueringer + await Promise.all([ + checkDailyLimit(userId, registration, rules), + checkWeeklyLimit(userId, registration, rules), + checkDailyRest(userId, registration, rules), + ]); + + console.log('Evaluering fullført for registrering:', context.params.registrationId); + return null; + } catch (error) { + console.error('Feil ved evaluering:', error); + return null; + } + }); + +// ===== SJEKK DAGLIG GRENSE ===== + +async function checkDailyLimit( + userId: string, + registration: TimeRegistration, + rules: TariffRules +): Promise { + const regDate = registration.startTime.toDate(); + const startOfDay = new Date(regDate.getFullYear(), regDate.getMonth(), regDate.getDate()); + const endOfDay = new Date(regDate.getFullYear(), regDate.getMonth(), regDate.getDate(), 23, 59, 59); + + // Hent alle registreringer for dagen + const snapshot = await db.collection('time_registrations') + .where('userId', '==', userId) + .where('startTime', '>=', admin.firestore.Timestamp.fromDate(startOfDay)) + .where('startTime', '<=', admin.firestore.Timestamp.fromDate(endOfDay)) + .get(); + + let totalMinutes = 0; + snapshot.docs.forEach(doc => { + const data = doc.data(); + if (data.endTime) { + const netMinutes = data.duration - (data.breaks || []).reduce((sum: number, b: any) => sum + b.duration, 0); + totalMinutes += netMinutes; + } + }); + + const totalHours = totalMinutes / 60; + const maxHours = rules.maxDailyHours; + + if (totalHours > maxHours) { + await createDeviation( + userId, + registration.organizationId, + registration.id, + 'dailyMax', + 'violation', + `Daglig arbeidstid overskredet: ${totalHours.toFixed(1)} timer (maks ${maxHours} timer)`, + { + actualValue: totalHours, + limitValue: maxHours, + period: startOfDay.toISOString().split('T')[0], + } + ); + } +} + +// ===== SJEKK UKENTLIG GRENSE ===== + +async function checkWeeklyLimit( + userId: string, + registration: TimeRegistration, + rules: TariffRules +): Promise { + const regDate = registration.startTime.toDate(); + + // Finn mandag i uken + const dayOfWeek = regDate.getDay(); + const diff = regDate.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); + const monday = new Date(regDate.getFullYear(), regDate.getMonth(), diff); + monday.setHours(0, 0, 0, 0); + + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + sunday.setHours(23, 59, 59, 999); + + // Hent alle registreringer for uken + const snapshot = await db.collection('time_registrations') + .where('userId', '==', userId) + .where('startTime', '>=', admin.firestore.Timestamp.fromDate(monday)) + .where('startTime', '<=', admin.firestore.Timestamp.fromDate(sunday)) + .get(); + + let totalMinutes = 0; + snapshot.docs.forEach(doc => { + const data = doc.data(); + if (data.endTime) { + const netMinutes = data.duration - (data.breaks || []).reduce((sum: number, b: any) => sum + b.duration, 0); + totalMinutes += netMinutes; + } + }); + + const totalHours = totalMinutes / 60; + const maxHours = rules.maxWeeklyHours; + + if (totalHours > maxHours) { + const weekNumber = getWeekNumber(monday); + await createDeviation( + userId, + registration.organizationId, + registration.id, + 'weeklyMax', + 'violation', + `Ukentlig arbeidstid overskredet: ${totalHours.toFixed(1)} timer (maks ${maxHours} timer)`, + { + actualValue: totalHours, + limitValue: maxHours, + period: `${regDate.getFullYear()}-W${weekNumber}`, + } + ); + } +} + +// ===== SJEKK DAGLIG HVILETID ===== + +async function checkDailyRest( + userId: string, + registration: TimeRegistration, + rules: TariffRules +): Promise { + if (!registration.endTime) return; + + const endTime = registration.endTime.toDate(); + + // Finn neste registrering + const nextSnapshot = await db.collection('time_registrations') + .where('userId', '==', userId) + .where('startTime', '>', registration.endTime) + .orderBy('startTime', 'asc') + .limit(1) + .get(); + + if (nextSnapshot.empty) return; + + const nextReg = nextSnapshot.docs[0].data(); + const nextStartTime = nextReg.startTime.toDate(); + + const restMinutes = (nextStartTime.getTime() - endTime.getTime()) / (1000 * 60); + const minRestMinutes = rules.minDailyRest; + + if (restMinutes < minRestMinutes) { + const restHours = restMinutes / 60; + const minRestHours = minRestMinutes / 60; + + await createDeviation( + userId, + registration.organizationId, + registration.id, + 'dailyRest', + 'violation', + `Daglig hviletid for kort: ${restHours.toFixed(1)} timer (minimum ${minRestHours} timer)`, + { + actualValue: restHours, + limitValue: minRestHours, + } + ); + } +} + +// ===== OPPRETT AVVIK ===== + +async function createDeviation( + userId: string, + organizationId: string, + timeRegistrationId: string, + type: string, + severity: string, + description: string, + metadata: any +): Promise { + // Sjekk om avvik allerede eksisterer + const existing = await db.collection('deviations') + .where('userId', '==', userId) + .where('timeRegistrationId', '==', timeRegistrationId) + .where('type', '==', type) + .get(); + + if (!existing.empty) { + console.log('Avvik eksisterer allerede for denne registreringen og typen'); + return; + } + + await db.collection('deviations').add({ + userId, + organizationId, + timeRegistrationId, + type, + severity, + description, + detectedAt: admin.firestore.FieldValue.serverTimestamp(), + acknowledgedAt: null, + acknowledgedBy: null, + metadata, + }); + + console.log('Avvik opprettet:', type, 'for bruker:', userId); +} + +// ===== HJELPEFUNKSJONER ===== + +function getWeekNumber(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); +} + +// ===== AUDIT LOG ===== + +export const createAuditLog = functions.firestore + .document('{collection}/{documentId}') + .onUpdate(async (change, context) => { + const collection = context.params.collection; + + // Kun logg for spesifikke collections + if (!['time_registrations', 'users', 'tariff_profiles'].includes(collection)) { + return null; + } + + const before = change.before.data(); + const after = change.after.data(); + + await db.collection('audit_logs').add({ + userId: after.lastModifiedBy || after.userId || 'system', + organizationId: after.organizationId || 'unknown', + action: 'update', + entityType: collection === 'time_registrations' ? 'timeRegistration' : + collection === 'users' ? 'user' : 'tariffProfile', + entityId: context.params.documentId, + changes: { + before, + after, + }, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + }); + + return null; + }); diff --git a/functions/tsconfig.json b/functions/tsconfig.json new file mode 100644 index 0000000..8fb6498 --- /dev/null +++ b/functions/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017", + "esModuleInterop": true + }, + "compileOnSave": true, + "include": [ + "src" + ] +}