gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit4137be0f4137be0fantcolony#40: mission references point to the moved missionsmre4137be0f/store.hl

50.5 KB

  1. // store.hl — THE SERVER REALM of tickets.worldapi.org. Five hl:mpackdb tables and
  2. // the functions that read and write them; statics only (a component member imports
  3. // what it reads by brace, a face / an API route what it writes).
  4. //
  5. // CREATOR'S CONVENTIONS (2026-09-24, ticket #38): every primary key is the mpackdb UUID
  6. // (`@id`, a string like '0mufbkwhlpjq'), never a `*id` counter; the files live in
  7. // storage/mpackdb/. A NUMBER A PERSON SEES is a separate field: tickets are numbered PER
  8. // PROJECT from 1 (`number`); tickets migrated from the old global store keep their old
  9. // global number as `oldNumber` (docs and comments say "#38"). Order never comes from key
  10. // order: lists sort by the stored times, a history by (created, seq).
  11. //
  12. // PROJECTS ARE OWN DATA (ticket #20, 2026-09-26):
  13. // projectsTable pk @id index !slug { title, slug, description (Markdown), oldSlugs ('a,b' — the
  14. // slugs it had before; they still resolve), created }
  15. // membersTable pk @id index project user { project (project @id), user (users @id), role, created }
  16. // role = 'use' | 'edit' | 'admin' (below). One row per (project, user).
  17. // ticketsTable pk @id index project state source !path *oldNumber
  18. // { project (project @id — NOT the name), number, path ('<project @id>/<number>', unique),
  19. // oldNumber?, subject, summary, state, source, created, updated, events,
  20. // createdBy (users @id, '' = opened before the login), assignee (users @id, '') }
  21. // eventsTable pk @id index @ticket APPEND ONLY: { ticket (ticket @id), seq
  22. // (1, 2, … per ticket), kind, author, user?, text, from, to, created, oldId? }
  23. // kind = 'created' | 'comment' | 'state' | 'edit' | 'link' | 'assign'
  24. // 'assign' (ticket #20): { assignee (users @id, '' = unassigned), previous (users @id, '') }
  25. // 'link' (ticket #4, mission 017): { linkKind ('parent' | 'blockedBy'), action
  26. // ('set' | 'removed'), other, otherProject, otherNumber, previous?, previousProject?,
  27. // previousNumber? } — on the CHILD / the BLOCKED ticket (the one whose relation changed).
  28. // 'edit' (ticket #8, mission 014): { oldSubject, oldSummary, subject, summary } —
  29. // the values before AND after; the ticket record then carries the new ones.
  30. // `user` (users @id, users.hl) since ticket #7: the event's author is that
  31. // user's CURRENT display name; `author` keeps the name at writing time.
  32. // linksTable pk @id index @ticket @other (ticket #4, mission 017) { kind, ticket, other, user, created }
  33. // kind 'parent': `ticket` is a child of `other` (at most one per ticket)
  34. // kind 'blockedBy': `ticket` is blocked by `other` (waits on it)
  35. // A removed relation is deleted here; the history keeps it ('link' events).
  36. //
  37. // THE THREE ROLES (a member of a project; anybody else only reads):
  38. // use = comment, and move a ticket between open and review
  39. // edit = use + create, edit, assign, any state, relations
  40. // admin = edit + the project's settings and its members
  41. // THE PROJECT SLUG (the URL part) is generated from the title and only holds letters,
  42. // digits, '.', '_' and '-' (URL-safe); an admin can change it, the old one keeps resolving.
  43. //
  44. // Nothing here knows who is looking except where a function takes a `user`; a write answers
  45. // the rows the push carries. `source` is the import key (the ticket file's name) — an import
  46. // with a known source is answered with the existing ticket, never a second one.
  47. import { MPackDB } from 'hl:mpackdb'
  48. import { env } from 'hl:proc'
  49. import { now } from 'hl:time'
  50. import { localStamp } from './localtime.hl'
  51. import { nameOfUser, userRecord, usersTable, ensureUser, ensureCreatorUser, countOfList } from './users.hl'
  52. import { markdownBlocks } from './markdown.hl'
  53. // TICKETS_STORAGE names another table DIRECTORY (the gate runs on its own). Relative
  54. // paths resolve against the ENTRY SCRIPT's directory — tools must pass an absolute one.
  55. static dir = env('TICKETS_STORAGE') != null ? env('TICKETS_STORAGE') : './storage/mpackdb'
  56. static projectsTable = new MPackDB(file = dir + '/projects.db', primaryKey = '@id', indexes = ['!slug'])
  57. static membersTable = new MPackDB(file = dir + '/members.db', primaryKey = '@id', indexes = ['project', 'user'])
  58. static ticketsTable = new MPackDB(file = dir + '/tickets.db', primaryKey = '@id', indexes = ['project', 'state', 'source', '!path', '*oldNumber'])
  59. static eventsTable = new MPackDB(file = dir + '/events.db', primaryKey = '@id', indexes = ['@ticket'])
  60. static linksTable = new MPackDB(file = dir + '/links.db', primaryKey = '@id', indexes = ['@ticket', '@other'])
  61. // THE STATES, in the order a ticket usually walks them; the state's name is also its URL form.
  62. // The OLD names are still accepted (the scheduler and the tools use them): stateOf() maps them.
  63. static states = ['open' 'progress' 'pending' 'review' 'reopened' 'done' 'canceled']
  64. static stateAliases = { 'in progress' = 'progress' 'awaiting creator' = 'review' 'confirmed' = 'done' 'rejected' = 'reopened' 'on hold' = 'pending' }
  65. static roles = ['use' 'edit' 'admin']
  66. static rankOf = (role) => { return role == 'admin' ? 3 : (role == 'edit' ? 2 : (role == 'use' ? 1 : 0)) }
  67. // an empty list written to hl:mpackdb comes back as an empty hybrid (`.length` null):
  68. // every read of a found list goes through here
  69. static countOf = (list) => { return countOfList(list) }
  70. // the state a name stands for (a new name, an old one, or its URL form with '-' / '+'), or null
  71. static stateOf = (name) => {
  72. if (name == null || hlTypeName(name) != 'String') { return null }
  73. let s = name.replaceAll('-', ' ').replaceAll('+', ' ').trim().toLowerCase()
  74. if (states.includes(s)) { return s }
  75. let a = stateAliases[s]
  76. return a != null ? a : null
  77. }
  78. static slugOf = (state) => { return state == null ? '' : state.replaceAll(' ', '-') }
  79. static stateOfSlug = (slug) => { return stateOf(slug) }
  80. static validState = (s) => { return stateOf(s) != null }
  81. // the states a person moves a ticket to: the inbox ones
  82. static inboxStates = ['pending' 'review']
  83. // a string of ASCII digits only (a ticket number in a URL); '' is not
  84. static isDigits = (s) => {
  85. if (s == null) { return false }
  86. let t = '' + s
  87. if (t.length == 0 || t.length > 12) { return false }
  88. let i = 0
  89. while (i < t.length) {
  90. let c = t.charCodeAt(i)
  91. if (c < 48 || c > 57) { return false }
  92. i = i + 1
  93. }
  94. return true
  95. }
  96. // a project slug that a URL carries as is: letters, digits, . _ -
  97. static isSlugChar = (c) => {
  98. return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 46 || c == 95 || c == 45
  99. }
  100. static validProjectName = (p) => {
  101. if (p == null || hlTypeName(p) != 'String' || p.length == 0 || p.length > 100) { return false }
  102. let i = 0
  103. while (i < p.length) {
  104. if (!isSlugChar(p.charCodeAt(i))) { return false }
  105. i = i + 1
  106. }
  107. return true
  108. }
  109. // what a person reads: Europe/Vienna wall time (storage stays epoch ms, UTC)
  110. static stamp = (ms) => { return localStamp(ms) }
  111. // every record is written as a whole (hl:mpackdb's update replaces it)
  112. static merged = (rec, changes) => {
  113. let out = {}
  114. for (k of rec.keys()) { out[k] = rec[k] }
  115. for (k of changes.keys()) { out[k] = changes[k] }
  116. return out
  117. }
  118. static clean = (s) => { return s == null ? '' : ('' + s).trim() }
  119. static first = (list) => { return countOf(list) > 0 ? list[0] : null }
  120. // ---- the URLs ---------------------------------------------------------------------------
  121. static ticketHref = (project, number) => { return '/projects/' + project + '/' + number }
  122. static ticketApiHref = (project, number) => { return '/api/projects/' + project + '/tickets/' + number }
  123. static projectHrefOf = (project) => { return '/projects/' + project }
  124. static settingsHrefOf = (project) => { return '/projects/' + project + '/settings' }
  125. // ---- projects ------------------------------------------------------------------------------
  126. static projectRecord = (id) => { return id == null || id == '' || hlTypeName(id) != 'String' ? null : projectsTable.fetch(id) }
  127. static projectBySlug = (slug) => { return slug == null || hlTypeName(slug) != 'String' ? null : first(projectsTable.find('slug', slug)) }
  128. // projects in the order they were created (stored time, not key order)
  129. static projectRecords = () => {
  130. let all = projectsTable.find(null, null)
  131. let ps = []
  132. if (countOf(all) == 0) { return ps }
  133. for (p of all) { ps.push(p) }
  134. let i = 1
  135. while (i < ps.length) {
  136. let cur = ps[i]
  137. let j = i - 1
  138. while (j >= 0 && ps[j].created > cur.created) {
  139. ps[j + 1] = ps[j]
  140. j = j - 1
  141. }
  142. ps[j + 1] = cur
  143. i = i + 1
  144. }
  145. return ps
  146. }
  147. static oldSlugsOf = (p) => {
  148. let out = []
  149. if (p.oldSlugs == null || p.oldSlugs == '') { return out }
  150. for (s of p.oldSlugs.split(',')) { if (s != '') { out.push(s) } }
  151. return out
  152. }
  153. // a project by its CURRENT slug, or by a slug it once had (old links keep working)
  154. static projectByAnySlug = (slug) => {
  155. let p = projectBySlug(slug)
  156. if (p != null) { return p }
  157. if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }
  158. for (q of projectRecords()) { if (oldSlugsOf(q).includes(slug)) { return q } }
  159. return null
  160. }
  161. // what the API takes for a project: its slug (also an old one) or its id
  162. static projectByRef = (ref) => {
  163. if (ref == null || hlTypeName(ref) != 'String' || ref == '') { return null }
  164. let p = projectByAnySlug(ref)
  165. return p != null ? p : projectRecord(ref)
  166. }
  167. static projectNames = () => {
  168. let out = []
  169. for (p of projectRecords()) { out.push(p.slug) }
  170. return out
  171. }
  172. static projectExists = (slug) => { return projectByAnySlug(slug) != null }
  173. static projectRowOf = (p) => {
  174. return { id = p.id title = p.title slug = p.slug description = p.description != null ? p.description : '' href = projectHrefOf(p.slug) settingsHref = settingsHrefOf(p.slug) created = stamp(p.created) createdMs = p.created }
  175. }
  176. // the slug a title proposes: lower case letters and digits, other runs become one '-'
  177. static slugFromTitle = (title) => {
  178. let t = clean(title).toLowerCase()
  179. let out = ''
  180. let i = 0
  181. while (i < t.length && out.length < 60) {
  182. let c = t.charCodeAt(i)
  183. if ((c >= 48 && c <= 57) || (c >= 97 && c <= 122)) { out = out + t[i] } else if (out != '' && !out.endsWith('-')) { out = out + '-' }
  184. i = i + 1
  185. }
  186. while (out.endsWith('-')) { out = out.slice(0, out.length - 1) }
  187. return out == '' ? 'project' : out
  188. }
  189. // is this slug free? (`except` = the project that may keep it)
  190. static slugFree = (slug, except) => {
  191. for (q of projectRecords()) {
  192. if (except == null || q.id != except) {
  193. if (q.slug == slug || oldSlugsOf(q).includes(slug)) { return false }
  194. }
  195. }
  196. return true
  197. }
  198. static uniqueSlug = (base) => {
  199. if (slugFree(base, null)) { return base }
  200. let n = 2
  201. while (!slugFree(base + '-' + n, null) && n < 10000) { n = n + 1 }
  202. return base + '-' + n
  203. }
  204. // ---- members and roles ------------------------------------------------------------------------
  205. static memberRecord = (projectId, userId) => {
  206. if (projectId == null || userId == null || userId == '') { return null }
  207. let all = membersTable.find('project', projectId)
  208. if (countOf(all) == 0) { return null }
  209. for (m of all) { if (m.user == userId) { return m } }
  210. return null
  211. }
  212. // the role of a user in a project ('use' | 'edit' | 'admin'), or null
  213. static roleOf = (projectId, user) => {
  214. if (user == null || user.id == null) { return null }
  215. let m = memberRecord(projectId, user.id)
  216. return m == null ? null : m.role
  217. }
  218. static atLeast = (projectId, user, role) => { return rankOf(roleOf(projectId, user)) >= rankOf(role) }
  219. static isMember = (projectId, user) => { return roleOf(projectId, user) != null }
  220. static mayEditIn = (projectId, user) => { return atLeast(projectId, user, 'edit') }
  221. static isAdminOf = (projectId, user) => { return atLeast(projectId, user, 'admin') }
  222. // the member rows of a project: [{ id, user (users @id), name, role, rank, created }], the oldest first
  223. static memberRows = (projectId) => {
  224. let out = []
  225. let all = membersTable.find('project', projectId)
  226. if (countOf(all) == 0) { return out }
  227. for (m of all) { out.push({ id = m.id user = m.user name = nameOfUser(m.user) role = m.role rank = rankOf(m.role) created = m.created }) }
  228. let i = 1
  229. while (i < out.length) {
  230. let cur = out[i]
  231. let j = i - 1
  232. while (j >= 0 && out[j].created > cur.created) {
  233. out[j + 1] = out[j]
  234. j = j - 1
  235. }
  236. out[j + 1] = cur
  237. i = i + 1
  238. }
  239. return out
  240. }
  241. // makes `userId` a member with `role` (no permission check: the caller decided); an existing
  242. // member keeps the HIGHER of the two roles. Answers the member record.
  243. static addMember = (projectId, userId, role) => {
  244. let m = memberRecord(projectId, userId)
  245. if (m != null) {
  246. if (rankOf(role) > rankOf(m.role)) { membersTable.update(m.id, merged(m, { role = role })) }
  247. return membersTable.fetch(m.id)
  248. }
  249. let id = membersTable.put({ project = projectId user = userId role = role created = now() })
  250. return id == null ? null : membersTable.fetch(id)
  251. }
  252. static adminCount = (projectId) => {
  253. let n = 0
  254. for (m of memberRows(projectId)) { if (m.role == 'admin') { n = n + 1 } }
  255. return n
  256. }
  257. static notAdmin = { error = 'only an admin of the project can do that' field = '' forbidden = true }
  258. // an admin sets a member's role, or adds a person by the ident id / display name of a user that
  259. // has logged in here. `who` = { user (users @id) }. Answers { members } or { error, forbidden? }
  260. static setMemberRole = (projectId, actor, userId, role) => {
  261. let p = projectRecord(projectId)
  262. if (p == null) { return { error = 'no such project' } }
  263. if (!isAdminOf(p.id, actor)) { return notAdmin }
  264. if (!roles.includes(role)) { return { error = 'role must be one of: ' + roles.join(', ') field = 'role' } }
  265. if (userRecord(userId) == null) { return { error = 'no such user' field = 'user' } }
  266. let m = memberRecord(p.id, userId)
  267. if (m != null && m.role == 'admin' && role != 'admin' && adminCount(p.id) < 2) { return { error = 'a project needs at least one admin' field = 'role' } }
  268. if (m == null) { addMember(p.id, userId, role) } else { membersTable.update(m.id, merged(m, { role = role })) }
  269. return { members = memberRows(p.id) }
  270. }
  271. static removeMember = (projectId, actor, userId) => {
  272. let p = projectRecord(projectId)
  273. if (p == null) { return { error = 'no such project' } }
  274. if (!isAdminOf(p.id, actor)) { return notAdmin }
  275. let m = memberRecord(p.id, userId)
  276. if (m == null) { return { error = 'not a member' field = 'user' } }
  277. if (m.role == 'admin' && adminCount(p.id) < 2) { return { error = 'a project needs at least one admin' field = 'user' } }
  278. membersTable.delete(m.id)
  279. // the tickets assigned to the person are no longer assigned to anybody
  280. let all = ticketsTable.find('project', p.id)
  281. if (countOf(all) > 0) {
  282. for (t of all) {
  283. if (t.assignee == userId) { ticketsTable.update(t.id, merged(t, { assignee = '' })) }
  284. }
  285. }
  286. return { members = memberRows(p.id) }
  287. }
  288. // the user a name (exact display name), an ident id (identity) or a users @id stands for, among
  289. // the members of a project when `projectId` is given; { user } | { error }
  290. static userByRef = (ref, projectId) => {
  291. let r = clean(ref)
  292. if (r == '') { return { error = 'name a person' } }
  293. let found = []
  294. let all = usersTable.find(null, null)
  295. if (countOf(all) > 0) {
  296. for (u of all) {
  297. if (projectId == null || memberRecord(projectId, u.id) != null) {
  298. if (u.id == r || u.identity == r || (u.name != '' && u.name == r)) { found.push(u) }
  299. }
  300. }
  301. }
  302. if (found.length == 0) { return { error = 'no such person' + (projectId != null ? ' among the members' : '') + ': ' + r } }
  303. if (found.length > 1) { return { error = 'more than one person is called ' + r } }
  304. return { user = found[0] }
  305. }
  306. // the members a ticket can be assigned to: [{ value (users @id), label (display name) }]
  307. static assignChoices = (projectId) => {
  308. let out = []
  309. for (m of memberRows(projectId)) { out.push({ value = m.user label = m.name }) }
  310. return out
  311. }
  312. // projects a user may create tickets in: [{ slug, title }] (creation order)
  313. static editableProjects = (user) => {
  314. let out = []
  315. if (user == null) { return out }
  316. for (p of projectRecords()) { if (mayEditIn(p.id, user)) { out.push({ slug = p.slug title = p.title }) } }
  317. return out
  318. }
  319. // the projects a user is a member of, with the role: [{ slug, title, role, href }]
  320. static projectsOfUser = (user) => {
  321. let out = []
  322. if (user == null) { return out }
  323. for (p of projectRecords()) {
  324. let r = roleOf(p.id, user)
  325. if (r != null) { out.push({ slug = p.slug title = p.title role = r href = projectHrefOf(p.slug) }) }
  326. }
  327. return out
  328. }
  329. static userOk = (u) => { return u != null && u.id != null && u.name != null && u.name != '' }
  330. static authorMissing = { error = 'a write needs a logged-in user' field = 'author' }
  331. static notMember = (what) => { return { error = what field = '' forbidden = true } }
  332. // answers { error, field } or { project (row), members }. ANY logged-in user with a display name
  333. // opens a project and is its first admin. A slug is generated from the title unless one is given.
  334. static createProject = (user, title, slug, description) => {
  335. if (!userOk(user)) { return authorMissing }
  336. let t = clean(title)
  337. if (t == '') { return { error = 'the title is required' field = 'title' } }
  338. if (t.length > 100) { return { error = 'the title is too long (at most 100 characters)' field = 'title' } }
  339. let s = clean(slug)
  340. if (s == '') { s = uniqueSlug(slugFromTitle(t)) }
  341. if (!validProjectName(s)) { return { error = "a slug has no spaces or slashes: letters, digits, '.', '_' and '-' only" field = 'slug' } }
  342. if (!slugFree(s, null)) { return { error = 'the slug ' + s + ' is already used by a project' field = 'slug' } }
  343. let id = projectsTable.put({ title = t slug = s description = clean(description) oldSlugs = '' created = now() })
  344. if (id == null) { return { error = 'could not store the project: ' + projectsTable.lastError() field = '' } }
  345. addMember(id, user.id, 'admin')
  346. return { project = projectRowOf(projectsTable.fetch(id)) members = memberRows(id) }
  347. }
  348. // an admin changes title / slug / description (null = keep). A new slug keeps the old one working.
  349. static updateProject = (projectId, actor, title, slug, description) => {
  350. let p = projectRecord(projectId)
  351. if (p == null) { return { error = 'no such project' } }
  352. if (!userOk(actor)) { return authorMissing }
  353. if (!isAdminOf(p.id, actor)) { return notAdmin }
  354. if (title == null && slug == null && description == null) { return { error = 'nothing to change: give title, slug and/or description' field = '' } }
  355. let t = title != null ? clean(title) : p.title
  356. if (t == '') { return { error = 'the title is required' field = 'title' } }
  357. if (t.length > 100) { return { error = 'the title is too long (at most 100 characters)' field = 'title' } }
  358. let s = slug != null ? clean(slug) : p.slug
  359. if (s == '') { return { error = 'the slug is required' field = 'slug' } }
  360. if (!validProjectName(s)) { return { error = "a slug has no spaces or slashes: letters, digits, '.', '_' and '-' only" field = 'slug' } }
  361. if (!slugFree(s, p.id)) { return { error = 'the slug ' + s + ' is already used by another project' field = 'slug' } }
  362. let d = description != null ? clean(description) : (p.description != null ? p.description : '')
  363. let olds = oldSlugsOf(p)
  364. if (s != p.slug) {
  365. let kept = []
  366. for (o of olds) { if (o != s) { kept.push(o) } }
  367. kept.push(p.slug)
  368. olds = kept
  369. }
  370. projectsTable.update(p.id, merged(p, { title = t slug = s description = d oldSlugs = olds.join(',') }))
  371. return { project = projectRowOf(projectsTable.fetch(p.id)) }
  372. }
  373. // ---- the shapes a page and the API show ---------------------------------------------
  374. // A ROW IS A PLAIN HYBRID: what the View reads is what the browser receives.
  375. // `id` is the UUID; `number` + `project` (the slug) are what a person uses; `oldNumber` / `oldRef`
  376. // only on tickets migrated from the global numbering.
  377. static ticketRowOf = (t) => {
  378. let n = t.events != null ? t.events : 0
  379. let p = projectRecord(t.project)
  380. let slug = p != null ? p.slug : ''
  381. let byId = t.createdBy != null ? t.createdBy : ''
  382. let toId = t.assignee != null ? t.assignee : ''
  383. let row = {
  384. id = t.id
  385. project = slug
  386. projectId = t.project
  387. projectTitle = p != null ? p.title : ''
  388. number = t.number
  389. ref = '#' + t.number
  390. subject = t.subject
  391. summary = t.summary
  392. state = t.state
  393. stateSlug = slugOf(t.state)
  394. source = t.source != null ? t.source : ''
  395. created = stamp(t.created)
  396. createdMs = t.created
  397. updated = stamp(t.updated)
  398. updatedMs = t.updated
  399. events = n
  400. href = ticketHref(slug, t.number)
  401. apiHref = ticketApiHref(slug, t.number)
  402. projectHref = projectHrefOf(slug)
  403. hasOld = t.oldNumber != null
  404. oldRef = t.oldNumber != null ? '#' + t.oldNumber : ''
  405. createdById = byId
  406. createdBy = byId != '' ? nameOfUser(byId) : creatorNameOf(t)
  407. assigneeId = toId
  408. assignee = toId != '' ? nameOfUser(toId) : ''
  409. hasAssignee = toId != ''
  410. }
  411. if (t.oldNumber != null) { row.oldNumber = t.oldNumber }
  412. // ticket #4 (mission 017): the relations, as refs with their states (relationsOf below)
  413. let rel = relationsOf(t)
  414. row.parent = rel.parent
  415. row.children = rel.children
  416. row.allChildrenConfirmed = rel.allChildrenConfirmed
  417. row.blockedBy = rel.blockedBy
  418. row.blocks = rel.blocks
  419. return row
  420. }
  421. // a ticket from before the login has no createdBy: the free-text author of its 'created' event
  422. static creatorNameOf = (t) => {
  423. let all = eventsTable.find('ticket', t.id)
  424. if (countOf(all) == 0) { return '' }
  425. for (e of all) { if (e.kind == 'created') { return e.author != null ? e.author : '' } }
  426. return ''
  427. }
  428. static eventRowOf = (e, t) => {
  429. let kind = e.kind
  430. // computed BEFORE the literal: inside a literal that has an `id` entry, a called function's
  431. // parameter named `id` read that entry instead (mission 011, reported as a Hybriel issue)
  432. let who = e.user != null ? nameOfUser(e.user) : e.author
  433. let label = kind == 'created' ? 'opened the ticket' : (kind == 'state' ? 'changed the state' : (kind == 'edit' ? 'edited the ticket' : 'commented'))
  434. if (kind == 'link') { label = linkLabelOf(e) }
  435. let assignedTo = ''
  436. let assignedWas = ''
  437. if (kind == 'assign') {
  438. assignedTo = e.assignee != null && e.assignee != '' ? nameOfUser(e.assignee) : ''
  439. assignedWas = e.previous != null && e.previous != '' ? nameOfUser(e.previous) : ''
  440. label = assignedTo != '' ? 'assigned the ticket to ' + assignedTo : 'unassigned the ticket' + (assignedWas != '' ? ' (was ' + assignedWas + ')' : '')
  441. }
  442. let row = {
  443. id = e.id
  444. ticket = e.ticket
  445. project = t != null ? projectSlugOf(t) : ''
  446. number = t != null ? t.number : 0
  447. seq = e.seq != null ? e.seq : 0
  448. kind = kind
  449. author = who
  450. userId = e.user != null ? e.user : ''
  451. text = e.text != null ? e.text : ''
  452. hasText = e.text != null && e.text != ''
  453. from = e.from != null ? e.from : ''
  454. to = e.to != null ? e.to : ''
  455. toSlug = slugOf(e.to)
  456. isState = kind == 'state'
  457. isComment = kind == 'comment'
  458. isCreated = kind == 'created'
  459. label = label
  460. when = stamp(e.created)
  461. createdMs = e.created
  462. }
  463. // ticket #8: only an edit event carries these (the JSON of every other event is unchanged)
  464. if (kind == 'edit') {
  465. row.isEdit = true
  466. row.oldSubject = e.oldSubject != null ? e.oldSubject : ''
  467. row.oldSummary = e.oldSummary != null ? e.oldSummary : ''
  468. row.newSubject = e.subject != null ? e.subject : ''
  469. row.newSummary = e.summary != null ? e.summary : ''
  470. row.subjectChanged = row.oldSubject != row.newSubject
  471. row.summaryChanged = row.oldSummary != row.newSummary
  472. }
  473. // ticket #4: only a link event carries these
  474. if (kind == 'link') {
  475. row.isLink = true
  476. row.linkKind = e.linkKind
  477. row.linkAction = e.action
  478. row.otherRef = e.otherProject + ' #' + e.otherNumber
  479. row.otherHref = ticketHref(e.otherProject, e.otherNumber)
  480. row.previousRef = e.previous != null ? e.previousProject + ' #' + e.previousNumber : ''
  481. }
  482. // ticket #20: only an assign event carries these
  483. if (kind == 'assign') {
  484. row.isAssign = true
  485. row.assignee = assignedTo
  486. row.previousAssignee = assignedWas
  487. }
  488. return row
  489. }
  490. static projectSlugOf = (t) => {
  491. let p = projectRecord(t.project)
  492. return p != null ? p.slug : ''
  493. }
  494. // "set the parent to ident.worldapi.org #1", "removed the blocker tickets.worldapi.org #6", …
  495. static linkLabelOf = (e) => {
  496. let other = e.otherProject + ' #' + e.otherNumber
  497. if (e.linkKind == 'parent') {
  498. if (e.action == 'removed') { return 'removed the parent ' + other }
  499. let was = e.previous != null ? ' (was ' + e.previousProject + ' #' + e.previousNumber + ')' : ''
  500. return 'set the parent to ' + other + was
  501. }
  502. if (e.action == 'removed') { return 'removed the blocker ' + other }
  503. return 'marked it blocked by ' + other
  504. }
  505. // THE PAGE'S EVENT ROW (ticket #6): the API row + its text as Markdown blocks (`md`), and for an
  506. // edit the previous summary's blocks (`oldSummaryMd`) and the new one's (`summaryMd`, the ticket
  507. // page takes it over live). Only pages and pushes carry these — the JSON API does not.
  508. static pageEventOf = (ev) => {
  509. let out = {}
  510. for (k of ev.keys()) { out[k] = ev[k] }
  511. out.md = markdownBlocks(ev.text)
  512. if (ev.isEdit == true) {
  513. out.oldSummaryMd = markdownBlocks(ev.oldSummary)
  514. out.summaryMd = markdownBlocks(ev.newSummary)
  515. }
  516. return out
  517. }
  518. static pageEventsOf = (evs) => {
  519. let out = []
  520. for (ev of evs) { out.push(pageEventOf(ev)) }
  521. return out
  522. }
  523. // ---- finding a ticket -------------------------------------------------------------------
  524. static ticketRecord = (uuid) => { return uuid == null || uuid == '' ? null : ticketsTable.fetch('' + uuid) }
  525. // `project` is a slug (a former one too)
  526. static ticketAt = (project, number) => {
  527. if (project == null || !isDigits(number)) { return null }
  528. let p = projectByAnySlug(project)
  529. if (p == null) { return null }
  530. return first(ticketsTable.find('path', p.id + '/' + toNumber('' + number)))
  531. }
  532. static ticketByOld = (oldNumber) => {
  533. if (!isDigits(oldNumber)) { return null }
  534. return first(ticketsTable.find('oldNumber', toNumber('' + oldNumber)))
  535. }
  536. // what `/api/tickets/:ref` and `/tickets/:ref` accept: the OLD global number (digits)
  537. // or the ticket's UUID
  538. static ticketByRef = (ref) => {
  539. if (ref == null) { return null }
  540. if (isDigits(ref)) { return ticketByOld(ref) }
  541. return ticketRecord(ref)
  542. }
  543. // ---- reads -------------------------------------------------------------------------
  544. static sortByUpdated = (rows) => {
  545. // insertion sort by updated, descending (lists are short)
  546. let i = 1
  547. while (i < rows.length) {
  548. let cur = rows[i]
  549. let j = i - 1
  550. while (j >= 0 && rows[j].updatedMs < cur.updatedMs) {
  551. rows[j + 1] = rows[j]
  552. j = j - 1
  553. }
  554. rows[j + 1] = cur
  555. i = i + 1
  556. }
  557. return rows
  558. }
  559. // newest update first; both filters optional (null = all); `project` is a slug
  560. static ticketRows = (project, state) => {
  561. let rows = []
  562. let all = null
  563. if (project != null) {
  564. let p = projectByAnySlug(project)
  565. if (p == null) { return rows }
  566. all = ticketsTable.find('project', p.id)
  567. } else {
  568. all = ticketsTable.find(null, null)
  569. }
  570. if (countOf(all) == 0) { return rows }
  571. for (t of all) {
  572. if (state == null || t.state == state) { rows.push(ticketRowOf(t)) }
  573. }
  574. return sortByUpdated(rows)
  575. }
  576. // THE INBOX of a user: the tickets ASSIGNED TO THEM in pending and in review, two lists
  577. static inboxRows = (user) => {
  578. let out = { pending = [] review = [] }
  579. if (user == null || user.id == null) { return out }
  580. let a = []
  581. let b = []
  582. let ps = ticketsTable.find('state', 'pending')
  583. if (countOf(ps) > 0) { for (t of ps) { if (t.assignee == user.id) { a.push(ticketRowOf(t)) } } }
  584. let rs = ticketsTable.find('state', 'review')
  585. if (countOf(rs) > 0) { for (t of rs) { if (t.assignee == user.id) { b.push(ticketRowOf(t)) } } }
  586. out.pending = sortByUpdated(a)
  587. out.review = sortByUpdated(b)
  588. return out
  589. }
  590. static inboxCountOf = (user) => {
  591. let x = inboxRows(user)
  592. return x.pending.length + x.review.length
  593. }
  594. // the page's row of `/projects/<project>/<number>`, or null
  595. static ticketRowAt = (project, number) => {
  596. let t = ticketAt(project, number)
  597. return t == null ? null : ticketRowOf(t)
  598. }
  599. // the event history of a ticket (UUID), oldest first: by stored time, then seq
  600. static eventRows = (ticketId) => {
  601. let out = []
  602. let t = ticketRecord(ticketId)
  603. if (t == null) { return out }
  604. let all = eventsTable.find('ticket', t.id)
  605. if (countOf(all) == 0) { return out }
  606. for (e of all) { out.push(eventRowOf(e, t)) }
  607. let i = 1
  608. while (i < out.length) {
  609. let cur = out[i]
  610. let j = i - 1
  611. while (j >= 0 && (out[j].createdMs > cur.createdMs || (out[j].createdMs == cur.createdMs && out[j].seq > cur.seq))) {
  612. out[j + 1] = out[j]
  613. j = j - 1
  614. }
  615. out[j + 1] = cur
  616. i = i + 1
  617. }
  618. return out
  619. }
  620. // the filter links of the list page: [{ label, href, cls }] — `cls` is 'selected' on
  621. // the one in effect. The href keeps the OTHER filter.
  622. static projectLinks = (project, state) => {
  623. let tail = state != null ? '/state/' + slugOf(state) : ''
  624. let out = [{ label = 'all projects' href = state != null ? tail : '/' cls = project == null ? 'selected' : '' }]
  625. for (p of projectRecords()) {
  626. out.push({ label = p.title href = '/project/' + p.slug + tail cls = p.slug == project ? 'selected' : '' })
  627. }
  628. return out
  629. }
  630. static stateLinks = (project, state) => {
  631. let head = project != null ? '/project/' + project : ''
  632. let out = [{ label = 'all states' href = project != null ? head : '/' cls = state == null ? 'selected' : '' }]
  633. for (s of states) {
  634. out.push({ label = s href = head + '/state/' + slugOf(s) cls = s == state ? 'selected ' + slugOf(s) : slugOf(s) })
  635. }
  636. return out
  637. }
  638. // the options of a state select: [{ value, label }] — the states THIS user may move the ticket to
  639. // (a `use` member: only open ⇄ review; `edit` / `admin`: every other state)
  640. static stateChoices = (t, user) => {
  641. let out = []
  642. if (t == null || user == null) { return out }
  643. let role = roleOf(t.project, user)
  644. for (s of states) {
  645. if (s != t.state && mayMoveTo(role, t.state, s)) { out.push({ value = s label = s }) }
  646. }
  647. return out
  648. }
  649. static mayMoveTo = (role, current, target) => {
  650. if (rankOf(role) >= 2) { return true }
  651. if (rankOf(role) == 1) { return (current == 'open' && target == 'review') || (current == 'review' && target == 'open') }
  652. return false
  653. }
  654. // ---- writes: each answers what the push carries --------------------------------------
  655. // EVERY WRITE HAS A USER (ticket #7): the logged-in user of the web session or of the API
  656. // token (users.hl). The author is that user — there is no free author field any more.
  657. // WHO MAY is the role in the ticket's project (see the top): a refusal for a missing right has
  658. // `forbidden = true` (the API answers 403).
  659. // the next per-project number: the highest one in the project + 1 (first = 1)
  660. static nextNumber = (projectId) => {
  661. let top = 0
  662. let all = ticketsTable.find('project', projectId)
  663. if (countOf(all) > 0) {
  664. for (t of all) { if (t.number != null && t.number > top) { top = t.number } }
  665. }
  666. return top + 1
  667. }
  668. // answers { error, field, forbidden? } or { ticket (row), event (row), existed }
  669. // `project` = the slug (or id) of an existing project; `assignee` optional (a member's name / id)
  670. static createTicket = (project, subject, summary, user, source, assignee) => {
  671. let s = clean(subject)
  672. if (clean(project) == '') { return { error = 'project is required' field = 'project' } }
  673. if (s == '') { return { error = 'subject is required' field = 'subject' } }
  674. if (!userOk(user)) { return authorMissing }
  675. let p = projectByRef(clean(project))
  676. if (p == null) { return { error = 'no such project: ' + clean(project) field = 'project' } }
  677. if (!mayEditIn(p.id, user)) { return { error = 'you need the role edit or admin in ' + p.slug + ' to open a ticket' field = 'project' forbidden = true } }
  678. let who = ''
  679. if (assignee != null && clean(assignee) != '') {
  680. let r = userByRef(assignee, p.id)
  681. if (r.error != null) { return { error = r.error field = 'assignee' } }
  682. who = r.user.id
  683. }
  684. let src = clean(source)
  685. if (src != '') {
  686. let known = ticketsTable.find('source', src)
  687. if (countOf(known) > 0) { return { ticket = ticketRowOf(known[0]) event = null existed = true } }
  688. }
  689. let t0 = now()
  690. let n = nextNumber(p.id)
  691. let id = ticketsTable.put({ project = p.id number = n path = p.id + '/' + n subject = s summary = clean(summary) state = 'open' source = src created = t0 updated = t0 events = 1 createdBy = user.id assignee = who })
  692. if (id == null) { return { error = 'could not store the ticket: ' + ticketsTable.lastError() field = '' } }
  693. let t = ticketsTable.fetch(id)
  694. let eid = eventsTable.put({ ticket = id seq = 1 kind = 'created' author = user.name user = user.id text = src != '' ? 'imported from ' + src : '' created = t0 })
  695. let out = { ticket = ticketRowOf(t) event = eventRowOf(eventsTable.fetch(eid), t) existed = false }
  696. if (who != '') {
  697. let seq = 2
  698. let aid = eventsTable.put({ ticket = id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = who previous = '' created = t0 })
  699. ticketsTable.update(id, merged(t, { events = seq }))
  700. let t2 = ticketsTable.fetch(id)
  701. out.ticket = ticketRowOf(t2)
  702. out.assignEvent = eventRowOf(eventsTable.fetch(aid), t2)
  703. }
  704. return out
  705. }
  706. // answers { error } or { ticket, event }; `ticketId` is the UUID. Any member may comment.
  707. static addComment = (ticketId, user, text) => {
  708. let t = ticketRecord(ticketId)
  709. if (t == null) { return { error = 'no such ticket' } }
  710. let body = clean(text)
  711. if (body == '') { return { error = 'a comment needs text' field = 'text' } }
  712. if (!userOk(user)) { return authorMissing }
  713. if (!isMember(t.project, user)) { return { error = 'only a member of the project can comment' field = '' forbidden = true } }
  714. let t0 = now()
  715. let seq = (t.events != null ? t.events : 0) + 1
  716. let eid = eventsTable.put({ ticket = t.id seq = seq kind = 'comment' author = user.name user = user.id text = body created = t0 })
  717. ticketsTable.update(t.id, merged(t, { updated = t0 events = seq }))
  718. let t2 = ticketsTable.fetch(t.id)
  719. return { ticket = ticketRowOf(t2) event = eventRowOf(eventsTable.fetch(eid), t2) }
  720. }
  721. // the admin who gets a ticket that lands in the inbox with nobody assigned: the oldest admin
  722. static defaultAssignee = (projectId) => {
  723. for (m of memberRows(projectId)) { if (m.role == 'admin') { return m.user } }
  724. return ''
  725. }
  726. // answers { error } or { ticket, event, assignEvent? }; an optional note rides on the state event.
  727. // `forbidden = true` on a refusal the user may not overcome (the role does not allow that move).
  728. // A ticket that goes to review / pending with nobody assigned is assigned to the project's first admin
  729. // (so it reaches an inbox) — a second, 'assign' event.
  730. static changeState = (ticketId, state, user, note) => {
  731. let t = ticketRecord(ticketId)
  732. if (t == null) { return { error = 'no such ticket' } }
  733. let s = stateOf(state)
  734. if (s == null) { return { error = 'unknown state — one of: ' + states.join(', ') field = 'state' } }
  735. if (s == t.state) { return { error = 'the ticket is already ' + s field = 'state' } }
  736. if (!userOk(user)) { return authorMissing }
  737. let role = roleOf(t.project, user)
  738. if (role == null) { return { error = 'only a member of the project can change the state' field = 'state' forbidden = true } }
  739. if (!mayMoveTo(role, t.state, s)) { return { error = 'the role ' + role + ' can only move a ticket between open and review' field = 'state' forbidden = true } }
  740. let t0 = now()
  741. let seq = (t.events != null ? t.events : 0) + 1
  742. let eid = eventsTable.put({ ticket = t.id seq = seq kind = 'state' author = user.name user = user.id text = clean(note) from = t.state to = s created = t0 })
  743. let changes = { state = s updated = t0 events = seq }
  744. let aid = null
  745. let auto = ''
  746. if (inboxStates.includes(s) && (t.assignee == null || t.assignee == '')) { auto = defaultAssignee(t.project) }
  747. if (auto != '') {
  748. seq = seq + 1
  749. aid = eventsTable.put({ ticket = t.id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = auto previous = '' created = t0 })
  750. changes.assignee = auto
  751. changes.events = seq
  752. }
  753. ticketsTable.update(t.id, merged(t, changes))
  754. let t2 = ticketsTable.fetch(t.id)
  755. let out = { ticket = ticketRowOf(t2) event = eventRowOf(eventsTable.fetch(eid), t2) }
  756. if (aid != null) { out.assignEvent = eventRowOf(eventsTable.fetch(aid), t2) }
  757. return out
  758. }
  759. // answers { error, forbidden? } or { ticket, event }; `assignee` = a member's users @id, '' = nobody
  760. static assignTicket = (ticketId, user, assignee) => {
  761. let t = ticketRecord(ticketId)
  762. if (t == null) { return { error = 'no such ticket' } }
  763. if (!userOk(user)) { return authorMissing }
  764. if (!mayEditIn(t.project, user)) { return { error = 'you need the role edit or admin to assign a ticket' field = 'assignee' forbidden = true } }
  765. let who = assignee == null ? '' : clean(assignee)
  766. if (who != '' && memberRecord(t.project, who) == null) { return { error = 'the assignee must be a member of the project' field = 'assignee' } }
  767. let was = t.assignee != null ? t.assignee : ''
  768. if (who == was) { return { error = who == '' ? 'the ticket is not assigned' : 'the ticket is already assigned to that person' field = 'assignee' } }
  769. let t0 = now()
  770. let seq = (t.events != null ? t.events : 0) + 1
  771. let eid = eventsTable.put({ ticket = t.id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = who previous = was created = t0 })
  772. ticketsTable.update(t.id, merged(t, { assignee = who updated = t0 events = seq }))
  773. let t2 = ticketsTable.fetch(t.id)
  774. return { ticket = ticketRowOf(t2) event = eventRowOf(eventsTable.fetch(eid), t2) }
  775. }
  776. // ---- editing (ticket #8, mission 014; ticket #20: by the role) --------------------------------
  777. // THE AUTHOR of a ticket is `createdBy` (users @id), or null for a ticket opened before the login
  778. // existed. MAY THIS USER EDIT the ticket (a record)? Any member with the role edit or admin of
  779. // its project (`use` members only comment and move it between open and review).
  780. static ownerOf = (t) => { return t.createdBy != null && t.createdBy != '' ? t.createdBy : null }
  781. static mayEdit = (t, user) => {
  782. if (t == null || !userOk(user)) { return false }
  783. return mayEditIn(t.project, user)
  784. }
  785. static mayEditId = (ticketId, user) => { return mayEdit(ticketRecord(ticketId), user) }
  786. // answers { error, field?, forbidden? } or { ticket, event }. `subject` / `summary` null = keep
  787. // that one. The previous values stay in the 'edit' event (append-only history).
  788. static editTicket = (ticketId, user, subject, summary) => {
  789. let t = ticketRecord(ticketId)
  790. if (t == null) { return { error = 'no such ticket' } }
  791. if (!userOk(user)) { return authorMissing }
  792. if (!mayEdit(t, user)) { return { error = 'you need the role edit or admin in the project to edit a ticket' field = '' forbidden = true } }
  793. if (subject == null && summary == null) { return { error = 'nothing to change: give subject and/or summary' field = '' } }
  794. let oldSummary = t.summary != null ? t.summary : ''
  795. let s = subject != null ? clean(subject) : t.subject
  796. if (s == '') { return { error = 'subject is required' field = 'subject' } }
  797. let sm = summary != null ? clean(summary) : oldSummary
  798. if (s == t.subject && sm == oldSummary) { return { error = 'nothing changed' field = subject != null ? 'subject' : 'summary' } }
  799. let t0 = now()
  800. let seq = (t.events != null ? t.events : 0) + 1
  801. let eid = eventsTable.put({ ticket = t.id seq = seq kind = 'edit' author = user.name user = user.id text = '' oldSubject = t.subject oldSummary = oldSummary subject = s summary = sm created = t0 })
  802. if (eid == null) { return { error = 'could not store the edit: ' + eventsTable.lastError() field = '' } }
  803. ticketsTable.update(t.id, merged(t, { subject = s summary = sm updated = t0 events = seq }))
  804. let t2 = ticketsTable.fetch(t.id)
  805. return { ticket = ticketRowOf(t2) event = eventRowOf(eventsTable.fetch(eid), t2) }
  806. }
  807. // the API's view of one ticket (a record): the row and its whole history
  808. static ticketWithEvents = (t) => {
  809. if (t == null) { return null }
  810. return { ticket = ticketRowOf(t) events = eventRows(t.id) }
  811. }
  812. // ---- relations: parent / child and blocked-by (ticket #4, mission 017) ----------------------
  813. // A ticket has at most ONE PARENT (any project); a parent lists its CHILDREN with their states.
  814. // A ticket can be BLOCKED BY other tickets (any project) — it waits on them; the other side
  815. // shows what it BLOCKS. The scheduler reads them from the JSON rows (`parent`, `children`,
  816. // `blockedBy`, `blocks`, each a ref with the state) and the Markdown view.
  817. // PARENT STATE: `allChildrenConfirmed` (the key kept for the clients; it now means DONE) is true when
  818. // a ticket has children and every one of them is `done`. It is only SHOWN ("all children done") — the
  819. // parent's own state is never changed by it.
  820. // WHO MAY set / remove a relation (ticket #20): a member with the role edit or admin in the project of
  821. // EITHER of the two tickets. Replacing a parent needs the
  822. // right for the old pair AND the new pair. No loops: a ticket cannot be its own ancestor, and
  823. // cannot (transitively) wait on itself.
  824. // Tickets are named as `<project>#<number>` (spaces around `#` allowed) or by their UUID.
  825. // what a relation shows of a ticket (no relations of its own — no recursion)
  826. static refOf = (t) => {
  827. let slug = slugOf(t.state)
  828. let pslug = projectSlugOf(t)
  829. let href = ticketHref(pslug, t.number)
  830. let apiHref = ticketApiHref(pslug, t.number)
  831. let key = pslug + '#' + t.number
  832. return { id = t.id project = pslug number = t.number ref = '#' + t.number key = key subject = t.subject state = t.state stateSlug = slug href = href apiHref = apiHref }
  833. }
  834. // the link records of one side, oldest first (stored time): field 'ticket' = this ticket's own
  835. // relations (its parent, its blockers), field 'other' = the ones pointing at it (children, blocked)
  836. static linkRecords = (field, ticketId, kind) => {
  837. let out = []
  838. if (ticketId == null || ticketId == '') { return out }
  839. let all = linksTable.find(field, '' + ticketId)
  840. if (countOf(all) == 0) { return out }
  841. for (l of all) { if (l.kind == kind) { out.push(l) } }
  842. let i = 1
  843. while (i < out.length) {
  844. let cur = out[i]
  845. let j = i - 1
  846. while (j >= 0 && out[j].created > cur.created) {
  847. out[j + 1] = out[j]
  848. j = j - 1
  849. }
  850. out[j + 1] = cur
  851. i = i + 1
  852. }
  853. return out
  854. }
  855. static parentLinkOf = (ticketId) => { return first(linkRecords('ticket', ticketId, 'parent')) }
  856. static parentIdOf = (ticketId) => {
  857. let l = parentLinkOf(ticketId)
  858. return l == null ? null : l.other
  859. }
  860. static idsOfLinks = (links, field) => {
  861. let out = []
  862. for (l of links) { out.push(l[field]) }
  863. return out
  864. }
  865. static childIdsOf = (ticketId) => { return idsOfLinks(linkRecords('other', ticketId, 'parent'), 'ticket') }
  866. static blockerIdsOf = (ticketId) => { return idsOfLinks(linkRecords('ticket', ticketId, 'blockedBy'), 'other') }
  867. static blockedIdsOf = (ticketId) => { return idsOfLinks(linkRecords('other', ticketId, 'blockedBy'), 'ticket') }
  868. static refsOfIds = (ids) => {
  869. let out = []
  870. for (i of ids) {
  871. let t = ticketRecord(i)
  872. if (t != null) { out.push(refOf(t)) }
  873. }
  874. return out
  875. }
  876. // { parent (ref or null), children, blockedBy, blocks (lists of refs), allChildrenConfirmed }
  877. static relationsOf = (t) => {
  878. let pid = parentIdOf(t.id)
  879. let p = pid != null ? ticketRecord(pid) : null
  880. let kids = refsOfIds(childIdsOf(t.id))
  881. let all = kids.length > 0
  882. for (c of kids) { if (c.state != 'done') { all = false } }
  883. return { parent = p != null ? refOf(p) : null children = kids allChildrenConfirmed = all blockedBy = refsOfIds(blockerIdsOf(t.id)) blocks = refsOfIds(blockedIdsOf(t.id)) }
  884. }
  885. // what the ticket PAGE shows of a row's relations (SSR members and the `ticketsRelated` push)
  886. static noRef = { id = '' project = '' number = 0 ref = '' key = '' subject = '' state = '' stateSlug = '' href = '/' apiHref = '' }
  887. static relationViewOf = (row) => {
  888. let total = row.children.length
  889. let done = 0
  890. for (c of row.children) { if (c.state == 'done') { done = done + 1 } }
  891. let note = ''
  892. if (total > 0) { note = done == total ? 'all children done (' + total + ')' : done + ' of ' + total + ' children done' }
  893. let hasParent = row.parent != null
  894. let hasChildren = total > 0
  895. let hasBlockedBy = row.blockedBy.length > 0
  896. let hasBlocks = row.blocks.length > 0
  897. return { id = row.id parent = hasParent ? row.parent : noRef hasParent = hasParent children = row.children hasChildren = hasChildren
  898. childNote = note childClass = row.allChildrenConfirmed ? 'done' : '' allChildrenConfirmed = row.allChildrenConfirmed
  899. blockedBy = row.blockedBy hasBlockedBy = hasBlockedBy blocks = row.blocks hasBlocks = hasBlocks
  900. hasRelations = hasParent || hasChildren || hasBlockedBy || hasBlocks }
  901. }
  902. // the relation views of a ticket AND every ticket related to it (+ `extra` ids: a parent or
  903. // blocker just removed) — what a write pushes, so every open page of them follows
  904. static relatedViews = (ticketId, extra) => {
  905. let ids = ['' + ticketId]
  906. let seen = {}
  907. seen['' + ticketId] = true
  908. let more = []
  909. let pid = parentIdOf(ticketId)
  910. if (pid != null) { more.push(pid) }
  911. for (i of childIdsOf(ticketId)) { more.push(i) }
  912. for (i of blockerIdsOf(ticketId)) { more.push(i) }
  913. for (i of blockedIdsOf(ticketId)) { more.push(i) }
  914. for (i of extra) { if (i != null) { more.push(i) } }
  915. for (i of more) {
  916. if (seen['' + i] != true) {
  917. seen['' + i] = true
  918. ids.push('' + i)
  919. }
  920. }
  921. let out = []
  922. for (i of ids) {
  923. let t = ticketRecord(i)
  924. if (t != null) { out.push(relationViewOf(ticketRowOf(t))) }
  925. }
  926. return out
  927. }
  928. // `<project>#<number>` (spaces around '#' allowed) or a UUID → the ticket record, or null
  929. static ticketByKey = (key) => {
  930. let k = clean(key)
  931. if (k == '') { return null }
  932. let at = k.indexOf('#')
  933. if (at < 0) { return isDigits(k) ? null : ticketRecord(k) }
  934. let p = k.slice(0, at).trim()
  935. let n = k.slice(at + 1).trim()
  936. if (p == '') { return null }
  937. return ticketAt(p, n)
  938. }
  939. static mayLink = (a, b, user) => {
  940. if (!userOk(user)) { return false }
  941. if (a != null && mayEditIn(a.project, user)) { return true }
  942. if (b != null && mayEditIn(b.project, user)) { return true }
  943. return false
  944. }
  945. static linkForbidden = { error = 'you need the role edit or admin in the project of either ticket to change this relation' field = '' forbidden = true }
  946. static keyOf = (t) => { return projectSlugOf(t) + '#' + t.number }
  947. // the history event of a relation change, on ticket `t`; answers { ticket, event, related }
  948. static linkEvent = (t, user, linkKind, action, other, previous, extra) => {
  949. let t0 = now()
  950. let seq = (t.events != null ? t.events : 0) + 1
  951. // the values BEFORE the literal: an entry named `other` / `user` would shadow the variable
  952. // for the entries after it (mission 011 lesson)
  953. let uname = user.name
  954. let uid = user.id
  955. let oid = other.id
  956. let oproject = projectSlugOf(other)
  957. let onumber = other.number
  958. let rec = { ticket = t.id seq = seq kind = 'link' author = uname user = uid text = '' linkKind = linkKind action = action other = oid otherProject = oproject otherNumber = onumber created = t0 }
  959. if (previous != null) {
  960. rec.previous = previous.id
  961. rec.previousProject = projectSlugOf(previous)
  962. rec.previousNumber = previous.number
  963. }
  964. let eid = eventsTable.put(rec)
  965. if (eid == null) { return { error = 'could not store the event: ' + eventsTable.lastError() field = '' } }
  966. ticketsTable.update(t.id, merged(t, { updated = t0 events = seq }))
  967. let t2 = ticketsTable.fetch(t.id)
  968. return { ticket = ticketRowOf(t2) event = eventRowOf(eventsTable.fetch(eid), t2) related = relatedViews(t.id, extra) }
  969. }
  970. // SET (key = `<project>#<n>` / UUID) or REMOVE (key = '') the parent of a ticket (UUID).
  971. // Answers { error, field, forbidden? } or { ticket, event, related }.
  972. static setParent = (ticketId, user, key) => {
  973. let t = ticketRecord(ticketId)
  974. if (t == null) { return { error = 'no such ticket' } }
  975. if (!userOk(user)) { return authorMissing }
  976. let k = clean(key)
  977. let cur = parentLinkOf(t.id)
  978. let curT = cur != null ? ticketRecord(cur.other) : null
  979. if (k == '') {
  980. if (cur == null || curT == null) { return { error = 'the ticket has no parent' field = 'parent' } }
  981. if (!mayLink(t, curT, user)) { return linkForbidden }
  982. linksTable.delete(cur.id)
  983. return linkEvent(t, user, 'parent', 'removed', curT, null, [curT.id])
  984. }
  985. let p = ticketByKey(k)
  986. if (p == null) { return { error = 'no such ticket: ' + k + ' (name it as <project>#<number>)' field = 'parent' } }
  987. if (p.id == t.id) { return { error = 'a ticket cannot be its own parent' field = 'parent' } }
  988. if (curT != null && curT.id == p.id) { return { error = 'the parent is already ' + keyOf(p) field = 'parent' } }
  989. // no loop: the new parent must not be this ticket's child, grandchild, …
  990. let up = p.id
  991. let steps = 0
  992. while (up != null && steps < 1000) {
  993. if (up == t.id) { return { error = 'a loop: ' + keyOf(p) + ' is below this ticket' field = 'parent' } }
  994. up = parentIdOf(up)
  995. steps = steps + 1
  996. }
  997. if (!mayLink(t, p, user)) { return linkForbidden }
  998. if (curT != null && !mayLink(t, curT, user)) { return linkForbidden }
  999. let lid = linksTable.put({ kind = 'parent' ticket = t.id other = p.id user = user.id created = now() })
  1000. if (lid == null) { return { error = 'could not store the relation: ' + linksTable.lastError() field = '' } }
  1001. if (cur != null) { linksTable.delete(cur.id) }
  1002. return linkEvent(t, user, 'parent', 'set', p, curT, [curT != null ? curT.id : null])
  1003. }
  1004. // does ticket `start` (transitively) wait on ticket `target`? (blocked-by links)
  1005. static waitsOn = (start, target) => {
  1006. let stack = [start]
  1007. let seen = {}
  1008. let steps = 0
  1009. while (stack.length > 0 && steps < 10000) {
  1010. let cur = stack.pop()
  1011. steps = steps + 1
  1012. if (cur == target) { return true }
  1013. if (seen['' + cur] != true) {
  1014. seen['' + cur] = true
  1015. for (i of blockerIdsOf(cur)) { stack.push(i) }
  1016. }
  1017. }
  1018. return false
  1019. }
  1020. // ADD (adding = true) or REMOVE a blocker of a ticket (UUID): ticket `key` blocks it.
  1021. // `field` names the API field ('add' / 'remove'). Answers as setParent.
  1022. static changeBlocker = (ticketId, user, key, adding, field) => {
  1023. let t = ticketRecord(ticketId)
  1024. if (t == null) { return { error = 'no such ticket' } }
  1025. if (!userOk(user)) { return authorMissing }
  1026. let k = clean(key)
  1027. if (k == '') { return { error = 'name the blocking ticket as <project>#<number>' field = field } }
  1028. let o = ticketByKey(k)
  1029. if (o == null) { return { error = 'no such ticket: ' + k + ' (name it as <project>#<number>)' field = field } }
  1030. let existing = null
  1031. for (l of linkRecords('ticket', t.id, 'blockedBy')) { if (l.other == o.id) { existing = l } }
  1032. if (adding) {
  1033. if (o.id == t.id) { return { error = 'a ticket cannot block itself' field = field } }
  1034. if (existing != null) { return { error = 'already blocked by ' + keyOf(o) field = field } }
  1035. if (waitsOn(o.id, t.id)) { return { error = 'a loop: ' + keyOf(o) + ' already waits on this ticket' field = field } }
  1036. if (!mayLink(t, o, user)) { return linkForbidden }
  1037. let lid = linksTable.put({ kind = 'blockedBy' ticket = t.id other = o.id user = user.id created = now() })
  1038. if (lid == null) { return { error = 'could not store the relation: ' + linksTable.lastError() field = '' } }
  1039. return linkEvent(t, user, 'blockedBy', 'set', o, null, [])
  1040. }
  1041. if (existing == null) { return { error = 'not blocked by ' + keyOf(o) field = field } }
  1042. if (!mayLink(t, o, user)) { return linkForbidden }
  1043. linksTable.delete(existing.id)
  1044. return linkEvent(t, user, 'blockedBy', 'removed', o, null, [o.id])
  1045. }

Branches

Latest commits

  • 4137be0fantcolony#40: mission references point to the moved missionsmre
  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre