Skip to content

Commit 2352e80

Browse files
committed
feat: add server-side movement audit timestamps
Move createdBy tracking from frontend saga to a Cloud Function that sets createdBy, createdByName, createdAt, updatedBy, updatedByName, and updatedAt using server timestamps and user lookup. Display audit info to admins in MovementDetails.
1 parent afc1181 commit 2352e80

7 files changed

Lines changed: 460 additions & 27 deletions

File tree

functions/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const webhook = require('./webhook');
2424
const associatedMovementsTriggers = require('./associatedMovements/setAssociatedMovementsTriggers');
2525
const invoiceRecipientsTrigger = require('./invoiceRecipients/invoiceRecipientsTrigger');
2626
const updateArrivalPaymentStatus = require('./updateArrivalPaymentStatus');
27+
const movementAudit = require('./movementAudit');
2728

2829
exports.auth = auth;
2930
exports.generateSignInLink = generateSignInLink;
@@ -47,3 +48,8 @@ exports.enrichArrivalOnCreate = enrichMovements.enrichArrivalOnCreate;
4748
exports.enrichArrivalOnUpdate = enrichMovements.enrichArrivalOnUpdate;
4849

4950
exports.updateArrivalPaymentStatusOnCardPaymentUpdate = updateArrivalPaymentStatus.updateArrivalPaymentStatusOnCardPaymentUpdate;
51+
52+
exports.auditDepartureOnCreate = movementAudit.auditDepartureOnCreate;
53+
exports.auditArrivalOnCreate = movementAudit.auditArrivalOnCreate;
54+
exports.auditDepartureOnWrite = movementAudit.auditDepartureOnWrite;
55+
exports.auditArrivalOnWrite = movementAudit.auditArrivalOnWrite;

functions/movementAudit.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
const functions = require('firebase-functions');
2+
const admin = require('firebase-admin');
3+
4+
const AUDIT_FIELDS = ['createdBy', 'createdByName', 'createdAt', 'createdBy_orderKey', 'updatedBy', 'updatedByName', 'updatedAt'];
5+
6+
function stripAuditFields(obj) {
7+
if (!obj) return obj;
8+
const result = { ...obj };
9+
AUDIT_FIELDS.forEach(field => delete result[field]);
10+
return result;
11+
}
12+
13+
function getCollectionPath(movementType) {
14+
return movementType === 'departure' ? 'departures' : 'arrivals';
15+
}
16+
17+
async function getUserDetails(uid) {
18+
const userSnapshot = await admin.database()
19+
.ref('users')
20+
.child(uid)
21+
.once('value');
22+
23+
if (!userSnapshot.exists()) {
24+
return null;
25+
}
26+
27+
return userSnapshot.val();
28+
}
29+
30+
async function handleCreate(snapshot, context, movementType) {
31+
if (context.authType !== 'USER') {
32+
return null;
33+
}
34+
35+
const uid = context.auth.uid;
36+
const user = await getUserDetails(uid);
37+
const movement = snapshot.val();
38+
39+
const auditData = {
40+
createdAt: admin.database.ServerValue.TIMESTAMP,
41+
};
42+
43+
if (user) {
44+
auditData.createdBy = user.email || null;
45+
auditData.createdByName = [user.firstname, user.lastname].filter(Boolean).join(' ') || null;
46+
if (user.email && movement.negativeTimestamp) {
47+
auditData.createdBy_orderKey = user.email + '_' + String(Math.abs(movement.negativeTimestamp));
48+
}
49+
}
50+
51+
return admin.database().ref(getCollectionPath(movementType)).child(snapshot.ref.key).update(auditData);
52+
}
53+
54+
async function handleUpdate(change, context, movementType) {
55+
if (context.authType !== 'USER') {
56+
return null;
57+
}
58+
59+
if (!change.before.exists() || !change.after.exists()) {
60+
return null;
61+
}
62+
63+
const before = stripAuditFields(change.before.val());
64+
const after = stripAuditFields(change.after.val());
65+
66+
if (JSON.stringify(before) === JSON.stringify(after)) {
67+
return null;
68+
}
69+
70+
const uid = context.auth.uid;
71+
const user = await getUserDetails(uid);
72+
73+
const auditData = {
74+
updatedAt: admin.database.ServerValue.TIMESTAMP,
75+
};
76+
77+
if (user) {
78+
auditData.updatedBy = user.email || null;
79+
auditData.updatedByName = [user.firstname, user.lastname].filter(Boolean).join(' ') || null;
80+
}
81+
82+
return admin.database().ref(getCollectionPath(movementType)).child(change.after.ref.key).update(auditData);
83+
}
84+
85+
const instance = functions.config().rtdb.instance;
86+
87+
exports.auditDepartureOnCreate = functions.region('europe-west1').database
88+
.instance(instance)
89+
.ref('/departures/{departureId}')
90+
.onCreate((snapshot, context) => handleCreate(snapshot, context, 'departure'));
91+
92+
exports.auditArrivalOnCreate = functions.region('europe-west1').database
93+
.instance(instance)
94+
.ref('/arrivals/{arrivalId}')
95+
.onCreate((snapshot, context) => handleCreate(snapshot, context, 'arrival'));
96+
97+
exports.auditDepartureOnWrite = functions.region('europe-west1').database
98+
.instance(instance)
99+
.ref('/departures/{departureId}')
100+
.onWrite((change, context) => handleUpdate(change, context, 'departure'));
101+
102+
exports.auditArrivalOnWrite = functions.region('europe-west1').database
103+
.instance(instance)
104+
.ref('/arrivals/{arrivalId}')
105+
.onWrite((change, context) => handleUpdate(change, context, 'arrival'));

0 commit comments

Comments
 (0)