DUE DATE REMINDERS
DUE DATE REMINDERS
Turn on one option on a date field and, when that day arrives, the record's assignee gets a notification — and a task, linked back to the record.
Turning it on
The switch lives on the field, not on a workflow. It is accepted on the three
date-only field types (date, start_date, due_date), all of which are backed
by a timezone-free DATE column, so a value is a bare YYYY-MM-DD calendar day
— see parseCalendarDay.
datetime is not supported: an instant needs a time-of-day to fire at, which
this feature does not model.
// PATCH /object/field/{slug} → options
{
"due_notification": {
"enabled": true, // required boolean — the master switch
"create_task": true // optional, defaults to true; false = notify only
}
}Both flags are validated strictly at config time — "enabled": "false" is
rejected, not coerced. A truthy string would arm a reminder the operator just
switched off, the same trap SEC-406 closed for boolean defaults. Absent means off,
so every field that predates the feature is untouched.
What fires
| Recipient | the record's assignee (admin_id). Unassigned records are skipped in SQL — there is nobody to notify. |
| Channels | in-app notification (type due_date_arrived) and a push to the assignee's registered devices |
| Task | a task record with the due date, assigned to the same admin, linked_data_slug pointing back at the source record |
The notification carries actionUrl plus object_slug / record_slug /
field_slug / due_date in metadata, so a client can deep-link straight to the
record that came due.
Why the scan is hourly
"Has the due date arrived?" is only answerable in the assignee's timezone. A single UTC-midnight tick fires hours early for one admin and hours late for another.
So each hourly tick selects records within a ±1 day window around the server's
CURRENT_DATE — no timezone is further than that from UTC — and then fires only
for rows whose date equals today in that assignee's own timezone
(admin.timezone, falling back to Europe/Istanbul to match AdminContext).
A record is therefore seen by up to 24 ticks while its day is current, and a Redis key makes those repeats idempotent:
v1:due-reminder:fire:tenant=<db>:ws=<id>:rec=<id>:field=<slug>:date=<YYYY-MM-DD>The key carries tenant and workspace (CLAUDE.md R4). Redis has no RLS, and two
workspaces on the shared hyper DB hold colliding record ids — without ws=, one
workspace's reminder would silently suppress another's.
Isolation
The cron runs outside any HTTP request, so there is no AsyncLocalStorage context.
It follows the same split as
TimeRelativeRecomputeService:
- Dedicated tenant — no RLS, so the resolved
DataSourceruns raw SQL directly. - Shared hyper DB — RLS resolves rows against the
app.workspace_idGUC, so an unpinned pass would read zero rows and silently do nothing. That DB is enumerated workspace by workspace insideTenantScope.runInScope, which pins a connection with the GUC set; work then routes throughContextAwareRepositoryProvider.
Failure isolation
Every step is independently guarded, because none of them is worth losing the others over:
- Push needs Firebase credentials and a registered device — neither is guaranteed per tenant. It is best-effort and never costs the in-app notification.
- Task creation goes through
DataMutationService, so a renamed task field throws. It is caught; the reminder still counts as delivered. - A field whose table is missing is logged and skipped without aborting the rest of the tenant's scan.
Only a failed in-app notification counts as a failed reminder.
Guards
- No task-from-task. A reminder on a field of the
taskobject never creates a task — a task spawning a follow-up for its own due date is a loop, not a feature. - Per-field cap of 5000 records per tick, so one misconfigured field cannot monopolise a run.
- Records that are soft-deleted, unassigned, or have a null date are excluded in SQL.
Configuration
DUE_REMINDER_CRON=0 * * * * # default: hourly
DUE_REMINDER_ENABLED=true # set to anything else to disable the scanWhere the code lives
| Option type | fields/services/field/options/interfaces/due-notification-config.interface.ts |
| Config-time validation | fields/services/field/options/helpers/assert-due-notification.ts |
| Scan (cron, tenant iteration, de-dupe) | due-reminder/services/due-reminder-scan.service.ts |
| Delivery (notify + task) | due-reminder/services/due-reminder-dispatch.service.ts |
DueReminderModule is a leaf: it imports RecordsModule, which already
imports FieldsModule, so hosting these services inside FieldsModule would close
a cycle — and forwardRef is banned (CLAUDE.md §4.8). Nothing imports it back; the
cron is its own entry point.
Related
For a reminder with different recipients, an offset ("3 days before"), or extra
actions, the workflow engine already covers it: a SCHEDULED trigger with an
ON_DATE / WITHIN_NEXT condition on the same field. This feature is the
one-switch case.