Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The Notifications kit — telling a person that something needs them

use Osysharp.Notifications@1; · policy ReceivesNotifications => …; · [Event] class OrderShipped { … public override string Sentence() { … } } · Notify(person, new OrderShipped { … }, by:) · NotifyAll(people, e) · Mention(person, about, by:) · NotificationBell()

`Osysharp.Notifications` tells a person that something needs them — in the app, a bell with a live count and a list with read and unread and "Mark all read", and by mail when they have not seen it: a daily digest, or a mention at once, each person's own choice, in their own language. Delivery is durable and after the commit, one unread notification per thing, and never about a record the person may not read under their own rules.

preview1 example compiled by CIuikitnotificationsmail

Source on GitHub: Osysharp.Notifications — github.com/osysharp/notifications ↗

Summary#

use Osysharp.Notifications@1; gives an app a bell. Tell people with one call from anywhere — WHAT HAPPENED, as the event the app (or a kit) already raises for it, which says itself:

[Event("Ready to pack")] public sealed class ReadyToPack { public Order Order; }   // says its label, or overrides Sentence()

Notify(packer, new ReadyToPack { Order = order });   // from a page, a function, a workflow body or an [On] handler
NotificationBell();                                   // in the shell's actions

Nothing is registered and no words are written for the kit: the event is kept, and said when it is read.

The kit asks nothing else about the record: what it is called, where it opens and who may read it are the record's own facts — its [[entity-title|[Title]]], the page that [[stdlib-records#opens|[Opens]]] it, its security { } — which Records answers for any record the app keeps.

Signature#

use Osysharp.Notifications@1;        // with Osysharp.Identity@0, Osysharp.Mail@0, Osysharp.EmailTemplates@0,
                                     // Osysharp.Workflow and Osysharp.Ui

policy ReceivesNotifications => <who has a bell>;      // REQUIRED — and who has notification preferences

app.Notifications = new NotificationsSetup {   // optional — every member has a default
  AppName = "",                        // what a mail is signed with
  KeepRead = TimeSpan.FromDays(30),    // a read notification is deleted after this
  KeepUnread = TimeSpan.FromDays(180), // …an unread one after this
  MentionMailAfter = TimeSpan.FromMinutes(5),   // a mention waits this long, unread, before it is mailed
  DigestHour = 6,                      // the daily digest goes at the first hourly sweep from this hour, on the
                                       // app's clock (Osysharp.TimeZone — the time zone staff pick)
  People = new NotificationPeople(),   // override LanguageOf(person) when the account keeps a language
};

// anywhere — `e` is any [Event]; the record it is about is its first record-typed member:
void Notify(Principal to, Event e, Principal? by = null, string key = "", bool mention = false)
void NotifyAll(Principal[] people, Event e, Principal? by = null)
void Mention(Principal to, Entity about, Principal? by = null)     // the kit's own words for it
string SaidOf(Notification n)                                      // what it says, to whoever reads it now

NotificationBell(string settingsAt = "/settings/notifications")   // the bell and its list, for the shell's actions
NotificationList(string settingsAt, Action onDone)                 // the list alone
int UnreadNotifications()  ·  List<NotificationLine> MyNotificationLines(int take = 40)
string OpenNotification(Guid id)  ·  MarkNotificationRead(Guid id, bool read = true)  ·  int MarkAllNotificationsRead()

Description#

Who is told what#

Notify writes a request to tell somebody, in the caller's unit of work. It is delivered after the commit by a workflow (a throw is tried again), so nobody hears about something that was rolled back. Nobody is told what they did themselves (by is them). A person's own request that tells somebody — a note that names a colleague — needs the app's grant, because the app decides who may tell whom: partial entity Notification { security { allow create when IsAgent; } }. The app's workflows and timers need none. Whatever the grant, a new notification starts unseen, and nobody signed in can put another person's name in its by.

Never about a record they may not read#

