Implementert Firebase backend konfigurasjon og Cloud Functions

This commit is contained in:
steinhelge
2025-11-24 20:59:47 +01:00
parent 73f8775a46
commit f76b2e5c72
6 changed files with 535 additions and 0 deletions
+21
View File
@@ -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"
]
}
]
}
+47
View File
@@ -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": []
}
+110
View File
@@ -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;
}
}
}
+26
View File
@@ -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
}
+315
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
// 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;
});
+16
View File
@@ -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"
]
}