Persistent notifications
An app declares synchronous rules in notifications/*.notification.ts. The framework stores an occurrence for each authorized recipient in the document transaction and keeps it available while that user is offline. The Desk central is /app/notifications. Run ddcore migrate for the additive internal storage and ddcore types for SDK declarations. The development watcher reloads notification files with the app.
Event rules
import { defineNotification, _ } from "@ddcore/sdk";
export default defineNotification({
name: "shop.order_submitted",
doctype: "Order",
event: "on_submit",
condition: (doc, before) => doc.total > 0,
recipients: (doc, before) => [doc.owner],
desk: {
title: (doc) => _("Order {0} submitted", [doc.id]),
message: (doc) => _("Your order is ready for review."),
},
email: {
template: "shop.order_submitted",
args: (doc) => ({ order: doc.id }),
},
});Names are unique across installed apps; prefix them with the app name. Declare one event or one date trigger, and at least one of desk and email. The supported events are on_insert, on_update, on_submit and on_cancel, with the same lifecycle boundaries as outgoing webhooks. dbSet, rename and delete do not trigger rules. Single and Child DocTypes and internal delivery/audit records cannot be rule targets. Invalid definitions fail loading, including missing email templates and a declared email without email.args.
condition, recipients, Desk content and email arguments receive the current document and the previous document (null on insert). condition and recipients see every field; Desk content and email arguments are rendered once per recipient, from a copy without the fields above that recipient's permission level (see field-permissions). All functions are synchronous: never use async, await or return a Promise. condition must return a boolean; a non-boolean result throws and rolls back the document operation, occurrences and jobs together, same as any other evaluation error. A false condition skips the occurrence.
recipients returns active User identifiers, not arbitrary external email addresses. Duplicate recipients collapse to one occurrence. Missing, disabled and unauthorized users are discarded. Read authorization includes role permissions, user access scopes and the controller's hasPermission and permissionQuery hooks. A notification never grants document access. A recipient whose User has no email address gets no email; there is no error or fallback.
Desk title and message are plain text. Use literal _() keys and translate them in translations/<lang>.csv; content renders in the recipient's language. Email uses an existing mail template and the normal delivery record, queue and transport retries. There is no independent SMTP implementation or external-recipient option.
Date rules
Replace event with a date trigger:
export default defineNotification({
name: "shop.order_overdue",
doctype: "Order",
date: { field: "due_date", days: 1 },
condition: (doc) => doc.status === "Open",
recipients: (doc) => [doc.owner],
desk: {
title: (doc) => _("Order {0} is overdue", [doc.id]),
message: () => _("Review the outstanding order."),
},
});The field must be Date or Datetime; days is an integer calendar-day offset (negative before the field value, zero at it, positive after it). The internal sweep is queued every five minutes and scans in batches of 100 using the site's timezone, including daylight saving transitions, but only while the scheduler is running (a dev or jobs process); date rules are never evaluated otherwise. It catches up overdue matching documents after downtime and on first activation, including historical dates. Use condition to limit that set. Date callbacks have no prior save document.
A database uniqueness constraint deduplicates each rule/document/planned-date/recipient combination, including concurrent scans and process restarts. Once a planned date's condition has held, the sweep records it and does not evaluate that document for that date again: recipients are resolved at that moment, and a user added later is not notified for the same date. A date whose condition does not hold yet is re-evaluated on every sweep. Changing the due date allows a new occurrence for the new planned date. Event occurrences use an individual event identity, so separate updates can notify again. Retries do not create new occurrences.
Resolving occurrences
An occurrence that stopped being true would otherwise stay unread in every recipient's inbox. resolveWhen says when the rule's occurrences on a document are stale:
export default defineNotification<WebChatSession>({
name: "chat.waiting",
doctype: "Web Chat Session",
event: "on_update",
condition: (doc, before) => doc.status === "Waiting" && before?.status !== "Waiting",
resolveWhen: (doc) => doc.status !== "Waiting",
recipients: () => usersWith(["Chat Agent"]).map((u) => u.user),
desk: {
title: (doc) => _("{0} is waiting for an agent", [doc.contact_name]),
message: () => _("A conversation entered the queue."),
},
});- It is evaluated on every
on_update,on_submitandon_cancelof the document, whatever the rule's own trigger, so a date rule and anon_submitrule resolved by a cancel both work. It sees the whole document and the previous one, is synchronous and must return a boolean; anything else throws and rolls back the save. - When it holds, every recipient's open occurrences of that rule on that document are marked read and resolved, in the document's transaction. Ones already read are only marked resolved. Each affected recipient gets
notifications_changedafter the commit. - An email of a resolved occurrence still in the queue is not sent: its delivery ends
FailedwithNotification resolved. A sent email cannot be withdrawn. - It runs before the rule's
conditionon the same event, so a document that matches again (a chat back in the queue) gets one fresh occurrence, not a second on top of the first. A resolved occurrence stays in the central as read; it can be marked unread again.
Server code resolves where no condition on the document can say so:
const { resolved } = ddcore.notifications.resolve({ rule: "chat.waiting", doctype: "Web Chat Session", id: doc.id });rule is an app's rule, or assignment or share for the core's own; an unknown rule throws. The caller must be able to read the document, unless the second argument passes { ignorePermissions: true } (access scopes still apply). It writes on the caller's transaction, like resolveWhen, and returns how many occurrences it resolved.
Reading and authorization
All operations use the authenticated user. There is no recipient selector, even for an admin; resolving occurrences (above) is the one operation that reaches every recipient, and only server code can do it. Every listing, unread count and read-state change rechecks access to the referenced document, one document read per stored occurrence, so the cost of a count grows with the user's inbox. Access includes user access scopes: a scope change is observed the same way a role or permission change is. Deleted documents and revoked access or scope hide the occurrence; renames update its document reference. Email attempts recheck access, including scopes, before sending. An email already sent cannot be withdrawn after access changes or resolution. There is no retention sweep for read notifications: they remain in ddcore_notification until the referenced document is deleted or access is revoked.
The internal ddcore_notification table is not a DocType. Generic REST, reports and export cannot expose it. Use these dedicated endpoints:
| Operation | Request | Response |
|---|---|---|
| List | GET /api/notifications?limit=20&offset=0&read=false | {data: {data: Notification[], total: number}} |
| Unread count | GET /api/notifications/count | {data: number} |
| Set read state | PATCH /api/notifications/{id} with {read: true} or {read: false} | {data: Notification} |
limit defaults to 20 and accepts 1–100; offset defaults to zero and is nonnegative. Omit read for both states, or use true or false. Results are newest first and the total reflects the filter and current access. Unknown or repeated query parameters are rejected. A notification has id, title, message, creation, read, reference_doctype and reference_id.
The Desk SDK exposes ddcore.notifications.list({limit, offset, read}), ddcore.notifications.count() and ddcore.notifications.setRead(id, read). The central shows unread count, document links and individual read/unread actions. It refreshes on opening, tab return, SSE updates and reconnection, so persisted notifications produced offline are recovered. Logging out clears the local connection and state.
notifications_changed is an after-commit SSE invalidation sent only to the affected recipient. It carries no document content; clients fetch the authorized persisted state. Rollback emits no invalidation. SSE is a refresh hint, not the durable inbox.
This version does not include a visual rule editor or custom event triggers; server code sends a browser push itself with ddcore.push.send (see push). Assignments land in the same inbox through NotifyUser and the core todo_due date rule, not through app-defined notification rules. A new document share lands there the same way, under rule share; the recipient must be able to read the document, and they are never notified of their own action.
Email for the core's own notifications
The three notifications the core writes itself also go out by email, through the mail template core.notification: the subject is the notification's title, the body its message and a button to the document (ddcore.docUrl, see mail).
| Kind | Inbox rule | Opt-out field on User | Profile key |
|---|---|---|---|
| A document is assigned | assignment | mute_assignment_email | assignment |
| A document is shared | share | mute_share_email | share |
| A task is due today | core.todo_due | mute_due_email | due |
- Each person switches each kind off under "Email notifications" on their profile, which calls
profile.updateMyProfile({emailNotifications: {assignment?, share?, due?}})(see auth). A System Manager can tick the same fields on the User record. - The fields are opt-outs: unset means the email is sent, so existing users receive it without a data migration.
- The switch only affects the email. The inbox notification is always written.
- The email is a copy: a recipient without a mailable address (the
Adminaccount) gets none, and a failure to queue it is logged and rolled back to a savepoint while the inbox notification, the assignment or share, and the date sweep carry on. - The delivery is linked to the notification, so access is rechecked when it is sent, as for a rule's email.
- The link always points at the desk (
/app/<doctype>/<id>); a Website User who follows it lands on the portal.
These preferences do not reach app-defined rules: a rule's email is always sent, whatever the rule is called. There are no per-user preferences for app rules.