gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitcc063ea2cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmrecc063ea2/notify.hl

22.4 KB

  1. // 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 in project.hl, the pages
  3. // in components/inbox.hl (/inbox) and components/appsettings.hl (/inbox/<connection id>).
  4. // DELIVERY IS NOT HERE (pieces 5/6: the daily mail at 17:00, urgent at once, push): this
  5. // file only STORES what delivery needs — per notification the channels and whether each
  6. // was delivered — and sends nothing.
  7. //
  8. // kindsTable pk @id index !pair, @app
  9. // A NOTIFICATION KIND of an app, by its name:
  10. // { pair = '<app @id>:<name>', app, name, registered (bool), push, email
  11. // (the app's PRESET channels, bools), created, updated }
  12. // registered = the app registered it (POST /api/kinds). A name the app
  13. // sends WITHOUT registering it gets a row with registered = false,
  14. // push = false, email = true (CONCEPT: "every notification goes to the
  15. // daily mail"; push only after registering the kind).
  16. // settingsTable pk @id index !pair, @connection
  17. // THE USER'S SWITCHES per connection (= app + identity) and THE
  18. // EFFECTIVE SETTINGS:
  19. // a kind: { pair = '<connection @id>:<kind name>', connection, account,
  20. // kind = name, userPush, userEmail ('' = the app's preset,
  21. // 'on', 'off'), push, email (EFFECTIVE, bools), updated }
  22. // the override for all (kind = ''):
  23. // { pair = '<connection @id>:', connection, account, kind = '',
  24. // active (bool), push, email (bools), updated }
  25. // notificationsTable pk @id index @account, @connection
  26. // { account, identity, app, connection (@ids), name, text, icon, link
  27. // ('' = none), urgent, created, read, readAt,
  28. // push (bool: to be pushed), mail ('daily' | 'now' | 'none'),
  29. // pushSent, mailSent (bools, false until delivery does it) }
  30. //
  31. // EFFECTIVE (per connection and kind): the app's preset, replaced by the user's switch
  32. // where the user set one; the override, when active, replaces both for every kind; and
  33. // push is always off for a kind the app has not registered.
  34. // THE CHANNELS OF A NOTIFICATION, fixed when it arrives:
  35. // normal: push = effective push; mail = 'daily' if effective email, else 'none'
  36. // urgent: push if effective push AND the user has a push device (none exist before
  37. // piece 6, so never yet), else mail = 'now' — when the user left push or email
  38. // on for that kind; with both off: 'none' (the inbox only)
  39. // Every notification lands in the inbox, whatever the channels.
  40. import { MPackDB } from 'hl:mpackdb'
  41. import { now, timestamp } from 'hl:time'
  42. import { sha256 } from 'hl:crypto'
  43. import { dir, countOf, first, merged, hasControl, isId, identitiesTable, identityRecords, labelOf } from './store.hl'
  44. import { appsTable, connectionsTable, appByKey, urlChars, connectionByAnyId } from './apps.hl'
  45. static kindsTable = new MPackDB(file = dir + '/kinds.db', primaryKey = '@id', indexes = ['!pair' '@app'])
  46. static settingsTable = new MPackDB(file = dir + '/settings.db', primaryKey = '@id', indexes = ['!pair' '@connection'])
  47. static notificationsTable = new MPackDB(file = dir + '/notifications.db', primaryKey = '@id', indexes = ['@account' '@connection'])
  48. static maxName = 60
  49. static maxText = 2000
  50. static maxUrl = 2000
  51. static maxKindsPerCall = 50
  52. static maxKindsPerApp = 200
  53. static inboxLimit = 200
  54. // ---- small helpers ----------------------------------------------------------------------
  55. static bool = (v) => { return v == true }
  56. static isList = (v) => { return v != null && hlTypeName(v) == 'Hybrid' && v.length != null }
  57. static isObject = (v) => { return v != null && hlTypeName(v) == 'Hybrid' && v.length == null }
  58. static str = (v) => { return v == null ? '' : v }
  59. // "YYYY-MM-DD HH:MM UTC" (hl:time knows no zones, hybriel #11)
  60. static stamp = (ms) => {
  61. if (ms == null) { return '' }
  62. let t = timestamp(ms)
  63. return t.slice(0, 10) + ' ' + t.slice(11, 16) + ' UTC'
  64. }
  65. // NEWEST FIRST by `created` (a tie by the id); insertion sort by hand (no list sort(), hybriel #6)
  66. static idAfter = (a, b) => {
  67. let i = 0
  68. while (i < a.length && i < b.length) {
  69. let x = a.charCodeAt(i)
  70. let y = b.charCodeAt(i)
  71. if (x != y) { return x > y }
  72. i = i + 1
  73. }
  74. return a.length > b.length
  75. }
  76. static newerThan = (a, b) => {
  77. let ca = a.created == null ? 0 : a.created
  78. let cb = b.created == null ? 0 : b.created
  79. if (ca != cb) { return ca > cb }
  80. return idAfter(a.id, b.id)
  81. }
  82. static newestFirst = (list) => {
  83. let out = []
  84. if (countOf(list) == 0) { return out }
  85. for (x of list) { out.push(x) }
  86. let i = 1
  87. while (i < out.length) {
  88. let cur = out[i]
  89. let j = i - 1
  90. while (j >= 0 && newerThan(cur, out[j])) {
  91. out[j + 1] = out[j]
  92. j = j - 1
  93. }
  94. out[j + 1] = cur
  95. i = i + 1
  96. }
  97. return out
  98. }
  99. // kinds by name, A-Z (char codes)
  100. static nameBefore = (a, b) => {
  101. let x = a.name.toLowerCase()
  102. let y = b.name.toLowerCase()
  103. let i = 0
  104. while (i < x.length && i < y.length) {
  105. if (x.charCodeAt(i) != y.charCodeAt(i)) { return x.charCodeAt(i) < y.charCodeAt(i) }
  106. i = i + 1
  107. }
  108. return x.length < y.length
  109. }
  110. static byName = (list) => {
  111. let out = []
  112. if (countOf(list) == 0) { return out }
  113. for (x of list) { out.push(x) }
  114. let i = 1
  115. while (i < out.length) {
  116. let cur = out[i]
  117. let j = i - 1
  118. while (j >= 0 && nameBefore(cur, out[j])) {
  119. out[j + 1] = out[j]
  120. j = j - 1
  121. }
  122. out[j + 1] = cur
  123. i = i + 1
  124. }
  125. return out
  126. }
  127. // ---- validation -------------------------------------------------------------------------
  128. // A KIND NAME / NOTIFICATION NAME: trimmed, 1-60 chars, one line. Answers { name } or { error }.
  129. static checkKindName = (v, what) => {
  130. if (v == null || hlTypeName(v) != 'String') { return { error = 'field ' + what + ' must be a string' } }
  131. let t = v.trim()
  132. if (t == '') { return { error = 'field ' + what + ' is empty' } }
  133. if (t.length > maxName) { return { error = 'field ' + what + ' is longer than ' + maxName + ' characters' } }
  134. if (hasControl(t)) { return { error = 'field ' + what + ' contains a control character' } }
  135. return { name = t }
  136. }
  137. // THE TEXT: 1-2000 chars (Markdown by the worldapi convention; shown as plain text with
  138. // its line breaks); tab, line feed and carriage return are the only control characters
  139. static checkText = (v) => {
  140. if (v == null || hlTypeName(v) != 'String') { return { error = 'field text must be a string' } }
  141. let t = v.trim()
  142. if (t == '') { return { error = 'field text is empty' } }
  143. if (t.length > maxText) { return { error = 'field text is longer than ' + maxText + ' characters' } }
  144. let i = 0
  145. while (i < t.length) {
  146. let c = t.charCodeAt(i)
  147. if ((c < 32 && c != 9 && c != 10 && c != 13) || c == 127) { return { error = 'field text contains a control character' } }
  148. i = i + 1
  149. }
  150. return { text = t }
  151. }
  152. // AN ICON / ACTION LINK: an absolute http(s) URL, printable URL characters only, ≤ 2000
  153. static checkUrl = (v, what) => {
  154. if (v == null || hlTypeName(v) != 'String') { return { error = 'field ' + what + ' must be a string' } }
  155. let t = v.trim()
  156. if (t.length > maxUrl) { return { error = 'field ' + what + ' is longer than ' + maxUrl + ' characters' } }
  157. let i = 0
  158. while (i < t.length) {
  159. if (!urlChars.includes(t[i])) { return { error = 'field ' + what + ' contains a character that is not allowed in a URL' } }
  160. i = i + 1
  161. }
  162. let lower = t.toLowerCase()
  163. let scheme = lower.slice(0, 7) == 'http://' ? 7 : (lower.slice(0, 8) == 'https://' ? 8 : 0)
  164. if (scheme == 0) { return { error = 'field ' + what + ' must be an http:// or https:// URL' } }
  165. let rest = t.slice(scheme, t.length)
  166. if (rest == '' || rest[0] == '/' || rest[0] == '?') { return { error = 'field ' + what + ' has no host' } }
  167. return { url = t }
  168. }
  169. // ---- the app's side: key + secret ------------------------------------------------------
  170. // answers the app record or null (unknown key or wrong secret: one answer, 401)
  171. static appOfSecret = (key, secret) => {
  172. let a = appByKey(key)
  173. if (a == null || hlTypeName(secret) != 'String' || sha256(secret) != a.secretHash) { return null }
  174. return a
  175. }
  176. static kindOf = (appId, name) => { return first(kindsTable.find('pair', appId + ':' + name)) }
  177. static kindRowOut = (k) => { return { name = k.name push = bool(k.push) email = bool(k.email) } }
  178. static registeredKinds = (appId) => {
  179. let out = []
  180. for (k of byName(kindsTable.find('app', appId))) { if (bool(k.registered)) { out.push(kindRowOut(k)) } }
  181. return out
  182. }
  183. // THE APP REGISTERS KINDS: `kinds` = [{ name, push, email }] (strict). A name already
  184. // there gets the new preset (and counts as registered from now on). Answers
  185. // { kinds (every registered one) } or { status, error }.
  186. static kindFields = ['name' 'push' 'email']
  187. static registerKinds = (appRec, kinds) => {
  188. if (!isList(kinds)) { return { status = 400 error = 'field kinds must be a list' } }
  189. if (kinds.length > maxKindsPerCall) { return { status = 400 error = 'at most ' + maxKindsPerCall + ' kinds per call' } }
  190. // check ALL first: a refused call writes nothing
  191. let clean = []
  192. let names = []
  193. let n = 0
  194. for (k of kinds) {
  195. let at = 'kinds[' + n + ']'
  196. if (!isObject(k)) { return { status = 400 error = at + ' must be an object' } }
  197. for (f of k.keys()) {
  198. if (!kindFields.includes(f)) { return { status = 400 error = 'unknown field: ' + at + '.' + f } }
  199. }
  200. for (f of kindFields) {
  201. if (k[f] == null) { return { status = 400 error = 'missing field: ' + at + '.' + f } }
  202. }
  203. let c = checkKindName(k.name, at + '.name')
  204. if (c.error != null) { return { status = 400 error = c.error } }
  205. if (hlTypeName(k.push) != 'Boolean') { return { status = 400 error = 'field ' + at + '.push must be a boolean' } }
  206. if (hlTypeName(k.email) != 'Boolean') { return { status = 400 error = 'field ' + at + '.email must be a boolean' } }
  207. if (names.includes(c.name)) { return { status = 400 error = 'the name ' + c.name + ' is in the list twice' } }
  208. names.push(c.name)
  209. clean.push({ name = c.name push = k.push email = k.email })
  210. n = n + 1
  211. }
  212. let have = countOf(kindsTable.find('app', appRec.id))
  213. let fresh = 0
  214. for (c of clean) { if (kindOf(appRec.id, c.name) == null) { fresh = fresh + 1 } }
  215. if (have + fresh > maxKindsPerApp) { return { status = 400 error = 'an app has at most ' + maxKindsPerApp + ' kinds' } }
  216. for (c of clean) {
  217. let k = kindOf(appRec.id, c.name)
  218. if (k == null) {
  219. 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() })
  220. } else {
  221. kindsTable.update(k.id, merged(k, { registered = true push = c.push email = c.email updated = now() }))
  222. }
  223. }
  224. // the presets changed: every connection's stored effective settings follow
  225. let conns = connectionsTable.find('app', appRec.id)
  226. if (countOf(conns) > 0) { for (conn of conns) { storeEffective(conn) } }
  227. return { kinds = registeredKinds(appRec.id) }
  228. }
  229. // ---- effective settings ----------------------------------------------------------------
  230. static settingOf = (connId, kindName) => { return first(settingsTable.find('pair', connId + ':' + kindName)) }
  231. static overrideOf = (connId) => { return settingOf(connId, '') }
  232. static switchValue = (userValue, preset) => {
  233. if (userValue == 'on') { return true }
  234. if (userValue == 'off') { return false }
  235. return preset
  236. }
  237. // the effective { push, email } of one kind for one connection
  238. static effectiveOf = (kind, userRow, ovr) => {
  239. let registered = bool(kind.registered)
  240. let push = switchValue(userRow != null ? userRow.userPush : '', registered && bool(kind.push))
  241. let email = switchValue(userRow != null ? userRow.userEmail : '', bool(kind.email))
  242. if (ovr != null && bool(ovr.active)) {
  243. push = bool(ovr.push)
  244. email = bool(ovr.email)
  245. }
  246. if (!registered) { push = false }
  247. return { push = push email = email }
  248. }
  249. // the account an identity belongs to (null when the identity is gone)
  250. static identityRecord = (identityId) => { return isId(identityId) ? identitiesTable.fetch(identityId) : null }
  251. static accountOfIdentity = (identityId) => {
  252. let r = identityRecord(identityId)
  253. return r != null ? r.account : null
  254. }
  255. // WRITE THE EFFECTIVE SETTINGS of every kind of the connection's app (a row per kind)
  256. static storeEffective = (conn) => {
  257. let account = accountOfIdentity(conn.identity)
  258. let ovr = overrideOf(conn.id)
  259. let kinds = kindsTable.find('app', conn.app)
  260. if (countOf(kinds) == 0) { return null }
  261. for (k of kinds) {
  262. let row = settingOf(conn.id, k.name)
  263. let eff = effectiveOf(k, row, ovr)
  264. if (row == null) {
  265. 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() })
  266. } else if (row.push != eff.push || row.email != eff.email) {
  267. settingsTable.update(row.id, merged(row, { push = eff.push email = eff.email updated = now() }))
  268. }
  269. }
  270. return null
  271. }
  272. // ---- SENDING (the app) -----------------------------------------------------------------
  273. // hasPushDevice: no push devices exist before piece 6
  274. static hasPushDevice = (accountId) => { return false }
  275. // POST /api/notify: `b` = the strict body. Answers { status, error } or { id }.
  276. static sendNotification = (b) => {
  277. let a = appOfSecret(b.key, b.secret)
  278. if (a == null) { return { status = 401 error = 'unknown API key or wrong secret' } }
  279. let conn = connectionByAnyId(a.id, b.identity)
  280. if (conn == null) { return { status = 404 error = 'unknown identity for this app' } }
  281. let ident = identityRecord(conn.identity)
  282. if (ident == null) { return { status = 404 error = 'unknown identity for this app' } }
  283. let nm = checkKindName(b.name, 'name')
  284. if (nm.error != null) { return { status = 400 error = nm.error } }
  285. let tx = checkText(b.text)
  286. if (tx.error != null) { return { status = 400 error = tx.error } }
  287. let icon = ''
  288. if (b.icon != null) {
  289. let c = checkUrl(b.icon, 'icon')
  290. if (c.error != null) { return { status = 400 error = c.error } }
  291. icon = c.url
  292. }
  293. let link = ''
  294. if (b.link != null) {
  295. let c = checkUrl(b.link, 'link')
  296. if (c.error != null) { return { status = 400 error = c.error } }
  297. link = c.url
  298. }
  299. let urgent = bool(b.urgent)
  300. // an UNREGISTERED name is allowed: it becomes a kind the app has not registered
  301. // (daily mail, no push) — the user sees it on the per-app page and can switch it
  302. let kind = kindOf(a.id, nm.name)
  303. if (kind == null) {
  304. if (countOf(kindsTable.find('app', a.id)) >= maxKindsPerApp) { return { status = 400 error = 'an app has at most ' + maxKindsPerApp + ' kinds' } }
  305. 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() })
  306. kind = kindsTable.fetch(kid)
  307. storeEffective(conn)
  308. }
  309. let eff = effectiveOf(kind, settingOf(conn.id, kind.name), overrideOf(conn.id))
  310. let push = eff.push
  311. let mail = eff.email ? 'daily' : 'none'
  312. if (urgent) {
  313. if (eff.push && hasPushDevice(ident.account)) {
  314. push = true
  315. mail = 'none'
  316. } else {
  317. push = false
  318. mail = eff.push || eff.email ? 'now' : 'none'
  319. }
  320. }
  321. 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 })
  322. return { id = id }
  323. }
  324. // ---- THE INBOX (the user) --------------------------------------------------------------
  325. // the account's identities: id → label (as in the lists)
  326. static identityLabels = (accountId) => {
  327. let out = {}
  328. let n = 1
  329. for (r of identityRecords(accountId)) {
  330. out[r.id] = labelOf(r, n)
  331. n = n + 1
  332. }
  333. return out
  334. }
  335. static appName = (appId) => {
  336. let a = isId(appId) ? appsTable.fetch(appId) : null
  337. return a != null ? a.name : 'a deleted app'
  338. }
  339. static inboxRowOf = (r, labels) => {
  340. let label = labels[r.identity]
  341. let read = bool(r.read)
  342. return {
  343. id = r.id
  344. identity = label != null ? label : 'a deleted identity'
  345. app = appName(r.app)
  346. connection = r.connection
  347. name = r.name
  348. text = r.text
  349. icon = str(r.icon)
  350. hasIcon = str(r.icon) != ''
  351. link = str(r.link)
  352. hasLink = str(r.link) != ''
  353. urgent = bool(r.urgent)
  354. time = stamp(r.created)
  355. read = read
  356. unread = !read
  357. state = read ? 'read' : 'unread'
  358. toggleText = read ? 'Mark unread' : 'Mark read'
  359. }
  360. }
  361. // the account's notifications (all identities), newest first, at most inboxLimit
  362. static inboxRows = (accountId) => {
  363. let out = []
  364. if (!isId(accountId)) { return out }
  365. let labels = identityLabels(accountId)
  366. let n = 0
  367. for (r of newestFirst(notificationsTable.find('account', accountId))) {
  368. if (n >= inboxLimit) { return out }
  369. out.push(inboxRowOf(r, labels))
  370. n = n + 1
  371. }
  372. return out
  373. }
  374. static unreadCount = (rows) => {
  375. let n = 0
  376. for (r of rows) { if (r.unread) { n = n + 1 } }
  377. return n
  378. }
  379. // read / unread: only the account's own. Answers { error } or { ok }.
  380. static markRead = (accountId, id, read) => {
  381. if (!isId(id)) { return { error = 'no such notification' } }
  382. if (hlTypeName(read) != 'Boolean') { return { error = 'field read must be a boolean' } }
  383. let r = notificationsTable.fetch(id)
  384. if (r == null || r.account != accountId) { return { error = 'no such notification' } }
  385. notificationsTable.update(r.id, merged(r, { read = read readAt = read ? now() : 0 }))
  386. return { ok = true }
  387. }
  388. // ---- THE PER-APP PAGE (the user) -------------------------------------------------------
  389. // the connection `id` if its identity belongs to the account, else null
  390. static ownConnection = (accountId, id) => {
  391. if (!isId(id)) { return null }
  392. let c = connectionsTable.fetch(id)
  393. if (c == null) { return null }
  394. let ident = identityRecord(c.identity)
  395. if (ident == null || ident.account != accountId) { return null }
  396. if (!isId(c.app) || appsTable.fetch(c.app) == null) { return null }
  397. return c
  398. }
  399. // the account's connections (every app each identity registered in), oldest first
  400. static connectionRows = (accountId) => {
  401. let out = []
  402. if (!isId(accountId)) { return out }
  403. let labels = identityLabels(accountId)
  404. let all = connectionsTable.find(null, null)
  405. if (countOf(all) == 0) { return out }
  406. let mine = []
  407. for (c of all) {
  408. if (labels[c.identity] != null && isId(c.app) && appsTable.fetch(c.app) != null) { mine.push(c) }
  409. }
  410. // oldest first by created (reverse of newestFirst)
  411. let sorted = newestFirst(mine)
  412. let i = sorted.length - 1
  413. while (i >= 0) {
  414. let c = sorted[i]
  415. out.push({ id = c.id href = '/inbox/' + c.id app = appName(c.app) identity = labels[c.identity] since = stamp(c.created) })
  416. i = i - 1
  417. }
  418. return out
  419. }
  420. static onOff = (b) => { return b ? 'on' : 'off' }
  421. // everything the per-app page shows. Answers null for a connection that is not the account's.
  422. static settingsPage = (accountId, connId) => {
  423. let c = ownConnection(accountId, connId)
  424. if (c == null) { return null }
  425. let labels = identityLabels(accountId)
  426. let ovr = overrideOf(c.id)
  427. let active = ovr != null && bool(ovr.active)
  428. let oPush = ovr != null && bool(ovr.push)
  429. // no override row yet: its switches show what it would start with (the concept's
  430. // default: the daily mail, no push)
  431. let oEmail = ovr == null ? true : bool(ovr.email)
  432. let kinds = []
  433. for (k of byName(kindsTable.find('app', c.app))) {
  434. let row = settingOf(c.id, k.name)
  435. let eff = effectiveOf(k, row, ovr)
  436. let registered = bool(k.registered)
  437. kinds.push({
  438. name = k.name
  439. registered = registered
  440. canPush = registered && !active
  441. noPush = !registered
  442. lockedPush = registered && active
  443. canEmail = !active
  444. lockedEmail = active
  445. push = onOff(eff.push)
  446. email = onOff(eff.email)
  447. pushChecked = '' + eff.push
  448. emailChecked = '' + eff.email
  449. preset = 'app default: push ' + onOff(registered && bool(k.push)) + ', email ' + onOff(bool(k.email)) + (registered ? '' : ' (not registered by the app: no push)')
  450. })
  451. }
  452. return {
  453. id = c.id
  454. app = appName(c.app)
  455. identity = labels[c.identity]
  456. since = stamp(c.created)
  457. overrideActive = onOff(active)
  458. overridePush = onOff(oPush)
  459. overrideEmail = onOff(oEmail)
  460. overrideActiveChecked = '' + active
  461. overridePushChecked = '' + oPush
  462. overrideEmailChecked = '' + oEmail
  463. kinds = kinds
  464. hasKinds = kinds.length > 0
  465. noKinds = kinds.length == 0
  466. }
  467. }
  468. // THE USER FLIPS A KIND'S SWITCH: channel 'push' | 'email', value bool. Answers { error } or { page }.
  469. static setSwitch = (accountId, connId, kindName, channel, value) => {
  470. let c = ownConnection(accountId, connId)
  471. if (c == null) { return { error = 'no such app connection' } }
  472. if (kindName == null || hlTypeName(kindName) != 'String') { return { error = 'field kind must be a string' } }
  473. if (channel != 'push' && channel != 'email') { return { error = 'field channel must be push or email' } }
  474. if (hlTypeName(value) != 'Boolean') { return { error = 'field value must be a boolean' } }
  475. let k = kindOf(c.app, kindName)
  476. if (k == null) { return { error = 'no such notification kind' } }
  477. if (channel == 'push' && !bool(k.registered)) { return { error = 'the app has not registered this notification — it cannot be pushed' } }
  478. let account = accountId
  479. let row = settingOf(c.id, k.name)
  480. let change = channel == 'push' ? { userPush = onOff(value) updated = now() } : { userEmail = onOff(value) updated = now() }
  481. if (row == null) {
  482. let rec = { pair = c.id + ':' + k.name connection = c.id account = account kind = k.name userPush = '' userEmail = '' push = false email = false updated = now() }
  483. settingsTable.put(merged(rec, change))
  484. } else {
  485. settingsTable.update(row.id, merged(row, change))
  486. }
  487. storeEffective(c)
  488. return { page = settingsPage(accountId, c.id) }
  489. }
  490. // THE OVERRIDE FOR ALL: field 'active' | 'push' | 'email', value bool. Answers { error } or { page }.
  491. static setOverride = (accountId, connId, field, value) => {
  492. let c = ownConnection(accountId, connId)
  493. if (c == null) { return { error = 'no such app connection' } }
  494. if (field != 'active' && field != 'push' && field != 'email') { return { error = 'field field must be active, push or email' } }
  495. if (hlTypeName(value) != 'Boolean') { return { error = 'field value must be a boolean' } }
  496. let row = overrideOf(c.id)
  497. let change = { updated = now() }
  498. change[field] = value
  499. if (row == null) {
  500. let rec = { pair = c.id + ':' connection = c.id account = accountId kind = '' active = false push = false email = true updated = now() }
  501. settingsTable.put(merged(rec, change))
  502. } else {
  503. settingsTable.update(row.id, merged(row, change))
  504. }
  505. storeEffective(c)
  506. return { page = settingsPage(accountId, c.id) }
  507. }

Branches

Latest commits

  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre