Implementert Firebase backend konfigurasjon og Cloud Functions
This commit is contained in:
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user