@tmlmobilidade/interfaces
Version:
This package provides SDK-style connectors for interacting with databases (e.g., stops, plans, rides, alerts) and external providers (e.g., authentication, storage). It simplifies data access and integration across projects.
481 lines (480 loc) • 21.5 kB
JavaScript
import { Dates } from '@tmlmobilidade/dates';
//
// Time thresholds
const LAST_SEEN_WINDOW = 30 * 1000; // 30 seconds
const OPERATIONAL_WINDOW = 60 * 1000 * 10; // 10 minutes
const DELAY_THRESHOLDS = {
delayed: 5 * 60 * 1000, // 5 minutes after scheduled time
early: -1 * 60 * 1000, // 1 minute before scheduled time
};
/**
* Creates MongoDB aggregation pipeline stages to calculate and categorize delay statuses.
*
* This function generates three aggregation stages:
* 1. Calculates delay differences (in milliseconds) between scheduled and observed times
* 2. Categorizes delays into statuses: 'delayed', 'early', 'ontime', or 'none'
* 3. Removes intermediate calculation fields
*
* Delay thresholds:
* - Delayed: > 5 minutes (300000 ms)
* - Early: < -1 minute (-60000 ms)
* - On-time: between -1 minute and 5 minutes
* - None: missing scheduled or observed time data
*
* @returns {Array} Array of MongoDB aggregation pipeline stages
*/
export function ridesPipelineDelayStatus({ filter } = {}) {
const pipeline = [
// Stage 1: Calculate delay differences in milliseconds
// Only compute if both scheduled and observed times exist
{
$addFields: {
end_delay_diff: {
$cond: {
else: null,
if: { $and: [
{ $ifNull: ['$end_time_scheduled', false] },
{ $ifNull: ['$end_time_observed', false] },
] },
then: { $subtract: ['$end_time_observed', '$end_time_scheduled'] },
},
},
start_delay_diff: {
$cond: {
else: null,
if: { $and: [
{ $ifNull: ['$start_time_scheduled', false] },
{ $ifNull: ['$start_time_observed', false] },
] },
then: { $subtract: ['$start_time_observed', '$start_time_scheduled'] },
},
},
},
},
// Stage 2: Categorize delays into status strings
// Uses switch statement to classify based on delay thresholds
{
$addFields: {
end_delay_status: {
$switch: {
branches: [
// Delayed: > 5 minutes (300000 ms)
{ case: { $gt: ['$end_delay_diff', DELAY_THRESHOLDS.delayed] }, then: 'delayed' },
// Early: < -1 minute (-60000 ms)
{ case: { $lt: ['$end_delay_diff', DELAY_THRESHOLDS.early] }, then: 'early' },
// On-time: between -1 minute and 5 minutes
{ case: { $and: [
{ $gte: ['$end_delay_diff', DELAY_THRESHOLDS.early] },
{ $lte: ['$end_delay_diff', DELAY_THRESHOLDS.delayed] },
] }, then: 'ontime' },
],
default: 'none',
},
},
start_delay_status: {
$switch: {
branches: [
// Delayed: > 5 minutes (300000 ms)
{ case: { $gt: ['$start_delay_diff', DELAY_THRESHOLDS.delayed] }, then: 'delayed' },
// Early: < -1 minute (-60000 ms)
{ case: { $lt: ['$start_delay_diff', DELAY_THRESHOLDS.early] }, then: 'early' },
// On-time: between -1 minute and 5 minutes
{ case: { $and: [
{ $gte: ['$start_delay_diff', DELAY_THRESHOLDS.early] },
{ $lte: ['$start_delay_diff', DELAY_THRESHOLDS.delayed] },
] }, then: 'ontime' },
],
default: 'none',
},
},
},
},
// Stage 3: Remove intermediate calculation fields
// These fields were only used for status calculation and are not needed in final output
{ $project: { end_delay_diff: 0, start_delay_diff: 0 } },
];
// Stage 5: Filter by delay status if provided
if (filter?.end_delay_status) {
pipeline.push({ $match: { end_delay_status: { $in: filter.end_delay_status } } });
}
if (filter?.start_delay_status) {
pipeline.push({ $match: { start_delay_status: { $in: filter.start_delay_status } } });
}
return pipeline;
}
/**
* Creates MongoDB aggregation pipeline stages to calculate and categorize operational statuses.
*
* This function generates four aggregation stages:
* 1. Adds the current timestamp (now) to each document
* 2. Calculates time differences from last seen and from start time
* 3. Categorizes operational status: 'scheduled', 'missed', 'running', or 'ended'
* 4. Removes intermediate calculation fields
*
* Operational status logic:
* - Scheduled: within 10 minutes of start time and never seen
* - Missed: more than 10 minutes after start time and never seen
* - Running: last seen within 10 minutes
* - Ended: default fallback (last seen more than 10 minutes ago)
*
* Time thresholds:
* - Operational window: 10 minutes (600000 ms)
*
* @param {number} now - Current timestamp in milliseconds
* @returns {Array} Array of MongoDB aggregation pipeline stages
*/
export function ridesPipelineOperationalStatus({ filter } = {}) {
const now = Dates.now('Europe/Lisbon').unix_timestamp;
const pipeline = [
// Stage 1: Add current timestamp to each document
{ $addFields: { now } },
// Stage 2: Calculate time differences from last seen and from start time
// Only compute if the relevant fields exist
{
$addFields: {
milliseconds_from_last_seen_to_now: {
$cond: {
else: null,
if: { $ifNull: ['$seen_last_at', false] },
then: { $subtract: ['$now', '$seen_last_at'] },
},
},
milliseconds_from_start_to_now: { $subtract: ['$now', '$start_time_scheduled'] },
},
},
// Stage 3: Categorize operational status using switch statement
{
$addFields: {
operational_status: {
$switch: {
branches: [
// Scheduled: within 10 minutes of start time and never seen
{
case: {
$and: [
{ $lte: ['$milliseconds_from_start_to_now', OPERATIONAL_WINDOW] },
{ $or: [{ $eq: ['$seen_last_at', null] }, { $not: ['$seen_last_at'] }] },
],
},
then: 'scheduled',
},
// Missed: more than 10 minutes after start time and never seen
{
case: {
$and: [
{ $gt: ['$milliseconds_from_start_to_now', OPERATIONAL_WINDOW] },
{ $or: [{ $eq: ['$seen_last_at', null] }, { $not: ['$seen_last_at'] }] },
],
},
then: 'missed',
},
// Running: last seen within 10 minutes
{
case: { $lte: ['$milliseconds_from_last_seen_to_now', OPERATIONAL_WINDOW] },
then: 'running',
},
],
default: 'ended',
},
},
},
},
// Stage 4: Remove intermediate calculation fields
// These fields were only used for status calculation and are not needed in final output
{
$project: {
milliseconds_from_last_seen_to_now: 0,
milliseconds_from_start_to_now: 0,
now: 0,
},
},
];
// Stage 5: Filter by operational status if provided
if (filter?.operational_status) {
pipeline.push({ $match: { operational_status: { $in: filter.operational_status } } });
}
return pipeline;
}
/**
* Creates MongoDB aggregation pipeline stages to calculate and categorize seen statuses.
*
* This function generates three aggregation stages:
* 1. Adds the current timestamp (now) to each document
* 2. Calculates time difference from last seen to now
* 3. Categorizes seen status: 'gone', 'seen', or 'unseen'
* 4. Removes intermediate calculation fields
*
* Seen status logic:
* - Gone: last seen more than 30 seconds ago
* - Seen: last seen within 30 seconds
* - Unseen: no last seen time
*
* Time thresholds:
* - Seen window: 30 seconds (30000 ms)
*
* @param {number} now - Current timestamp in milliseconds
* @returns {Array} Array of MongoDB aggregation pipeline stages
*/
export function ridesPipelineSeenStatus({ filter } = {}) {
const now = Dates.now('Europe/Lisbon').unix_timestamp;
const pipeline = [
// Stage 1: Add current timestamp to each document
{ $addFields: { now } },
// Stage 2: Calculate time difference from last seen to now
// Only compute if the last seen time exists
{
$addFields: {
milliseconds_from_last_seen_to_now: {
$cond: {
else: null,
if: { $ifNull: ['$seen_last_at', false] },
then: { $subtract: ['$now', '$seen_last_at'] },
},
},
},
},
// Stage 3: Categorize seen status using switch statement
{
$addFields: {
seen_status: {
$switch: {
branches: [
{ case: { $eq: ['$seen_last_at', null] }, then: 'unseen' },
{ case: { $lte: ['$milliseconds_from_last_seen_to_now', LAST_SEEN_WINDOW] }, then: 'seen' },
],
default: 'gone',
},
},
},
},
// Stage 4: Remove intermediate calculation fields
// These fields were only used for status calculation and are not needed in final output
{
$project: {
milliseconds_from_last_seen_to_now: 0,
now: 0,
},
},
];
// Stage 5: Filter by seen status if provided
if (filter?.seen_status) {
pipeline.push({ $match: { seen_status: { $in: filter.seen_status } } });
}
return pipeline;
}
export function ridesPipelineTicketingStatus({ filter } = {}) {
const pipeline = [];
if (!filter?.ticketing_status?.length)
return pipeline;
const includesHasTicketing = filter.ticketing_status.includes('has_ticketing');
const includesNoTicketing = filter.ticketing_status.includes('no_ticketing');
// If both are present, match all documents (no filter needed)
if (includesHasTicketing && includesNoTicketing)
return pipeline;
if (includesHasTicketing) {
pipeline.push({ $match: { apex_validations_qty: { $gte: 1 } } });
}
if (includesNoTicketing) {
pipeline.push({ $match: { apex_validations_qty: { $eq: 0 } } });
}
return pipeline;
}
/**
* Attempts to map search term to specific indexed field based on structural
* heuristics. Returns null when term does not match known pattern.
*/
function routeTermToField(term) {
// Exact pattern_id match: "1001_0_2"
if (/^\d+_\d+_\d+$/.test(term)) {
return { pattern_id: term };
}
// Wildcard trip_id: any string containing %%; %% → regex .*
// NOTE: case-sensitive (no $options: 'i') so anchored prefix regex can use
// the { trip_id: 1 } / { trip_id: 1, start_time_scheduled: 1 } index.
if (term.includes('%%')) {
let escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
escaped = escaped.replace(/%%/g, '.*');
return { trip_id: { $regex: `^${escaped}$` } };
}
// route_id: "1001_0"
if (/^\d+_\d+$/.test(term)) {
return { route_id: term };
}
// operational_date: "20240101"
if (/^\d{8}$/.test(term)) {
return { operational_date: term };
}
// line_id: pure integer string like "1001"
// agency_id also numeric, so use length heuristic
if (/^\d+$/.test(term)) {
const n = Number(term);
if (term.length >= 3)
return { line_id: n };
}
return null;
}
function buildSearchPipeline(filter) {
const pipeline = [];
if (!filter.search)
return pipeline;
const vehicleMatch = filter.search.match(/v:([\d,]+)/);
const driverMatch = filter.search.match(/d:([\d,]+)/);
const rawSearch = filter.search
.replace(/v:[\d,]+/g, '')
.replace(/d:[\d,]+/g, '')
.trim();
if (rawSearch.length > 0) {
const terms = rawSearch
.split(/\s+/)
.filter(k => k.length > 0);
const conditions = [];
const unroutableTerms = [];
const routedFields = new Set();
for (const term of terms) {
const routed = routeTermToField(term);
if (routed) {
const [field] = Object.keys(routed);
if (routedFields.has(field)) {
unroutableTerms.push(term);
continue;
}
routedFields.add(field);
conditions.push(routed);
}
else {
unroutableTerms.push(term);
}
}
if (unroutableTerms.length > 0) {
// Anchor first term as prefix on trip_id so the query can use the
// { trip_id: 1, start_time_scheduled: 1 } compound index.
// Remaining terms become plain contains-regex (no index, but bounded
// by the prefix scan of the first term).
// NOTE: case-sensitive; adding $options: 'i' disables index usage.
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const [firstTerm, ...restTerms] = unroutableTerms;
conditions.push({ trip_id: { $regex: `^${escape(firstTerm)}` } });
for (const rest of restTerms) {
conditions.push({ trip_id: { $regex: escape(rest) } });
}
}
if (conditions.length === 1) {
pipeline.push({ $match: conditions[0] });
}
else if (conditions.length > 1) {
pipeline.push({ $match: { $and: conditions } });
}
}
if (vehicleMatch) {
const vehicleIDs = vehicleMatch[1]
.split(',')
.map(id => Number(id.trim()))
.filter(id => !isNaN(id));
if (vehicleIDs.length > 0) {
pipeline.push({ $match: { vehicle_ids: { $in: vehicleIDs } } });
}
}
if (driverMatch) {
const driverIDs = driverMatch[1]
.split(',')
.map(id => id.trim())
.filter(Boolean);
if (driverIDs.length > 0) {
pipeline.push({ $match: { driver_ids: { $in: driverIDs } } });
}
}
return pipeline;
}
/**
* Creates MongoDB aggregation pipeline stages to filter and process ride data.
*
* This function generates an aggregation pipeline with the following stages:
* 1. Filters rides by scheduled time range (date_start to date_end)
* 2. Optionally filters by line IDs
* 3. Optionally filters by agency IDs
* 4. Optionally searches rides by ride ID using regex pattern matching and, if present, by vehicle IDs in the search string (with support for "v:1117,1118" etc.)
* 5. Adds acceptance status by joining from the ride_acceptances collection
* 6. Filters by analysis grades (ended_at_last_stop, expected_apex_validation_interval, simple_three_vehicle_events)
* 7. Filters by acceptance status (excluding 'none' if present)
* 8. Applies delay, operational, and seen status filters using dedicated pipeline functions
*
* @param {Object} params - Parameters object
* @param {RidesPipelineFilter} params.filter - Filter criteria for rides
* @returns {AggregationPipeline<Ride>} Array of MongoDB aggregation pipeline stages
*/
export function ridesBatchAggregationPipeline({ ...filter }) {
const pipeline = [];
// Stage 1: Filter by scheduled time range (required)
if ('date_start' in filter && 'date_end' in filter) {
pipeline.push({ $match: { start_time_scheduled: { $gte: filter.date_start, $lte: filter.date_end } } });
}
else if ('operational_date_start' in filter && 'operational_date_end' in filter) {
pipeline.push({ $match: { operational_date: { $gte: filter.operational_date_start, $lte: filter.operational_date_end } } });
}
else {
throw new Error('Either date_start and date_end or operational_date_start and operational_date_end must be provided');
}
// Stage 2: Filter by agency IDs (required)
pipeline.push({ $match: { agency_id: { $in: filter.agency_ids ?? [] } } });
// Stage 2.1: Sort by start_time_scheduled
pipeline.push({ $sort: { start_time_scheduled: 1 } });
// Stage 3: Filter by line IDs if provided
if (filter.line_ids?.length)
pipeline.push({ $match: { line_id: { $in: filter.line_ids.map(id => Number(id)) } } });
// Stage 4: Search by term routing and selective fallback regex
pipeline.push(...buildSearchPipeline(filter));
// Stage 5: Search by vehicle IDs from search field if provided (legacy support)
// This allows filtering via search field with "v:1117" or "v:1117,1118" format
// Remove literal 'v:' from the search string since it is breaking stuff
// Stage 6: Add acceptance status from ride_acceptances collection
// Lookup joins acceptance data, unwinds to flatten, adds status field, and removes intermediate data
pipeline.push({ $lookup: { as: 'acceptance', foreignField: 'ride_id', from: 'ride_acceptances', localField: '_id' } }, { $unwind: { path: '$acceptance', preserveNullAndEmptyArrays: true } }, { $addFields: { acceptance_status: '$acceptance.acceptance_status' } }, { $project: { acceptance: 0 } });
// Stage 7: Filter by analysis grades
// Maps filter fields to their corresponding analysis paths in the document
const analysisFilters = [
{ field: 'analysis_ended_at_last_stop_grade', path: 'analysis.ENDED_AT_LAST_STOP.grade' },
{ field: 'analysis_expected_apex_validation_interval', path: 'analysis.EXPECTED_APEX_VALIDATION_INTERVAL.grade' },
{ field: 'analysis_simple_three_vehicle_events_grade', path: 'analysis.SIMPLE_THREE_VEHICLE_EVENTS.grade' },
{ field: 'analysis_transaction_sequentiality', path: 'analysis.TRANSACTION_SEQUENTIALITY.grade' },
];
analysisFilters.forEach(({ field, path }) => {
if (!filter[field])
return;
if (filter[field].includes('none')) {
// When 'none' is included, set the field to 'none'
// if it doesn't exist, then match on the filter array
pipeline.push({
$addFields: {
[path]: { $ifNull: [`$${path}`, 'none'] },
},
});
}
// Match documents where the field value is in the filter array
pipeline.push({
$match: {
[path]: { $in: filter[field] },
},
});
});
// Stage 8: Filter by acceptance status
// Only applies filter if acceptance_status is provided and doesn't include 'none'
if (filter.acceptance_status && filter.acceptance_status) {
if (filter.acceptance_status.includes('none')) {
// When 'none' is included, set the field to 'none'
// if it doesn't exist, then match on the filter array
pipeline.push({
$addFields: {
acceptance_status: { $ifNull: [`$acceptance_status`, 'none'] },
},
});
}
pipeline.push({ $match: { acceptance_status: { $exists: true } } }, { $match: { acceptance_status: { $in: filter.acceptance_status } } });
}
// Stage 9: Apply status filters using dedicated pipeline functions
// These functions add calculated status fields and filter by them
pipeline.push(...ridesPipelineDelayStatus({ filter: { end_delay_status: filter.delay_statuses, start_delay_status: filter.delay_statuses } }));
pipeline.push(...ridesPipelineOperationalStatus({ filter: { operational_status: filter.operational_statuses } }));
pipeline.push(...ridesPipelineSeenStatus({ filter: { seen_status: filter.seen_statuses } }));
pipeline.push(...ridesPipelineTicketingStatus({ filter: { ticketing_status: filter.ticketing_status } }));
return pipeline;
}