@socialgouv/matomo-postgres 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ const { importDate } = require("../importDate");
2
+
3
+ const mock_pgQuery = jest.fn();
4
+ const mock_matomoApi = jest.fn();
5
+
6
+ process.env.MATOMO_SITE = "42";
7
+ process.env.PROJECT_NAME = "some-project";
8
+
9
+ const matomoVisit = require("./visit.json");
10
+
11
+ jest.mock("pg", () => {
12
+ class Client {
13
+ escapeIdentifier(name) {
14
+ return name;
15
+ }
16
+ end() {}
17
+ connect() {
18
+ return Promise.resolve();
19
+ }
20
+ query(...args) {
21
+ return mock_pgQuery(...args);
22
+ }
23
+ }
24
+ return {
25
+ Client,
26
+ };
27
+ });
28
+
29
+ jest.mock("piwik-client", () => {
30
+ class MatomoClient {
31
+ api(...args) {
32
+ return mock_matomoApi(...args);
33
+ }
34
+ }
35
+ return MatomoClient;
36
+ });
37
+
38
+ beforeEach(() => {
39
+ jest.resetAllMocks();
40
+ jest.resetModules();
41
+ process.env.STARTDATE = "";
42
+ });
43
+
44
+ test("importDate: should import all events for a given date", async () => {
45
+ const pgSpy = jest.fn().mockResolvedValue();
46
+ const matomoSpy = jest.fn().mockImplementation((options, cb) => {
47
+ return cb(null, [
48
+ {
49
+ ...matomoVisit,
50
+ idVisit: 123,
51
+ },
52
+ {
53
+ ...matomoVisit,
54
+ idVisit: 124,
55
+ },
56
+ ]);
57
+ });
58
+ const fakeClient = {
59
+ escapeIdentifier(name) {
60
+ return name;
61
+ },
62
+ query: pgSpy,
63
+ };
64
+ //@ts-expect-error
65
+ await importDate(fakeClient, matomoSpy, new Date("2021-08-02"));
66
+ expect(matomoSpy.mock.calls.map((c) => c[0])).toMatchSnapshot();
67
+ expect(pgSpy.mock.calls).toMatchSnapshot();
68
+ });
69
+
70
+ test("importDate: should import when new results", async () => {
71
+ const pgResult = { rows: [] };
72
+ const pgSpy = jest.fn().mockResolvedValue(pgResult);
73
+ const matomoSpy = jest.fn().mockImplementation((options, cb) => {
74
+ return cb(null, [
75
+ {
76
+ ...matomoVisit,
77
+ idVisit: 123,
78
+ },
79
+ ]);
80
+ });
81
+ const fakeClient = {
82
+ query: pgSpy,
83
+ escapeIdentifier(name) {
84
+ return name;
85
+ },
86
+ };
87
+ //@ts-expect-error
88
+ const imported = await importDate(fakeClient, matomoSpy, new Date("2021-08-03T00:00:00"));
89
+ expect(pgSpy.mock.calls.length).toEqual(3);
90
+ expect(imported.length).toEqual(2);
91
+ expect(matomoSpy.mock.calls[0][0].filter_offset).toEqual(0);
92
+ });
93
+
94
+ test("importDate: should offset matomo calls when results already exist", async () => {
95
+ const pgResult = { rows: [{ count: 42 }] };
96
+ const pgSpy = jest.fn().mockResolvedValue(pgResult);
97
+ const matomoSpy = jest.fn().mockImplementation((options, cb) => {
98
+ return cb(null, [
99
+ {
100
+ ...matomoVisit,
101
+ idVisit: 123,
102
+ },
103
+ ]);
104
+ });
105
+ const fakeClient = {
106
+ query: pgSpy,
107
+ escapeIdentifier(name) {
108
+ return name;
109
+ },
110
+ };
111
+ //@ts-expect-error
112
+ const imported = await importDate(fakeClient, matomoSpy, new Date("2021-08-03T00:00:00"));
113
+ expect(pgSpy.mock.calls.length).toEqual(3);
114
+ expect(imported.length).toEqual(2);
115
+ expect(matomoSpy.mock.calls[0][0].filter_offset).toEqual(42);
116
+ });
117
+
118
+ test("importDate: should NOT import when no matomo results", async () => {
119
+ const pgSpy = jest.fn().mockResolvedValue({ rows: [{ action_timestamp: 1627948800000 }] });
120
+ const matomoSpy = jest.fn().mockImplementation((options, cb) => {
121
+ return cb(null, []);
122
+ });
123
+ const fakeClient = {
124
+ query: pgSpy,
125
+ escapeIdentifier(name) {
126
+ return name;
127
+ },
128
+ };
129
+ //@ts-expect-error
130
+ const imported = await importDate(fakeClient, matomoSpy, new Date("2021-08-03"));
131
+ expect(pgSpy.mock.calls.length).toEqual(1);
132
+ expect(imported.length).toEqual(0);
133
+ });
@@ -0,0 +1,22 @@
1
+ process.env.MATOMO_SITE = "42";
2
+ process.env.PROJECT_NAME = "some-project";
3
+
4
+ const matomoVisit = require("./visit.json");
5
+ const { getEventsFromMatomoVisit, importEvent } = require("../importEvent");
6
+
7
+ test("importEvent: should extract events from matomo visit actionsDetails and create insert queries", () => {
8
+ //@ts-expect-error
9
+ const visits = getEventsFromMatomoVisit(matomoVisit);
10
+ expect(visits).toMatchSnapshot();
11
+ const spy = jest.fn();
12
+ spy.mockResolvedValue(Promise.resolve());
13
+ const fakeClient = {
14
+ escapeIdentifier(name) {
15
+ return name;
16
+ },
17
+ query: spy,
18
+ };
19
+ //@ts-expect-error
20
+ visits.map((visit) => importEvent(fakeClient, visit));
21
+ expect(spy.mock.calls).toMatchSnapshot();
22
+ });
@@ -0,0 +1,198 @@
1
+ const mock_pgQuery = jest.fn();
2
+ const mock_matomoApi = jest.fn();
3
+ const formatISO = require("date-fns/formatISO");
4
+ const addDays = require("date-fns/addDays");
5
+
6
+ const { OFFSET } = require("../config");
7
+
8
+ process.env.MATOMO_SITE = "42";
9
+ process.env.PROJECT_NAME = "some-project";
10
+
11
+ const matomoVisit = require("./visit.json");
12
+
13
+ const run = require("../index");
14
+
15
+ const TEST_DATE = new Date();
16
+
17
+ // @ts-ignore
18
+ const isoDate = (date) => formatISO(date, { representation: "date" });
19
+
20
+ jest.mock("pg", () => {
21
+ class Client {
22
+ escapeIdentifier(name) {
23
+ return name;
24
+ }
25
+ end() {}
26
+ connect() {
27
+ return Promise.resolve();
28
+ }
29
+ query(...args) {
30
+ return Promise.resolve(mock_pgQuery(...args));
31
+ }
32
+ }
33
+ return {
34
+ Client,
35
+ };
36
+ });
37
+
38
+ jest.mock("piwik-client", () => {
39
+ class MatomoClient {
40
+ api(...args) {
41
+ return mock_matomoApi(...args);
42
+ }
43
+ }
44
+ return MatomoClient;
45
+ });
46
+
47
+ beforeEach(() => {
48
+ jest.resetAllMocks();
49
+ jest.resetModules();
50
+ process.env.STARTDATE = "";
51
+ //process.env.OFFSET = "";
52
+ });
53
+
54
+ test("run: should create table", async () => {
55
+ mock_pgQuery.mockReturnValue({ rows: [] });
56
+ mock_matomoApi.mockImplementation((options, cb) => {
57
+ return cb(null, []);
58
+ });
59
+ await run();
60
+ expect(mock_pgQuery.mock.calls[0]).toMatchSnapshot();
61
+ });
62
+
63
+ test("run: should fetch the latest event date if no date provided", async () => {
64
+ jest.useFakeTimers("modern").setSystemTime(TEST_DATE.getTime());
65
+ mock_pgQuery.mockReturnValue({ rows: [] });
66
+
67
+ mock_matomoApi.mockImplementation((options, cb) => {
68
+ return cb(null, [
69
+ {
70
+ ...matomoVisit,
71
+ idVisit: 123,
72
+ },
73
+ {
74
+ ...matomoVisit,
75
+ idVisit: 124,
76
+ },
77
+ ]);
78
+ });
79
+
80
+ await run();
81
+
82
+ // check matomo requests
83
+ expect(mock_matomoApi.mock.calls[0][0].date).toEqual(isoDate(TEST_DATE));
84
+ expect(mock_matomoApi.mock.calls[0][0].filter_offset).toEqual(0);
85
+
86
+ // check db queries
87
+ expect(mock_pgQuery.mock.calls[3][0]).toEqual(
88
+ // call 0 is create table
89
+ // call 1 is add column usercustomdimension
90
+ // call 2 is add column action_url
91
+ //
92
+ "select action_timestamp from matomo order by action_timestamp desc limit 1"
93
+ );
94
+ });
95
+
96
+ test("run: should resume using latest event date - offset if no date provided", async () => {
97
+ jest.useFakeTimers("modern").setSystemTime(TEST_DATE.getTime());
98
+
99
+ const LAST_EVENT_DATE_OFFSET = 2;
100
+ // @ts-ignore
101
+ const LAST_EVENT_DATE = addDays(TEST_DATE, -LAST_EVENT_DATE_OFFSET);
102
+
103
+ mock_pgQuery.mockReturnValue({ rows: [{ action_timestamp: LAST_EVENT_DATE.getTime() }] });
104
+
105
+ mock_matomoApi.mockImplementation((options, cb) => {
106
+ return cb(null, [
107
+ {
108
+ ...matomoVisit,
109
+ idVisit: 123,
110
+ },
111
+ {
112
+ ...matomoVisit,
113
+ idVisit: 124,
114
+ },
115
+ ]);
116
+ });
117
+
118
+ await run();
119
+
120
+ // check matomo requests
121
+ expect(mock_matomoApi.mock.calls[0][0].date).toEqual(
122
+ // @ts-ignore
123
+ isoDate(addDays(LAST_EVENT_DATE, -parseInt(OFFSET)))
124
+ );
125
+ expect(mock_matomoApi.mock.calls[0][0].filter_offset).toEqual(0);
126
+
127
+ const daysCount = LAST_EVENT_DATE_OFFSET + parseInt(OFFSET) + 1;
128
+ expect(mock_matomoApi.mock.calls.length).toEqual(daysCount);
129
+
130
+ // check db queries
131
+ expect(mock_pgQuery.mock.calls.length).toEqual(4 + daysCount * 5); // create + alter + alter + select queries + days offset
132
+ });
133
+
134
+ test("run: should use today date if nothing in DB", async () => {
135
+ jest.useFakeTimers("modern").setSystemTime(TEST_DATE.getTime());
136
+ mock_pgQuery.mockReturnValue({ rows: [] });
137
+
138
+ mock_matomoApi.mockImplementation((options, cb) => {
139
+ return cb(null, [
140
+ {
141
+ ...matomoVisit,
142
+ idVisit: 123,
143
+ },
144
+ ]);
145
+ });
146
+
147
+ await run();
148
+
149
+ // check matomo requests
150
+ expect(mock_matomoApi.mock.calls.length).toEqual(1);
151
+ console.log(TEST_DATE, isoDate(TEST_DATE));
152
+ expect(mock_matomoApi.mock.calls[0][0].date).toEqual(isoDate(TEST_DATE));
153
+
154
+ // check the 4 events inserted
155
+ expect(mock_pgQuery.mock.calls.length).toEqual(4 + 3); // create, alter, alter, check date, latest + 2 inserts
156
+ });
157
+
158
+ test("run: should use given date if any", async () => {
159
+ jest.useFakeTimers("modern").setSystemTime(TEST_DATE.getTime());
160
+ mock_pgQuery.mockReturnValue({ rows: [] });
161
+
162
+ mock_matomoApi.mockImplementation((options, cb) => {
163
+ return cb(null, [
164
+ {
165
+ ...matomoVisit,
166
+ idVisit: 123,
167
+ },
168
+ ]);
169
+ });
170
+
171
+ // @ts-ignore
172
+ await run(isoDate(addDays(TEST_DATE, -10)) + "T00:00:00.000Z");
173
+
174
+ expect(mock_matomoApi.mock.calls.length).toEqual(11);
175
+ expect(mock_pgQuery.mock.calls.length).toEqual(3 + 11 * 3); // create table + alter + alter + inserts. no initial select as date is provided
176
+ });
177
+
178
+ test("run: should use STARTDATE if any", async () => {
179
+ // @ts-ignore
180
+ process.env.STARTDATE = isoDate(addDays(TEST_DATE, -5)) + "T00:00:00.000Z";
181
+ jest.useFakeTimers("modern").setSystemTime(TEST_DATE.getTime());
182
+ mock_pgQuery.mockReturnValue({ rows: [] });
183
+
184
+ mock_matomoApi.mockImplementation((options, cb) => {
185
+ return cb(null, [
186
+ {
187
+ ...matomoVisit,
188
+ idVisit: 123,
189
+ },
190
+ ]);
191
+ });
192
+
193
+ await run();
194
+
195
+ expect(mock_matomoApi.mock.calls.length).toEqual(6);
196
+
197
+ expect(mock_pgQuery.mock.calls.length).toEqual(4 + 6 * 3); // create table + alter + alter + initial select + inserts.
198
+ });
@@ -0,0 +1,74 @@
1
+ {
2
+ "idVisit": "124",
3
+ "idSite": "42",
4
+ "country": "Argentine",
5
+ "operatingSystemName": "Mac",
6
+ "deviceBrand": "Inconnu",
7
+ "deviceModel": "Générique Bureau",
8
+ "visitDuration": "300",
9
+ "daysSinceFirstVisit": "23",
10
+ "actions": "2",
11
+ "visitorType": "returningCustomer",
12
+ "siteName": "tests",
13
+ "userId": "24",
14
+ "region": "Buenos Aires",
15
+ "city": "Buenos Aires",
16
+ "dimension1": "guest",
17
+ "dimension3": "page",
18
+ "firstActionTimestamp": 1629496512,
19
+ "actionDetails": [
20
+ {
21
+ "type": "event",
22
+ "url": "https://dive-shop.net/products/basic-wetsuit/",
23
+ "pageIdAction": "304",
24
+ "idpageview": "",
25
+ "serverTimePretty": "20 août 2021 21:35:18",
26
+ "pageId": "19696671",
27
+ "eventCategory": "Ecommerce",
28
+ "eventAction": "Cart change",
29
+ "timeSpent": 48,
30
+ "timeSpentPretty": "48s",
31
+ "pageviewPosition": "8",
32
+ "timestamp": 1629495318,
33
+ "icon": "plugins/Morpheus/images/event.png",
34
+ "iconSVG": "plugins/Morpheus/images/event.svg",
35
+ "title": "Evènement",
36
+ "subtitle": "Catégorie: \"Ecommerce', Action: \"Cart change\"",
37
+ "eventName": "added - Basic Wetsuit",
38
+ "eventValue": 1,
39
+ "dimension2": "julien",
40
+ "dimension4": "indonesia",
41
+ "dimension5": "diving",
42
+ "customVariables": {
43
+ "1": {
44
+ "customVariableName1": "page-author",
45
+ "customVariableValue1": "Julien"
46
+ },
47
+ "2": {
48
+ "customVariableName2": "post-age",
49
+ "customVariableValue2": "-430 days"
50
+ }
51
+ }
52
+ },
53
+ {
54
+ "type": "action",
55
+ "url": "https://dive-shop.net/products/diving-boots/",
56
+ "pageTitle": "Divezone Brand Diving Boots - Divezone Store",
57
+ "pageIdAction": "60",
58
+ "idpageview": "8CDIez",
59
+ "serverTimePretty": "20 août 2021 21:30:25",
60
+ "pageId": "19696664",
61
+ "timeSpent": "2",
62
+ "timeSpentPretty": "2s",
63
+ "pageviewPosition": "5",
64
+ "title": "Divezone Brand Diving Boots - Divezone Store",
65
+ "subtitle": "https://dive-shop.net/products/diving-boots/",
66
+ "icon": "",
67
+ "iconSVG": "plugins/Morpheus/images/action.svg",
68
+ "timestamp": 1629495025,
69
+ "dimension2": "julien",
70
+ "dimension4": "indonesia",
71
+ "dimension5": "diving"
72
+ }
73
+ ]
74
+ }
package/src/config.js ADDED
@@ -0,0 +1,9 @@
1
+ const MATOMO_KEY = process.env.MATOMO_KEY || "";
2
+ const MATOMO_URL = process.env.MATOMO_URL || "https://matomo.fabrique.social.gouv.fr/";
3
+ const MATOMO_SITE = process.env.MATOMO_SITE || 0;
4
+ const PGDATABASE = process.env.PGDATABASE || "";
5
+ const DESTINATION_TABLE = process.env.DESTINATION_TABLE || "matomo";
6
+ const OFFSET = process.env.OFFSET || "3";
7
+ const RESULTPERPAGE = process.env.RESULTPERPAGE || "500";
8
+
9
+ module.exports = { MATOMO_KEY, MATOMO_URL, MATOMO_SITE, PGDATABASE, DESTINATION_TABLE, OFFSET, RESULTPERPAGE };
@@ -0,0 +1,48 @@
1
+ const { Client } = require("pg");
2
+
3
+ const { DESTINATION_TABLE } = require("./config");
4
+ /**
5
+ *
6
+ * @param {Client} client
7
+ */
8
+ async function createTable(client) {
9
+ const text = `CREATE TABLE IF NOT EXISTS ${client.escapeIdentifier(DESTINATION_TABLE)}
10
+ (
11
+ idsite text,
12
+ idvisit text,
13
+ actions text,
14
+ country text,
15
+ region text,
16
+ city text,
17
+ operatingsystemname text,
18
+ devicemodel text,
19
+ devicebrand text,
20
+ visitduration text,
21
+ dayssincefirstvisit text,
22
+ visitortype text,
23
+ sitename text,
24
+ userid text,
25
+ serverdateprettyfirstaction date,
26
+ action_id text UNIQUE,
27
+ action_type text,
28
+ action_eventcategory text,
29
+ action_eventaction text,
30
+ action_eventname text,
31
+ action_eventvalue text,
32
+ action_timespent text,
33
+ action_timestamp timestamp with time zone,
34
+ usercustomproperties json,
35
+ usercustomdimensions json,
36
+ action_url text
37
+ )`;
38
+ await client.query(text, []);
39
+ const addUserCustomDimensionColumn = `ALTER TABLE IF EXISTS ${client.escapeIdentifier(DESTINATION_TABLE)}
40
+ ADD COLUMN IF NOT EXISTS "usercustomdimensions" json;`;
41
+ await client.query(addUserCustomDimensionColumn, []);
42
+
43
+ const addActionUrlColumn = `ALTER TABLE IF EXISTS ${client.escapeIdentifier(DESTINATION_TABLE)}
44
+ ADD COLUMN IF NOT EXISTS "action_url" text;`;
45
+ await client.query(addActionUrlColumn, []);
46
+ }
47
+
48
+ module.exports = { createTable };
@@ -0,0 +1,105 @@
1
+ const { Client } = require("pg");
2
+ const pAll = require("p-all");
3
+ const debug = require("debug")("importDate");
4
+ const formatISO = require("date-fns/formatISO");
5
+
6
+ const { importEvent, getEventsFromMatomoVisit } = require("./importEvent");
7
+
8
+ const { RESULTPERPAGE, MATOMO_SITE, DESTINATION_TABLE } = require("./config");
9
+
10
+ /**
11
+ * return date as ISO yyyy-mm-dd
12
+ *
13
+ * @param {Date} date date
14
+ *
15
+ * @returns {string}
16
+ */
17
+ // @ts-ignore
18
+ const isoDate = (date) => formatISO(date, { representation: "date" });
19
+
20
+ /**
21
+ * check count if imported rows for given date
22
+ *
23
+ * @param {Client} client PG client
24
+ * @param {Date} date datetime as ISO string
25
+ *
26
+ * @returns {Promise<any>}
27
+ */
28
+ const getRecordsCount = async (client, date) => {
29
+ const text = `SELECT COUNT(distinct idvisit) FROM ${client.escapeIdentifier(
30
+ DESTINATION_TABLE
31
+ )} WHERE action_timestamp::date='${isoDate(date)}';`;
32
+ const result = await client.query(text);
33
+ return parseInt((result && result.rows && result.rows.length && result.rows[0].count) || 0);
34
+ };
35
+
36
+ /**
37
+ * import all matomo data for a given date
38
+ *
39
+ * @param {Client} client PG client
40
+ * @param {any} piwikApi piwik.api instance
41
+ * @param {Date} date datetime as ISO string
42
+ *
43
+ * @returns {Promise<any[]>}
44
+ */
45
+ const importDate = async (client, piwikApi, date, filterOffset = 0) => {
46
+ const limit = parseInt(RESULTPERPAGE);
47
+ const offset = filterOffset || (await getRecordsCount(client, date));
48
+ if (!offset) {
49
+ debug(`${isoDate(date)}: load ${limit} visits`);
50
+ } else {
51
+ debug(`${isoDate(date)}: load ${limit} more visits after ${offset}`);
52
+ }
53
+ // fetch visits details
54
+ const visits = await new Promise((resolve) =>
55
+ piwikApi(
56
+ {
57
+ method: "Live.getLastVisitsDetails",
58
+ period: "day",
59
+ date: isoDate(date),
60
+ filter_limit: limit,
61
+ filter_offset: offset,
62
+ filter_sort_order: "asc",
63
+ idSite: MATOMO_SITE,
64
+ },
65
+ (err, responseObject = []) => {
66
+ if (err) {
67
+ console.error("err", err);
68
+ resolve([]);
69
+ }
70
+ return resolve(responseObject || []);
71
+ }
72
+ )
73
+ );
74
+
75
+ debug(`fetched ${visits.length} visits`);
76
+
77
+ // flatten events
78
+ const allEvents = visits.flatMap(getEventsFromMatomoVisit);
79
+
80
+ if (!allEvents.length) {
81
+ debug(`no more valid events after ${isoDate(date)}`);
82
+ return [];
83
+ }
84
+
85
+ debug(`import ${allEvents.length} events`);
86
+
87
+ // serial-import events into PG
88
+ const importedEvents = await pAll(
89
+ allEvents.map((event) => () => importEvent(client, event)),
90
+ { concurrency: 10, stopOnError: true }
91
+ );
92
+
93
+ // continue to next page if necessary
94
+ if (visits.length === limit) {
95
+ const nextOffset = offset + limit;
96
+ const nextEvents = await importDate(client, piwikApi, date, nextOffset);
97
+ return [...importedEvents, ...(nextEvents || [])];
98
+ }
99
+
100
+ debug(`finished importing ${isoDate(date)}, offset ${offset}`);
101
+
102
+ return importedEvents || [];
103
+ };
104
+
105
+ module.exports = { importDate };
@@ -0,0 +1,99 @@
1
+ const { Client } = require("pg");
2
+
3
+ const { DESTINATION_TABLE } = require("./config");
4
+
5
+ /**
6
+ *
7
+ * @param {Client} client
8
+ * @param {import("types").Event} event
9
+ *
10
+ * @return {Promise<Record<"rows", any[]>>}
11
+ */
12
+ const importEvent = (client, event) => {
13
+ const eventKeys = Object.keys(event);
14
+ const text = `insert into ${client.escapeIdentifier(DESTINATION_TABLE)}
15
+ (${eventKeys.join(", ")})
16
+ values (${eventKeys.map((k, i) => `\$${i + 1}`).join(", ")})
17
+ ON CONFLICT DO NOTHING`;
18
+ const values = [...eventKeys.map((k) => event[k])];
19
+ return client.query(text, values).catch((e) => {
20
+ console.log("QUERY error", e);
21
+ return { rows: [] };
22
+ });
23
+ };
24
+
25
+ const matomoProps = [
26
+ "idSite",
27
+ "idVisit",
28
+ "actions",
29
+ "country",
30
+ "region",
31
+ "city",
32
+ "operatingSystemName",
33
+ "deviceModel",
34
+ "deviceBrand",
35
+ "visitDuration",
36
+ "daysSinceFirstVisit",
37
+ "visitorType",
38
+ "siteName",
39
+ "userId",
40
+ ];
41
+
42
+ /** @type Record<string, (a: import("types/matomo").ActionDetail) => string | number> */
43
+ const actionProps = {
44
+ action_type: (action) => action.type,
45
+ action_eventcategory: (action) => action.eventCategory,
46
+ action_eventaction: (action) => action.eventAction,
47
+ action_eventname: (action) => action.eventName,
48
+ action_eventvalue: (action) => action.eventValue,
49
+ action_timespent: (action) => action.timeSpent,
50
+ action_timestamp: (action) => new Date(action.timestamp * 1000).toISOString(),
51
+ action_url: (action) => action.url,
52
+ };
53
+
54
+ /**
55
+ * Convert a single matomo visit {<import("types/matomo").Visit} to multiple {Event[]}
56
+ *
57
+ * @param {Partial<import("types/matomo").Visit>} matomoVisit
58
+ *
59
+ * @return {import("types").Event[]} list of events
60
+ *
61
+ */
62
+ const getEventsFromMatomoVisit = (matomoVisit) => {
63
+ return matomoVisit.actionDetails.map((actionDetail, actionIndex) => {
64
+ /** @type {Record<string, string>} */
65
+ const usercustomproperties = {};
66
+ for (let k = 1; k < 10; k++) {
67
+ const property = actionDetail.customVariables && actionDetail.customVariables[k];
68
+ if (!property) continue; // max 10 custom variables
69
+ usercustomproperties[property[`customVariableName${k}`]] = property[`customVariableValue${k}`];
70
+ }
71
+
72
+ /** @type {Record<string, string>} */
73
+ const usercustomdimensions = {};
74
+ for (let k = 1; k < 10; k++) {
75
+ const dimension = `dimension${k}`;
76
+ const value = actionDetail[dimension] || matomoVisit[dimension];
77
+ if (!value) continue; // max 10 custom variables
78
+ usercustomdimensions[dimension] = value;
79
+ }
80
+
81
+ /** @type {import("types").Event} */
82
+ // @ts-ignore
83
+ const event = {
84
+ // default matomo visit properties
85
+ ...matomoProps.reduce((a, prop) => ({ ...a, [prop.toLowerCase()]: matomoVisit[prop] }), {}),
86
+ serverdateprettyfirstaction: new Date((matomoVisit.firstActionTimestamp || 0) * 1000).toISOString(),
87
+ // action specific properties
88
+ ...Object.keys(actionProps).reduce((a, prop) => ({ ...a, [prop]: actionProps[prop](actionDetail) }), {
89
+ action_id: `${matomoVisit.idVisit}_${actionIndex}`,
90
+ }),
91
+ // custom variables
92
+ usercustomproperties,
93
+ usercustomdimensions,
94
+ };
95
+ return event;
96
+ });
97
+ };
98
+
99
+ module.exports = { getEventsFromMatomoVisit, importEvent };