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