@socialgouv/matomo-postgres 2.3.12 → 2.3.13

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,95 @@
1
+ import * as http from 'http';
2
+ import * as https from 'https';
3
+ import * as querystring from 'querystring';
4
+ import * as url from 'url';
5
+ export default class PiwikClient {
6
+ constructor(baseURL, token) {
7
+ const parsedUrl = url.parse(baseURL, true);
8
+ this.settings = {
9
+ apihost: parsedUrl.hostname || '',
10
+ apipath: parsedUrl.pathname || ''
11
+ };
12
+ // Determine protocol and set http module
13
+ switch (parsedUrl.protocol) {
14
+ case 'http:':
15
+ this.http = http;
16
+ this.settings.apiport = parsedUrl.port
17
+ ? parseInt(parsedUrl.port, 10)
18
+ : 80;
19
+ break;
20
+ case 'https:':
21
+ this.http = https;
22
+ this.settings.apiport = parsedUrl.port
23
+ ? parseInt(parsedUrl.port, 10)
24
+ : 443;
25
+ break;
26
+ default:
27
+ this.http = http;
28
+ this.settings.apiport = 80;
29
+ }
30
+ // Set token from URL query or constructor parameter
31
+ if (parsedUrl.query && parsedUrl.query.token_auth) {
32
+ this.settings.token = parsedUrl.query.token_auth;
33
+ }
34
+ if (token) {
35
+ this.settings.token = token;
36
+ }
37
+ }
38
+ api(vars, cb) {
39
+ if (typeof vars !== 'object') {
40
+ vars = {};
41
+ }
42
+ // Set default values
43
+ vars.module = 'API';
44
+ vars.format = 'JSON';
45
+ // Set token if not provided in vars
46
+ if (vars.token_auth == null) {
47
+ vars.token_auth = this.settings.token;
48
+ }
49
+ // Extract token_auth for POST body
50
+ const token_auth = vars.token_auth;
51
+ const postData = querystring.stringify({ token_auth });
52
+ // Remove token_auth from URL query params
53
+ const queryVars = Object.assign({}, vars);
54
+ delete queryVars.token_auth;
55
+ // Prepare request options
56
+ const options = {
57
+ host: this.settings.apihost,
58
+ port: this.settings.apiport,
59
+ path: this.settings.apipath + '?' + querystring.stringify(queryVars),
60
+ method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/x-www-form-urlencoded'
63
+ }
64
+ };
65
+ // Make HTTP POST request
66
+ const req = this.http.request(options, (response) => {
67
+ let data = '';
68
+ // Collect data chunks
69
+ response.on('data', (chunk) => {
70
+ data += chunk;
71
+ });
72
+ // Process complete response
73
+ response.on('end', () => {
74
+ try {
75
+ const resObj = JSON.parse(data);
76
+ if (resObj.result === 'error') {
77
+ return cb(new Error(resObj.message), null);
78
+ }
79
+ return cb(null, resObj);
80
+ }
81
+ catch (error) {
82
+ return cb(error instanceof Error ? error : new Error(String(error)), null);
83
+ }
84
+ });
85
+ });
86
+ // Handle request errors
87
+ req.on('error', (error) => {
88
+ cb(error, null);
89
+ });
90
+ // Write POST data and end request
91
+ req.write(postData);
92
+ req.end();
93
+ return req;
94
+ }
95
+ }
@@ -0,0 +1,134 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ // Set environment variables BEFORE any imports
11
+ process.env.MATOMO_SITE = '42';
12
+ process.env.PROJECT_NAME = 'some-project';
13
+ process.env.RESULTPERPAGE = '10';
14
+ process.env.DESTINATION_TABLE = 'matomo';
15
+ import { Pool } from 'pg';
16
+ import { importDate } from '../importDate';
17
+ import matomoVisit from './visit.json';
18
+ const TEST_DATE = new Date(2023, 3, 15);
19
+ let queries = [];
20
+ const result = {
21
+ command: 'string',
22
+ rowCount: 0
23
+ };
24
+ jest.mock('pg', () => {
25
+ const client = {
26
+ query: (query, values) => {
27
+ queries.push([query, values]);
28
+ return result;
29
+ },
30
+ release: jest.fn()
31
+ };
32
+ const methods = {
33
+ connect: () => client,
34
+ on: jest.fn(),
35
+ query: jest.fn()
36
+ };
37
+ return { Pool: jest.fn(() => methods) };
38
+ });
39
+ let pool;
40
+ beforeEach(() => {
41
+ pool = new Pool();
42
+ queries = [];
43
+ });
44
+ afterEach(() => {
45
+ jest.clearAllMocks();
46
+ });
47
+ test('importDate: should import given date', () => __awaiter(void 0, void 0, void 0, function* () {
48
+ const piwikApi = jest.fn();
49
+ piwikApi.mockImplementation((options, cb) => {
50
+ cb(null, [
51
+ Object.assign(Object.assign({}, matomoVisit), { idVisit: 123 }),
52
+ Object.assign(Object.assign({}, matomoVisit), { idVisit: 124 })
53
+ ]);
54
+ });
55
+ pool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
56
+ const result = yield importDate(piwikApi, TEST_DATE);
57
+ expect(piwikApi.mock.calls.length).toEqual(1);
58
+ expect(piwikApi.mock.calls[0][0]).toMatchInlineSnapshot(`
59
+ {
60
+ "date": "2023-04-15",
61
+ "filter_limit": 10,
62
+ "filter_offset": 0,
63
+ "filter_sort_order": "asc",
64
+ "idSite": "42",
65
+ "method": "Live.getLastVisitsDetails",
66
+ "period": "day",
67
+ }
68
+ `);
69
+ expect(queries[0]).toMatchInlineSnapshot(`
70
+ [
71
+ "select count(distinct "idvisit") as "count" from "matomo" where date(timezone('UTC', action_timestamp)) = $1",
72
+ [
73
+ "2023-04-15",
74
+ ],
75
+ ]
76
+ `);
77
+ expect(queries.length).toEqual(1 + matomoVisit.actionDetails.length * 2);
78
+ expect(result).toMatchObject({
79
+ date: '2023-04-15',
80
+ pages: 1,
81
+ visitsFetched: 2,
82
+ eventsImported: matomoVisit.actionDetails.length * 2
83
+ });
84
+ }));
85
+ test('importDate: should handle pagination across multiple pages', () => __awaiter(void 0, void 0, void 0, function* () {
86
+ const piwikApi = jest.fn();
87
+ // Mock first call to return exactly 10 visits (triggers pagination)
88
+ piwikApi
89
+ .mockImplementationOnce((options, cb) => {
90
+ const visits = Array.from({ length: 10 }, (_, i) => (Object.assign(Object.assign({}, matomoVisit), { idVisit: 200 + i })));
91
+ cb(null, visits);
92
+ })
93
+ // Mock second call to return 5 visits (stops pagination)
94
+ .mockImplementationOnce((options, cb) => {
95
+ const visits = Array.from({ length: 5 }, (_, i) => (Object.assign(Object.assign({}, matomoVisit), { idVisit: 300 + i })));
96
+ cb(null, visits);
97
+ });
98
+ // Mock database query for record count
99
+ pool.query.mockResolvedValue({ rows: [], rowCount: 0 });
100
+ const result = yield importDate(piwikApi, TEST_DATE);
101
+ // Should make exactly 2 API calls due to pagination
102
+ expect(piwikApi.mock.calls.length).toEqual(2);
103
+ // First call should have offset 0
104
+ expect(piwikApi.mock.calls[0][0]).toMatchObject({
105
+ date: '2023-04-15',
106
+ filter_limit: 10,
107
+ filter_offset: 0,
108
+ filter_sort_order: 'asc',
109
+ idSite: '42',
110
+ method: 'Live.getLastVisitsDetails',
111
+ period: 'day'
112
+ });
113
+ // Second call should have offset 10
114
+ expect(piwikApi.mock.calls[1][0]).toMatchObject({
115
+ date: '2023-04-15',
116
+ filter_limit: 10,
117
+ filter_offset: 10,
118
+ filter_sort_order: 'asc',
119
+ idSite: '42',
120
+ method: 'Live.getLastVisitsDetails',
121
+ period: 'day'
122
+ });
123
+ // Should process all events from both pages
124
+ // 15 visits total × 3 actionDetails each = 45 events
125
+ expect(result).toMatchObject({
126
+ date: '2023-04-15',
127
+ pages: 2,
128
+ visitsFetched: 15,
129
+ eventsImported: 45
130
+ });
131
+ // Verify database queries: 1 count query + (45 events × 1 query per event)
132
+ // Note: Each event generates 1 database query for insertion
133
+ expect(queries.length).toEqual(1 + 45);
134
+ }));
@@ -0,0 +1,9 @@
1
+ import { getEventsFromMatomoVisit } from '../importEvent.js';
2
+ import matomoVisit from './visit.json';
3
+ process.env.MATOMO_SITE = '42';
4
+ process.env.PROJECT_NAME = 'some-project';
5
+ process.env.RESULTPERPAGE = '10';
6
+ test('getEventsFromMatomoVisit: should merge action events', () => {
7
+ const visits = getEventsFromMatomoVisit(matomoVisit);
8
+ expect(visits).toMatchSnapshot();
9
+ });
@@ -0,0 +1,38 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { readdir, readFile } from 'fs/promises';
11
+ import { join } from 'path';
12
+ describe('Migration Import Validation', () => {
13
+ it('should not have src/ imports in migration files', () => __awaiter(void 0, void 0, void 0, function* () {
14
+ const migrationsDir = join(__dirname, '../migrations');
15
+ const migrationFiles = yield readdir(migrationsDir);
16
+ const tsFiles = migrationFiles.filter((file) => file.endsWith('.ts'));
17
+ const problematicFiles = [];
18
+ for (const file of tsFiles) {
19
+ const filePath = join(migrationsDir, file);
20
+ const content = yield readFile(filePath, 'utf-8');
21
+ // Check for imports from 'src/' paths
22
+ const srcImportRegex = /(?:import\s+[^;]+from\s+['"]src\/|require\s*\(\s*['"]src\/)/g;
23
+ const matches = content.match(srcImportRegex);
24
+ if (matches) {
25
+ problematicFiles.push(`${file}: ${matches.join(', ')}`);
26
+ }
27
+ }
28
+ if (problematicFiles.length > 0) {
29
+ fail(`Migration files contain problematic src/ imports that will fail in production:\n${problematicFiles.join('\n')}\n\nMigration files should use direct constants or relative imports instead.`);
30
+ }
31
+ }));
32
+ it('should have at least one migration file', () => __awaiter(void 0, void 0, void 0, function* () {
33
+ const migrationsDir = join(__dirname, '../migrations');
34
+ const migrationFiles = yield readdir(migrationsDir);
35
+ const tsFiles = migrationFiles.filter((file) => file.endsWith('.ts'));
36
+ expect(tsFiles.length).toBeGreaterThan(0);
37
+ }));
38
+ });
@@ -0,0 +1,95 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ process.env.MATOMO_SITE = '42';
11
+ process.env.PROJECT_NAME = 'some-project';
12
+ process.env.RESULTPERPAGE = '10';
13
+ delete process.env.INITIAL_OFFSET;
14
+ delete process.env.DESTINATION_TABLE;
15
+ delete process.env.STARTDATE;
16
+ // Clear STARTDATE to avoid conflicts with fake timers
17
+ const TEST_DATE = new Date(2023, 3, 1);
18
+ let queries = [];
19
+ let piwikApiCalls = [];
20
+ const result = {
21
+ command: 'string',
22
+ rowCount: 0
23
+ };
24
+ jest.mock('pg', () => {
25
+ const client = {
26
+ query: (query, values) => {
27
+ queries.push([query, values]);
28
+ return result;
29
+ },
30
+ release: jest.fn()
31
+ };
32
+ const methods = {
33
+ connect: () => client,
34
+ on: jest.fn(),
35
+ query: jest.fn()
36
+ };
37
+ return { Pool: jest.fn(() => methods) };
38
+ });
39
+ jest.mock('../PiwikClient', () => {
40
+ class PiwikMock {
41
+ constructor(options) {
42
+ this.options = options;
43
+ }
44
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
45
+ api(options, cb) {
46
+ return __awaiter(this, void 0, void 0, function* () {
47
+ // Import the visit data dynamically to avoid circular dependency
48
+ const { default: matomoVisit } = yield import('./visit.json');
49
+ const matomoVisits = [
50
+ Object.assign(Object.assign({}, matomoVisit), { idVisit: 123 }),
51
+ Object.assign(Object.assign({}, matomoVisit), { idVisit: 124 })
52
+ ];
53
+ piwikApiCalls.push(options);
54
+ cb(null, matomoVisits);
55
+ });
56
+ }
57
+ }
58
+ return PiwikMock;
59
+ });
60
+ // Import after mocks are set up
61
+ import run from '../index';
62
+ beforeEach(() => {
63
+ queries = [];
64
+ piwikApiCalls = [];
65
+ });
66
+ afterEach(() => {
67
+ jest.clearAllMocks();
68
+ });
69
+ test('run: should fetch the latest 5 days on matomo', () => __awaiter(void 0, void 0, void 0, function* () {
70
+ jest.useFakeTimers().setSystemTime(TEST_DATE.getTime());
71
+ yield run();
72
+ expect(piwikApiCalls).toMatchSnapshot();
73
+ }));
74
+ test('run: should fetch the latest event date if no date provided', () => __awaiter(void 0, void 0, void 0, function* () {
75
+ jest.useFakeTimers().setSystemTime(TEST_DATE.getTime());
76
+ yield run();
77
+ expect(queries[0]).toMatchSnapshot();
78
+ }));
79
+ test('run: should run based on existing data if any', () => __awaiter(void 0, void 0, void 0, function* () {
80
+ // ensure we use the latest entry in DB
81
+ expect(1).toEqual(1);
82
+ }));
83
+ test('run: should run SQL queries', () => __awaiter(void 0, void 0, void 0, function* () {
84
+ jest.useFakeTimers().setSystemTime(TEST_DATE.getTime());
85
+ const result = yield run();
86
+ expect(queries).toMatchSnapshot();
87
+ // Updated expectation based on actual behavior with INITIAL_OFFSET=3 (5 days total: 3 days before + today + 1 day after)
88
+ // 5 days * (6 events per day + 1 count query per day)
89
+ // Note: We also capture the initial "findLastEventInMatomo" query.
90
+ expect(queries.length).toEqual(1 + 5 * (6 + 1));
91
+ expect(result).toMatchObject({
92
+ daysProcessed: 5,
93
+ eventsImportedTotal: 5 * 6
94
+ });
95
+ }));
@@ -0,0 +1,104 @@
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
+ "visitorId": "visitorId",
13
+ "referrerType": "referrerType",
14
+ "referrerName": "referrerName",
15
+ "siteName": "tests",
16
+ "userId": "24",
17
+ "region": "Buenos Aires",
18
+ "city": "Buenos Aires",
19
+ "resolution": "1920x1080",
20
+ "dimension1": "guest",
21
+ "dimension3": "page",
22
+ "dimension6": "shop",
23
+ "dimension7": "v1.2.3",
24
+ "dimension8": "fr",
25
+ "dimension9": "light",
26
+ "dimension10": "36",
27
+ "firstActionTimestamp": 1629496512,
28
+ "actionDetails": [
29
+ {
30
+ "type": "event",
31
+ "url": "https://dive-shop.net/products/basic-wetsuit/",
32
+ "pageIdAction": "304",
33
+ "idpageview": "",
34
+ "serverTimePretty": "20 août 2021 21:35:18",
35
+ "pageId": "19696671",
36
+ "eventCategory": "Ecommerce",
37
+ "eventAction": "Cart change",
38
+ "timeSpent": 48,
39
+ "timeSpentPretty": "48s",
40
+ "pageviewPosition": "8",
41
+ "timestamp": 1629495318,
42
+ "icon": "plugins/Morpheus/images/event.png",
43
+ "iconSVG": "plugins/Morpheus/images/event.svg",
44
+ "title": "Evènement",
45
+ "subtitle": "Catégorie: \"Ecommerce', Action: \"Cart change\"",
46
+ "eventName": "added - Basic Wetsuit",
47
+ "eventValue": 1,
48
+ "dimension2": "julien",
49
+ "dimension4": "indonesia",
50
+ "dimension5": "diving",
51
+ "customVariables": {
52
+ "1": {
53
+ "customVariableName1": "page-author",
54
+ "customVariableValue1": "Julien"
55
+ },
56
+ "2": {
57
+ "customVariableName2": "post-age",
58
+ "customVariableValue2": "-430 days"
59
+ }
60
+ }
61
+ },
62
+ {
63
+ "type": "action",
64
+ "url": "https://dive-shop.net/products/diving-boots/",
65
+ "pageTitle": "Divezone Brand Diving Boots - Divezone Store",
66
+ "pageIdAction": "60",
67
+ "idpageview": "8CDIez",
68
+ "serverTimePretty": "20 août 2021 21:30:25",
69
+ "pageId": "19696664",
70
+ "timeSpent": "2",
71
+ "timeSpentPretty": "2s",
72
+ "pageviewPosition": "5",
73
+ "title": "Divezone Brand Diving Boots - Divezone Store",
74
+ "subtitle": "https://dive-shop.net/products/diving-boots/",
75
+ "icon": "",
76
+ "iconSVG": "plugins/Morpheus/images/action.svg",
77
+ "timestamp": 1629495025,
78
+ "dimension2": "julien",
79
+ "dimension4": "indonesia",
80
+ "dimension5": "diving"
81
+ },
82
+ {
83
+ "type": "search",
84
+ "url": "https://dive-shop.net/products/diving-boots/",
85
+ "pageTitle": "Divezone Brand Diving Boots - Divezone Store",
86
+ "pageIdAction": "60",
87
+ "idpageview": "8CDIez",
88
+ "serverTimePretty": "20 août 2021 21:30:25",
89
+ "pageId": "19696664",
90
+ "timeSpent": "2",
91
+ "timeSpentPretty": "2s",
92
+ "pageviewPosition": "5",
93
+ "title": "Divezone Brand Diving Boots - Divezone Store",
94
+ "subtitle": "https://dive-shop.net/products/diving-boots/",
95
+ "icon": "",
96
+ "iconSVG": "plugins/Morpheus/images/action.svg",
97
+ "timestamp": 1629495022,
98
+ "dimension2": "julien",
99
+ "dimension4": "indonesia",
100
+ "dimension5": "diving",
101
+ "siteSearchKeyword": "scuba"
102
+ }
103
+ ]
104
+ }
package/dist/config.js ADDED
@@ -0,0 +1,12 @@
1
+ export const MATOMO_KEY = process.env.MATOMO_KEY || '';
2
+ export const MATOMO_URL = process.env.MATOMO_URL || 'https://matomo.fabrique.social.gouv.fr/';
3
+ export const MATOMO_SITE = process.env.MATOMO_SITE || 0;
4
+ export const PGDATABASE = process.env.PGDATABASE || '';
5
+ export const INITIAL_OFFSET = process.env.INITIAL_OFFSET || '3';
6
+ export const RESULTPERPAGE = process.env.RESULTPERPAGE || '500';
7
+ export const FORCE_STARTDATE = process.env.FORCE_STARTDATE === 'true';
8
+ // We will create both a normal and a partitioned table (MATOMO_TABLE_NAME and PARTITIONED_MATOMO_TABLE_NAME)
9
+ // and use DESTINATION_TABLE to determine which one to write to.
10
+ export const DESTINATION_TABLE = process.env.DESTINATION_TABLE || 'matomo';
11
+ export const MATOMO_TABLE_NAME = process.env.MATOMO_TABLE_NAME || 'matomo';
12
+ export const PARTITIONED_MATOMO_TABLE_NAME = process.env.PARTITIONED_MATOMO_TABLE_NAME || 'matomo_partitioned';
package/dist/db.js ADDED
@@ -0,0 +1,31 @@
1
+ import startDebug from 'debug';
2
+ import { Kysely, PostgresDialect } from 'kysely';
3
+ import pkg from 'pg';
4
+ const { Pool } = pkg;
5
+ import { PGDATABASE } from './config.js';
6
+ startDebug('db');
7
+ export const pool = new Pool({
8
+ connectionString: PGDATABASE,
9
+ ssl: {
10
+ rejectUnauthorized: false
11
+ }
12
+ });
13
+ // Validate pool is properly initialized
14
+ if (!pool || typeof pool.connect !== 'function') {
15
+ throw new Error('Failed to initialize PostgreSQL connection pool');
16
+ }
17
+ export const db = new Kysely({
18
+ dialect: new PostgresDialect({
19
+ pool: pool
20
+ }),
21
+ log(event) {
22
+ if (event.level === 'query') {
23
+ // debug(event.query.sql)
24
+ // debug(event.query.parameters)
25
+ }
26
+ }
27
+ });
28
+ // Validate the Kysely instance
29
+ if (!db) {
30
+ throw new Error('Failed to initialize Kysely database instance');
31
+ }
@@ -0,0 +1,101 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { formatISO } from 'date-fns';
11
+ import startDebug from 'debug';
12
+ import { sql } from 'kysely';
13
+ import pAll from 'p-all';
14
+ import { DESTINATION_TABLE, MATOMO_SITE, RESULTPERPAGE } from './config.js';
15
+ import { db, pool } from './db.js';
16
+ import { getEventsFromMatomoVisit, importEvent } from './importEvent.js';
17
+ const debug = startDebug('importDate');
18
+ /** return date as ISO yyyy-mm-dd */
19
+ const isoDate = (date) => formatISO(date, { representation: 'date' });
20
+ /** check how many visits complete for a given date */
21
+ const getRecordsCount = (date) => __awaiter(void 0, void 0, void 0, function* () {
22
+ if (!pool || typeof pool.connect !== 'function') {
23
+ throw new Error('Database connection pool is invalid or undefined in getRecordsCount');
24
+ }
25
+ const result = yield db
26
+ .selectFrom(DESTINATION_TABLE)
27
+ .select(db.fn.count('idvisit').distinct().as('count'))
28
+ // UTC to be iso with matomo matomo data
29
+ .where(sql `date(timezone('UTC', action_timestamp))`, '=', date)
30
+ .executeTakeFirst();
31
+ // start at previous visit in case action didnt finished to record
32
+ const count = Math.max(0, (result && parseInt(result.count) - 1) || 0);
33
+ return count;
34
+ });
35
+ /** import all event from givent date */
36
+ export const importDate = (piwikApi_1, date_1, ...args_1) => __awaiter(void 0, [piwikApi_1, date_1, ...args_1], void 0, function* (piwikApi, date, filterOffset = 0) {
37
+ const limit = parseInt(RESULTPERPAGE);
38
+ // Guard against misconfiguration that can cause infinite pagination loops
39
+ // (e.g. limit=NaN makes `visits.length < limit` always false).
40
+ if (!Number.isFinite(limit) || limit <= 0) {
41
+ throw new Error(`Invalid RESULTPERPAGE: expected a positive integer, got '${RESULTPERPAGE}'`);
42
+ }
43
+ const dateIso = isoDate(date);
44
+ let offset = filterOffset || (yield getRecordsCount(dateIso));
45
+ if (!Number.isFinite(offset) || offset < 0) {
46
+ offset = 0;
47
+ }
48
+ let pages = 0;
49
+ let visitsFetched = 0;
50
+ let eventsImported = 0;
51
+ while (true) {
52
+ pages += 1;
53
+ if (!offset) {
54
+ debug(`${dateIso}: load ${limit} visits`);
55
+ }
56
+ else {
57
+ debug(`${dateIso}: load ${limit} more visits after ${offset}`);
58
+ }
59
+ // fetch visits details
60
+ const visits = yield new Promise((resolve, reject) => piwikApi({
61
+ method: 'Live.getLastVisitsDetails',
62
+ period: 'day',
63
+ date: dateIso,
64
+ // minTimestamp: isoDate(new Date()) === isoDate(date) ? date.getTime() / 1000 : undefined, // if today, dont go further (??)
65
+ filter_limit: limit,
66
+ filter_offset: offset,
67
+ filter_sort_order: 'asc',
68
+ idSite: MATOMO_SITE
69
+ }, (err, visits = []) => {
70
+ if (err) {
71
+ return reject(err);
72
+ }
73
+ return resolve(visits);
74
+ }));
75
+ visitsFetched += visits.length;
76
+ debug(`fetched ${visits.length} visits`);
77
+ // flatten all events
78
+ const allEvents = visits.flatMap(getEventsFromMatomoVisit);
79
+ if (allEvents.length) {
80
+ debug(`import ${allEvents.length} events`);
81
+ // import events into PG
82
+ yield pAll(allEvents.map((event) => () => importEvent(event)), { concurrency: 10, stopOnError: true });
83
+ eventsImported += allEvents.length;
84
+ }
85
+ else {
86
+ debug(`no events to import on this page (${dateIso}, offset ${offset})`);
87
+ }
88
+ // stop if we didn't fetch a full page
89
+ if (visits.length < limit) {
90
+ break;
91
+ }
92
+ offset += limit;
93
+ }
94
+ debug(`finished importing ${dateIso}, pages ${pages}, visits ${visitsFetched}`);
95
+ return {
96
+ date: dateIso,
97
+ pages,
98
+ visitsFetched,
99
+ eventsImported
100
+ };
101
+ });