tickets
All repositories: gitoria
50.5 KB
// store.hl — THE SERVER REALM of tickets.worldapi.org. Five hl:mpackdb tables and// the functions that read and write them; statics only (a component member imports// what it reads by brace, a face / an API route what it writes).//// CREATOR'S CONVENTIONS (2026-09-24, ticket #38): every primary key is the mpackdb UUID// (`@id`, a string like '0mufbkwhlpjq'), never a `*id` counter; the files live in// storage/mpackdb/. A NUMBER A PERSON SEES is a separate field: tickets are numbered PER// PROJECT from 1 (`number`); tickets migrated from the old global store keep their old// global number as `oldNumber` (docs and comments say "#38"). Order never comes from key// order: lists sort by the stored times, a history by (created, seq).//// PROJECTS ARE OWN DATA (ticket #20, 2026-09-26):// projectsTable pk @id index !slug { title, slug, description (Markdown), oldSlugs ('a,b' — the// slugs it had before; they still resolve), created }// membersTable pk @id index project user { project (project @id), user (users @id), role, created }// role = 'use' | 'edit' | 'admin' (below). One row per (project, user).// ticketsTable pk @id index project state source !path *oldNumber// { project (project @id — NOT the name), number, path ('<project @id>/<number>', unique),// oldNumber?, subject, summary, state, source, created, updated, events,// createdBy (users @id, '' = opened before the login), assignee (users @id, '') }// eventsTable pk @id index @ticket APPEND ONLY: { ticket (ticket @id), seq// (1, 2, … per ticket), kind, author, user?, text, from, to, created, oldId? }// kind = 'created' | 'comment' | 'state' | 'edit' | 'link' | 'assign'// 'assign' (ticket #20): { assignee (users @id, '' = unassigned), previous (users @id, '') }// 'link' (ticket #4, mission 017): { linkKind ('parent' | 'blockedBy'), action// ('set' | 'removed'), other, otherProject, otherNumber, previous?, previousProject?,// previousNumber? } — on the CHILD / the BLOCKED ticket (the one whose relation changed).// 'edit' (ticket #8, mission 014): { oldSubject, oldSummary, subject, summary } —// the values before AND after; the ticket record then carries the new ones.// `user` (users @id, users.hl) since ticket #7: the event's author is that// user's CURRENT display name; `author` keeps the name at writing time.// linksTable pk @id index @ticket @other (ticket #4, mission 017) { kind, ticket, other, user, created }// kind 'parent': `ticket` is a child of `other` (at most one per ticket)// kind 'blockedBy': `ticket` is blocked by `other` (waits on it)// A removed relation is deleted here; the history keeps it ('link' events).//// THE THREE ROLES (a member of a project; anybody else only reads):// use = comment, and move a ticket between open and review// edit = use + create, edit, assign, any state, relations// admin = edit + the project's settings and its members// THE PROJECT SLUG (the URL part) is generated from the title and only holds letters,// digits, '.', '_' and '-' (URL-safe); an admin can change it, the old one keeps resolving.//// Nothing here knows who is looking except where a function takes a `user`; a write answers// the rows the push carries. `source` is the import key (the ticket file's name) — an import// with a known source is answered with the existing ticket, never a second one.import { MPackDB } from 'hl:mpackdb'import { env } from 'hl:proc'import { now } from 'hl:time'import { localStamp } from './localtime.hl'import { nameOfUser, userRecord, usersTable, ensureUser, ensureCreatorUser, countOfList } from './users.hl'import { markdownBlocks } from './markdown.hl'// TICKETS_STORAGE names another table DIRECTORY (the gate runs on its own). Relative// paths resolve against the ENTRY SCRIPT's directory — tools must pass an absolute one.static dir = env('TICKETS_STORAGE') != null ? env('TICKETS_STORAGE') : './storage/mpackdb'static projectsTable = new MPackDB(file = dir + '/projects.db', primaryKey = '@id', indexes = ['!slug'])static membersTable = new MPackDB(file = dir + '/members.db', primaryKey = '@id', indexes = ['project', 'user'])static ticketsTable = new MPackDB(file = dir + '/tickets.db', primaryKey = '@id', indexes = ['project', 'state', 'source', '!path', '*oldNumber'])static eventsTable = new MPackDB(file = dir + '/events.db', primaryKey = '@id', indexes = ['@ticket'])static linksTable = new MPackDB(file = dir + '/links.db', primaryKey = '@id', indexes = ['@ticket', '@other'])// THE STATES, in the order a ticket usually walks them; the state's name is also its URL form.// The OLD names are still accepted (the scheduler and the tools use them): stateOf() maps them.static states = ['open' 'progress' 'pending' 'review' 'reopened' 'done' 'canceled']static stateAliases = { 'in progress' = 'progress' 'awaiting creator' = 'review' 'confirmed' = 'done' 'rejected' = 'reopened' 'on hold' = 'pending' }static roles = ['use' 'edit' 'admin']static rankOf = (role) => { return role == 'admin' ? 3 : (role == 'edit' ? 2 : (role == 'use' ? 1 : 0)) }// an empty list written to hl:mpackdb comes back as an empty hybrid (`.length` null):// every read of a found list goes through herestatic countOf = (list) => { return countOfList(list) }// the state a name stands for (a new name, an old one, or its URL form with '-' / '+'), or nullstatic stateOf = (name) => {if (name == null || hlTypeName(name) != 'String') { return null }let s = name.replaceAll('-', ' ').replaceAll('+', ' ').trim().toLowerCase()if (states.includes(s)) { return s }let a = stateAliases[s]return a != null ? a : null}static slugOf = (state) => { return state == null ? '' : state.replaceAll(' ', '-') }static stateOfSlug = (slug) => { return stateOf(slug) }static validState = (s) => { return stateOf(s) != null }// the states a person moves a ticket to: the inbox onesstatic inboxStates = ['pending' 'review']// a string of ASCII digits only (a ticket number in a URL); '' is notstatic isDigits = (s) => {if (s == null) { return false }let t = '' + sif (t.length == 0 || t.length > 12) { return false }let i = 0while (i < t.length) {let c = t.charCodeAt(i)if (c < 48 || c > 57) { return false }i = i + 1}return true}// a project slug that a URL carries as is: letters, digits, . _ -static isSlugChar = (c) => {return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 46 || c == 95 || c == 45}static validProjectName = (p) => {if (p == null || hlTypeName(p) != 'String' || p.length == 0 || p.length > 100) { return false }let i = 0while (i < p.length) {if (!isSlugChar(p.charCodeAt(i))) { return false }i = i + 1}return true}// what a person reads: Europe/Vienna wall time (storage stays epoch ms, UTC)static stamp = (ms) => { return localStamp(ms) }// every record is written as a whole (hl:mpackdb's update replaces it)static merged = (rec, changes) => {let out = {}for (k of rec.keys()) { out[k] = rec[k] }for (k of changes.keys()) { out[k] = changes[k] }return out}static clean = (s) => { return s == null ? '' : ('' + s).trim() }static first = (list) => { return countOf(list) > 0 ? list[0] : null }// ---- the URLs ---------------------------------------------------------------------------static ticketHref = (project, number) => { return '/projects/' + project + '/' + number }static ticketApiHref = (project, number) => { return '/api/projects/' + project + '/tickets/' + number }static projectHrefOf = (project) => { return '/projects/' + project }static settingsHrefOf = (project) => { return '/projects/' + project + '/settings' }// ---- projects ------------------------------------------------------------------------------static projectRecord = (id) => { return id == null || id == '' || hlTypeName(id) != 'String' ? null : projectsTable.fetch(id) }static projectBySlug = (slug) => { return slug == null || hlTypeName(slug) != 'String' ? null : first(projectsTable.find('slug', slug)) }// projects in the order they were created (stored time, not key order)static projectRecords = () => {let all = projectsTable.find(null, null)let ps = []if (countOf(all) == 0) { return ps }for (p of all) { ps.push(p) }let i = 1while (i < ps.length) {let cur = ps[i]let j = i - 1while (j >= 0 && ps[j].created > cur.created) {ps[j + 1] = ps[j]j = j - 1}ps[j + 1] = curi = i + 1}return ps}static oldSlugsOf = (p) => {let out = []if (p.oldSlugs == null || p.oldSlugs == '') { return out }for (s of p.oldSlugs.split(',')) { if (s != '') { out.push(s) } }return out}// a project by its CURRENT slug, or by a slug it once had (old links keep working)static projectByAnySlug = (slug) => {let p = projectBySlug(slug)if (p != null) { return p }if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }for (q of projectRecords()) { if (oldSlugsOf(q).includes(slug)) { return q } }return null}// what the API takes for a project: its slug (also an old one) or its idstatic projectByRef = (ref) => {if (ref == null || hlTypeName(ref) != 'String' || ref == '') { return null }let p = projectByAnySlug(ref)return p != null ? p : projectRecord(ref)}static projectNames = () => {let out = []for (p of projectRecords()) { out.push(p.slug) }return out}static projectExists = (slug) => { return projectByAnySlug(slug) != null }static projectRowOf = (p) => {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 }}// the slug a title proposes: lower case letters and digits, other runs become one '-'static slugFromTitle = (title) => {let t = clean(title).toLowerCase()let out = ''let i = 0while (i < t.length && out.length < 60) {let c = t.charCodeAt(i)if ((c >= 48 && c <= 57) || (c >= 97 && c <= 122)) { out = out + t[i] } else if (out != '' && !out.endsWith('-')) { out = out + '-' }i = i + 1}while (out.endsWith('-')) { out = out.slice(0, out.length - 1) }return out == '' ? 'project' : out}// is this slug free? (`except` = the project that may keep it)static slugFree = (slug, except) => {for (q of projectRecords()) {if (except == null || q.id != except) {if (q.slug == slug || oldSlugsOf(q).includes(slug)) { return false }}}return true}static uniqueSlug = (base) => {if (slugFree(base, null)) { return base }let n = 2while (!slugFree(base + '-' + n, null) && n < 10000) { n = n + 1 }return base + '-' + n}// ---- members and roles ------------------------------------------------------------------------static memberRecord = (projectId, userId) => {if (projectId == null || userId == null || userId == '') { return null }let all = membersTable.find('project', projectId)if (countOf(all) == 0) { return null }for (m of all) { if (m.user == userId) { return m } }return null}// the role of a user in a project ('use' | 'edit' | 'admin'), or nullstatic roleOf = (projectId, user) => {if (user == null || user.id == null) { return null }let m = memberRecord(projectId, user.id)return m == null ? null : m.role}static atLeast = (projectId, user, role) => { return rankOf(roleOf(projectId, user)) >= rankOf(role) }static isMember = (projectId, user) => { return roleOf(projectId, user) != null }static mayEditIn = (projectId, user) => { return atLeast(projectId, user, 'edit') }static isAdminOf = (projectId, user) => { return atLeast(projectId, user, 'admin') }// the member rows of a project: [{ id, user (users @id), name, role, rank, created }], the oldest firststatic memberRows = (projectId) => {let out = []let all = membersTable.find('project', projectId)if (countOf(all) == 0) { return out }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 }) }let i = 1while (i < out.length) {let cur = out[i]let j = i - 1while (j >= 0 && out[j].created > cur.created) {out[j + 1] = out[j]j = j - 1}out[j + 1] = curi = i + 1}return out}// makes `userId` a member with `role` (no permission check: the caller decided); an existing// member keeps the HIGHER of the two roles. Answers the member record.static addMember = (projectId, userId, role) => {let m = memberRecord(projectId, userId)if (m != null) {if (rankOf(role) > rankOf(m.role)) { membersTable.update(m.id, merged(m, { role = role })) }return membersTable.fetch(m.id)}let id = membersTable.put({ project = projectId user = userId role = role created = now() })return id == null ? null : membersTable.fetch(id)}static adminCount = (projectId) => {let n = 0for (m of memberRows(projectId)) { if (m.role == 'admin') { n = n + 1 } }return n}static notAdmin = { error = 'only an admin of the project can do that' field = '' forbidden = true }// an admin sets a member's role, or adds a person by the ident id / display name of a user that// has logged in here. `who` = { user (users @id) }. Answers { members } or { error, forbidden? }static setMemberRole = (projectId, actor, userId, role) => {let p = projectRecord(projectId)if (p == null) { return { error = 'no such project' } }if (!isAdminOf(p.id, actor)) { return notAdmin }if (!roles.includes(role)) { return { error = 'role must be one of: ' + roles.join(', ') field = 'role' } }if (userRecord(userId) == null) { return { error = 'no such user' field = 'user' } }let m = memberRecord(p.id, userId)if (m != null && m.role == 'admin' && role != 'admin' && adminCount(p.id) < 2) { return { error = 'a project needs at least one admin' field = 'role' } }if (m == null) { addMember(p.id, userId, role) } else { membersTable.update(m.id, merged(m, { role = role })) }return { members = memberRows(p.id) }}static removeMember = (projectId, actor, userId) => {let p = projectRecord(projectId)if (p == null) { return { error = 'no such project' } }if (!isAdminOf(p.id, actor)) { return notAdmin }let m = memberRecord(p.id, userId)if (m == null) { return { error = 'not a member' field = 'user' } }if (m.role == 'admin' && adminCount(p.id) < 2) { return { error = 'a project needs at least one admin' field = 'user' } }membersTable.delete(m.id)// the tickets assigned to the person are no longer assigned to anybodylet all = ticketsTable.find('project', p.id)if (countOf(all) > 0) {for (t of all) {if (t.assignee == userId) { ticketsTable.update(t.id, merged(t, { assignee = '' })) }}}return { members = memberRows(p.id) }}// the user a name (exact display name), an ident id (identity) or a users @id stands for, among// the members of a project when `projectId` is given; { user } | { error }static userByRef = (ref, projectId) => {let r = clean(ref)if (r == '') { return { error = 'name a person' } }let found = []let all = usersTable.find(null, null)if (countOf(all) > 0) {for (u of all) {if (projectId == null || memberRecord(projectId, u.id) != null) {if (u.id == r || u.identity == r || (u.name != '' && u.name == r)) { found.push(u) }}}}if (found.length == 0) { return { error = 'no such person' + (projectId != null ? ' among the members' : '') + ': ' + r } }if (found.length > 1) { return { error = 'more than one person is called ' + r } }return { user = found[0] }}// the members a ticket can be assigned to: [{ value (users @id), label (display name) }]static assignChoices = (projectId) => {let out = []for (m of memberRows(projectId)) { out.push({ value = m.user label = m.name }) }return out}// projects a user may create tickets in: [{ slug, title }] (creation order)static editableProjects = (user) => {let out = []if (user == null) { return out }for (p of projectRecords()) { if (mayEditIn(p.id, user)) { out.push({ slug = p.slug title = p.title }) } }return out}// the projects a user is a member of, with the role: [{ slug, title, role, href }]static projectsOfUser = (user) => {let out = []if (user == null) { return out }for (p of projectRecords()) {let r = roleOf(p.id, user)if (r != null) { out.push({ slug = p.slug title = p.title role = r href = projectHrefOf(p.slug) }) }}return out}static userOk = (u) => { return u != null && u.id != null && u.name != null && u.name != '' }static authorMissing = { error = 'a write needs a logged-in user' field = 'author' }static notMember = (what) => { return { error = what field = '' forbidden = true } }// answers { error, field } or { project (row), members }. ANY logged-in user with a display name// opens a project and is its first admin. A slug is generated from the title unless one is given.static createProject = (user, title, slug, description) => {if (!userOk(user)) { return authorMissing }let t = clean(title)if (t == '') { return { error = 'the title is required' field = 'title' } }if (t.length > 100) { return { error = 'the title is too long (at most 100 characters)' field = 'title' } }let s = clean(slug)if (s == '') { s = uniqueSlug(slugFromTitle(t)) }if (!validProjectName(s)) { return { error = "a slug has no spaces or slashes: letters, digits, '.', '_' and '-' only" field = 'slug' } }if (!slugFree(s, null)) { return { error = 'the slug ' + s + ' is already used by a project' field = 'slug' } }let id = projectsTable.put({ title = t slug = s description = clean(description) oldSlugs = '' created = now() })if (id == null) { return { error = 'could not store the project: ' + projectsTable.lastError() field = '' } }addMember(id, user.id, 'admin')return { project = projectRowOf(projectsTable.fetch(id)) members = memberRows(id) }}// an admin changes title / slug / description (null = keep). A new slug keeps the old one working.static updateProject = (projectId, actor, title, slug, description) => {let p = projectRecord(projectId)if (p == null) { return { error = 'no such project' } }if (!userOk(actor)) { return authorMissing }if (!isAdminOf(p.id, actor)) { return notAdmin }if (title == null && slug == null && description == null) { return { error = 'nothing to change: give title, slug and/or description' field = '' } }let t = title != null ? clean(title) : p.titleif (t == '') { return { error = 'the title is required' field = 'title' } }if (t.length > 100) { return { error = 'the title is too long (at most 100 characters)' field = 'title' } }let s = slug != null ? clean(slug) : p.slugif (s == '') { return { error = 'the slug is required' field = 'slug' } }if (!validProjectName(s)) { return { error = "a slug has no spaces or slashes: letters, digits, '.', '_' and '-' only" field = 'slug' } }if (!slugFree(s, p.id)) { return { error = 'the slug ' + s + ' is already used by another project' field = 'slug' } }let d = description != null ? clean(description) : (p.description != null ? p.description : '')let olds = oldSlugsOf(p)if (s != p.slug) {let kept = []for (o of olds) { if (o != s) { kept.push(o) } }kept.push(p.slug)olds = kept}projectsTable.update(p.id, merged(p, { title = t slug = s description = d oldSlugs = olds.join(',') }))return { project = projectRowOf(projectsTable.fetch(p.id)) }}// ---- the shapes a page and the API show ---------------------------------------------// A ROW IS A PLAIN HYBRID: what the View reads is what the browser receives.// `id` is the UUID; `number` + `project` (the slug) are what a person uses; `oldNumber` / `oldRef`// only on tickets migrated from the global numbering.static ticketRowOf = (t) => {let n = t.events != null ? t.events : 0let p = projectRecord(t.project)let slug = p != null ? p.slug : ''let byId = t.createdBy != null ? t.createdBy : ''let toId = t.assignee != null ? t.assignee : ''let row = {id = t.idproject = slugprojectId = t.projectprojectTitle = p != null ? p.title : ''number = t.numberref = '#' + t.numbersubject = t.subjectsummary = t.summarystate = t.statestateSlug = slugOf(t.state)source = t.source != null ? t.source : ''created = stamp(t.created)createdMs = t.createdupdated = stamp(t.updated)updatedMs = t.updatedevents = nhref = ticketHref(slug, t.number)apiHref = ticketApiHref(slug, t.number)projectHref = projectHrefOf(slug)hasOld = t.oldNumber != nulloldRef = t.oldNumber != null ? '#' + t.oldNumber : ''createdById = byIdcreatedBy = byId != '' ? nameOfUser(byId) : creatorNameOf(t)assigneeId = toIdassignee = toId != '' ? nameOfUser(toId) : ''hasAssignee = toId != ''}if (t.oldNumber != null) { row.oldNumber = t.oldNumber }// ticket #4 (mission 017): the relations, as refs with their states (relationsOf below)let rel = relationsOf(t)row.parent = rel.parentrow.children = rel.childrenrow.allChildrenConfirmed = rel.allChildrenConfirmedrow.blockedBy = rel.blockedByrow.blocks = rel.blocksreturn row}// a ticket from before the login has no createdBy: the free-text author of its 'created' eventstatic creatorNameOf = (t) => {let all = eventsTable.find('ticket', t.id)if (countOf(all) == 0) { return '' }for (e of all) { if (e.kind == 'created') { return e.author != null ? e.author : '' } }return ''}static eventRowOf = (e, t) => {let kind = e.kind// computed BEFORE the literal: inside a literal that has an `id` entry, a called function's// parameter named `id` read that entry instead (mission 011, reported as a Hybriel issue)let who = e.user != null ? nameOfUser(e.user) : e.authorlet label = kind == 'created' ? 'opened the ticket' : (kind == 'state' ? 'changed the state' : (kind == 'edit' ? 'edited the ticket' : 'commented'))if (kind == 'link') { label = linkLabelOf(e) }let assignedTo = ''let assignedWas = ''if (kind == 'assign') {assignedTo = e.assignee != null && e.assignee != '' ? nameOfUser(e.assignee) : ''assignedWas = e.previous != null && e.previous != '' ? nameOfUser(e.previous) : ''label = assignedTo != '' ? 'assigned the ticket to ' + assignedTo : 'unassigned the ticket' + (assignedWas != '' ? ' (was ' + assignedWas + ')' : '')}let row = {id = e.idticket = e.ticketproject = t != null ? projectSlugOf(t) : ''number = t != null ? t.number : 0seq = e.seq != null ? e.seq : 0kind = kindauthor = whouserId = e.user != null ? e.user : ''text = e.text != null ? e.text : ''hasText = e.text != null && e.text != ''from = e.from != null ? e.from : ''to = e.to != null ? e.to : ''toSlug = slugOf(e.to)isState = kind == 'state'isComment = kind == 'comment'isCreated = kind == 'created'label = labelwhen = stamp(e.created)createdMs = e.created}// ticket #8: only an edit event carries these (the JSON of every other event is unchanged)if (kind == 'edit') {row.isEdit = truerow.oldSubject = e.oldSubject != null ? e.oldSubject : ''row.oldSummary = e.oldSummary != null ? e.oldSummary : ''row.newSubject = e.subject != null ? e.subject : ''row.newSummary = e.summary != null ? e.summary : ''row.subjectChanged = row.oldSubject != row.newSubjectrow.summaryChanged = row.oldSummary != row.newSummary}// ticket #4: only a link event carries theseif (kind == 'link') {row.isLink = truerow.linkKind = e.linkKindrow.linkAction = e.actionrow.otherRef = e.otherProject + ' #' + e.otherNumberrow.otherHref = ticketHref(e.otherProject, e.otherNumber)row.previousRef = e.previous != null ? e.previousProject + ' #' + e.previousNumber : ''}// ticket #20: only an assign event carries theseif (kind == 'assign') {row.isAssign = truerow.assignee = assignedTorow.previousAssignee = assignedWas}return row}static projectSlugOf = (t) => {let p = projectRecord(t.project)return p != null ? p.slug : ''}// "set the parent to ident.worldapi.org #1", "removed the blocker tickets.worldapi.org #6", …static linkLabelOf = (e) => {let other = e.otherProject + ' #' + e.otherNumberif (e.linkKind == 'parent') {if (e.action == 'removed') { return 'removed the parent ' + other }let was = e.previous != null ? ' (was ' + e.previousProject + ' #' + e.previousNumber + ')' : ''return 'set the parent to ' + other + was}if (e.action == 'removed') { return 'removed the blocker ' + other }return 'marked it blocked by ' + other}// THE PAGE'S EVENT ROW (ticket #6): the API row + its text as Markdown blocks (`md`), and for an// edit the previous summary's blocks (`oldSummaryMd`) and the new one's (`summaryMd`, the ticket// page takes it over live). Only pages and pushes carry these — the JSON API does not.static pageEventOf = (ev) => {let out = {}for (k of ev.keys()) { out[k] = ev[k] }out.md = markdownBlocks(ev.text)if (ev.isEdit == true) {out.oldSummaryMd = markdownBlocks(ev.oldSummary)out.summaryMd = markdownBlocks(ev.newSummary)}return out}static pageEventsOf = (evs) => {let out = []for (ev of evs) { out.push(pageEventOf(ev)) }return out}// ---- finding a ticket -------------------------------------------------------------------static ticketRecord = (uuid) => { return uuid == null || uuid == '' ? null : ticketsTable.fetch('' + uuid) }// `project` is a slug (a former one too)static ticketAt = (project, number) => {if (project == null || !isDigits(number)) { return null }let p = projectByAnySlug(project)if (p == null) { return null }return first(ticketsTable.find('path', p.id + '/' + toNumber('' + number)))}static ticketByOld = (oldNumber) => {if (!isDigits(oldNumber)) { return null }return first(ticketsTable.find('oldNumber', toNumber('' + oldNumber)))}// what `/api/tickets/:ref` and `/tickets/:ref` accept: the OLD global number (digits)// or the ticket's UUIDstatic ticketByRef = (ref) => {if (ref == null) { return null }if (isDigits(ref)) { return ticketByOld(ref) }return ticketRecord(ref)}// ---- reads -------------------------------------------------------------------------static sortByUpdated = (rows) => {// insertion sort by updated, descending (lists are short)let i = 1while (i < rows.length) {let cur = rows[i]let j = i - 1while (j >= 0 && rows[j].updatedMs < cur.updatedMs) {rows[j + 1] = rows[j]j = j - 1}rows[j + 1] = curi = i + 1}return rows}// newest update first; both filters optional (null = all); `project` is a slugstatic ticketRows = (project, state) => {let rows = []let all = nullif (project != null) {let p = projectByAnySlug(project)if (p == null) { return rows }all = ticketsTable.find('project', p.id)} else {all = ticketsTable.find(null, null)}if (countOf(all) == 0) { return rows }for (t of all) {if (state == null || t.state == state) { rows.push(ticketRowOf(t)) }}return sortByUpdated(rows)}// THE INBOX of a user: the tickets ASSIGNED TO THEM in pending and in review, two listsstatic inboxRows = (user) => {let out = { pending = [] review = [] }if (user == null || user.id == null) { return out }let a = []let b = []let ps = ticketsTable.find('state', 'pending')if (countOf(ps) > 0) { for (t of ps) { if (t.assignee == user.id) { a.push(ticketRowOf(t)) } } }let rs = ticketsTable.find('state', 'review')if (countOf(rs) > 0) { for (t of rs) { if (t.assignee == user.id) { b.push(ticketRowOf(t)) } } }out.pending = sortByUpdated(a)out.review = sortByUpdated(b)return out}static inboxCountOf = (user) => {let x = inboxRows(user)return x.pending.length + x.review.length}// the page's row of `/projects/<project>/<number>`, or nullstatic ticketRowAt = (project, number) => {let t = ticketAt(project, number)return t == null ? null : ticketRowOf(t)}// the event history of a ticket (UUID), oldest first: by stored time, then seqstatic eventRows = (ticketId) => {let out = []let t = ticketRecord(ticketId)if (t == null) { return out }let all = eventsTable.find('ticket', t.id)if (countOf(all) == 0) { return out }for (e of all) { out.push(eventRowOf(e, t)) }let i = 1while (i < out.length) {let cur = out[i]let j = i - 1while (j >= 0 && (out[j].createdMs > cur.createdMs || (out[j].createdMs == cur.createdMs && out[j].seq > cur.seq))) {out[j + 1] = out[j]j = j - 1}out[j + 1] = curi = i + 1}return out}// the filter links of the list page: [{ label, href, cls }] — `cls` is 'selected' on// the one in effect. The href keeps the OTHER filter.static projectLinks = (project, state) => {let tail = state != null ? '/state/' + slugOf(state) : ''let out = [{ label = 'all projects' href = state != null ? tail : '/' cls = project == null ? 'selected' : '' }]for (p of projectRecords()) {out.push({ label = p.title href = '/project/' + p.slug + tail cls = p.slug == project ? 'selected' : '' })}return out}static stateLinks = (project, state) => {let head = project != null ? '/project/' + project : ''let out = [{ label = 'all states' href = project != null ? head : '/' cls = state == null ? 'selected' : '' }]for (s of states) {out.push({ label = s href = head + '/state/' + slugOf(s) cls = s == state ? 'selected ' + slugOf(s) : slugOf(s) })}return out}// the options of a state select: [{ value, label }] — the states THIS user may move the ticket to// (a `use` member: only open ⇄ review; `edit` / `admin`: every other state)static stateChoices = (t, user) => {let out = []if (t == null || user == null) { return out }let role = roleOf(t.project, user)for (s of states) {if (s != t.state && mayMoveTo(role, t.state, s)) { out.push({ value = s label = s }) }}return out}static mayMoveTo = (role, current, target) => {if (rankOf(role) >= 2) { return true }if (rankOf(role) == 1) { return (current == 'open' && target == 'review') || (current == 'review' && target == 'open') }return false}// ---- writes: each answers what the push carries --------------------------------------// EVERY WRITE HAS A USER (ticket #7): the logged-in user of the web session or of the API// token (users.hl). The author is that user — there is no free author field any more.// WHO MAY is the role in the ticket's project (see the top): a refusal for a missing right has// `forbidden = true` (the API answers 403).// the next per-project number: the highest one in the project + 1 (first = 1)static nextNumber = (projectId) => {let top = 0let all = ticketsTable.find('project', projectId)if (countOf(all) > 0) {for (t of all) { if (t.number != null && t.number > top) { top = t.number } }}return top + 1}// answers { error, field, forbidden? } or { ticket (row), event (row), existed }// `project` = the slug (or id) of an existing project; `assignee` optional (a member's name / id)static createTicket = (project, subject, summary, user, source, assignee) => {let s = clean(subject)if (clean(project) == '') { return { error = 'project is required' field = 'project' } }if (s == '') { return { error = 'subject is required' field = 'subject' } }if (!userOk(user)) { return authorMissing }let p = projectByRef(clean(project))if (p == null) { return { error = 'no such project: ' + clean(project) field = 'project' } }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 } }let who = ''if (assignee != null && clean(assignee) != '') {let r = userByRef(assignee, p.id)if (r.error != null) { return { error = r.error field = 'assignee' } }who = r.user.id}let src = clean(source)if (src != '') {let known = ticketsTable.find('source', src)if (countOf(known) > 0) { return { ticket = ticketRowOf(known[0]) event = null existed = true } }}let t0 = now()let n = nextNumber(p.id)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 })if (id == null) { return { error = 'could not store the ticket: ' + ticketsTable.lastError() field = '' } }let t = ticketsTable.fetch(id)let eid = eventsTable.put({ ticket = id seq = 1 kind = 'created' author = user.name user = user.id text = src != '' ? 'imported from ' + src : '' created = t0 })let out = { ticket = ticketRowOf(t) event = eventRowOf(eventsTable.fetch(eid), t) existed = false }if (who != '') {let seq = 2let aid = eventsTable.put({ ticket = id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = who previous = '' created = t0 })ticketsTable.update(id, merged(t, { events = seq }))let t2 = ticketsTable.fetch(id)out.ticket = ticketRowOf(t2)out.assignEvent = eventRowOf(eventsTable.fetch(aid), t2)}return out}// answers { error } or { ticket, event }; `ticketId` is the UUID. Any member may comment.static addComment = (ticketId, user, text) => {let t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }let body = clean(text)if (body == '') { return { error = 'a comment needs text' field = 'text' } }if (!userOk(user)) { return authorMissing }if (!isMember(t.project, user)) { return { error = 'only a member of the project can comment' field = '' forbidden = true } }let t0 = now()let seq = (t.events != null ? t.events : 0) + 1let eid = eventsTable.put({ ticket = t.id seq = seq kind = 'comment' author = user.name user = user.id text = body created = t0 })ticketsTable.update(t.id, merged(t, { updated = t0 events = seq }))let t2 = ticketsTable.fetch(t.id)return { ticket = ticketRowOf(t2) event = eventRowOf(eventsTable.fetch(eid), t2) }}// the admin who gets a ticket that lands in the inbox with nobody assigned: the oldest adminstatic defaultAssignee = (projectId) => {for (m of memberRows(projectId)) { if (m.role == 'admin') { return m.user } }return ''}// answers { error } or { ticket, event, assignEvent? }; an optional note rides on the state event.// `forbidden = true` on a refusal the user may not overcome (the role does not allow that move).// A ticket that goes to review / pending with nobody assigned is assigned to the project's first admin// (so it reaches an inbox) — a second, 'assign' event.static changeState = (ticketId, state, user, note) => {let t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }let s = stateOf(state)if (s == null) { return { error = 'unknown state — one of: ' + states.join(', ') field = 'state' } }if (s == t.state) { return { error = 'the ticket is already ' + s field = 'state' } }if (!userOk(user)) { return authorMissing }let role = roleOf(t.project, user)if (role == null) { return { error = 'only a member of the project can change the state' field = 'state' forbidden = true } }if (!mayMoveTo(role, t.state, s)) { return { error = 'the role ' + role + ' can only move a ticket between open and review' field = 'state' forbidden = true } }let t0 = now()let seq = (t.events != null ? t.events : 0) + 1let 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 })let changes = { state = s updated = t0 events = seq }let aid = nulllet auto = ''if (inboxStates.includes(s) && (t.assignee == null || t.assignee == '')) { auto = defaultAssignee(t.project) }if (auto != '') {seq = seq + 1aid = eventsTable.put({ ticket = t.id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = auto previous = '' created = t0 })changes.assignee = autochanges.events = seq}ticketsTable.update(t.id, merged(t, changes))let t2 = ticketsTable.fetch(t.id)let out = { ticket = ticketRowOf(t2) event = eventRowOf(eventsTable.fetch(eid), t2) }if (aid != null) { out.assignEvent = eventRowOf(eventsTable.fetch(aid), t2) }return out}// answers { error, forbidden? } or { ticket, event }; `assignee` = a member's users @id, '' = nobodystatic assignTicket = (ticketId, user, assignee) => {let t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }if (!userOk(user)) { return authorMissing }if (!mayEditIn(t.project, user)) { return { error = 'you need the role edit or admin to assign a ticket' field = 'assignee' forbidden = true } }let who = assignee == null ? '' : clean(assignee)if (who != '' && memberRecord(t.project, who) == null) { return { error = 'the assignee must be a member of the project' field = 'assignee' } }let was = t.assignee != null ? t.assignee : ''if (who == was) { return { error = who == '' ? 'the ticket is not assigned' : 'the ticket is already assigned to that person' field = 'assignee' } }let t0 = now()let seq = (t.events != null ? t.events : 0) + 1let eid = eventsTable.put({ ticket = t.id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = who previous = was created = t0 })ticketsTable.update(t.id, merged(t, { assignee = who updated = t0 events = seq }))let t2 = ticketsTable.fetch(t.id)return { ticket = ticketRowOf(t2) event = eventRowOf(eventsTable.fetch(eid), t2) }}// ---- editing (ticket #8, mission 014; ticket #20: by the role) --------------------------------// THE AUTHOR of a ticket is `createdBy` (users @id), or null for a ticket opened before the login// existed. MAY THIS USER EDIT the ticket (a record)? Any member with the role edit or admin of// its project (`use` members only comment and move it between open and review).static ownerOf = (t) => { return t.createdBy != null && t.createdBy != '' ? t.createdBy : null }static mayEdit = (t, user) => {if (t == null || !userOk(user)) { return false }return mayEditIn(t.project, user)}static mayEditId = (ticketId, user) => { return mayEdit(ticketRecord(ticketId), user) }// answers { error, field?, forbidden? } or { ticket, event }. `subject` / `summary` null = keep// that one. The previous values stay in the 'edit' event (append-only history).static editTicket = (ticketId, user, subject, summary) => {let t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }if (!userOk(user)) { return authorMissing }if (!mayEdit(t, user)) { return { error = 'you need the role edit or admin in the project to edit a ticket' field = '' forbidden = true } }if (subject == null && summary == null) { return { error = 'nothing to change: give subject and/or summary' field = '' } }let oldSummary = t.summary != null ? t.summary : ''let s = subject != null ? clean(subject) : t.subjectif (s == '') { return { error = 'subject is required' field = 'subject' } }let sm = summary != null ? clean(summary) : oldSummaryif (s == t.subject && sm == oldSummary) { return { error = 'nothing changed' field = subject != null ? 'subject' : 'summary' } }let t0 = now()let seq = (t.events != null ? t.events : 0) + 1let 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 })if (eid == null) { return { error = 'could not store the edit: ' + eventsTable.lastError() field = '' } }ticketsTable.update(t.id, merged(t, { subject = s summary = sm updated = t0 events = seq }))let t2 = ticketsTable.fetch(t.id)return { ticket = ticketRowOf(t2) event = eventRowOf(eventsTable.fetch(eid), t2) }}// the API's view of one ticket (a record): the row and its whole historystatic ticketWithEvents = (t) => {if (t == null) { return null }return { ticket = ticketRowOf(t) events = eventRows(t.id) }}// ---- relations: parent / child and blocked-by (ticket #4, mission 017) ----------------------// A ticket has at most ONE PARENT (any project); a parent lists its CHILDREN with their states.// A ticket can be BLOCKED BY other tickets (any project) — it waits on them; the other side// shows what it BLOCKS. The scheduler reads them from the JSON rows (`parent`, `children`,// `blockedBy`, `blocks`, each a ref with the state) and the Markdown view.// PARENT STATE: `allChildrenConfirmed` (the key kept for the clients; it now means DONE) is true when// a ticket has children and every one of them is `done`. It is only SHOWN ("all children done") — the// parent's own state is never changed by it.// WHO MAY set / remove a relation (ticket #20): a member with the role edit or admin in the project of// EITHER of the two tickets. Replacing a parent needs the// right for the old pair AND the new pair. No loops: a ticket cannot be its own ancestor, and// cannot (transitively) wait on itself.// Tickets are named as `<project>#<number>` (spaces around `#` allowed) or by their UUID.// what a relation shows of a ticket (no relations of its own — no recursion)static refOf = (t) => {let slug = slugOf(t.state)let pslug = projectSlugOf(t)let href = ticketHref(pslug, t.number)let apiHref = ticketApiHref(pslug, t.number)let key = pslug + '#' + t.numberreturn { 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 }}// the link records of one side, oldest first (stored time): field 'ticket' = this ticket's own// relations (its parent, its blockers), field 'other' = the ones pointing at it (children, blocked)static linkRecords = (field, ticketId, kind) => {let out = []if (ticketId == null || ticketId == '') { return out }let all = linksTable.find(field, '' + ticketId)if (countOf(all) == 0) { return out }for (l of all) { if (l.kind == kind) { out.push(l) } }let i = 1while (i < out.length) {let cur = out[i]let j = i - 1while (j >= 0 && out[j].created > cur.created) {out[j + 1] = out[j]j = j - 1}out[j + 1] = curi = i + 1}return out}static parentLinkOf = (ticketId) => { return first(linkRecords('ticket', ticketId, 'parent')) }static parentIdOf = (ticketId) => {let l = parentLinkOf(ticketId)return l == null ? null : l.other}static idsOfLinks = (links, field) => {let out = []for (l of links) { out.push(l[field]) }return out}static childIdsOf = (ticketId) => { return idsOfLinks(linkRecords('other', ticketId, 'parent'), 'ticket') }static blockerIdsOf = (ticketId) => { return idsOfLinks(linkRecords('ticket', ticketId, 'blockedBy'), 'other') }static blockedIdsOf = (ticketId) => { return idsOfLinks(linkRecords('other', ticketId, 'blockedBy'), 'ticket') }static refsOfIds = (ids) => {let out = []for (i of ids) {let t = ticketRecord(i)if (t != null) { out.push(refOf(t)) }}return out}// { parent (ref or null), children, blockedBy, blocks (lists of refs), allChildrenConfirmed }static relationsOf = (t) => {let pid = parentIdOf(t.id)let p = pid != null ? ticketRecord(pid) : nulllet kids = refsOfIds(childIdsOf(t.id))let all = kids.length > 0for (c of kids) { if (c.state != 'done') { all = false } }return { parent = p != null ? refOf(p) : null children = kids allChildrenConfirmed = all blockedBy = refsOfIds(blockerIdsOf(t.id)) blocks = refsOfIds(blockedIdsOf(t.id)) }}// what the ticket PAGE shows of a row's relations (SSR members and the `ticketsRelated` push)static noRef = { id = '' project = '' number = 0 ref = '' key = '' subject = '' state = '' stateSlug = '' href = '/' apiHref = '' }static relationViewOf = (row) => {let total = row.children.lengthlet done = 0for (c of row.children) { if (c.state == 'done') { done = done + 1 } }let note = ''if (total > 0) { note = done == total ? 'all children done (' + total + ')' : done + ' of ' + total + ' children done' }let hasParent = row.parent != nulllet hasChildren = total > 0let hasBlockedBy = row.blockedBy.length > 0let hasBlocks = row.blocks.length > 0return { id = row.id parent = hasParent ? row.parent : noRef hasParent = hasParent children = row.children hasChildren = hasChildrenchildNote = note childClass = row.allChildrenConfirmed ? 'done' : '' allChildrenConfirmed = row.allChildrenConfirmedblockedBy = row.blockedBy hasBlockedBy = hasBlockedBy blocks = row.blocks hasBlocks = hasBlockshasRelations = hasParent || hasChildren || hasBlockedBy || hasBlocks }}// the relation views of a ticket AND every ticket related to it (+ `extra` ids: a parent or// blocker just removed) — what a write pushes, so every open page of them followsstatic relatedViews = (ticketId, extra) => {let ids = ['' + ticketId]let seen = {}seen['' + ticketId] = truelet more = []let pid = parentIdOf(ticketId)if (pid != null) { more.push(pid) }for (i of childIdsOf(ticketId)) { more.push(i) }for (i of blockerIdsOf(ticketId)) { more.push(i) }for (i of blockedIdsOf(ticketId)) { more.push(i) }for (i of extra) { if (i != null) { more.push(i) } }for (i of more) {if (seen['' + i] != true) {seen['' + i] = trueids.push('' + i)}}let out = []for (i of ids) {let t = ticketRecord(i)if (t != null) { out.push(relationViewOf(ticketRowOf(t))) }}return out}// `<project>#<number>` (spaces around '#' allowed) or a UUID → the ticket record, or nullstatic ticketByKey = (key) => {let k = clean(key)if (k == '') { return null }let at = k.indexOf('#')if (at < 0) { return isDigits(k) ? null : ticketRecord(k) }let p = k.slice(0, at).trim()let n = k.slice(at + 1).trim()if (p == '') { return null }return ticketAt(p, n)}static mayLink = (a, b, user) => {if (!userOk(user)) { return false }if (a != null && mayEditIn(a.project, user)) { return true }if (b != null && mayEditIn(b.project, user)) { return true }return false}static linkForbidden = { error = 'you need the role edit or admin in the project of either ticket to change this relation' field = '' forbidden = true }static keyOf = (t) => { return projectSlugOf(t) + '#' + t.number }// the history event of a relation change, on ticket `t`; answers { ticket, event, related }static linkEvent = (t, user, linkKind, action, other, previous, extra) => {let t0 = now()let seq = (t.events != null ? t.events : 0) + 1// the values BEFORE the literal: an entry named `other` / `user` would shadow the variable// for the entries after it (mission 011 lesson)let uname = user.namelet uid = user.idlet oid = other.idlet oproject = projectSlugOf(other)let onumber = other.numberlet 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 }if (previous != null) {rec.previous = previous.idrec.previousProject = projectSlugOf(previous)rec.previousNumber = previous.number}let eid = eventsTable.put(rec)if (eid == null) { return { error = 'could not store the event: ' + eventsTable.lastError() field = '' } }ticketsTable.update(t.id, merged(t, { updated = t0 events = seq }))let t2 = ticketsTable.fetch(t.id)return { ticket = ticketRowOf(t2) event = eventRowOf(eventsTable.fetch(eid), t2) related = relatedViews(t.id, extra) }}// SET (key = `<project>#<n>` / UUID) or REMOVE (key = '') the parent of a ticket (UUID).// Answers { error, field, forbidden? } or { ticket, event, related }.static setParent = (ticketId, user, key) => {let t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }if (!userOk(user)) { return authorMissing }let k = clean(key)let cur = parentLinkOf(t.id)let curT = cur != null ? ticketRecord(cur.other) : nullif (k == '') {if (cur == null || curT == null) { return { error = 'the ticket has no parent' field = 'parent' } }if (!mayLink(t, curT, user)) { return linkForbidden }linksTable.delete(cur.id)return linkEvent(t, user, 'parent', 'removed', curT, null, [curT.id])}let p = ticketByKey(k)if (p == null) { return { error = 'no such ticket: ' + k + ' (name it as <project>#<number>)' field = 'parent' } }if (p.id == t.id) { return { error = 'a ticket cannot be its own parent' field = 'parent' } }if (curT != null && curT.id == p.id) { return { error = 'the parent is already ' + keyOf(p) field = 'parent' } }// no loop: the new parent must not be this ticket's child, grandchild, …let up = p.idlet steps = 0while (up != null && steps < 1000) {if (up == t.id) { return { error = 'a loop: ' + keyOf(p) + ' is below this ticket' field = 'parent' } }up = parentIdOf(up)steps = steps + 1}if (!mayLink(t, p, user)) { return linkForbidden }if (curT != null && !mayLink(t, curT, user)) { return linkForbidden }let lid = linksTable.put({ kind = 'parent' ticket = t.id other = p.id user = user.id created = now() })if (lid == null) { return { error = 'could not store the relation: ' + linksTable.lastError() field = '' } }if (cur != null) { linksTable.delete(cur.id) }return linkEvent(t, user, 'parent', 'set', p, curT, [curT != null ? curT.id : null])}// does ticket `start` (transitively) wait on ticket `target`? (blocked-by links)static waitsOn = (start, target) => {let stack = [start]let seen = {}let steps = 0while (stack.length > 0 && steps < 10000) {let cur = stack.pop()steps = steps + 1if (cur == target) { return true }if (seen['' + cur] != true) {seen['' + cur] = truefor (i of blockerIdsOf(cur)) { stack.push(i) }}}return false}// ADD (adding = true) or REMOVE a blocker of a ticket (UUID): ticket `key` blocks it.// `field` names the API field ('add' / 'remove'). Answers as setParent.static changeBlocker = (ticketId, user, key, adding, field) => {let t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }if (!userOk(user)) { return authorMissing }let k = clean(key)if (k == '') { return { error = 'name the blocking ticket as <project>#<number>' field = field } }let o = ticketByKey(k)if (o == null) { return { error = 'no such ticket: ' + k + ' (name it as <project>#<number>)' field = field } }let existing = nullfor (l of linkRecords('ticket', t.id, 'blockedBy')) { if (l.other == o.id) { existing = l } }if (adding) {if (o.id == t.id) { return { error = 'a ticket cannot block itself' field = field } }if (existing != null) { return { error = 'already blocked by ' + keyOf(o) field = field } }if (waitsOn(o.id, t.id)) { return { error = 'a loop: ' + keyOf(o) + ' already waits on this ticket' field = field } }if (!mayLink(t, o, user)) { return linkForbidden }let lid = linksTable.put({ kind = 'blockedBy' ticket = t.id other = o.id user = user.id created = now() })if (lid == null) { return { error = 'could not store the relation: ' + linksTable.lastError() field = '' } }return linkEvent(t, user, 'blockedBy', 'set', o, null, [])}if (existing == null) { return { error = 'not blocked by ' + keyOf(o) field = field } }if (!mayLink(t, o, user)) { return linkForbidden }linksTable.delete(existing.id)return linkEvent(t, user, 'blockedBy', 'removed', o, null, [o.id])}
Branches
- mainmain branch
Latest commits
- f12fa1bcState of 2026-09-27, before the move to gitoriamre