ident
All repositories: gitoria
17.3 KB
// lib/notify.hl — NOTIFICATIONS (piece 4 of 6, ticket ident#6; CONCEPT.md "Notifications" and// "Per-app page"). Statics only, the server realm. The routes are lib/api.hl (apiKinds, apiNotify), the pages// components/inbox.hl (/inbox) and components/appsettings.hl (/inbox/<connection id>). Every write to the// kinds, settings and notifications tables is here; the checks, orders and stamps: lib/notify-helpers.hl.// DELIVERY IS NOT HERE (pieces 5/6: the daily mail at 17:00, urgent at once, push): this// file only STORES what delivery needs — per notification the channels and whether each// was delivered — and sends nothing.//// kindsTable pk @id index !pair, @app// A NOTIFICATION KIND of an app, by its name:// { pair = '<app @id>:<name>', app, name, registered (bool), push, email// (the app's PRESET channels, bools), created, updated }// registered = the app registered it (POST /api/kinds). A name the app// sends WITHOUT registering it gets a row with registered = false,// push = false, email = true (CONCEPT: "every notification goes to the// daily mail"; push only after registering the kind).// settingsTable pk @id index !pair, @connection// THE USER'S SWITCHES per connection (= app + identity) and THE// EFFECTIVE SETTINGS:// a kind: { pair = '<connection @id>:<kind name>', connection, account,// kind = name, userPush, userEmail ('' = the app's preset,// 'on', 'off'), push, email (EFFECTIVE, bools), updated }// the override for all (kind = ''):// { pair = '<connection @id>:', connection, account, kind = '',// active (bool), push, email (bools), updated }// notificationsTable pk @id index @account, @connection// { account, identity, app, connection (@ids), name, text, icon, link// ('' = none), urgent, created, read, readAt,// push (bool: to be pushed), mail ('daily' | 'now' | 'none'),// pushSent, mailSent (bools, false until delivery does it) }//// EFFECTIVE (per connection and kind): the app's preset, replaced by the user's switch// where the user set one; the override, when active, replaces both for every kind; and// push is always off for a kind the app has not registered.// THE CHANNELS OF A NOTIFICATION, fixed when it arrives:// normal: push = effective push; mail = 'daily' if effective email, else 'none'// urgent: push if effective push AND the user has a push device (none exist before// piece 6, so never yet), else mail = 'now' — when the user left push or email// on for that kind; with both off: 'none' (the inbox only)// Every notification lands in the inbox, whatever the channels.import { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { dir, countOf, first, merged, isId, isList, isObject, str } from './util.hl'import { bool, onOff, stamp, newestFirst, byName, checkKindName, checkText, checkUrl, switchValue, kindRowOut } from './notify-helpers.hl'import { identityRecords, identityRecord, accountOfIdentity } from './identities.hl'import { labelOf } from './identities-helpers.hl'import { appsTable, connectionsTable, appOfSecret, connectionByAnyId } from './apps.hl'static kindsTable = new MPackDB(file = dir + '/kinds.db', primaryKey = '@id', indexes = ['!pair' '@app'])static settingsTable = new MPackDB(file = dir + '/settings.db', primaryKey = '@id', indexes = ['!pair' '@connection'])static notificationsTable = new MPackDB(file = dir + '/notifications.db', primaryKey = '@id', indexes = ['@account' '@connection'])static maxKindsPerCall = 50static maxKindsPerApp = 200static inboxLimit = 200// ---- THE KINDS (the app, with its key + secret: lib/apps.hl appOfSecret) ---------------static kindOf = (appId, name) => { return first(kindsTable.find('pair', appId + ':' + name)) }static registeredKinds = (appId) => {out = []for (k of byName(kindsTable.find('app', appId))) { if (bool(k.registered)) { out.push(kindRowOut(k)) } }return out}// THE APP REGISTERS KINDS: `kinds` = [{ name, push, email }] (strict). A name already// there gets the new preset (and counts as registered from now on). Answers// { kinds (every registered one) } or { status, error }.static kindFields = ['name' 'push' 'email']static registerKinds = (appRec, kinds) => {if (!isList(kinds)) { return { status = 400 error = 'field kinds must be a list' } }if (kinds.length > maxKindsPerCall) { return { status = 400 error = 'at most ' + maxKindsPerCall + ' kinds per call' } }// check ALL first: a refused call writes nothingclean = []names = []let n = 0for (k of kinds) {let at = 'kinds[' + n + ']'if (!isObject(k)) { return { status = 400 error = at + ' must be an object' } }for (f of k.keys()) {if (!kindFields.includes(f)) { return { status = 400 error = 'unknown field: ' + at + '.' + f } }}for (f of kindFields) {if (k[f] == null) { return { status = 400 error = 'missing field: ' + at + '.' + f } }}let c = checkKindName(k.name, at + '.name')if (c.error != null) { return { status = 400 error = c.error } }if (hlTypeName(k.push) != 'Boolean') { return { status = 400 error = 'field ' + at + '.push must be a boolean' } }if (hlTypeName(k.email) != 'Boolean') { return { status = 400 error = 'field ' + at + '.email must be a boolean' } }if (names.includes(c.name)) { return { status = 400 error = 'the name ' + c.name + ' is in the list twice' } }names.push(c.name)clean.push({ name = c.name push = k.push email = k.email })n = n + 1}have = countOf(kindsTable.find('app', appRec.id))let fresh = 0for (c of clean) { if (kindOf(appRec.id, c.name) == null) { fresh = fresh + 1 } }if (have + fresh > maxKindsPerApp) { return { status = 400 error = 'an app has at most ' + maxKindsPerApp + ' kinds' } }for (c of clean) {let k = kindOf(appRec.id, c.name)if (k == null) {kindsTable.put({ pair = appRec.id + ':' + c.name app = appRec.id name = c.name registered = true push = c.push email = c.email created = now() updated = now() })} else {kindsTable.update(k.id, merged(k, { registered = true push = c.push email = c.email updated = now() }))}}// the presets changed: every connection's stored effective settings followconns = connectionsTable.find('app', appRec.id)if (countOf(conns) > 0) { for (conn of conns) { storeEffective(conn) } }return { kinds = registeredKinds(appRec.id) }}// ---- effective settings ----------------------------------------------------------------static settingOf = (connId, kindName) => { return first(settingsTable.find('pair', connId + ':' + kindName)) }static overrideOf = (connId) => { return settingOf(connId, '') }// the effective { push, email } of one kind for one connectionstatic effectiveOf = (kind, userRow, ovr) => {registered = bool(kind.registered)let push = switchValue(userRow != null ? userRow.userPush : '', registered && bool(kind.push))let email = switchValue(userRow != null ? userRow.userEmail : '', bool(kind.email))if (ovr != null && bool(ovr.active)) {push = bool(ovr.push)email = bool(ovr.email)}if (!registered) { push = false }return { push = push email = email }}// WRITE THE EFFECTIVE SETTINGS of every kind of the connection's app (a row per kind)static storeEffective = (conn) => {let account = accountOfIdentity(conn.identity)ovr = overrideOf(conn.id)kinds = kindsTable.find('app', conn.app)if (countOf(kinds) == 0) { return null }for (k of kinds) {let row = settingOf(conn.id, k.name)let eff = effectiveOf(k, row, ovr)if (row == null) {settingsTable.put({ pair = conn.id + ':' + k.name connection = conn.id account = str(account) kind = k.name userPush = '' userEmail = '' push = eff.push email = eff.email updated = now() })} else if (row.push != eff.push || row.email != eff.email) {settingsTable.update(row.id, merged(row, { push = eff.push email = eff.email updated = now() }))}}return null}// ---- SENDING (the app) -----------------------------------------------------------------// hasPushDevice: no push devices exist before piece 6static hasPushDevice = (accountId) => { return false }// POST /api/notify: `b` = the strict body. Answers { status, error } or { id }.static sendNotification = (b) => {a = appOfSecret(b.key, b.secret)if (a == null) { return { status = 401 error = 'unknown API key or wrong secret' } }conn = connectionByAnyId(a.id, b.identity)if (conn == null) { return { status = 404 error = 'unknown identity for this app' } }ident = identityRecord(conn.identity)if (ident == null) { return { status = 404 error = 'unknown identity for this app' } }nm = checkKindName(b.name, 'name')if (nm.error != null) { return { status = 400 error = nm.error } }tx = checkText(b.text)if (tx.error != null) { return { status = 400 error = tx.error } }let icon = ''if (b.icon != null) {let c = checkUrl(b.icon, 'icon')if (c.error != null) { return { status = 400 error = c.error } }icon = c.url}let link = ''if (b.link != null) {let c = checkUrl(b.link, 'link')if (c.error != null) { return { status = 400 error = c.error } }link = c.url}let urgent = bool(b.urgent)// an UNREGISTERED name is allowed: it becomes a kind the app has not registered// (daily mail, no push) — the user sees it on the per-app page and can switch itlet kind = kindOf(a.id, nm.name)if (kind == null) {if (countOf(kindsTable.find('app', a.id)) >= maxKindsPerApp) { return { status = 400 error = 'an app has at most ' + maxKindsPerApp + ' kinds' } }kid = kindsTable.put({ pair = a.id + ':' + nm.name app = a.id name = nm.name registered = false push = false email = true created = now() updated = now() })kind = kindsTable.fetch(kid)storeEffective(conn)}eff = effectiveOf(kind, settingOf(conn.id, kind.name), overrideOf(conn.id))let push = eff.pushlet mail = eff.email ? 'daily' : 'none'if (urgent) {if (eff.push && hasPushDevice(ident.account)) {push = truemail = 'none'} else {push = falsemail = eff.push || eff.email ? 'now' : 'none'}}let id = notificationsTable.put({ account = ident.account identity = ident.id app = a.id connection = conn.id name = nm.name text = tx.text icon = icon link = link urgent = urgent created = now() read = false readAt = 0 push = push mail = mail pushSent = false mailSent = false })return { id = id }}// ---- THE INBOX (the user) --------------------------------------------------------------// the account's identities: id → label (as in the lists)static identityLabels = (accountId) => {out = {}let n = 1for (r of identityRecords(accountId)) {out[r.id] = labelOf(r, n)n = n + 1}return out}static appName = (appId) => {a = isId(appId) ? appsTable.fetch(appId) : nullreturn a != null ? a.name : 'a deleted app'}static inboxRowOf = (r, labels) => {label = labels[r.identity]let read = bool(r.read)return {id = r.ididentity = label != null ? label : 'a deleted identity'app = appName(r.app)connection = r.connectionname = r.nametext = r.texticon = str(r.icon)hasIcon = str(r.icon) != ''link = str(r.link)hasLink = str(r.link) != ''urgent = bool(r.urgent)time = stamp(r.created)read = readunread = !readstate = read ? 'read' : 'unread'toggleText = read ? 'Mark unread' : 'Mark read'}}// the account's notifications (all identities), newest first, at most inboxLimitstatic inboxRows = (accountId) => {out = []if (!isId(accountId)) { return out }labels = identityLabels(accountId)let n = 0for (r of newestFirst(notificationsTable.find('account', accountId))) {if (n >= inboxLimit) { return out }out.push(inboxRowOf(r, labels))n = n + 1}return out}// read / unread: only the account's own. Answers { error } or { ok }.static markRead = (accountId, id, read) => {if (!isId(id)) { return { error = 'no such notification' } }if (hlTypeName(read) != 'Boolean') { return { error = 'field read must be a boolean' } }r = notificationsTable.fetch(id)if (r == null || r.account != accountId) { return { error = 'no such notification' } }notificationsTable.update(r.id, merged(r, { read = read readAt = read ? now() : 0 }))return { ok = true }}// ---- THE PER-APP PAGE (the user) -------------------------------------------------------// the connection `id` if its identity belongs to the account, else nullstatic ownConnection = (accountId, id) => {if (!isId(id)) { return null }c = connectionsTable.fetch(id)if (c == null) { return null }ident = identityRecord(c.identity)if (ident == null || ident.account != accountId) { return null }if (!isId(c.app) || appsTable.fetch(c.app) == null) { return null }return c}// the account's connections (every app each identity registered in), oldest firststatic connectionRows = (accountId) => {out = []if (!isId(accountId)) { return out }labels = identityLabels(accountId)all = connectionsTable.find(null, null)if (countOf(all) == 0) { return out }mine = []for (c of all) {if (labels[c.identity] != null && isId(c.app) && appsTable.fetch(c.app) != null) { mine.push(c) }}// oldest first by created (reverse of newestFirst)sorted = newestFirst(mine)let i = sorted.length - 1while (i >= 0) {let c = sorted[i]out.push({ id = c.id href = '/inbox/' + c.id app = appName(c.app) identity = labels[c.identity] since = stamp(c.created) })i = i - 1}return out}// everything the per-app page shows. Answers null for a connection that is not the account's.static settingsPage = (accountId, connId) => {c = ownConnection(accountId, connId)if (c == null) { return null }labels = identityLabels(accountId)ovr = overrideOf(c.id)active = ovr != null && bool(ovr.active)oPush = ovr != null && bool(ovr.push)// no override row yet: its switches show what it would start with (the concept's// default: the daily mail, no push)oEmail = ovr == null ? true : bool(ovr.email)let kinds = []for (k of byName(kindsTable.find('app', c.app))) {let row = settingOf(c.id, k.name)let eff = effectiveOf(k, row, ovr)let registered = bool(k.registered)kinds.push({name = k.nameregistered = registeredcanPush = registered && !activenoPush = !registeredlockedPush = registered && activecanEmail = !activelockedEmail = activepush = onOff(eff.push)email = onOff(eff.email)pushChecked = '' + eff.pushemailChecked = '' + eff.emailpreset = 'app default: push ' + onOff(registered && bool(k.push)) + ', email ' + onOff(bool(k.email)) + (registered ? '' : ' (not registered by the app: no push)')})}return {id = c.idapp = appName(c.app)identity = labels[c.identity]since = stamp(c.created)overrideActive = onOff(active)overridePush = onOff(oPush)overrideEmail = onOff(oEmail)overrideActiveChecked = '' + activeoverridePushChecked = '' + oPushoverrideEmailChecked = '' + oEmailkinds = kindshasKinds = kinds.length > 0noKinds = kinds.length == 0}}// THE USER FLIPS A KIND'S SWITCH: channel 'push' | 'email', value bool. Answers { error } or { page }.static setSwitch = (accountId, connId, kindName, channel, value) => {c = ownConnection(accountId, connId)if (c == null) { return { error = 'no such app connection' } }if (kindName == null || hlTypeName(kindName) != 'String') { return { error = 'field kind must be a string' } }if (channel != 'push' && channel != 'email') { return { error = 'field channel must be push or email' } }if (hlTypeName(value) != 'Boolean') { return { error = 'field value must be a boolean' } }k = kindOf(c.app, kindName)if (k == null) { return { error = 'no such notification kind' } }if (channel == 'push' && !bool(k.registered)) { return { error = 'the app has not registered this notification — it cannot be pushed' } }let account = accountIdrow = settingOf(c.id, k.name)change = channel == 'push' ? { userPush = onOff(value) updated = now() } : { userEmail = onOff(value) updated = now() }if (row == null) {rec = { pair = c.id + ':' + k.name connection = c.id account = account kind = k.name userPush = '' userEmail = '' push = false email = false updated = now() }settingsTable.put(merged(rec, change))} else {settingsTable.update(row.id, merged(row, change))}storeEffective(c)return { page = settingsPage(accountId, c.id) }}// THE OVERRIDE FOR ALL: field 'active' | 'push' | 'email', value bool. Answers { error } or { page }.static setOverride = (accountId, connId, field, value) => {c = ownConnection(accountId, connId)if (c == null) { return { error = 'no such app connection' } }if (field != 'active' && field != 'push' && field != 'email') { return { error = 'field field must be active, push or email' } }if (hlTypeName(value) != 'Boolean') { return { error = 'field value must be a boolean' } }row = overrideOf(c.id)change = { updated = now() }change[field] = valueif (row == null) {rec = { pair = c.id + ':' connection = c.id account = accountId kind = '' active = false push = false email = true updated = now() }settingsTable.put(merged(rec, change))} else {settingsTable.update(row.id, merged(row, change))}storeEffective(c)return { page = settingsPage(accountId, c.id) }}
Branches
- mainmain branch
Latest commits
- 74235815ident mission 009 (4/4): docs (README files map + same-output test, STATUS, LOG), report, tests/letcount.py + realdata-baseline/comparemre
- fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
- d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
- 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
- f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
- ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
- a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
- 98226b41antcolony#40: mission references point to the moved missionsmre
- ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
- 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
- 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
- 81b15b7bState of 2026-09-27, before the move to gitoriamre