diff --git a/functions/index.js b/functions/index.js index bed98513..30c186d2 100644 --- a/functions/index.js +++ b/functions/index.js @@ -24,6 +24,7 @@ const webhook = require('./webhook'); const associatedMovementsTriggers = require('./associatedMovements/setAssociatedMovementsTriggers'); const invoiceRecipientsTrigger = require('./invoiceRecipients/invoiceRecipientsTrigger'); const updateArrivalPaymentStatus = require('./updateArrivalPaymentStatus'); +const movementAudit = require('./movementAudit'); exports.auth = auth; exports.generateSignInLink = generateSignInLink; @@ -47,3 +48,8 @@ exports.enrichArrivalOnCreate = enrichMovements.enrichArrivalOnCreate; exports.enrichArrivalOnUpdate = enrichMovements.enrichArrivalOnUpdate; exports.updateArrivalPaymentStatusOnCardPaymentUpdate = updateArrivalPaymentStatus.updateArrivalPaymentStatusOnCardPaymentUpdate; + +exports.auditDepartureOnCreate = movementAudit.auditDepartureOnCreate; +exports.auditArrivalOnCreate = movementAudit.auditArrivalOnCreate; +exports.auditDepartureOnWrite = movementAudit.auditDepartureOnWrite; +exports.auditArrivalOnWrite = movementAudit.auditArrivalOnWrite; diff --git a/functions/movementAudit.js b/functions/movementAudit.js new file mode 100644 index 00000000..deece48e --- /dev/null +++ b/functions/movementAudit.js @@ -0,0 +1,105 @@ +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); + +const AUDIT_FIELDS = ['createdBy', 'createdByName', 'createdAt', 'createdBy_orderKey', 'updatedBy', 'updatedByName', 'updatedAt']; + +function stripAuditFields(obj) { + if (!obj) return obj; + const result = { ...obj }; + AUDIT_FIELDS.forEach(field => delete result[field]); + return result; +} + +function getCollectionPath(movementType) { + return movementType === 'departure' ? 'departures' : 'arrivals'; +} + +async function getUserDetails(uid) { + const userSnapshot = await admin.database() + .ref('users') + .child(uid) + .once('value'); + + if (!userSnapshot.exists()) { + return null; + } + + return userSnapshot.val(); +} + +async function handleCreate(snapshot, context, movementType) { + if (context.authType !== 'USER') { + return null; + } + + const uid = context.auth.uid; + const user = await getUserDetails(uid); + const movement = snapshot.val(); + + const auditData = { + createdAt: admin.database.ServerValue.TIMESTAMP, + }; + + if (user) { + auditData.createdBy = user.email || null; + auditData.createdByName = [user.firstname, user.lastname].filter(Boolean).join(' ') || null; + if (user.email && movement.negativeTimestamp) { + auditData.createdBy_orderKey = user.email + '_' + String(Math.abs(movement.negativeTimestamp)); + } + } + + return admin.database().ref(getCollectionPath(movementType)).child(snapshot.ref.key).update(auditData); +} + +async function handleUpdate(change, context, movementType) { + if (context.authType !== 'USER') { + return null; + } + + if (!change.before.exists() || !change.after.exists()) { + return null; + } + + const before = stripAuditFields(change.before.val()); + const after = stripAuditFields(change.after.val()); + + if (JSON.stringify(before) === JSON.stringify(after)) { + return null; + } + + const uid = context.auth.uid; + const user = await getUserDetails(uid); + + const auditData = { + updatedAt: admin.database.ServerValue.TIMESTAMP, + }; + + if (user) { + auditData.updatedBy = user.email || null; + auditData.updatedByName = [user.firstname, user.lastname].filter(Boolean).join(' ') || null; + } + + return admin.database().ref(getCollectionPath(movementType)).child(change.after.ref.key).update(auditData); +} + +const instance = functions.config().rtdb.instance; + +exports.auditDepartureOnCreate = functions.region('europe-west1').database + .instance(instance) + .ref('/departures/{departureId}') + .onCreate((snapshot, context) => handleCreate(snapshot, context, 'departure')); + +exports.auditArrivalOnCreate = functions.region('europe-west1').database + .instance(instance) + .ref('/arrivals/{arrivalId}') + .onCreate((snapshot, context) => handleCreate(snapshot, context, 'arrival')); + +exports.auditDepartureOnWrite = functions.region('europe-west1').database + .instance(instance) + .ref('/departures/{departureId}') + .onWrite((change, context) => handleUpdate(change, context, 'departure')); + +exports.auditArrivalOnWrite = functions.region('europe-west1').database + .instance(instance) + .ref('/arrivals/{arrivalId}') + .onWrite((change, context) => handleUpdate(change, context, 'arrival')); diff --git a/functions/movementAudit.spec.js b/functions/movementAudit.spec.js new file mode 100644 index 00000000..131bcfa8 --- /dev/null +++ b/functions/movementAudit.spec.js @@ -0,0 +1,331 @@ +'use strict'; + +const mockCapturedHandlers = {}; + +jest.mock('firebase-functions', () => { + const makeRef = (handlers) => (path) => ({ + onCreate: jest.fn(handler => { + const key = `onCreate:${path}`; + handlers[key] = handler; + }), + onWrite: jest.fn(handler => { + const key = `onWrite:${path}`; + handlers[key] = handler; + }) + }); + + const mock = { + config: jest.fn(() => ({ rtdb: { instance: 'test-instance' } })), + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + log: jest.fn() + }, + database: { + instance: jest.fn(() => ({ + ref: makeRef(mockCapturedHandlers) + })) + } + }; + mock.region = jest.fn(() => mock); + return mock; +}); + +const mockOnce = jest.fn(); +const mockUpdate = jest.fn(); +const mockRef = jest.fn(); +const mockChild = jest.fn(); + +jest.mock('firebase-admin', () => ({ + database: Object.assign( + jest.fn(() => ({ + ref: mockRef + })), + { + ServerValue: { + TIMESTAMP: 'SERVER_TIMESTAMP' + } + } + ) +})); + +require('./movementAudit'); + +const buildRefChain = (onceImpl, updateImpl) => { + mockChild.mockReturnValue({ + once: onceImpl || mockOnce, + update: updateImpl || mockUpdate + }); + mockRef.mockReturnValue({ + child: mockChild + }); +}; + +const makeSnapshot = (key, val) => ({ + ref: { key }, + val: () => val, + exists: () => val !== null +}); + +const makeChange = (beforeKey, beforeVal, afterKey, afterVal) => ({ + before: { + exists: () => beforeVal !== null, + val: () => beforeVal, + ref: { key: beforeKey } + }, + after: { + exists: () => afterVal !== null, + val: () => afterVal, + ref: { key: afterKey } + } +}); + +describe('movementAudit', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUpdate.mockResolvedValue(undefined); + }); + + describe('onCreate triggers', () => { + const departureOnCreate = mockCapturedHandlers['onCreate:/departures/{departureId}']; + const arrivalOnCreate = mockCapturedHandlers['onCreate:/arrivals/{arrivalId}']; + + it('should register onCreate triggers', () => { + expect(departureOnCreate).toBeDefined(); + expect(arrivalOnCreate).toBeDefined(); + }); + + it('should set audit fields on departure create for USER auth', async () => { + const snapshot = makeSnapshot('dep1', { + immatriculation: 'HBABC', + negativeTimestamp: -1476021600000 + }); + + const context = { + authType: 'USER', + auth: { uid: 'user123' } + }; + + const userSnapshot = { + exists: () => true, + val: () => ({ email: 'pilot@example.com', firstname: 'John', lastname: 'Doe' }) + }; + + buildRefChain( + jest.fn().mockResolvedValue(userSnapshot), + mockUpdate + ); + + await departureOnCreate(snapshot, context); + + expect(mockRef).toHaveBeenCalledWith('users'); + expect(mockChild).toHaveBeenCalledWith('user123'); + expect(mockRef).toHaveBeenCalledWith('departures'); + expect(mockChild).toHaveBeenCalledWith('dep1'); + expect(mockUpdate).toHaveBeenCalledWith({ + createdAt: 'SERVER_TIMESTAMP', + createdBy: 'pilot@example.com', + createdByName: 'John Doe', + createdBy_orderKey: 'pilot@example.com_1476021600000' + }); + }); + + it('should set audit fields on arrival create for USER auth', async () => { + const snapshot = makeSnapshot('arr1', { + immatriculation: 'HBXYZ', + negativeTimestamp: -1476021600000 + }); + + const context = { + authType: 'USER', + auth: { uid: 'user456' } + }; + + const userSnapshot = { + exists: () => true, + val: () => ({ email: 'pilot2@example.com', firstname: 'Jane', lastname: 'Smith' }) + }; + + buildRefChain( + jest.fn().mockResolvedValue(userSnapshot), + mockUpdate + ); + + await arrivalOnCreate(snapshot, context); + + expect(mockRef).toHaveBeenCalledWith('arrivals'); + expect(mockChild).toHaveBeenCalledWith('arr1'); + expect(mockUpdate).toHaveBeenCalledWith({ + createdAt: 'SERVER_TIMESTAMP', + createdBy: 'pilot2@example.com', + createdByName: 'Jane Smith', + createdBy_orderKey: 'pilot2@example.com_1476021600000' + }); + }); + + it('should skip if authType is not USER', async () => { + const snapshot = makeSnapshot('dep1', { immatriculation: 'HBABC' }); + const context = { authType: 'ADMIN' }; + + const result = await departureOnCreate(snapshot, context); + + expect(result).toBeNull(); + expect(mockRef).not.toHaveBeenCalled(); + }); + + it('should set createdAt even if user not found', async () => { + const snapshot = makeSnapshot('dep1', { + immatriculation: 'HBABC', + negativeTimestamp: -1476021600000 + }); + + const context = { + authType: 'USER', + auth: { uid: 'unknown-user' } + }; + + const userSnapshot = { + exists: () => false, + val: () => null + }; + + buildRefChain( + jest.fn().mockResolvedValue(userSnapshot), + mockUpdate + ); + + await departureOnCreate(snapshot, context); + + expect(mockUpdate).toHaveBeenCalledWith({ + createdAt: 'SERVER_TIMESTAMP' + }); + }); + + it('should set createdBy to null if user found but email is undefined', async () => { + const snapshot = makeSnapshot('dep1', { + immatriculation: 'HBABC', + negativeTimestamp: -1476021600000 + }); + + const context = { + authType: 'USER', + auth: { uid: 'user-no-email' } + }; + + const userSnapshot = { + exists: () => true, + val: () => ({ firstname: 'John', lastname: 'Doe' }) + }; + + buildRefChain( + jest.fn().mockResolvedValue(userSnapshot), + mockUpdate + ); + + await departureOnCreate(snapshot, context); + + expect(mockUpdate).toHaveBeenCalledWith({ + createdAt: 'SERVER_TIMESTAMP', + createdBy: null, + createdByName: 'John Doe' + }); + }); + }); + + describe('onWrite triggers (update)', () => { + const departureOnWrite = mockCapturedHandlers['onWrite:/departures/{departureId}']; + const arrivalOnWrite = mockCapturedHandlers['onWrite:/arrivals/{arrivalId}']; + + it('should register onWrite triggers', () => { + expect(departureOnWrite).toBeDefined(); + expect(arrivalOnWrite).toBeDefined(); + }); + + it('should set updatedBy/updatedAt on departure update for USER auth', async () => { + const change = makeChange('dep1', { + immatriculation: 'HBABC', + location: 'LSZT' + }, 'dep1', { + immatriculation: 'HBABC', + location: 'LSZH' + }); + + const context = { + authType: 'USER', + auth: { uid: 'user123' } + }; + + const userSnapshot = { + exists: () => true, + val: () => ({ email: 'pilot@example.com', firstname: 'John', lastname: 'Doe' }) + }; + + buildRefChain( + jest.fn().mockResolvedValue(userSnapshot), + mockUpdate + ); + + await departureOnWrite(change, context); + + expect(mockUpdate).toHaveBeenCalledWith({ + updatedAt: 'SERVER_TIMESTAMP', + updatedBy: 'pilot@example.com', + updatedByName: 'John Doe' + }); + }); + + it('should skip if authType is not USER', async () => { + const change = makeChange('dep1', { immatriculation: 'HBABC' }, 'dep1', { immatriculation: 'HBXYZ' }); + const context = { authType: 'ADMIN' }; + + const result = await departureOnWrite(change, context); + + expect(result).toBeNull(); + expect(mockRef).not.toHaveBeenCalled(); + }); + + it('should skip if before does not exist (create case)', async () => { + const change = makeChange('dep1', null, 'dep1', { immatriculation: 'HBABC' }); + const context = { authType: 'USER', auth: { uid: 'user123' } }; + + const result = await departureOnWrite(change, context); + + expect(result).toBeNull(); + expect(mockRef).not.toHaveBeenCalled(); + }); + + it('should skip if after does not exist (delete case)', async () => { + const change = makeChange('dep1', { immatriculation: 'HBABC' }, 'dep1', null); + const context = { authType: 'USER', auth: { uid: 'user123' } }; + + const result = await departureOnWrite(change, context); + + expect(result).toBeNull(); + expect(mockRef).not.toHaveBeenCalled(); + }); + + it('should skip if only audit fields changed (prevent infinite loop)', async () => { + const change = makeChange('dep1', { + immatriculation: 'HBABC', + location: 'LSZT' + }, 'dep1', { + immatriculation: 'HBABC', + location: 'LSZT', + updatedBy: 'pilot@example.com', + updatedByName: 'John Doe', + updatedAt: 1234567890 + }); + + const context = { + authType: 'USER', + auth: { uid: 'user123' } + }; + + const result = await departureOnWrite(change, context); + + expect(result).toBeNull(); + expect(mockRef).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/MovementList/MovementDetails.tsx b/src/components/MovementList/MovementDetails.tsx index 76105454..21760cb5 100644 --- a/src/components/MovementList/MovementDetails.tsx +++ b/src/components/MovementList/MovementDetails.tsx @@ -127,6 +127,14 @@ class MovementDetails extends React.PureComponent { ) } + {props.isAdmin && (props.data.createdBy || props.data.createdAt) && ( + + + {props.data.createdAt && } + {props.data.updatedBy && } + {props.data.updatedAt && } + + )} {props.data.type === 'arrival' && props.data.landingFeeTotal !== undefined && ( diff --git a/src/locales/de.json b/src/locales/de.json index 18101fb8..78d48063 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -125,7 +125,12 @@ "referenceNumber": "Referenznummer", "landingFee": "Landetaxe", "paymentMethod": "Zahlungsart", - "mtow": "MTOW" + "mtow": "MTOW", + "audit": "Änderungsprotokoll", + "createdBy": "Erstellt von", + "createdAt": "Erstellt am", + "updatedBy": "Geändert von", + "updatedAt": "Geändert am" }, "filter": { "label": "Filter", diff --git a/src/modules/movements/sagas.spec.ts b/src/modules/movements/sagas.spec.ts index 88c2fa0d..40384116 100644 --- a/src/modules/movements/sagas.spec.ts +++ b/src/modules/movements/sagas.spec.ts @@ -540,20 +540,8 @@ describe('modules', () => { negativeTimestamp: -1476021600000, }; - expect(generator.next(formValues).value).toEqual(select(sagas.authSelector)); - - const auth = { - email: 'pilot@example.com' - } - - const expectedMovementForFirebase = { - ...formValuesForFirebase, - createdBy: 'pilot@example.com', - createdBy_orderKey: 'pilot@example.com_8523978399999' - }; - - expect(generator.next(auth).value) - .toEqual(call(remote.saveMovement, '/departures', undefined, expectedMovementForFirebase)); + expect(generator.next(formValues).value) + .toEqual(call(remote.saveMovement, '/departures', undefined, formValuesForFirebase)); const key = 'new-departure-key'; @@ -737,11 +725,7 @@ describe('modules', () => { time: '16:00', }; - expect(generator.next(formValues).value).toEqual(select(sagas.authSelector)); - - const auth = { email: 'pilot@example.com' }; - - expect(generator.next(auth).value).toMatchObject({ type: 'CALL' }); + expect(generator.next(formValues).value).toMatchObject({ type: 'CALL' }); const error = new Error('save failed'); expect(generator.throw(error).value).toEqual(put(actions.saveMovementFailed(error))); diff --git a/src/modules/movements/sagas.ts b/src/modules/movements/sagas.ts index 9290c06d..24884e72 100644 --- a/src/modules/movements/sagas.ts +++ b/src/modules/movements/sagas.ts @@ -1,5 +1,5 @@ import {onChildAdded, onChildChanged, onChildRemoved} from 'firebase/database'; -import {getPagination, toOrderKey} from './pagination'; +import {getPagination} from './pagination'; import {all, call, fork, put, select, takeEvery, takeLatest} from 'redux-saga/effects' import createChannel, {monitor} from '../../util/createChannel'; import * as actions from './actions'; @@ -456,7 +456,6 @@ export function* editMovement(action: any) { export function* saveMovement() { const values = yield select(wizardFormValuesSelector); - const auth = yield select(authSelector); const movement = localToFirebase(values); @@ -468,11 +467,6 @@ export function* saveMovement() { delete movement.type; delete movement.associatedMovement; - if (auth.email) { - movement.createdBy = auth.email - movement.createdBy_orderKey = toOrderKey(auth.email, movement.negativeTimestamp as number) - } - try { key = yield call(remote.saveMovement, path, key, movement); yield put(actions.saveMovementSuccess(key, values))