@tmlmobilidade/interfaces 20260514.1140.40 → 20260514.1436.37

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.
@@ -33,7 +33,7 @@ declare class RolesClass extends MongoCollectionClass<Role, CreateRoleDto, Updat
33
33
  };
34
34
  } | {
35
35
  scope: "sams";
36
- action: "read";
36
+ action: "read" | "export";
37
37
  } | {
38
38
  scope: "gtfs_validations";
39
39
  action: "create" | "read" | "lock" | "request_approval" | "update_processing_status";
@@ -163,7 +163,7 @@ declare class RolesClass extends MongoCollectionClass<Role, CreateRoleDto, Updat
163
163
  };
164
164
  } | {
165
165
  scope: "sams";
166
- action: "read";
166
+ action: "read" | "export";
167
167
  } | {
168
168
  scope: "gtfs_validations";
169
169
  action: "create" | "read" | "lock" | "request_approval" | "update_processing_status";
@@ -289,7 +289,7 @@ declare class RolesClass extends MongoCollectionClass<Role, CreateRoleDto, Updat
289
289
  };
290
290
  } | {
291
291
  scope: "sams";
292
- action: "read";
292
+ action: "read" | "export";
293
293
  } | {
294
294
  scope: "gtfs_validations";
295
295
  action: "create" | "read" | "lock" | "request_approval" | "update_processing_status";
@@ -57,7 +57,7 @@ declare class UsersClass extends MongoCollectionClass<User_UNSAFE, CreateUserDto
57
57
  };
58
58
  } | {
59
59
  scope: "sams";
60
- action: "read";
60
+ action: "read" | "export";
61
61
  } | {
62
62
  scope: "gtfs_validations";
63
63
  action: "create" | "read" | "lock" | "request_approval" | "update_processing_status";
@@ -194,7 +194,7 @@ declare class UsersClass extends MongoCollectionClass<User_UNSAFE, CreateUserDto
194
194
  };
195
195
  } | {
196
196
  scope: "sams";
197
- action: "read";
197
+ action: "read" | "export";
198
198
  } | {
199
199
  scope: "gtfs_validations";
200
200
  action: "create" | "read" | "lock" | "request_approval" | "update_processing_status";
@@ -335,7 +335,7 @@ declare class UsersClass extends MongoCollectionClass<User_UNSAFE, CreateUserDto
335
335
  };
336
336
  } | {
337
337
  scope: "sams";
338
- action: "read";
338
+ action: "read" | "export";
339
339
  } | {
340
340
  scope: "gtfs_validations";
341
341
  action: "create" | "read" | "lock" | "request_approval" | "update_processing_status";
@@ -472,7 +472,7 @@ declare class UsersClass extends MongoCollectionClass<User_UNSAFE, CreateUserDto
472
472
  };