A notification's About is an Entity — a reference to a row of any type, read as its reader. Delivery asks Records.MayRead(recipient, about) — after the commit, of the record as stored, under the recipient's OWN rules: a record in a restricted queue, a private document, a row they lost access to since. One that fails is withheld and never shown or mailed. The bell reads each notification's record as the person reading it, and the digest asks Records.Readable once per person before each mail, so a reader who loses access later finds it gone from the bell — and from the mail. SECURITY-INVARIANT notifications-only-about-what-the-recipient-may-read.

A notification goes with its record: About is [OnDelete(Remove)] ([OnDelete]).

One unread notification per thing#

A notification's key (by default its record and its kind; a mention's is its record) is what makes two the same. While one is unread in the bell, a second with its key joins it — it moves to the top, says what happened last and how many times ("2 times") — rather than adding a row. Once read, the next one is new. The database holds that: one unread per person and key.

The event, said in the reader's words#

A notification keeps the EVENT — its class (Kind, the full name) and the event itself (Payload) — and who did it (By). When it is read, the event is read back and says its own [[function-event#sentence|Sentence()]] — in the bell, in each reader's language, and in a mail, in its recipient's, from the same row (A history in the reader's language — keep what happened, say it when it is read). A sentence that reads the record (a customer's name) reads it as it is NOW: a renamed record reads with its new name, and an erased person's name is in no notification. An event whose class is gone since says its label. The line's title is the record's [[entity-title|[Title]]], and the row opens at its [[stdlib-records#opens|Records.Link]].

A notice the app tells somebody but nobody else should be offered — not a rule's trigger, not a webhook's — is an internal event: it says itself the same way and stays the app's own business.

What the bell shows, and how it opens#

NotificationBell() is the shell's count button ("Notifications, 2 new" — the count is in its name) with the list behind it: a panel under the bell on a wide screen, a sheet from the bottom edge on a phone. The list shows the newest forty, unread ones marked; All / Unread; "Mark all read"; a row opens where it happened and marks it read. It says what will appear there when there is nothing yet, and that all is caught up when nothing is unread. The count and each row's read state are live; the words are fetched once as the list opens, and a row that arrives meanwhile fetches its own.

What is mailed, and in which language#

Each person chooses on their own settings page (/settings/notifications, a personal declared section — [Settings] and [Setting]: a settings section declared on its row, each field drawn by its type): mentions at once, everything else in a daily digest (the default), a daily digest, or nothing. Mail is only ever about what they have not read in the app: a mention waits MentionMailAfter first, and is not mailed if they read it meanwhile; the digest goes once a day, of what is unread and not yet mailed — at DigestHour in the team's time zone, the one staff pick in Settings › Time and date (The TimeZone kit — the time zone an app's days start in, picked from a list), so it arrives in the team's morning in summer and in winter. It is written in the language they last read the app in (the bell records it), or the one the app's People.LanguageOf answers.

Kept no longer than needed#

An hourly sweep deletes a read notification after KeepRead, an unread one after KeepUnread, and one never shown after a day. A notification is personal data (its event, Payload, is classified Personal.Content): with Osysharp.Privacy, Osysharp.Privacy.Notifications arrives by itself (Bridges — a package that arrives when an app uses the two it completes) and a person's notifications and preferences are counted, copied, kept restricted and erased by their requests — the app's privacy managers read them for that (partial entity Notification { security { allow read when ManagesPrivacy; } }).

Examples#

A small studio telling writers about drafts#

app Studio {
  use Osysharp.Identity@0;
  use Osysharp.Notifications@1;
  use Osysharp.Mail@0;
  use Osysharp.EmailTemplates@0;
  use Osysharp.Workflow;
  use Osysharp.Ui;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Notifications;

[Role] enum StudioRole { Authenticator, Writer, Editor }
policy IsWriter => RoleGrant.Any(g => g.Member == user && (g.Role == StudioRole.Writer || g.Role == StudioRole.Editor));
policy IsEditor => RoleGrant.Any(g => g.Member == user && g.Role == StudioRole.Editor);
policy ReceivesNotifications => IsWriter;
policy ManagesClock => IsEditor;   // who sets the time zone the digest's morning is in

/// What a notification about a draft is called — its line in the bell and in a mail.
[Title(Title)]
entity Draft {
  [Required, MaxLength(120)] string Title;
  [Required] Principal Writer;
  bool Private;
  security {
    allow read, create, update when IsWriter where !Private || Writer == user;
    allow read, create, update when IsWriter && IsEditor;
  }
}

// Writers tell each other from their own requests.
partial entity Notification { security { allow create when IsWriter; } }

/// Somebody asked for a read — said in the reader's words, when the notification is read.
[Event("Read asked")]
public sealed class ReadAsked {
  public Draft Draft;
  public Principal? By;
  public override string Sentence() {
    var by = By == null ? "Somebody" : By.DisplayName;
    return $"{by} asked you to read a draft.";
  }
}

app.Notifications = new NotificationsSetup { AppName = "Studio" };

/// "Ask for a read" — tells the other writer, after this commit, if they may read the draft.
public void AskForARead(Draft d, Principal to) {
  Notify(to, new ReadAsked { Draft = d, By = Session.CurrentUser }, by: Session.CurrentUser);
  UnitOfWork.Commit();
}

/// A comment that names a writer — a mention, in the kit's own words.
public void NameIn(Draft d, Principal to) {
  Mention(to, d, by: Session.CurrentUser);
  UnitOfWork.Commit();
}

/// Where a notification about a draft opens.
[Route("/drafts/{id}")] [Opens(Draft)] [Authorize(IsWriter)] [Render(CSR)]
component DraftPage(Guid id) {
  render { Text("draft"); }
}

[Route("/")] [Authorize(IsWriter)] [Render(CSR)]
component Home() {
  render {
    Row(gap: 3, align: Align.Center, px: 5, py: 3) {
      Text("Studio", fontWeight: FontWeight.Semibold, grow: 1);
      NotificationBell();
    }
  }
}

See also#

Related

[Event] — what happens to a record, declared once

An event is a class marked `[Event]`: something that happens to a record, declared once by the code that owns the…

Records — what any record is called, where it opens, who may read it

`Records.*` answers facts about a row of ANY type from what the app declared — what it is called (`[Title]`), where it…

Entity

`Entity` is a reference to a row of ANY type the app keeps — what a notification, a comment or a question "about some…

[Title] on an entity

`[Title]` on an entity says what each of its records is called — the line a notification, a search result or a link…

[OnDelete]

`[OnDelete]` on an `Entity` member says what deleting the row it references does to the row holding it: Remove deletes…

Security.MayRead — may THIS person read this row, under their own rules

`Security.MayRead(person, row)` answers whether a given person may read a given row under THEIR OWN security — the…

[Settings] and [Setting]: a settings section declared on its row, each field drawn by its type

Mark the row staff configure `[Settings("Title", Authorize = Policy)]` and each field on it `[Setting("Label")]`, and…

The ReplyComposer kit — where a person writes a reply, on any record

`Osysharp.ReplyComposer` is the box a person writes a reply in — on a ticket, a case, an order, a lead. Saved replies…

The WorkQueues kit's screens — the rail, the list, an item's head, the team, the settings

`Osysharp.WorkQueues` ships the screens a team works an inbox from, on the Switchboard floor: its views, queues and…

[Email] — mail rendered from data

An `[Email]` component is a mail template. It is rendered on the server — from any function, a workflow step included —…

A history in the reader's language — keep what happened, say it when it is read

A timeline, an audit trail or a "why is this here" list is read long after it is written, by readers in other…

The Privacy kit — erasure that keeps only what the company must or may

`Osysharp.Privacy` answers a person's GDPR request — access, portability, erasure, restriction — per category of what…