fix(notifications): take today from the instance's TZ and catch up missed trip reminders

The reminder crons run at 09:00 in the instance's TZ but computed today
in UTC, so east of Greenwich a trip reminder arrived a day late. A day
the job did not run also lost that day's trip reminders for good: the
match was 'starts exactly N days from today'. Trips now record the start
date a reminder went out for, and the job sends every reminder that is
due and not yet sent. The active-trip redirect and the upcoming
reservations use the instance's date and clock as well.
This commit is contained in:
Maurice
2026-10-07 16:16:26 +02:00
parent e0aedbb2c6
commit a8fc93dfde
10 changed files with 119 additions and 31 deletions
+2
View File
@@ -52,6 +52,7 @@ export class Trips {
feed_token?: string | null;
created_at?: string | null;
updated_at?: string | null;
reminder_sent_for?: string | null;
budget_category_order_collection = new Collection<BudgetCategoryOrder>(this);
budget_items_collection = new Collection<BudgetItems>(this);
budget_settlements_collection = new Collection<BudgetSettlements>(this);
@@ -112,6 +113,7 @@ export const TripsSchema = defineEntity({
feed_token: p.text().nullable(),
created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`).index('idx_trips_created_at'),
updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
reminder_sent_for: p.text().nullable(),
budget_category_order_collection: () => p.oneToMany(BudgetCategoryOrder).mappedBy('trip').hidden(),
budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('trip').hidden(),
budget_settlements_collection: () => p.oneToMany(BudgetSettlements).mappedBy('trip').hidden(),
@@ -0,0 +1,25 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* A trip reminder remembers the start date it was sent for. The daily job
* used to match only "starts exactly N days from today", so a day the server
* was down at the job's hour lost that day's reminders for good. With the
* date on record the job sends every reminder that is due and not yet sent,
* and a trip moved to a new start date is reminded again.
*
* Trips whose reminder day already passed are marked as sent, so the first
* run after the upgrade does not repeat reminders that went out before.
*/
export class Migration20200101042100_a_trip_reminder_remembers_it_was_sent extends Migration {
override name = 'Migration20200101042100_a_trip_reminder_remembers_it_was_sent';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'trips', 'reminder_sent_for', 'reminder_sent_for TEXT');
this.addSql(`
UPDATE trips SET reminder_sent_for = start_date
WHERE start_date IS NOT NULL AND reminder_days > 0
AND date(start_date, '-' || reminder_days || ' days') < date('now')
`);
}
}
@@ -34,6 +34,7 @@ export interface TripRawRow {
feed_token: string | null;
created_at: string | null;
updated_at: string | null;
reminder_sent_for: string | null;
}
const _tripRawRowKeys: AssertRowKeys<TripRawRow, Trips> = true;
@@ -76,6 +77,7 @@ interface TripSelectKyselyDB {
feed_token: string | null;
created_at: string | null;
updated_at: string | null;
reminder_sent_for: string | null;
};
users: { id: number; username: string };
trip_members: { id: number; trip_id: number; user_id: number };
@@ -882,11 +884,16 @@ export class TripsRepository extends TrekRepository<Trips> {
*/
async listReminderCandidates(): Promise<TripReminderCandidateRow[]> {
return await this.qb('t')
.select(['t.id', 't.title', 't.user', 't.reminder_days', 't.start_date'])
.select(['t.id', 't.title', 't.user', 't.reminder_days', 't.start_date', 't.reminder_sent_for'])
.andWhere({ reminder_days: { $gt: 0 }, start_date: { $ne: null } })
.execute<TripReminderCandidateRow[]>('all', false);
}
/** `UPDATE trips SET reminder_sent_for = ? WHERE id = ?`: the start date the reminder went out for. */
async markReminderSent(id: number, startDate: string): Promise<void> {
await this.nativeUpdate({ id }, { reminder_sent_for: startDate });
}
// ---------------------------------------------------------------------------
// Plan 3f Task 6 (`SystemNoticesService`) — additive.
// ---------------------------------------------------------------------------
@@ -1076,6 +1083,7 @@ export interface TripReminderCandidateRow {
user_id: number;
reminder_days: number;
start_date: string;
reminder_sent_for: string | null;
}
/** `PublicApiService.listTrips`/`getTrip`'s 8-column trip projection. */
+12
View File
@@ -1,4 +1,5 @@
import tzlookup from 'tz-lookup';
import { readEnv } from '../../app-config';
/** Local calendar date and clock time for an instant in an IANA timezone. */
export function localParts(iso: string | null, timezone: string | null): { date: string | null; time: string | null } {
@@ -34,3 +35,14 @@ export function resolveTimeZone(lat: unknown, lng: unknown): string | null {
return null;
}
}
/**
* Today's date and the clock time where the instance runs, in the TZ the crons
* are scheduled in (CronRegistrarService). A UTC "today" disagreed with the
* schedule: east of Greenwich a 09:00 job saw the previous date.
*/
export function appClock(now: Date = new Date()): { date: string; time: string } {
const { date, time } = localParts(now.toISOString(), readEnv().app.tz || null);
return { date: date ?? now.toISOString().slice(0, 10), time: time ?? now.toISOString().slice(11, 16) };
}
@@ -9,6 +9,8 @@ import { Trips } from '../../db/entities/Trips.entity';
import { TodoItems } from '../../db/entities/TodoItems.entity';
import { NotificationsService } from './notifications.service';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { addIsoDays } from '@trek/shared';
import { appClock } from '../common/timezoneService';
/**
* The trip-reminder and todo-due reminder crons, in the domain that owns them
@@ -75,28 +77,27 @@ export class ReminderJobsService implements OnApplicationBootstrap {
this.registrar.register('todo-reminders', '0 9 * * *', () => this.todoTick());
}
/** Daily check for trips starting exactly reminder_days from now. */
/**
* Daily check for trips whose reminder is due: the reminder day has come,
* the trip has not started yet, and no reminder went out for this start
* date. A day the job did not run is caught up on the next one, and a trip
* moved to a new start date is reminded again. "Today" is the date in the
* TZ the job is scheduled in, not UTC.
*/
async tripTick(): Promise<void> {
try {
if ((await this.appSettings.getValue('notify_trip_reminder')) === 'false') return;
// RJ3 (Task 0's R9 ruling): the legacy statement concatenated a
// per-row column (`t.reminder_days`) into a `date('now', '+' ||
// t.reminder_days || ' days')` modifier — no bound-parameter or
// JS-constant-spelled helper can express that. Read the narrower
// candidate set (reminder_days set, start_date set) and do the
// per-row date-equality check in JS instead. `Date.UTC(...)` matches
// SQLite's own `date('now')`, which is UTC, not server-local time.
const today = appClock().date;
const candidates = await this.trips.listReminderCandidates();
const todayUtc = new Date();
const trips = candidates.filter((t) => {
const target = new Date(Date.UTC(todayUtc.getUTCFullYear(), todayUtc.getUTCMonth(), todayUtc.getUTCDate()));
target.setUTCDate(target.getUTCDate() + t.reminder_days);
return t.start_date === target.toISOString().slice(0, 10);
});
const trips = candidates.filter((t) =>
t.reminder_sent_for !== t.start_date
&& t.start_date >= today
&& addIsoDays(t.start_date, -t.reminder_days) <= today);
for (const trip of trips) {
await this.notifications.send({ event: 'trip_reminder', actorId: null, scope: 'trip', targetId: trip.id, params: { trip: trip.title, tripId: String(trip.id) } }).catch(() => {});
await this.trips.markReminderSent(trip.id, trip.start_date);
}
if (trips.length > 0) {
@@ -112,18 +113,10 @@ export class ReminderJobsService implements OnApplicationBootstrap {
try {
if ((await this.appSettings.getValue('notify_todo_due')) === 'false') return;
// RJ4 (Task 0's R9 ruling): `date('now', '+' || ? || ' days')`
// concatenates a bound parameter into the modifier — resolved in JS
// the same way RJ3 is (`Date.UTC`-based, matching SQLite's UTC
// `date('now')`). `due_date` is documented as always canonical
// `YYYY-MM-DD` text, so a direct text-range bind reproduces
// `date(ti.due_date) <= / >= ...` for well-formed rows.
const todayUtc = new Date();
const today = new Date(Date.UTC(todayUtc.getUTCFullYear(), todayUtc.getUTCMonth(), todayUtc.getUTCDate()));
const todayDate = today.toISOString().slice(0, 10);
const cutoff = new Date(today);
cutoff.setUTCDate(cutoff.getUTCDate() + TODO_REMINDER_LEAD_DAYS);
const cutoffDate = cutoff.toISOString().slice(0, 10);
// `due_date` is canonical `YYYY-MM-DD` text, so a text-range bind
// covers the lead window. "Today" is the date in the job's TZ.
const todayDate = appClock().date;
const cutoffDate = addIsoDays(todayDate, TODO_REMINDER_LEAD_DAYS);
const todos = await this.todoItems.listDueForReminder(todayDate, cutoffDate);
@@ -37,6 +37,7 @@ import { Trips } from '../../db/entities/Trips.entity';
import { BudgetItems } from '../../db/entities/BudgetItems.entity';
import type { BudgetItemsRepository } from '../../db/repositories/BudgetItems.repository';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { appClock } from '../common/timezoneService';
type Trip = TripAccess;
type BudgetEntry = { total_price?: number; category?: string; currency?: string | null; exchange_rate?: number } | undefined;
@@ -484,9 +485,10 @@ export class ReservationsService {
* docstring for the CTE/`UNION ALL`/GLOB/CAST shape.
*/
async listUpcoming(userId: number, limit = 6) {
const today = new Date().toISOString().slice(0, 10);
const now = new Date().toISOString();
return await this.reservationsRepo.listUpcomingForUser(userId, today, now.slice(11, 16), limit);
// Reservation dates and times are wall-clock values, so "now" is the
// instance's local clock rather than UTC.
const { date, time } = appClock();
return await this.reservationsRepo.listUpcomingForUser(userId, date, time, limit);
}
async getReservationWithJoins(id: string | number) {
+2 -1
View File
@@ -52,6 +52,7 @@ import { escapeLikePattern } from '../places/places.helpers';
import { NotFoundError, ValidationError } from '../common/domain-errors';
import { UnitOfWork } from '../database/unit-of-work';
import { legacyBoundIntegerText } from '../common/row-id';
import { appClock } from '../common/timezoneService';
/**
* The date range is refused, not cut short: generateDays used to clip the day
@@ -455,7 +456,7 @@ export class TripsService {
* trip with its per-trip day/place counts.
*/
/** TP21 — `TripsRepository.activeTrip`: the triple `CASE WHEN … relevance` projection and the double-`CASE WHEN` `ORDER BY`. */
async activeTrip(userId: number, today = new Date().toISOString().slice(0, 10)) {
async activeTrip(userId: number, today = appClock().date) {
return await this.tripsRepo.activeTrip(userId, today) as ActiveTrip & { relevance: number } | undefined;
}
@@ -32,6 +32,7 @@ const UNNUMBERED = [
BASELINE,
'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null',
'Migration20200101042000_tours',
'Migration20200101042100_a_trip_reminder_remembers_it_was_sent',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
+22
View File
@@ -0,0 +1,22 @@
import { afterEach, describe, expect, it } from 'vitest';
import { appClock } from '../../../src/nest/common/timezoneService';
describe('appClock', () => {
const realTz = process.env.TZ;
afterEach(() => {
process.env.TZ = realTz;
});
it('APPCLOCK-001: reads the date and time where the instance runs, not in UTC', () => {
process.env.TZ = 'Australia/Sydney';
// 23:30 UTC on the 6th is 10:30 on the 7th in Sydney.
expect(appClock(new Date('2026-10-06T23:30:00Z'))).toEqual({ date: '2026-10-07', time: '10:30' });
});
it('APPCLOCK-002: falls back to UTC without a TZ, and for one Intl does not know', () => {
delete process.env.TZ;
expect(appClock(new Date('2026-10-06T23:30:00Z'))).toEqual({ date: '2026-10-06', time: '23:30' });
process.env.TZ = 'Not/AZone';
expect(appClock(new Date('2026-10-06T23:30:00Z'))).toEqual({ date: '2026-10-06', time: '23:30' });
});
});
@@ -165,6 +165,28 @@ describe('trip reminder tick', () => {
expect(logMock.logInfo).toHaveBeenCalledWith(expect.stringMatching(/^Trip reminders sent for 2 trip\(s\): /));
});
it('RJOB-012 — catches up a reminder the job missed, sends each one once, and again for a moved trip', async () => {
const { user } = createUser(testDb);
// Due yesterday (start in 2 days, reminder 3 days ahead): the job did not run then.
const missed = createTrip(testDb, user.id, { title: 'Missed' });
testDb.prepare("UPDATE trips SET reminder_days = 3, start_date = date('now', '+2 days') WHERE id = ?").run(missed.id);
// Already started: no reminder any more.
const started = createTrip(testDb, user.id, { title: 'Started' });
testDb.prepare("UPDATE trips SET reminder_days = 3, start_date = date('now', '-1 days') WHERE id = ?").run(started.id);
const { svc, send } = makeJobs();
await svc.tripTick();
expect(send.mock.calls.map(([p]) => p.targetId)).toEqual([missed.id]);
send.mockClear();
await svc.tripTick();
expect(send).not.toHaveBeenCalled();
testDb.prepare("UPDATE trips SET start_date = date('now', '+1 days') WHERE id = ?").run(missed.id);
await svc.tripTick();
expect(send.mock.calls.map(([p]) => p.targetId)).toEqual([missed.id]);
});
it('RJOB-006 — the per-tick gate skips everything when notify_trip_reminder is false', async () => {
const { user } = createUser(testDb);
tripWithReminder(user.id, 3);