Corteksa
GuidesOther

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

Recipientthe record's assignee (admin_id). Unassigned records are skipped in SQL — there is nobody to notify.
Channelsin-app notification (type due_date_arrived) and a push to the assignee's registered devices
Taska 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 DataSource runs raw SQL directly.
  • Shared hyper DB — RLS resolves rows against the app.workspace_id GUC, so an unpinned pass would read zero rows and silently do nothing. That DB is enumerated workspace by workspace inside TenantScope.runInScope, which pins a connection with the GUC set; work then routes through ContextAwareRepositoryProvider.

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 task object 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 scan

Where the code lives

Option typefields/services/field/options/interfaces/due-notification-config.interface.ts
Config-time validationfields/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.

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.

On this page