ident
All repositories: gitoria
22.4 KB
// 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 in project.hl, the pages// in components/inbox.hl (/inbox) and components/appsettings.hl (/inbox/<connection id>).// 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, timestamp } from 'hl:time'import { sha256 } from 'hl:crypto'import { dir, countOf, first, merged, hasControl, isId, identitiesTable, identityRecords, labelOf } from './store.hl'import { appsTable, connectionsTable, appByKey, urlChars, 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 maxName = 60static maxText = 2000static maxUrl = 2000static maxKindsPerCall = 50static maxKindsPerApp = 200static inboxLimit = 200// ---- small helpers ----------------------------------------------------------------------static bool = (v) => { return v == true }static isList = (v) => { return v != null && hlTypeName(v) == 'Hybrid' && v.length != null }static isObject = (v) => { return v != null && hlTypeName(v) == 'Hybrid' && v.length == null }static str = (v) => { return v == null ? '' : v }// "YYYY-MM-DD HH:MM UTC" (hl:time knows no zones, hybriel #11)static stamp = (ms) => {if (ms == null) { return '' }let t = timestamp(ms)return t.slice(0, 10) + ' ' + t.slice(11, 16) + ' UTC'}// NEWEST FIRST by `created` (a tie by the id); insertion sort by hand (no list sort(), hybriel #6)static idAfter = (a, b) => {let i = 0while (i < a.length && i < b.length) {let x = a.charCodeAt(i)let y = b.charCodeAt(i)if (x != y) { return x > y }i = i + 1}return a.length > b.length}static newerThan = (a, b) => {let ca = a.created == null ? 0 : a.createdlet cb = b.created == null ? 0 : b.createdif (ca != cb) { return ca > cb }return idAfter(a.id, b.id)}static newestFirst = (list) => {let out = []if (countOf(list) == 0) { return out }for (x of list) { out.push(x) }let i = 1while (i < out.length) {let cur = out[i]let j = i - 1while (j >= 0 && newerThan(cur, out[j])) {out[j + 1] = out[j]j = j - 1}out[j + 1] = curi = i + 1}return out}// kinds by name, A-Z (char codes)static nameBefore = (a, b) => {let x = a.name.toLowerCase()let y = b.name.toLowerCase()let i = 0while (i < x.length && i < y.length) {if (x.charCodeAt(i) != y.charCodeAt(i)) { return x.charCodeAt(i) < y.charCodeAt(i) }i = i + 1}return x.length < y.length}static byName = (list) => {let out = []if (countOf(list) == 0) { return out }for (x of list) { out.push(x) }let i = 1while (i < out.length) {let cur = out[i]let j = i - 1while (j >= 0 && nameBefore(cur, out[j])) {out[j + 1] = out[j]j = j - 1}out[j + 1] = curi = i + 1}return out}// ---- validation -------------------------------------------------------------------------// A KIND NAME / NOTIFICATION NAME: trimmed, 1-60 chars, one line. Answers { name } or { error }.static checkKindName = (v, what) => {if (v == null || hlTypeName(v) != 'String') { return { error = 'field ' + what + ' must be a string' } }let t = v.trim()if (t == '') { return { error = 'field ' + what + ' is empty' } }if (t.length > maxName) { return { error = 'field ' + what + ' is longer than ' + maxName + ' characters' } }if (hasControl(t)) { return { error = 'field ' + what + ' contains a control character' } }return { name = t }}// THE TEXT: 1-2000 chars (Markdown by the worldapi convention; shown as plain text with// its line breaks); tab, line feed and carriage return are the only control charactersstatic checkText = (v) => {if (v == null || hlTypeName(v) != 'String') { return { error = 'field text must be a string' } }let t = v.trim()if (t == '') { return { error = 'field text is empty' } }if (t.length > maxText) { return { error = 'field text is longer than ' + maxText + ' characters' } }let i = 0while (i < t.length) {let c = t.charCodeAt(i)if ((c < 32 && c != 9 && c != 10 && c != 13) || c == 127) { return { error = 'field text contains a control character' } }i = i + 1}return { text = t }}// AN ICON / ACTION LINK: an absolute http(s) URL, printable URL characters only, ≤ 2000static checkUrl = (v, what) => {if (v == null || hlTypeName(v) != 'String') { return { error = 'field ' + what + ' must be a string' } }let t = v.trim()if (t.length > maxUrl) { return { error = 'field ' + what + ' is longer than ' + maxUrl + ' characters' } }let i = 0while (i < t.length) {if (!urlChars.includes(t[i])) { return { error = 'field ' + what + ' contains a character that is not allowed in a URL' } }i = i + 1}let lower = t.toLowerCase()let scheme = lower.slice(0, 7) == 'http://' ? 7 : (lower.slice(0, 8) == 'https://' ? 8 : 0)if (scheme == 0) { return { error = 'field ' + what + ' must be an http:// or https:// URL' } }let rest = t.slice(scheme, t.length)if (rest == '' || rest[0] == '/' || rest[0] == '?') { return { error = 'field ' + what + ' has no host' } }return { url = t }}// ---- the app's side: key + secret ------------------------------------------------------// answers the app record or null (unknown key or wrong secret: one answer, 401)static appOfSecret = (key, secret) => {let a = appByKey(key)if (a == null || hlTypeName(secret) != 'String' || sha256(secret) != a.secretHash) { return null }return a}static kindOf = (appId, name) => { return first(kindsTable.find('pair', appId + ':' + name)) }static kindRowOut = (k) => { return { name = k.name push = bool(k.push) email = bool(k.email) } }static registeredKinds = (appId) => {let 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 nothinglet clean = []let 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}let 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 followlet conns = 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, '') }static switchValue = (userValue, preset) => {if (userValue == 'on') { return true }if (userValue == 'off') { return false }return preset}// the effective { push, email } of one kind for one connectionstatic effectiveOf = (kind, userRow, ovr) => {let 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 }}// the account an identity belongs to (null when the identity is gone)static identityRecord = (identityId) => { return isId(identityId) ? identitiesTable.fetch(identityId) : null }static accountOfIdentity = (identityId) => {let r = identityRecord(identityId)return r != null ? r.account : null}// WRITE THE EFFECTIVE SETTINGS of every kind of the connection's app (a row per kind)static storeEffective = (conn) => {let account = accountOfIdentity(conn.identity)let ovr = overrideOf(conn.id)let 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) => {let a = appOfSecret(b.key, b.secret)if (a == null) { return { status = 401 error = 'unknown API key or wrong secret' } }let conn = connectionByAnyId(a.id, b.identity)if (conn == null) { return { status = 404 error = 'unknown identity for this app' } }let ident = identityRecord(conn.identity)if (ident == null) { return { status = 404 error = 'unknown identity for this app' } }let nm = checkKindName(b.name, 'name')if (nm.error != null) { return { status = 400 error = nm.error } }let 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' } }let 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)}let 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) => {let out = {}let n = 1for (r of identityRecords(accountId)) {out[r.id] = labelOf(r, n)n = n + 1}return out}static appName = (appId) => {let a = isId(appId) ? appsTable.fetch(appId) : nullreturn a != null ? a.name : 'a deleted app'}static inboxRowOf = (r, labels) => {let 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) => {let out = []if (!isId(accountId)) { return out }let 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}static unreadCount = (rows) => {let n = 0for (r of rows) { if (r.unread) { n = n + 1 } }return n}// 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' } }let 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 }let c = connectionsTable.fetch(id)if (c == null) { return null }let 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) => {let out = []if (!isId(accountId)) { return out }let labels = identityLabels(accountId)let all = connectionsTable.find(null, null)if (countOf(all) == 0) { return out }let 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)let 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}static onOff = (b) => { return b ? 'on' : 'off' }// everything the per-app page shows. Answers null for a connection that is not the account's.static settingsPage = (accountId, connId) => {let c = ownConnection(accountId, connId)if (c == null) { return null }let labels = identityLabels(accountId)let ovr = overrideOf(c.id)let active = ovr != null && bool(ovr.active)let 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)let 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) => {let 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' } }let 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 = accountIdlet row = settingOf(c.id, k.name)let change = channel == 'push' ? { userPush = onOff(value) updated = now() } : { userEmail = onOff(value) updated = now() }if (row == null) {let 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) => {let 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' } }let row = overrideOf(c.id)let change = { updated = now() }change[field] = valueif (row == null) {let 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
- 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