gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitd2e7f91bd2e7f91bident 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 outputmred2e7f91b/lib/notify.hl

17.5 KB

  1. // lib/notify.hl — NOTIFICATIONS (piece 4 of 6, ticket ident#6; CONCEPT.md "Notifications" and
  2. // "Per-app page"). Statics only, the server realm. The routes are lib/api.hl (apiKinds, apiNotify), the pages
  3. // components/inbox.hl (/inbox) and components/appsettings.hl (/inbox/<connection id>). Every write to the
  4. // kinds, settings and notifications tables is here; the checks, orders and stamps: lib/notify-helpers.hl.
  5. // DELIVERY IS NOT HERE (pieces 5/6: the daily mail at 17:00, urgent at once, push): this
  6. // file only STORES what delivery needs — per notification the channels and whether each
  7. // was delivered — and sends nothing.
  8. //
  9. // kindsTable pk @id index !pair, @app
  10. // A NOTIFICATION KIND of an app, by its name:
  11. // { pair = '<app @id>:<name>', app, name, registered (bool), push, email
  12. // (the app's PRESET channels, bools), created, updated }
  13. // registered = the app registered it (POST /api/kinds). A name the app
  14. // sends WITHOUT registering it gets a row with registered = false,
  15. // push = false, email = true (CONCEPT: "every notification goes to the
  16. // daily mail"; push only after registering the kind).
  17. // settingsTable pk @id index !pair, @connection
  18. // THE USER'S SWITCHES per connection (= app + identity) and THE
  19. // EFFECTIVE SETTINGS:
  20. // a kind: { pair = '<connection @id>:<kind name>', connection, account,
  21. // kind = name, userPush, userEmail ('' = the app's preset,
  22. // 'on', 'off'), push, email (EFFECTIVE, bools), updated }
  23. // the override for all (kind = ''):
  24. // { pair = '<connection @id>:', connection, account, kind = '',
  25. // active (bool), push, email (bools), updated }
  26. // notificationsTable pk @id index @account, @connection
  27. // { account, identity, app, connection (@ids), name, text, icon, link
  28. // ('' = none), urgent, created, read, readAt,
  29. // push (bool: to be pushed), mail ('daily' | 'now' | 'none'),
  30. // pushSent, mailSent (bools, false until delivery does it) }
  31. //
  32. // EFFECTIVE (per connection and kind): the app's preset, replaced by the user's switch
  33. // where the user set one; the override, when active, replaces both for every kind; and
  34. // push is always off for a kind the app has not registered.
  35. // THE CHANNELS OF A NOTIFICATION, fixed when it arrives:
  36. // normal: push = effective push; mail = 'daily' if effective email, else 'none'
  37. // urgent: push if effective push AND the user has a push device (none exist before
  38. // piece 6, so never yet), else mail = 'now' — when the user left push or email
  39. // on for that kind; with both off: 'none' (the inbox only)
  40. // Every notification lands in the inbox, whatever the channels.
  41. import { MPackDB } from 'hl:mpackdb'
  42. import { now } from 'hl:time'
  43. import { dir, countOf, first, merged, isId, isList, isObject, str } from './util.hl'
  44. import { bool, onOff, stamp, newestFirst, byName, checkKindName, checkText, checkUrl, switchValue, kindRowOut } from './notify-helpers.hl'
  45. import { identityRecords, identityRecord, accountOfIdentity } from './identities.hl'
  46. import { labelOf } from './identities-helpers.hl'
  47. import { appsTable, connectionsTable, appOfSecret, connectionByAnyId } from './apps.hl'
  48. static kindsTable = new MPackDB(file = dir + '/kinds.db', primaryKey = '@id', indexes = ['!pair' '@app'])
  49. static settingsTable = new MPackDB(file = dir + '/settings.db', primaryKey = '@id', indexes = ['!pair' '@connection'])
  50. static notificationsTable = new MPackDB(file = dir + '/notifications.db', primaryKey = '@id', indexes = ['@account' '@connection'])
  51. static maxKindsPerCall = 50
  52. static maxKindsPerApp = 200
  53. static inboxLimit = 200
  54. // ---- THE KINDS (the app, with its key + secret: lib/apps.hl appOfSecret) ---------------
  55. static kindOf = (appId, name) => { return first(kindsTable.find('pair', appId + ':' + name)) }
  56. static registeredKinds = (appId) => {
  57. let out = []
  58. for (k of byName(kindsTable.find('app', appId))) { if (bool(k.registered)) { out.push(kindRowOut(k)) } }
  59. return out
  60. }
  61. // THE APP REGISTERS KINDS: `kinds` = [{ name, push, email }] (strict). A name already
  62. // there gets the new preset (and counts as registered from now on). Answers
  63. // { kinds (every registered one) } or { status, error }.
  64. static kindFields = ['name' 'push' 'email']
  65. static registerKinds = (appRec, kinds) => {
  66. if (!isList(kinds)) { return { status = 400 error = 'field kinds must be a list' } }
  67. if (kinds.length > maxKindsPerCall) { return { status = 400 error = 'at most ' + maxKindsPerCall + ' kinds per call' } }
  68. // check ALL first: a refused call writes nothing
  69. let clean = []
  70. let names = []
  71. let n = 0
  72. for (k of kinds) {
  73. let at = 'kinds[' + n + ']'
  74. if (!isObject(k)) { return { status = 400 error = at + ' must be an object' } }
  75. for (f of k.keys()) {
  76. if (!kindFields.includes(f)) { return { status = 400 error = 'unknown field: ' + at + '.' + f } }
  77. }
  78. for (f of kindFields) {
  79. if (k[f] == null) { return { status = 400 error = 'missing field: ' + at + '.' + f } }
  80. }
  81. let c = checkKindName(k.name, at + '.name')
  82. if (c.error != null) { return { status = 400 error = c.error } }
  83. if (hlTypeName(k.push) != 'Boolean') { return { status = 400 error = 'field ' + at + '.push must be a boolean' } }
  84. if (hlTypeName(k.email) != 'Boolean') { return { status = 400 error = 'field ' + at + '.email must be a boolean' } }
  85. if (names.includes(c.name)) { return { status = 400 error = 'the name ' + c.name + ' is in the list twice' } }
  86. names.push(c.name)
  87. clean.push({ name = c.name push = k.push email = k.email })
  88. n = n + 1
  89. }
  90. let have = countOf(kindsTable.find('app', appRec.id))
  91. let fresh = 0
  92. for (c of clean) { if (kindOf(appRec.id, c.name) == null) { fresh = fresh + 1 } }
  93. if (have + fresh > maxKindsPerApp) { return { status = 400 error = 'an app has at most ' + maxKindsPerApp + ' kinds' } }
  94. for (c of clean) {
  95. let k = kindOf(appRec.id, c.name)
  96. if (k == null) {
  97. 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() })
  98. } else {
  99. kindsTable.update(k.id, merged(k, { registered = true push = c.push email = c.email updated = now() }))
  100. }
  101. }
  102. // the presets changed: every connection's stored effective settings follow
  103. let conns = connectionsTable.find('app', appRec.id)
  104. if (countOf(conns) > 0) { for (conn of conns) { storeEffective(conn) } }
  105. return { kinds = registeredKinds(appRec.id) }
  106. }
  107. // ---- effective settings ----------------------------------------------------------------
  108. static settingOf = (connId, kindName) => { return first(settingsTable.find('pair', connId + ':' + kindName)) }
  109. static overrideOf = (connId) => { return settingOf(connId, '') }
  110. // the effective { push, email } of one kind for one connection
  111. static effectiveOf = (kind, userRow, ovr) => {
  112. let registered = bool(kind.registered)
  113. let push = switchValue(userRow != null ? userRow.userPush : '', registered && bool(kind.push))
  114. let email = switchValue(userRow != null ? userRow.userEmail : '', bool(kind.email))
  115. if (ovr != null && bool(ovr.active)) {
  116. push = bool(ovr.push)
  117. email = bool(ovr.email)
  118. }
  119. if (!registered) { push = false }
  120. return { push = push email = email }
  121. }
  122. // WRITE THE EFFECTIVE SETTINGS of every kind of the connection's app (a row per kind)
  123. static storeEffective = (conn) => {
  124. let account = accountOfIdentity(conn.identity)
  125. let ovr = overrideOf(conn.id)
  126. let kinds = kindsTable.find('app', conn.app)
  127. if (countOf(kinds) == 0) { return null }
  128. for (k of kinds) {
  129. let row = settingOf(conn.id, k.name)
  130. let eff = effectiveOf(k, row, ovr)
  131. if (row == null) {
  132. 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() })
  133. } else if (row.push != eff.push || row.email != eff.email) {
  134. settingsTable.update(row.id, merged(row, { push = eff.push email = eff.email updated = now() }))
  135. }
  136. }
  137. return null
  138. }
  139. // ---- SENDING (the app) -----------------------------------------------------------------
  140. // hasPushDevice: no push devices exist before piece 6
  141. static hasPushDevice = (accountId) => { return false }
  142. // POST /api/notify: `b` = the strict body. Answers { status, error } or { id }.
  143. static sendNotification = (b) => {
  144. let a = appOfSecret(b.key, b.secret)
  145. if (a == null) { return { status = 401 error = 'unknown API key or wrong secret' } }
  146. let conn = connectionByAnyId(a.id, b.identity)
  147. if (conn == null) { return { status = 404 error = 'unknown identity for this app' } }
  148. let ident = identityRecord(conn.identity)
  149. if (ident == null) { return { status = 404 error = 'unknown identity for this app' } }
  150. let nm = checkKindName(b.name, 'name')
  151. if (nm.error != null) { return { status = 400 error = nm.error } }
  152. let tx = checkText(b.text)
  153. if (tx.error != null) { return { status = 400 error = tx.error } }
  154. let icon = ''
  155. if (b.icon != null) {
  156. let c = checkUrl(b.icon, 'icon')
  157. if (c.error != null) { return { status = 400 error = c.error } }
  158. icon = c.url
  159. }
  160. let link = ''
  161. if (b.link != null) {
  162. let c = checkUrl(b.link, 'link')
  163. if (c.error != null) { return { status = 400 error = c.error } }
  164. link = c.url
  165. }
  166. let urgent = bool(b.urgent)
  167. // an UNREGISTERED name is allowed: it becomes a kind the app has not registered
  168. // (daily mail, no push) — the user sees it on the per-app page and can switch it
  169. let kind = kindOf(a.id, nm.name)
  170. if (kind == null) {
  171. if (countOf(kindsTable.find('app', a.id)) >= maxKindsPerApp) { return { status = 400 error = 'an app has at most ' + maxKindsPerApp + ' kinds' } }
  172. 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() })
  173. kind = kindsTable.fetch(kid)
  174. storeEffective(conn)
  175. }
  176. let eff = effectiveOf(kind, settingOf(conn.id, kind.name), overrideOf(conn.id))
  177. let push = eff.push
  178. let mail = eff.email ? 'daily' : 'none'
  179. if (urgent) {
  180. if (eff.push && hasPushDevice(ident.account)) {
  181. push = true
  182. mail = 'none'
  183. } else {
  184. push = false
  185. mail = eff.push || eff.email ? 'now' : 'none'
  186. }
  187. }
  188. 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 })
  189. return { id = id }
  190. }
  191. // ---- THE INBOX (the user) --------------------------------------------------------------
  192. // the account's identities: id → label (as in the lists)
  193. static identityLabels = (accountId) => {
  194. let out = {}
  195. let n = 1
  196. for (r of identityRecords(accountId)) {
  197. out[r.id] = labelOf(r, n)
  198. n = n + 1
  199. }
  200. return out
  201. }
  202. static appName = (appId) => {
  203. let a = isId(appId) ? appsTable.fetch(appId) : null
  204. return a != null ? a.name : 'a deleted app'
  205. }
  206. static inboxRowOf = (r, labels) => {
  207. let label = labels[r.identity]
  208. let read = bool(r.read)
  209. return {
  210. id = r.id
  211. identity = label != null ? label : 'a deleted identity'
  212. app = appName(r.app)
  213. connection = r.connection
  214. name = r.name
  215. text = r.text
  216. icon = str(r.icon)
  217. hasIcon = str(r.icon) != ''
  218. link = str(r.link)
  219. hasLink = str(r.link) != ''
  220. urgent = bool(r.urgent)
  221. time = stamp(r.created)
  222. read = read
  223. unread = !read
  224. state = read ? 'read' : 'unread'
  225. toggleText = read ? 'Mark unread' : 'Mark read'
  226. }
  227. }
  228. // the account's notifications (all identities), newest first, at most inboxLimit
  229. static inboxRows = (accountId) => {
  230. let out = []
  231. if (!isId(accountId)) { return out }
  232. let labels = identityLabels(accountId)
  233. let n = 0
  234. for (r of newestFirst(notificationsTable.find('account', accountId))) {
  235. if (n >= inboxLimit) { return out }
  236. out.push(inboxRowOf(r, labels))
  237. n = n + 1
  238. }
  239. return out
  240. }
  241. // read / unread: only the account's own. Answers { error } or { ok }.
  242. static markRead = (accountId, id, read) => {
  243. if (!isId(id)) { return { error = 'no such notification' } }
  244. if (hlTypeName(read) != 'Boolean') { return { error = 'field read must be a boolean' } }
  245. let r = notificationsTable.fetch(id)
  246. if (r == null || r.account != accountId) { return { error = 'no such notification' } }
  247. notificationsTable.update(r.id, merged(r, { read = read readAt = read ? now() : 0 }))
  248. return { ok = true }
  249. }
  250. // ---- THE PER-APP PAGE (the user) -------------------------------------------------------
  251. // the connection `id` if its identity belongs to the account, else null
  252. static ownConnection = (accountId, id) => {
  253. if (!isId(id)) { return null }
  254. let c = connectionsTable.fetch(id)
  255. if (c == null) { return null }
  256. let ident = identityRecord(c.identity)
  257. if (ident == null || ident.account != accountId) { return null }
  258. if (!isId(c.app) || appsTable.fetch(c.app) == null) { return null }
  259. return c
  260. }
  261. // the account's connections (every app each identity registered in), oldest first
  262. static connectionRows = (accountId) => {
  263. let out = []
  264. if (!isId(accountId)) { return out }
  265. let labels = identityLabels(accountId)
  266. let all = connectionsTable.find(null, null)
  267. if (countOf(all) == 0) { return out }
  268. let mine = []
  269. for (c of all) {
  270. if (labels[c.identity] != null && isId(c.app) && appsTable.fetch(c.app) != null) { mine.push(c) }
  271. }
  272. // oldest first by created (reverse of newestFirst)
  273. let sorted = newestFirst(mine)
  274. let i = sorted.length - 1
  275. while (i >= 0) {
  276. let c = sorted[i]
  277. out.push({ id = c.id href = '/inbox/' + c.id app = appName(c.app) identity = labels[c.identity] since = stamp(c.created) })
  278. i = i - 1
  279. }
  280. return out
  281. }
  282. // everything the per-app page shows. Answers null for a connection that is not the account's.
  283. static settingsPage = (accountId, connId) => {
  284. let c = ownConnection(accountId, connId)
  285. if (c == null) { return null }
  286. let labels = identityLabels(accountId)
  287. let ovr = overrideOf(c.id)
  288. let active = ovr != null && bool(ovr.active)
  289. let oPush = ovr != null && bool(ovr.push)
  290. // no override row yet: its switches show what it would start with (the concept's
  291. // default: the daily mail, no push)
  292. let oEmail = ovr == null ? true : bool(ovr.email)
  293. let kinds = []
  294. for (k of byName(kindsTable.find('app', c.app))) {
  295. let row = settingOf(c.id, k.name)
  296. let eff = effectiveOf(k, row, ovr)
  297. let registered = bool(k.registered)
  298. kinds.push({
  299. name = k.name
  300. registered = registered
  301. canPush = registered && !active
  302. noPush = !registered
  303. lockedPush = registered && active
  304. canEmail = !active
  305. lockedEmail = active
  306. push = onOff(eff.push)
  307. email = onOff(eff.email)
  308. pushChecked = '' + eff.push
  309. emailChecked = '' + eff.email
  310. preset = 'app default: push ' + onOff(registered && bool(k.push)) + ', email ' + onOff(bool(k.email)) + (registered ? '' : ' (not registered by the app: no push)')
  311. })
  312. }
  313. return {
  314. id = c.id
  315. app = appName(c.app)
  316. identity = labels[c.identity]
  317. since = stamp(c.created)
  318. overrideActive = onOff(active)
  319. overridePush = onOff(oPush)
  320. overrideEmail = onOff(oEmail)
  321. overrideActiveChecked = '' + active
  322. overridePushChecked = '' + oPush
  323. overrideEmailChecked = '' + oEmail
  324. kinds = kinds
  325. hasKinds = kinds.length > 0
  326. noKinds = kinds.length == 0
  327. }
  328. }
  329. // THE USER FLIPS A KIND'S SWITCH: channel 'push' | 'email', value bool. Answers { error } or { page }.
  330. static setSwitch = (accountId, connId, kindName, channel, value) => {
  331. let c = ownConnection(accountId, connId)
  332. if (c == null) { return { error = 'no such app connection' } }
  333. if (kindName == null || hlTypeName(kindName) != 'String') { return { error = 'field kind must be a string' } }
  334. if (channel != 'push' && channel != 'email') { return { error = 'field channel must be push or email' } }
  335. if (hlTypeName(value) != 'Boolean') { return { error = 'field value must be a boolean' } }
  336. let k = kindOf(c.app, kindName)
  337. if (k == null) { return { error = 'no such notification kind' } }
  338. if (channel == 'push' && !bool(k.registered)) { return { error = 'the app has not registered this notification — it cannot be pushed' } }
  339. let account = accountId
  340. let row = settingOf(c.id, k.name)
  341. let change = channel == 'push' ? { userPush = onOff(value) updated = now() } : { userEmail = onOff(value) updated = now() }
  342. if (row == null) {
  343. let rec = { pair = c.id + ':' + k.name connection = c.id account = account kind = k.name userPush = '' userEmail = '' push = false email = false updated = now() }
  344. settingsTable.put(merged(rec, change))
  345. } else {
  346. settingsTable.update(row.id, merged(row, change))
  347. }
  348. storeEffective(c)
  349. return { page = settingsPage(accountId, c.id) }
  350. }
  351. // THE OVERRIDE FOR ALL: field 'active' | 'push' | 'email', value bool. Answers { error } or { page }.
  352. static setOverride = (accountId, connId, field, value) => {
  353. let c = ownConnection(accountId, connId)
  354. if (c == null) { return { error = 'no such app connection' } }
  355. if (field != 'active' && field != 'push' && field != 'email') { return { error = 'field field must be active, push or email' } }
  356. if (hlTypeName(value) != 'Boolean') { return { error = 'field value must be a boolean' } }
  357. let row = overrideOf(c.id)
  358. let change = { updated = now() }
  359. change[field] = value
  360. if (row == null) {
  361. let rec = { pair = c.id + ':' connection = c.id account = accountId kind = '' active = false push = false email = true updated = now() }
  362. settingsTable.put(merged(rec, change))
  363. } else {
  364. settingsTable.update(row.id, merged(row, change))
  365. }
  366. storeEffective(c)
  367. return { page = settingsPage(accountId, c.id) }
  368. }

Branches

Latest commits

  • 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