473
473
  } | {
474
474
  scope: "sams";
475
- action: "read";
475
+ action: "read" | "export";
476
476
  } | {
477
477
  scope: "gtfs_validations";
478
478
  action: "create" | "read" | "lock" | "request_approval" | "update_processing_status";
@@ -0,0 +1,12 @@
1
+ import { type AggregationPipeline } from '../../../common/aggregation-pipeline.js';
2
+ import { type Sam } from '@tmlmobilidade/types';
3
+ /**
4
+ * Builds a pipeline that enumerates all distinct `latest_apex_version`
5
+ * values present in SAM documents (ignores per-analysis versions).
6
+ *
7
+ * @param matchAnd - Optional array of $match conditions (ANDed).
8
+ * @returns Aggregation pipeline for MongoDB.
9
+ */
10
+ export declare function samsApexVersionsAggregationPipeline({ matchAnd, }: {
11
+ matchAnd: Record<string, unknown>[];
12
+ }): AggregationPipeline<Sam>;
@@ -0,0 +1,16 @@
1
+ /* * */
2
+ /**
3
+ * Builds a pipeline that enumerates all distinct `latest_apex_version`
4
+ * values present in SAM documents (ignores per-analysis versions).
5
+ *
6
+ * @param matchAnd - Optional array of $match conditions (ANDed).
7
+ * @returns Aggregation pipeline for MongoDB.
8
+ */
9
+ export function samsApexVersionsAggregationPipeline({ matchAnd, }) {
10
+ return [
11
+ ...(matchAnd.length > 0 ? [{ $match: { $and: matchAnd } }] : []),
12
+ { $group: { _id: '$latest_apex_version' } },
13
+ { $match: { _id: { $nin: [null, ''] } } },
14
+ { $sort: { _id: -1 } },
15
+ ];
16
+ }
@@ -0,0 +1,44 @@
1
+ import { type AggregationPipeline } from '../../../common/aggregation-pipeline.js';
2
+ import { type Sam, type SamTimelineSummary } from '@tmlmobilidade/types';
3
+ /**
4
+ * `timeline_summary` on SAM list responses: months with `key`, `month`, `count`,
5
+ * `successful_count`, `failed_count`, plus optional `undated` (computed from `analysis`).
6
+ */
7
+ export type SamsBatchListTimelineSummary = SamTimelineSummary;
8
+ /**
9
+ * Builds a pipeline to return all matching SAMs for list view (no skip/limit).
10
+ * Excludes `analysis` from list fields; `timeline_summary` is computed from `analysis`.
11
+ *
12
+ * @param matchAnd - An array of $match conditions (ANDed).
13
+ * @returns Aggregation pipeline for MongoDB.
14
+ */
15
+ export declare function samsBatchAggregationPipeline({ matchAnd, }: {
16
+ matchAnd: Record<string, unknown>[];
17
+ }): AggregationPipeline<Sam>;
18
+ /**
19
+ * Same list row shape as {@link samsBatchAggregationPipeline} for explicit `_id`s (e.g. favorites).
20
+ */
21
+ export declare function samsByIdsListViewAggregationPipeline({ agencyIds, ids, restrictByAgency, }: {
22
+ agencyIds?: string[];
23
+ ids: number[];
24
+ restrictByAgency: boolean;
25
+ }): AggregationPipeline<Sam>;
26
+ /**
27
+ * Full SAM by id: all fields preserved; `timeline_summary` is recomputed from `analysis`.
28
+ */
29
+ export declare function samsByIdAggregationPipeline(id: number): AggregationPipeline<Sam>;
30
+ /**
31
+ * Returns stored timeline summary rows for explicit SAM `_id`s.
32
+ * Used for fast SAM list timeline hydration.
33
+ */
34
+ export declare function samsByIdsTimelineSummaryAggregationPipeline({ agencyIds, ids, restrictByAgency, }: {
35
+ agencyIds?: string[];
36
+ ids: number[];
37
+ restrictByAgency: boolean;
38
+ }): AggregationPipeline<Sam>;
39
+ /**
40
+ * Lightweight SAM list rows without timeline data (used for fast initial list loading).
41
+ */
42
+ export declare function samsBatchBaseAggregationPipeline({ matchAnd, }: {
43
+ matchAnd: Record<string, unknown>[];
44
+ }): AggregationPipeline<Sam>;
@@ -0,0 +1,217 @@
1
+ /* * */
2
+ import { samsAnalysisTimelineRowsExpr, samsDetailSnapExpr, samsListViewCarryFieldsExpr, samsTimelineSummaryFromBucketsExpr, } from './batch-timeline-from-analysis.js';
3
+ /**
4
+ * List view: derive `timeline_summary` from embedded `analysis` (same shape as
5
+ * {@link samsTimelineSummaryFromBucketsExpr}: month + counts per bucket).
6
+ */
7
+ function samsListFacetMergeTimelineStages() {
8
+ return [
9
+ {
10
+ $addFields: {
11
+ __carry: samsListViewCarryFieldsExpr(),
12
+ __rowCount: { $size: { $ifNull: ['$analysis', []] } },
13
+ },
14
+ },
15
+ {
16
+ $facet: {
17
+ emptyAnalysis: [
18
+ { $match: { __rowCount: 0 } },
19
+ {
20
+ $replaceRoot: {
21
+ newRoot: {
22
+ $mergeObjects: [
23
+ '$__carry',
24
+ { timeline_summary: { months: [], undated: null } },
25
+ ],
26
+ },
27
+ },
28
+ },
29
+ ],
30
+ fromAnalysis: [
31
+ { $match: { __rowCount: { $gt: 0 } } },
32
+ { $addFields: { __rows: samsAnalysisTimelineRowsExpr() } },
33
+ { $unwind: '$__rows' },
34
+ {
35
+ $group: {
36
+ _id: { m: '$__rows.monthKey', sam: '$__carry._id' },
37
+ __carry: { $first: '$__carry' },
38
+ f: { $sum: '$__rows.failed' },
39
+ s: { $sum: '$__rows.successful' },
40
+ },
41
+ },
42
+ {
43
+ $group: {
44
+ _id: '$_id.sam',
45
+ __carry: { $first: '$__carry' },
46
+ buckets: { $push: { f: '$f', m: '$_id.m', s: '$s' } },
47
+ },
48
+ },
49
+ { $addFields: { timeline_summary: samsTimelineSummaryFromBucketsExpr() } },
50
+ {
51
+ $replaceRoot: {
52
+ newRoot: {
53
+ $mergeObjects: [
54
+ '$__carry',
55
+ { timeline_summary: '$timeline_summary' },
56
+ ],
57
+ },
58
+ },
59
+ },
60
+ ],
61
+ },
62
+ },
63
+ {
64
+ $project: {
65
+ merged: { $concatArrays: ['$emptyAnalysis', '$fromAnalysis'] },
66
+ },
67
+ },
68
+ { $unwind: '$merged' },
69
+ { $replaceRoot: { newRoot: '$merged' } },
70
+ { $sort: { created_at: -1 } },
71
+ ];
72
+ }
73
+ function samsDetailFacetMergeTimelineStages() {
74
+ return [
75
+ {
76
+ $addFields: {
77
+ __rowCount: { $size: { $ifNull: ['$analysis', []] } },
78
+ __snap: samsDetailSnapExpr(),
79
+ },
80
+ },
81
+ {
82
+ $facet: {
83
+ emptyAnalysis: [
84
+ { $match: { __rowCount: 0 } },
85
+ {
86
+ $replaceRoot: {
87
+ newRoot: {
88
+ $mergeObjects: [
89
+ '$__snap',
90
+ { timeline_summary: { months: [], undated: null } },
91
+ ],
92
+ },
93
+ },
94
+ },
95
+ ],
96
+ fromAnalysis: [
97
+ { $match: { __rowCount: { $gt: 0 } } },
98
+ { $addFields: { __rows: samsAnalysisTimelineRowsExpr() } },
99
+ { $unwind: '$__rows' },
100
+ {
101
+ $group: {
102
+ _id: { m: '$__rows.monthKey', sam: '$__snap._id' },
103
+ __snap: { $first: '$__snap' },
104
+ analysis: { $first: '$analysis' },
105
+ f: { $sum: '$__rows.failed' },
106
+ s: { $sum: '$__rows.successful' },
107
+ },
108
+ },
109
+ {
110
+ $group: {
111
+ _id: '$_id.sam',
112
+ __snap: { $first: '$__snap' },
113
+ analysis: { $first: '$analysis' },
114
+ buckets: { $push: { f: '$f', m: '$_id.m', s: '$s' } },
115
+ },
116
+ },
117
+ { $addFields: { timeline_summary: samsTimelineSummaryFromBucketsExpr() } },
118
+ {
119
+ $replaceRoot: {
120
+ newRoot: {
121
+ $mergeObjects: [
122
+ '$__snap',
123
+ { timeline_summary: '$timeline_summary' },
124
+ { __analysis: '$analysis' },
125
+ ],
126
+ },
127
+ },
128
+ },
129
+ ],
130
+ },
131
+ },
132
+ {
133
+ $project: {
134
+ merged: { $concatArrays: ['$emptyAnalysis', '$fromAnalysis'] },
135
+ },
136
+ },
137
+ { $unwind: '$merged' },
138
+ { $replaceRoot: { newRoot: '$merged' } },
139
+ ];
140
+ }
141
+ /**
142
+ * Builds a pipeline to return all matching SAMs for list view (no skip/limit).
143
+ * Excludes `analysis` from list fields; `timeline_summary` is computed from `analysis`.
144
+ *
145
+ * @param matchAnd - An array of $match conditions (ANDed).
146
+ * @returns Aggregation pipeline for MongoDB.
147
+ */
148
+ export function samsBatchAggregationPipeline({ matchAnd, }) {
149
+ return [
150
+ ...(matchAnd.length > 0 ? [{ $match: { $and: matchAnd } }] : []),
151
+ { $sort: { created_at: -1 } },
152
+ ...samsListFacetMergeTimelineStages(),
153
+ ];
154
+ }
155
+ /**
156
+ * Same list row shape as {@link samsBatchAggregationPipeline} for explicit `_id`s (e.g. favorites).
157
+ */
158
+ export function samsByIdsListViewAggregationPipeline({ agencyIds, ids, restrictByAgency, }) {
159
+ const matchAnd = [{ _id: { $in: ids } }];
160
+ if (restrictByAgency && agencyIds?.length)
161
+ matchAnd.push({ agency_id: { $in: agencyIds } });
162
+ return [
163
+ { $match: { $and: matchAnd } },
164
+ { $sort: { created_at: -1 } },
165
+ ...samsListFacetMergeTimelineStages(),
166
+ ];
167
+ }
168
+ /**
169
+ * Full SAM by id: all fields preserved; `timeline_summary` is recomputed from `analysis`.
170
+ */
171
+ export function samsByIdAggregationPipeline(id) {
172
+ return [
173
+ { $match: { _id: id } },
174
+ ...samsDetailFacetMergeTimelineStages(),
175
+ ];
176
+ }
177
+ /**
178
+ * Returns stored timeline summary rows for explicit SAM `_id`s.
179
+ * Used for fast SAM list timeline hydration.
180
+ */
181
+ export function samsByIdsTimelineSummaryAggregationPipeline({ agencyIds, ids, restrictByAgency, }) {
182
+ const matchAnd = [{ _id: { $in: ids } }];
183
+ if (restrictByAgency && agencyIds?.length)
184
+ matchAnd.push({ agency_id: { $in: agencyIds } });
185
+ return [
186
+ { $match: { $and: matchAnd } },
187
+ {
188
+ $project: {
189
+ _id: 1,
190
+ timeline_summary: { $ifNull: ['$timeline_summary', { months: [] }] },
191
+ },
192
+ },
193
+ ];
194
+ }
195
+ /**
196
+ * Lightweight SAM list rows without timeline data (used for fast initial list loading).
197
+ */
198
+ function samsListBaseProjectionStages() {
199
+ return [
200
+ {
201
+ $project: {
202
+ analysis: 0,
203
+ timeline_summary: 0,
204
+ },
205
+ },
206
+ { $sort: { created_at: -1 } },
207
+ ];
208
+ }
209
+ /**
210
+ * Lightweight SAM list rows without timeline data (used for fast initial list loading).
211
+ */
212
+ export function samsBatchBaseAggregationPipeline({ matchAnd, }) {
213
+ return [
214
+ ...(matchAnd.length > 0 ? [{ $match: { $and: matchAnd } }] : []),
215
+ ...samsListBaseProjectionStages(),
216
+ ];
217
+ }
@@ -0,0 +1,7 @@
1
+ /** One row per analysis × month (or one undated row). Success = both transaction ids non-null. */
2
+ export declare function samsAnalysisTimelineRowsExpr(): Record<string, unknown>;
3
+ /** Build `timeline_summary` from grouped `buckets: { m, s, f }[]`. */
4
+ export declare function samsTimelineSummaryFromBucketsExpr(): Record<string, unknown>;
5
+ export declare function samsListViewCarryFieldsExpr(): Record<string, unknown>;
6
+ /** Full SAM snapshot for detail merge (excludes temp fields added later in the same stage). */
7
+ export declare function samsDetailSnapExpr(): Record<string, unknown>;
@@ -0,0 +1,217 @@
1
+ /* * */
2
+ /* eslint-disable perfectionist/sort-objects -- MongoDB aggregation readability */
3
+ const LISBON_TZ = 'Europe/Lisbon';
4
+ /** One row per analysis × month (or one undated row). Success = both transaction ids non-null. */
5
+ export function samsAnalysisTimelineRowsExpr() {
6
+ return {
7
+ $reduce: {
8
+ initialValue: [],
9
+ in: {
10
+ $let: {
11
+ vars: {
12
+ acc: '$$value',
13
+ success: {
14
+ $and: [
15
+ { $ne: ['$$this.first_transaction_id', null] },
16
+ { $ne: ['$$this.last_transaction_id', null] },
17
+ ],
18
+ },
19
+ },
20
+ in: {
21
+ // One binding per $let: MongoDB may evaluate same `vars` object in an order
22
+ // where `d0`/`d1` run before `startMs`/`endMs`, causing "undefined variable".
23
+ $let: {
24
+ vars: {
25
+ startMs: { $ifNull: ['$$this.start_time', '$$this.end_time'] },
26
+ },
27
+ in: {
28
+ $let: {
29
+ vars: {
30
+ endMs: { $ifNull: ['$$this.end_time', '$$this.start_time'] },
31
+ },
32
+ in: {
33
+ $let: {
34
+ vars: {
35
+ failN: { $cond: ['$$success', 0, 1] },
36
+ succN: { $cond: ['$$success', 1, 0] },
37
+ },
38
+ in: {
39
+ $cond: [
40
+ {
41
+ $or: [
42
+ { $eq: ['$$startMs', null] },
43
+ { $eq: ['$$endMs', null] },
44
+ ],
45
+ },
46
+ {
47
+ $concatArrays: [
48
+ '$$acc',
49
+ [{
50
+ failed: '$$failN',
51
+ monthKey: null,
52
+ successful: '$$succN',
53
+ }],
54
+ ],
55
+ },
56
+ {
57
+ $let: {
58
+ vars: {
59
+ month0: {
60
+ $dateTrunc: {
61
+ date: {
62
+ $toDate: {
63
+ $min: ['$$startMs', '$$endMs'],
64
+ },
65
+ },
66
+ unit: 'month',
67
+ timezone: LISBON_TZ,
68
+ },
69
+ },
70
+ monthLast: {
71
+ $dateTrunc: {
72
+ date: {
73
+ $toDate: {
74
+ $max: ['$$startMs', '$$endMs'],
75
+ },
76
+ },
77
+ unit: 'month',
78
+ timezone: LISBON_TZ,
79
+ },
80
+ },
81
+ },
82
+ in: {
83
+ $concatArrays: [
84
+ '$$acc',
85
+ {
86
+ $map: {
87
+ as: 'idx',
88
+ in: {
89
+ $let: {
90
+ vars: {
91
+ monthStart: {
92
+ $dateAdd: {
93
+ amount: '$$idx',
94
+ startDate: '$$month0',
95
+ timezone: LISBON_TZ,
96
+ unit: 'month',
97
+ },
98
+ },
99
+ },
100
+ in: {
101
+ failed: '$$failN',
102
+ monthKey: {
103
+ $dateToString: {
104
+ date: '$$monthStart',
105
+ format: '%Y-%m',
106
+ timezone: LISBON_TZ,
107
+ },
108
+ },
109
+ successful: '$$succN',
110
+ },
111
+ },
112
+ },
113
+ input: {
114
+ $range: [
115
+ 0,
116
+ {
117
+ $max: [
118
+ 1,
119
+ {
120
+ $add: [
121
+ {
122
+ $dateDiff: {
123
+ endDate: '$$monthLast',
124
+ startDate: '$$month0',
125
+ timezone: LISBON_TZ,
126
+ unit: 'month',
127
+ },
128
+ },
129
+ 1,
130
+ ],
131
+ },
132
+ ],
133
+ },
134
+ ],
135
+ },
136
+ },
137
+ },
138
+ ],
139
+ },
140
+ },
141
+ },
142
+ ],
143
+ },
144
+ },
145
+ },
146
+ },
147
+ },
148
+ },
149
+ },
150
+ },
151
+ },
152
+ input: { $ifNull: ['$analysis', []] },
153
+ },
154
+ };
155
+ }
156
+ /** Build `timeline_summary` from grouped `buckets: { m, s, f }[]`. */
157
+ export function samsTimelineSummaryFromBucketsExpr() {
158
+ return {
159
+ months: {
160
+ $map: {
161
+ as: 'x',
162
+ in: {
163
+ failed_count: '$$x.f',
164
+ month: '$$x.m',
165
+ successful_count: '$$x.s',
166
+ },
167
+ input: {
168
+ $sortArray: {
169
+ input: {
170
+ $filter: {
171
+ as: 'b',
172
+ cond: { $ne: ['$$b.m', null] },
173
+ input: '$buckets',
174
+ },
175
+ },
176
+ sortBy: { m: 1 },
177
+ },
178
+ },
179
+ },
180
+ },
181
+ };
182
+ }
183
+ export function samsListViewCarryFieldsExpr() {
184
+ return {
185
+ _id: '$_id',
186
+ agency_id: '$agency_id',
187
+ created_at: '$created_at',
188
+ latest_apex_version: '$latest_apex_version',
189
+ remarks: '$remarks',
190
+ seen_first_at: { $ifNull: ['$seen_first_at', null] },
191
+ seen_last_at: { $ifNull: ['$seen_last_at', null] },
192
+ system_status: '$system_status',
193
+ transactions_expected: '$transactions_expected',
194
+ transactions_found: '$transactions_found',
195
+ transactions_missing: '$transactions_missing',
196
+ };
197
+ }
198
+ /** Full SAM snapshot for detail merge (excludes temp fields added later in the same stage). */
199
+ export function samsDetailSnapExpr() {
200
+ return {
201
+ _id: '$_id',
202
+ agency_id: '$agency_id',
203
+ analysis: '$analysis',
204
+ created_at: '$created_at',
205
+ latest_apex_version: '$latest_apex_version',
206
+ remarks: '$remarks',
207
+ seen_first_at: '$seen_first_at',
208
+ seen_last_at: '$seen_last_at',
209
+ system_status: '$system_status',
210
+ timeline_summary: '$timeline_summary',
211
+ transactions_expected: '$transactions_expected',
212
+ transactions_found: '$transactions_found',
213
+ transactions_missing: '$transactions_missing',
214
+ updated_at: '$updated_at',
215
+ };
216
+ }
217
+ /* eslint-enable perfectionist/sort-objects */
@@ -0,0 +1,28 @@
1
+ import { type AggregationPipeline } from '../../../common/aggregation-pipeline.js';
2
+ import { type Sam } from '@tmlmobilidade/types';
3
+ /**
4
+ * Optional per-analysis filters after `$unwind` (e.g. export file `start_time` / `end_time`).
5
+ */
6
+ export interface SamsAnalysisExportAnalysisFilter {
7
+ /** If set, keep rows where `analysis.end_time` is less than or equal to this timestamp. */
8
+ end_time?: number;
9
+ /** If set, keep rows where `analysis.start_time` is greater than or equal to this timestamp. */
10
+ start_time?: number;
11
+ }
12
+ /**
13
+ * Builds a pipeline that returns one document per analysis record (no SAM pagination),
14
+ * for CSV / file export. SAM documents are restricted by optional {@link samIds} and/or
15
+ * {@link matchAnd} (ANDed together). Callers must pass at least one non-empty constraint;
16
+ * otherwise the pipeline would not filter SAMs before unwind.
17
+ * Then `analysis` is unwound and optionally narrowed by {@link SamsAnalysisExportAnalysisFilter}.
18
+ *
19
+ * @param samIds - Optional SAM `_id` values to include. When omitted or empty, no `_id` filter is applied.
20
+ * @param matchAnd - Optional list conditions ANDed with each other (and with `samIds` when set).
21
+ * @param analysisFilter - Optional filters on the unwound `analysis` subdocument.
22
+ * @returns Aggregation pipeline for MongoDB.
23
+ */
24
+ export declare function samsAnalysisExportAggregationPipeline({ analysisFilter, matchAnd, samIds }: {
25
+ analysisFilter?: SamsAnalysisExportAnalysisFilter;
26
+ matchAnd?: Record<string, unknown>[];
27
+ samIds?: number[];
28
+ }): AggregationPipeline<Sam>;
@@ -0,0 +1,57 @@
1
+ /* * */
2
+ /**
3
+ * Builds a pipeline that returns one document per analysis record (no SAM pagination),
4
+ * for CSV / file export. SAM documents are restricted by optional {@link samIds} and/or
5
+ * {@link matchAnd} (ANDed together). Callers must pass at least one non-empty constraint;
6
+ * otherwise the pipeline would not filter SAMs before unwind.
7
+ * Then `analysis` is unwound and optionally narrowed by {@link SamsAnalysisExportAnalysisFilter}.
8
+ *
9
+ * @param samIds - Optional SAM `_id` values to include. When omitted or empty, no `_id` filter is applied.
10
+ * @param matchAnd - Optional list conditions ANDed with each other (and with `samIds` when set).
11
+ * @param analysisFilter - Optional filters on the unwound `analysis` subdocument.
12
+ * @returns Aggregation pipeline for MongoDB.
13
+ */
14
+ export function samsAnalysisExportAggregationPipeline({ analysisFilter, matchAnd, samIds }) {
15
+ const analysisConditions = [];
16
+ if (analysisFilter?.end_time != null) {
17
+ analysisConditions.push({ 'analysis.end_time': { $lte: analysisFilter.end_time } });
18
+ }
19
+ if (analysisFilter?.start_time != null) {
20
+ analysisConditions.push({ 'analysis.start_time': { $gte: analysisFilter.start_time } });
21
+ }
22
+ const docMatchConditions = [];
23
+ if (samIds != null && samIds.length > 0) {
24
+ docMatchConditions.push({ _id: { $in: samIds } });
25
+ }
26
+ if (matchAnd != null && matchAnd.length > 0) {
27
+ docMatchConditions.push(...matchAnd);
28
+ }
29
+ const docMatchStage = docMatchConditions.length === 0
30
+ ? undefined
31
+ : docMatchConditions.length === 1
32
+ ? { $match: docMatchConditions[0] }
33
+ : { $match: { $and: docMatchConditions } };
34
+ return [
35
+ ...(docMatchStage ? [docMatchStage] : []),
36
+ { $sort: { created_at: -1 } },
37
+ { $unwind: { path: '$analysis', preserveNullAndEmptyArrays: false } },
38
+ ...(analysisConditions.length > 0 ? [{ $match: { $and: analysisConditions } }] : []),
39
+ { $sort: { '_id': 1, 'analysis.start_time': -1 } },
40
+ {
41
+ $project: {
42
+ _id: 0,
43
+ agency_id: 1,
44
+ analysis: 1,
45
+ created_at: 1,
46
+ latest_apex_version: 1,
47
+ remarks: 1,
48
+ seen_first_at: 1,
49
+ seen_last_at: 1,
50
+ system_status: 1,
51
+ transactions_expected: 1,
52
+ transactions_found: 1,
53
+ transactions_missing: 1,
54
+ },
55
+ },
56
+ ];
57
+ }
@@ -0,0 +1,3 @@
1
+ export * from './apex-versions.js';
2
+ export * from './batch-list.js';
3
+ export * from './export-analysis.js';
@@ -0,0 +1,3 @@
1
+ export * from './apex-versions.js';
2
+ export * from './batch-list.js';
3
+ export * from './export-analysis.js';
@@ -0,0 +1,7 @@
1
+ import { type GetSamsBatchQuery } from '@tmlmobilidade/types';
2
+ /**
3
+ * Builds `$match` AND clauses for SAM list / export from a parsed batch query (same semantics as the controller list).
4
+ */
5
+ export declare function buildSamsMatch(parsedQuery: GetSamsBatchQuery, options?: {
6
+ includeApexVersionFilter?: boolean;
7
+ }): Record<string, unknown>[];
@@ -0,0 +1,87 @@
1
+ /* * */
2
+ import { parseSamsDeviceSearch, parseSamsVehicleSearch } from './regex-search.js';
3
+ import { PermissionCatalog } from '@tmlmobilidade/types';
4
+ /* * */
5
+ /**
6
+ * Escapes a regex string.
7
+ */
8
+ function escapeRegex(value) {
9
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10
+ }
11
+ /**
12
+ * Reserved query fields that are not used in the aggregation pipeline.
13
+ */
14
+ const RESERVED_QUERY_FIELDS = new Set(['agency_ids', 'search']);
15
+ const RANGE_QUERY_FIELDS = {
16
+ seen_first_at: { field: 'seen_first_at', operator: '$gte' },
17
+ seen_last_at: { field: 'seen_last_at', operator: '$lte' },
18
+ };
19
+ /**
20
+ * Builds `$match` AND clauses for SAM list / export from a parsed batch query (same semantics as the controller list).
21
+ */
22
+ export function buildSamsMatch(parsedQuery, options = {}) {
23
+ const { includeApexVersionFilter = true } = options;
24
+ const matchAnd = [];
25
+ const agencyIdsForMatch = parsedQuery.agency_ids.filter(id => id !== PermissionCatalog.ALLOW_ALL_FLAG);
26
+ if (agencyIdsForMatch.length > 0) {
27
+ matchAnd.push({ agency_id: { $in: agencyIdsForMatch } });
28
+ }
29
+ const searchRaw = parsedQuery.search?.trim() ?? '';
30
+ if (searchRaw.length > 0) {
31
+ const vehicleIds = parseSamsVehicleSearch(searchRaw);
32
+ if (vehicleIds.length > 0) {
33
+ matchAnd.push({
34
+ $or: [
35
+ { vehicle_id: { $in: vehicleIds } },
36
+ { 'analysis.vehicle_id': { $in: vehicleIds } },
37
+ ],
38
+ });
39
+ }
40
+ else {
41
+ const deviceIds = parseSamsDeviceSearch(searchRaw);
42
+ if (deviceIds.length > 0) {
43
+ matchAnd.push({
44
+ $or: [
45
+ { device_id: { $in: deviceIds } },
46
+ { 'analysis.device_id': { $in: deviceIds } },
47
+ ],
48
+ });
49
+ }
50
+ else {
51
+ const escaped = escapeRegex(searchRaw);
52
+ matchAnd.push({
53
+ $or: [
54
+ { $expr: { $regexMatch: { input: { $toString: '$_id' }, options: 'i', regex: escaped } } },
55
+ { agency_id: { $options: 'i', $regex: escaped } },
56
+ { 'analysis.first_transaction_id': { $options: 'i', $regex: escaped } },
57
+ { 'analysis.last_transaction_id': { $options: 'i', $regex: escaped } },
58
+ ],
59
+ });
60
+ }
61
+ }
62
+ }
63
+ for (const [key, value] of Object.entries(parsedQuery)) {
64
+ if (RESERVED_QUERY_FIELDS.has(key) || value === undefined || value === null || value === '')
65
+ continue;
66
+ if (!includeApexVersionFilter && key === 'latest_apex_version')
67
+ continue;
68
+ if (key in RANGE_QUERY_FIELDS) {
69
+ const rangeKey = key;
70
+ const rangeConfig = RANGE_QUERY_FIELDS[rangeKey];
71
+ matchAnd.push({ [rangeConfig.field]: { [rangeConfig.operator]: value } });
72
+ continue;
73
+ }
74
+ if (Array.isArray(value)) {
75
+ if (value.length > 0) {
76
+ if (key === 'latest_apex_version') {
77
+ matchAnd.push({ latest_apex_version: { $in: value } });
78
+ continue;
79
+ }
80
+ matchAnd.push({ [key]: { $in: value } });
81
+ }
82
+ continue;
83
+ }
84
+ matchAnd.push({ [key]: value });
85
+ }
86
+ return matchAnd;
87
+ }
@@ -1 +1,4 @@
1
+ export * from './aggregations/index.js';
2
+ export * from './build-sams-match.js';
3
+ export * from './regex-search.js';
1
4
  export * from './sams.js';
@@ -1 +1,4 @@
1
+ export * from './aggregations/index.js';
2
+ export * from './build-sams-match.js';
3
+ export * from './regex-search.js';
1
4
  export * from './sams.js';
@@ -0,0 +1,18 @@
1
+ export declare const SAMS_VEHICLE_SEARCH_REGEX: RegExp;
2
+ export declare const SAMS_DEVICE_SEARCH_REGEX: RegExp;
3
+ /**
4
+ * Parses a sams vehicle search string (e.g., "v:123,456").
5
+ * Returns a deduplicated array of vehicle IDs as numbers.
6
+ * Returns an empty array if the pattern does not match.
7
+ *
8
+ * @param searchRaw - The raw vehicle search string
9
+ */
10
+ export declare function parseSamsVehicleSearch(searchRaw: string): number[];
11
+ /**
12
+ * Parses a sams device search string (e.g., "d:DEVICE1,DEVICE2").
13
+ * Returns a deduplicated array of device IDs as strings.
14
+ * Returns an empty array if the pattern does not match.
15
+ *
16
+ * @param searchRaw - The raw device search string
17
+ */
18
+ export declare function parseSamsDeviceSearch(searchRaw: string): string[];
@@ -0,0 +1,53 @@
1
+ /* * */
2
+ // Regex to match vehicle searches in the format: "v:123,456,789"
3
+ export const SAMS_VEHICLE_SEARCH_REGEX = /^v:(?<vehicleIds>\d+(?:\s*,\s*\d+)*)$/i;
4
+ // Regex to match device searches in the format: "d:A1,B2,C3"
5
+ export const SAMS_DEVICE_SEARCH_REGEX = /^d:(?<deviceIds>[^,\s]+(?:\s*,\s*[^,\s]+)*)$/i;
6
+ /**
7
+ * Parses a sams vehicle search string (e.g., "v:123,456").
8
+ * Returns a deduplicated array of vehicle IDs as numbers.
9
+ * Returns an empty array if the pattern does not match.
10
+ *
11
+ * @param searchRaw - The raw vehicle search string
12
+ */
13
+ export function parseSamsVehicleSearch(searchRaw) {
14
+ // Remove any leading/trailing whitespace
15
+ const normalizedSearch = searchRaw.trim();
16
+ // Execute regex to extract vehicleIds group
17
+ const regexMatch = SAMS_VEHICLE_SEARCH_REGEX.exec(normalizedSearch);
18
+ // If there is no match or vehicleIds group, return []
19
+ if (!regexMatch?.groups?.vehicleIds)
20
+ return [];
21
+ // Split by comma, trim whitespace, convert to numbers, and filter invalid numbers
22
+ // Use Set to remove duplicates
23
+ return [
24
+ ...new Set(regexMatch.groups.vehicleIds
25
+ .split(',')
26
+ .map(item => Number(item.trim()))
27
+ .filter(item => Number.isInteger(item))),
28
+ ];
29
+ }
30
+ /**
31
+ * Parses a sams device search string (e.g., "d:DEVICE1,DEVICE2").
32
+ * Returns a deduplicated array of device IDs as strings.
33
+ * Returns an empty array if the pattern does not match.
34
+ *
35
+ * @param searchRaw - The raw device search string
36
+ */
37
+ export function parseSamsDeviceSearch(searchRaw) {
38
+ // Remove any leading/trailing whitespace
39
+ const normalizedSearch = searchRaw.trim();
40
+ // Execute regex to extract deviceIds group
41
+ const regexMatch = SAMS_DEVICE_SEARCH_REGEX.exec(normalizedSearch);
42
+ // If there is no match or deviceIds group, return []
43
+ if (!regexMatch?.groups?.deviceIds)
44
+ return [];
45
+ // Split by comma, trim whitespace, filter out empty values
46
+ // Use Set to remove duplicates
47
+ return [
48
+ ...new Set(regexMatch.groups.deviceIds
49
+ .split(',')
50
+ .map(item => item.trim())
51
+ .filter(Boolean)),
52
+ ];
53
+ }
@@ -22,7 +22,12 @@ class SamsClass extends MongoCollectionClass {
22
22
  return [
23
23
  { background: true, key: { created_at: 1 } },
24
24
  { background: true, key: { agency_id: 1 } },
25
+ { background: true, key: { agency_id: 1, created_at: -1 } },
26
+ { background: true, key: { latest_apex_version: 1 } },
27
+ { background: true, key: { created_at: -1, latest_apex_version: 1 } },
25
28
  { background: true, key: { mac_sam_serial_number: 1 } },
29
+ { background: true, key: { seen_first_at: 1 } },
30
+ { background: true, key: { seen_last_at: 1 } },
26
31
  ];
27
32
  }
28
33
  getCollectionName() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmlmobilidade/interfaces",
3
- "version": "20260514.1140.40",
3
+ "version": "20260514.1436.37",
4
4
  "author": {
5
5
  "email": "iso@tmlmobilidade.pt",
6
6
  "name": "TML-ISO"