Skip to content

Commit e7e2a23

Browse files
authored
Merge pull request #4045 from LiteFarmOrg/LF-5145/Record_offline_logs
LF-5145: Record offline logs
2 parents d86a4c9 + e291f77 commit e7e2a23

16 files changed

Lines changed: 2508 additions & 688 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2026 LiteFarm.org
3+
* This file is part of LiteFarm.
4+
*
5+
* LiteFarm is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* LiteFarm is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
14+
*/
15+
16+
/**
17+
* @param { import("knex").Knex } knex
18+
* @returns { Promise<void> }
19+
*/
20+
export const up = async function (knex) {
21+
return knex.schema.createTable('offline_event_log', function (table) {
22+
table.increments('id').primary();
23+
table.uuid('session_id').notNullable();
24+
table.string('event_name').notNullable();
25+
table.integer('status_code');
26+
table.string('url');
27+
table.integer('country_id').references('id').inTable('countries');
28+
table.string('network');
29+
table.string('browser');
30+
table.string('browser_version');
31+
table.string('device_vendor');
32+
table.string('os');
33+
table.string('device_model');
34+
table.string('app_version');
35+
table.string('log_status');
36+
table.dateTime('event_at').notNullable();
37+
table.dateTime('went_online_at');
38+
table.unique(['session_id', 'event_name', 'event_at']);
39+
});
40+
};
41+
42+
/**
43+
* @param { import("knex").Knex } knex
44+
* @returns { Promise<void> }
45+
*/
46+
export const down = async function (knex) {
47+
return knex.schema.dropTable('offline_event_log');
48+
};

packages/api/package-lock.json

Lines changed: 1887 additions & 685 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"@types/jest": "^30.0.0",
128128
"@types/jwk-to-pem": "^2.0.3",
129129
"@types/node": "^22.5.4",
130+
"@types/ua-parser-js": "^0.7.39",
130131
"eslint": "^9.10.0",
131132
"eslint-config-prettier": "^10.0.1",
132133
"eslint-plugin-json": "^4.0.1",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2026 LiteFarm.org
3+
* This file is part of LiteFarm.
4+
*
5+
* LiteFarm is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* LiteFarm is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
14+
*/
15+
16+
import { Response } from 'express';
17+
import parser from 'ua-parser-js';
18+
import offlineEventLogModel from '../models/offlineEventLogModel.js';
19+
import { HttpError, LiteFarmRequest } from '../types.js';
20+
import { OfflineEventLogReqBody } from '../middleware/validation/checkOfflineLogs.js';
21+
22+
const offlineEventLogController = {
23+
addOfflineEventLog() {
24+
return async (
25+
req: LiteFarmRequest<unknown, unknown, unknown, OfflineEventLogReqBody>,
26+
res: Response,
27+
) => {
28+
try {
29+
const { logs, went_online_at, app_version, network, session_id } = req.body;
30+
31+
const wentOnlineAt = went_online_at && new Date(went_online_at).toISOString();
32+
const ua = parser(req.headers['user-agent']);
33+
34+
const commonInfo = {
35+
country_id: res.locals.country_id,
36+
network,
37+
browser: ua.browser.name,
38+
browser_version: ua.browser.version,
39+
device_vendor: ua.device.vendor,
40+
os: ua.os.name,
41+
device_model: ua.device.model,
42+
log_status: res.locals.log_status,
43+
app_version,
44+
went_online_at: wentOnlineAt,
45+
};
46+
47+
const records = logs.map(({ event_name, event_at, status_code, url }, index) => {
48+
const isFirst = index === 0;
49+
50+
return {
51+
session_id,
52+
event_name,
53+
status_code,
54+
url,
55+
event_at: event_at && new Date(event_at).toISOString(),
56+
...(isFirst ? commonInfo : {}),
57+
};
58+
});
59+
60+
await offlineEventLogModel.query().insert(records);
61+
62+
return res.status(201).send();
63+
} catch (error: unknown) {
64+
console.error(error);
65+
66+
const err = error as HttpError;
67+
const status = err.status || err.code || 500;
68+
return res.status(status).json({
69+
error: err.message || err,
70+
});
71+
}
72+
};
73+
},
74+
};
75+
76+
export default offlineEventLogController;

