gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commite9d5c618e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmree9d5c618/lib/projects.hl

13.4 KB

  1. // lib/projects.hl — PROJECTS AND THEIR MEMBERS (ticket #20, 2026-09-26). Statics only, the server realm.
  2. // Two hl:mpackdb tables (storage/mpackdb/, UUID keys — creator's convention):
  3. // projectsTable pk @id index !slug { title, slug, description (Markdown), oldSlugs ('a,b' — the
  4. // slugs it had before; they still resolve), created }
  5. // membersTable pk @id index project user { project (project @id), user (users @id), role, created }
  6. // role = 'use' | 'edit' | 'admin' (below). One row per (project, user).
  7. //
  8. // THE THREE ROLES (a member of a project; anybody else only reads):
  9. // use = comment, and move a ticket between open and review
  10. // edit = use + create, edit, assign, any state, relations
  11. // admin = edit + the project's settings and its members
  12. // ANY logged-in user with a display name opens a project and is its first admin; a project always keeps one admin.
  13. // THE PROJECT SLUG (the URL part) is generated from the title and only holds letters,
  14. // digits, '.', '_' and '-' (URL-safe); an admin can change it, the old one keeps resolving.
  15. // Removing a member also unassigns their tickets: that is tickets.hl removeMember (it owns the tickets table).
  16. import { MPackDB } from 'hl:mpackdb'
  17. import { now } from 'hl:time'
  18. import { storageDir, countOf, first, merged, clean, localStamp, oldestFirst, projectHrefOf, settingsHrefOf } from './util.hl'
  19. import { nameOfUser, userRecord, userRecords } from './users.hl'
  20. static projectsTable = new MPackDB(file = storageDir + '/projects.db', primaryKey = '@id', indexes = ['!slug'])
  21. static membersTable = new MPackDB(file = storageDir + '/members.db', primaryKey = '@id', indexes = ['project', 'user'])
  22. static roles = ['use' 'edit' 'admin']
  23. static rankOf = (role) => { return role == 'admin' ? 3 : (role == 'edit' ? 2 : (role == 'use' ? 1 : 0)) }
  24. // the refusals a write answers (`forbidden = true` → the API answers 403)
  25. static notAdmin = { error = 'only an admin of the project can do that' field = '' forbidden = true }
  26. static userOk = (u) => { return u != null && u.id != null && u.name != null && u.name != '' }
  27. static authorMissing = { error = 'a write needs a logged-in user' field = 'author' }
  28. // ---- finding a project ---------------------------------------------------------------------------
  29. static projectRecord = (id) => { return id == null || id == '' || hlTypeName(id) != 'String' ? null : projectsTable.fetch(id) }
  30. static projectBySlug = (slug) => { return slug == null || hlTypeName(slug) != 'String' ? null : first(projectsTable.find('slug', slug)) }
  31. // projects in the order they were created (stored time, not key order)
  32. static projectRecords = () => {
  33. let all = projectsTable.find(null, null)
  34. let ps = []
  35. if (countOf(all) == 0) { return ps }
  36. for (p of all) { ps.push(p) }
  37. return oldestFirst(ps, 'created')
  38. }
  39. static oldSlugsOf = (p) => {
  40. let out = []
  41. if (p.oldSlugs == null || p.oldSlugs == '') { return out }
  42. for (s of p.oldSlugs.split(',')) { if (s != '') { out.push(s) } }
  43. return out
  44. }
  45. // a project by its CURRENT slug, or by a slug it once had (old links keep working)
  46. static projectByAnySlug = (slug) => {
  47. let p = projectBySlug(slug)
  48. if (p != null) { return p }
  49. if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }
  50. for (q of projectRecords()) { if (oldSlugsOf(q).includes(slug)) { return q } }
  51. return null
  52. }
  53. // what the API takes for a project: its slug (also an old one) or its id
  54. static projectByRef = (ref) => {
  55. if (ref == null || hlTypeName(ref) != 'String' || ref == '') { return null }
  56. let p = projectByAnySlug(ref)
  57. return p != null ? p : projectRecord(ref)
  58. }
  59. static projectNames = () => {
  60. let out = []
  61. for (p of projectRecords()) { out.push(p.slug) }
  62. return out
  63. }
  64. // the slug of a ticket's project ('' when the project is gone)
  65. static projectSlugOf = (t) => {
  66. let p = projectRecord(t.project)
  67. return p != null ? p.slug : ''
  68. }
  69. static projectRowOf = (p) => {
  70. 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 = localStamp(p.created) createdMs = p.created }
  71. }
  72. // ---- the slug ------------------------------------------------------------------------------------
  73. // a project slug that a URL carries as is: letters, digits, . _ -
  74. static isSlugChar = (c) => {
  75. return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 46 || c == 95 || c == 45
  76. }
  77. static validProjectName = (p) => {
  78. if (p == null || hlTypeName(p) != 'String' || p.length == 0 || p.length > 100) { return false }
  79. let i = 0
  80. while (i < p.length) {
  81. if (!isSlugChar(p.charCodeAt(i))) { return false }
  82. i = i + 1
  83. }
  84. return true
  85. }
  86. // the slug a title proposes: lower case letters and digits, other runs become one '-'
  87. static slugFromTitle = (title) => {
  88. let t = clean(title).toLowerCase()
  89. let out = ''
  90. let i = 0
  91. while (i < t.length && out.length < 60) {
  92. let c = t.charCodeAt(i)
  93. if ((c >= 48 && c <= 57) || (c >= 97 && c <= 122)) { out = out + t[i] } else if (out != '' && !out.endsWith('-')) { out = out + '-' }
  94. i = i + 1
  95. }
  96. while (out.endsWith('-')) { out = out.slice(0, out.length - 1) }
  97. return out == '' ? 'project' : out
  98. }
  99. // is this slug free? (`except` = the project that may keep it)
  100. static slugFree = (slug, except) => {
  101. for (q of projectRecords()) {
  102. if (except == null || q.id != except) {
  103. if (q.slug == slug || oldSlugsOf(q).includes(slug)) { return false }
  104. }
  105. }
  106. return true
  107. }
  108. static uniqueSlug = (base) => {
  109. if (slugFree(base, null)) { return base }
  110. let n = 2
  111. while (!slugFree(base + '-' + n, null) && n < 10000) { n = n + 1 }
  112. return base + '-' + n
  113. }
  114. // ---- opening and changing a project ---------------------------------------------------------------
  115. // answers { error, field } or { project (row), members }. A slug is generated from the title unless one is given.
  116. static createProject = (user, title, slug, description) => {
  117. if (!userOk(user)) { return authorMissing }
  118. let t = clean(title)
  119. if (t == '') { return { error = 'the title is required' field = 'title' } }
  120. if (t.length > 100) { return { error = 'the title is too long (at most 100 characters)' field = 'title' } }
  121. let s = clean(slug)
  122. if (s == '') { s = uniqueSlug(slugFromTitle(t)) }
  123. if (!validProjectName(s)) { return { error = "a slug has no spaces or slashes: letters, digits, '.', '_' and '-' only" field = 'slug' } }
  124. if (!slugFree(s, null)) { return { error = 'the slug ' + s + ' is already used by a project' field = 'slug' } }
  125. let id = projectsTable.put({ title = t slug = s description = clean(description) oldSlugs = '' created = now() })
  126. if (id == null) { return { error = 'could not store the project: ' + projectsTable.lastError() field = '' } }
  127. addMember(id, user.id, 'admin')
  128. return { project = projectRowOf(projectsTable.fetch(id)) members = memberRows(id) }
  129. }
  130. // an admin changes title / slug / description (null = keep). A new slug keeps the old one working.
  131. static updateProject = (projectId, actor, title, slug, description) => {
  132. let p = projectRecord(projectId)
  133. if (p == null) { return { error = 'no such project' } }
  134. if (!userOk(actor)) { return authorMissing }
  135. if (!isAdminOf(p.id, actor)) { return notAdmin }
  136. if (title == null && slug == null && description == null) { return { error = 'nothing to change: give title, slug and/or description' field = '' } }
  137. let t = title != null ? clean(title) : p.title
  138. if (t == '') { return { error = 'the title is required' field = 'title' } }
  139. if (t.length > 100) { return { error = 'the title is too long (at most 100 characters)' field = 'title' } }
  140. let s = slug != null ? clean(slug) : p.slug
  141. if (s == '') { return { error = 'the slug is required' field = 'slug' } }
  142. if (!validProjectName(s)) { return { error = "a slug has no spaces or slashes: letters, digits, '.', '_' and '-' only" field = 'slug' } }
  143. if (!slugFree(s, p.id)) { return { error = 'the slug ' + s + ' is already used by another project' field = 'slug' } }
  144. let d = description != null ? clean(description) : (p.description != null ? p.description : '')
  145. let olds = oldSlugsOf(p)
  146. if (s != p.slug) {
  147. let kept = []
  148. for (o of olds) { if (o != s) { kept.push(o) } }
  149. kept.push(p.slug)
  150. olds = kept
  151. }
  152. projectsTable.update(p.id, merged(p, { title = t slug = s description = d oldSlugs = olds.join(',') }))
  153. return { project = projectRowOf(projectsTable.fetch(p.id)) }
  154. }
  155. // ---- members and roles ------------------------------------------------------------------------
  156. static memberRecord = (projectId, userId) => {
  157. if (projectId == null || userId == null || userId == '') { return null }
  158. let all = membersTable.find('project', projectId)
  159. if (countOf(all) == 0) { return null }
  160. for (m of all) { if (m.user == userId) { return m } }
  161. return null
  162. }
  163. // the role of a user in a project ('use' | 'edit' | 'admin'), or null
  164. static roleOf = (projectId, user) => {
  165. if (user == null || user.id == null) { return null }
  166. let m = memberRecord(projectId, user.id)
  167. return m == null ? null : m.role
  168. }
  169. static atLeast = (projectId, user, role) => { return rankOf(roleOf(projectId, user)) >= rankOf(role) }
  170. static isMember = (projectId, user) => { return roleOf(projectId, user) != null }
  171. static mayEditIn = (projectId, user) => { return atLeast(projectId, user, 'edit') }
  172. static isAdminOf = (projectId, user) => { return atLeast(projectId, user, 'admin') }
  173. // the member rows of a project: [{ id, user (users @id), name, role, rank, created }], the oldest first
  174. static memberRows = (projectId) => {
  175. let out = []
  176. let all = membersTable.find('project', projectId)
  177. if (countOf(all) == 0) { return out }
  178. 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 }) }
  179. return oldestFirst(out, 'created')
  180. }
  181. // makes `userId` a member with `role` (no permission check: the caller decided); an existing
  182. // member keeps the HIGHER of the two roles. Answers the member record.
  183. static addMember = (projectId, userId, role) => {
  184. let m = memberRecord(projectId, userId)
  185. if (m != null) {
  186. if (rankOf(role) > rankOf(m.role)) { membersTable.update(m.id, merged(m, { role = role })) }
  187. return membersTable.fetch(m.id)
  188. }
  189. let id = membersTable.put({ project = projectId user = userId role = role created = now() })
  190. return id == null ? null : membersTable.fetch(id)
  191. }
  192. static adminCount = (projectId) => {
  193. let n = 0
  194. for (m of memberRows(projectId)) { if (m.role == 'admin') { n = n + 1 } }
  195. return n
  196. }
  197. // an admin sets a member's role, or adds a person by the ident id / display name of a user that
  198. // has logged in here. Answers { members } or { error, forbidden? }
  199. static setMemberRole = (projectId, actor, userId, role) => {
  200. let p = projectRecord(projectId)
  201. if (p == null) { return { error = 'no such project' } }
  202. if (!isAdminOf(p.id, actor)) { return notAdmin }
  203. if (!roles.includes(role)) { return { error = 'role must be one of: ' + roles.join(', ') field = 'role' } }
  204. if (userRecord(userId) == null) { return { error = 'no such user' field = 'user' } }
  205. let m = memberRecord(p.id, userId)
  206. if (m != null && m.role == 'admin' && role != 'admin' && adminCount(p.id) < 2) { return { error = 'a project needs at least one admin' field = 'role' } }
  207. if (m == null) { addMember(p.id, userId, role) } else { membersTable.update(m.id, merged(m, { role = role })) }
  208. return { members = memberRows(p.id) }
  209. }
  210. // an admin removes a member (tickets.hl removeMember then unassigns their tickets). Answers { members } or { error }
  211. static removeMembership = (projectId, actor, userId) => {
  212. let p = projectRecord(projectId)
  213. if (p == null) { return { error = 'no such project' } }
  214. if (!isAdminOf(p.id, actor)) { return notAdmin }
  215. let m = memberRecord(p.id, userId)
  216. if (m == null) { return { error = 'not a member' field = 'user' } }
  217. if (m.role == 'admin' && adminCount(p.id) < 2) { return { error = 'a project needs at least one admin' field = 'user' } }
  218. membersTable.delete(m.id)
  219. return { members = memberRows(p.id) }
  220. }
  221. // the user a name (exact display name), an ident id (identity) or a users @id stands for, among
  222. // the members of a project when `projectId` is given; { user } | { error }
  223. static userByRef = (ref, projectId) => {
  224. let r = clean(ref)
  225. if (r == '') { return { error = 'name a person' } }
  226. let found = []
  227. let all = userRecords()
  228. if (countOf(all) > 0) {
  229. for (u of all) {
  230. if (projectId == null || memberRecord(projectId, u.id) != null) {
  231. if (u.id == r || u.identity == r || (u.name != '' && u.name == r)) { found.push(u) }
  232. }
  233. }
  234. }
  235. if (found.length == 0) { return { error = 'no such person' + (projectId != null ? ' among the members' : '') + ': ' + r } }
  236. if (found.length > 1) { return { error = 'more than one person is called ' + r } }
  237. return { user = found[0] }
  238. }
  239. // the members a ticket can be assigned to: [{ value (users @id), label (display name) }]
  240. static assignChoices = (projectId) => {
  241. let out = []
  242. for (m of memberRows(projectId)) { out.push({ value = m.user label = m.name }) }
  243. return out
  244. }
  245. // projects a user may create tickets in: [{ slug, title }] (creation order)
  246. static editableProjects = (user) => {
  247. let out = []
  248. if (user == null) { return out }
  249. for (p of projectRecords()) { if (mayEditIn(p.id, user)) { out.push({ slug = p.slug title = p.title }) } }
  250. return out
  251. }
  252. // the projects a user is a member of, with the role: [{ slug, title, role, href }]
  253. static projectsOfUser = (user) => {
  254. let out = []
  255. if (user == null) { return out }
  256. for (p of projectRecords()) {
  257. let r = roleOf(p.id, user)
  258. if (r != null) { out.push({ slug = p.slug title = p.title role = r href = projectHrefOf(p.slug) }) }
  259. }
  260. return out
  261. }

Branches

Latest commits

  • e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmre
  • 97e269b5mission 010 (code order) 1/4: .hl files out of the root — lib/ (store, users, connections, invites, migrate, markdown, mdview, import = ticketfile, util = localtime, jsoncheck, api-helpers = api), tools/import.hl, components/styles.hl; import paths only. gate 249/0, connect 60/0, real-data reads identicalmre
  • 38f9d10ftickets: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gate 249/0, connect 60/0mre
  • d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mre
  • bce182e3tickets: Hybriel master 7eea0d32 (#126 memory, #48 lambda copies its argument); migrate.hl lambdas take &logmre
  • 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