packages/api/src/middleware/acl/checkJwt.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const checkJwt = expressjwt({
2727
'/user_farm/accept_invitation',
2828
'/notification_user/subscribe',
2929
'/irrigation_prescription_request/scheduler',
30+
'/offline_event_log',
3031
/\/time_notification\//i,
3132
/\/farm\/utc_offset_by_range\//i,
3233
/\/api-docs\/*/,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright 2026 LiteFarm.org
3+
* This file is part of LiteFarm.
4+
*
5+
* LiteFarm is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* LiteFarm is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
14+
*/
15+
16+
import { NextFunction, Response } from 'express';
17+
import jwt from 'jsonwebtoken';
18+
import { tokenType } from '../../util/jwt.js';
19+
import userModel from '../../models/userModel.js';
20+
import farmModel from '../../models/farmModel.js';
21+
import userFarmModel from '../../models/userFarmModel.js';
22+
import { HttpError, LiteFarmRequest } from '../../types.js';
23+
24+
export interface OfflineEventLogReqBody {
25+
logs: {
26+
event_name?: string;
27+
event_at?: Date | number;
28+
status_code?: number;
29+
url?: string;
30+
}[];
31+
went_online_at?: Date | number;
32+
farm_id?: string;
33+
app_version?: string;
34+
network?: string;
35+
session_id?: string;
36+
}
37+
38+
export function checkAuthForOfflineLogs() {
39+
return async (
40+
req: LiteFarmRequest<unknown, unknown, unknown, OfflineEventLogReqBody>,
41+
res: Response,
42+
next: NextFunction,
43+
) => {
44+
const token = req.headers.authorization?.split(' ')[1];
45+
46+
try {
47+
if (token) {
48+
const decoded = jwt.verify(token, tokenType.access!, { ignoreExpiration: true });
49+
50+
if (typeof decoded === 'string' || typeof decoded.exp !== 'number' || !decoded.user_id) {
51+
throw new Error('invalid token');
52+
}
53+
54+
const now = Math.floor(Date.now() / 1000); // JWT exp is in seconds
55+
56+
const tokenExpired = decoded.exp < now;
57+
58+
res.locals.log_status = tokenExpired ? 'expired' : 'authenticated';
59+
res.locals.user_id = decoded.user_id;
60+
} else {
61+
res.locals.log_status = 'anonymous';
62+
}
63+
64+
next();
65+
} catch (err) {
66+
console.error(err);
67+
return res.status(401).json({ error: 'Unauthorized' });
68+
}
69+
};
70+
}
71+
72+
export function checkOfflineLogs() {
73+
return async (
74+
req: LiteFarmRequest<unknown, unknown, unknown, OfflineEventLogReqBody>,
75+
res: Response,
76+
next: NextFunction,
77+
) => {
78+
try {
79+
if (!Array.isArray(req.body.logs) || req.body.logs.length === 0) {
80+
throw new Error('logs must be a non-empty array');
81+
}
82+
83+
if (res.locals.user_id) {
84+
const user = await userModel.query().findOne({ user_id: res.locals.user_id });
85+
86+
if (!user) {
87+
throw new Error('User not found');
88+
}
89+
}
90+
91+
const { farm_id } = req.body;
92+
93+
if (farm_id) {
94+
/* @ts-expect-error known issue with models */
95+
const farm = await farmModel.query().findOne({ farm_id });
96+
97+
if (!farm) {
98+
throw new Error('Invalid farm_id');
99+
}
100+
101+
if (res.locals.user_id) {
102+
const userFarm = await userFarmModel
103+
.query()
104+
.findOne({ user_id: res.locals.user_id, farm_id });
105+
106+
if (!userFarm) {
107+
throw new Error('user_id and farm_id do not match');
108+
}
109+
}
110+
111+
res.locals.country_id = farm.country_id;
112+
}
113+
114+
next();
115+
} catch (error: unknown) {
116+
console.error(error);
117+
118+
const err = error as HttpError;
119+
const status = err.status || err.code || 500;
120+
return res.status(status).json({
121+
error: err.message || err,
122+
});
123+
}
124+
};
125+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright 2026 LiteFarm.org
3+
* This file is part of LiteFarm.
4+
*
5+
* LiteFarm is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* LiteFarm is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
14+
*/
15+
16+
import Model from './baseFormatModel.js';
17+
18+
class offlineEventLogModel extends Model {
19+
static get tableName() {
20+
return 'offline_event_log';
21+
}
22+
23+
static get idColumn() {
24+
return 'id';
25+
}
26+
27+
static get jsonSchema() {
28+
return {
29+
type: 'object',
30+
required: ['session_id', 'event_name', 'event_at'],
31+
properties: {
32+
session_id: { type: 'string', format: 'uuid' },
33+
event_name: { type: 'string' },
34+
status_code: { type: ['integer', 'null'] },
35+
url: { type: ['string', 'null'] },
36+
country_id: { type: ['number', 'null'] },
37+
network: { type: ['string', 'null'] },
38+
browser: { type: ['string', 'null'] },
39+
browser_version: { type: ['string', 'null'] },
40+
device_vendor: { type: ['string', 'null'] },
41+
os: { type: ['string', 'null'] },
42+
device_model: { type: ['string', 'null'] },
43+
app_version: { type: ['string', 'null'] },
44+
log_status: { type: ['string', 'null'] },
45+
event_at: { type: 'string', format: 'date-time' },
46+
went_online_at: { type: ['string', 'null'], format: 'date-time' },
47+
},
48+
additionalProperties: false,
49+
};
50+
}
51+
}
52+
53+
export default offlineEventLogModel;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2026 LiteFarm.org
3+
* This file is part of LiteFarm.
4+
*
5+
* LiteFarm is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* LiteFarm is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
14+
*/
15+
16+
import express from 'express';
17+
import offlineEventLogController from '../controllers/offlineEventLogController.js';
18+
import {
19+
checkAuthForOfflineLogs,
20+
checkOfflineLogs,
21+
} from '../middleware/validation/checkOfflineLogs.js';
22+
23+
const router = express.Router();
24+
25+
router.post(
26+
'/',
27+
checkAuthForOfflineLogs(),
28+
checkOfflineLogs(),
29+
offlineEventLogController.addOfflineEventLog(),
30+
);
31+
32+
export default router;

packages/api/src/server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ import dataFoodConsortiumRoute from './routes/dataFoodConsortiumRoute.js';
179179
import marketDirectoryInfoRoute from './routes/marketDirectoryInfoRoute.js';
180180
import marketProductCategoryRoute from './routes/marketProductCategoryRoute.js';
181181
import marketDirectoryPartnerRoute from './routes/marketDirectoryPartnerRoute.js';
182+
import offlineEventLogRoute from './routes/offlineEventLogRoute.js';
182183

183184
// register API
184185
const router = promiseRouter();
@@ -358,7 +359,8 @@ app
358359
.use('/irrigation_prescription_request', irrigationPrescriptionRequestRoute)
359360
.use('/market_directory_info', marketDirectoryInfoRoute)
360361
.use('/market_product_categories', marketProductCategoryRoute)
361-
.use('/market_directory_partners', marketDirectoryPartnerRoute);
362+
.use('/market_directory_partners', marketDirectoryPartnerRoute)
363+
.use('/offline_event_log', offlineEventLogRoute);
362364

363365
// Allow a 1MB limit on sensors to match incoming Ensemble data
364366
app.use('/sensor', express.json({ limit: '1MB' }), rejectBodyInGetAndDelete, sensorRoute);

packages/webapp/src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { AppUIContext } from './contexts/appContext';
2727
import { useOfflineDetector } from './containers/hooks/useOfflineDetector/useOfflineDetector';
2828
import { useServiceWorkerListener } from './hooks/useServiceWorkerListener/useServiceWorkerListener';
2929
import { useGoogleMapsLoader } from './hooks/useGoogleMapsLoader';
30+
import useOfflineActivityLogger from './hooks/useOfflineActivityLogger';
3031

3132
function App() {
3233
const location = useLocation();
@@ -37,6 +38,7 @@ function App() {
3738

3839
useOfflineDetector();
3940
useServiceWorkerListener();
41+
useOfflineActivityLogger();
4042
const { isLoaded } = useGoogleMapsLoader();
4143

4244
return (

0 commit comments

Comments
 (0)