gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmainbb64d57cmission 012 (ticket #25 API part): every /api/projects/<x>/… route takes the project's id (12-char record id) as well as its slug, same answers byte for byte; tickets stay under their project, no global /api/tickets/<x>. gate 315/0 (old code: the 13 id checks fail), connect 60/0, live-data copy old vs new 1,484 identical, slug vs id 772 identicalmremain/lib/projects.hl

14.9 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. all = projectsTable.find(null, null)
  34. 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. 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. 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. p = projectByAnySlug(ref)
  57. return p != null ? p : projectRecord(ref)
  58. }
  59. // the project part of an /api/projects/<x>/… path (ticket #25): a slug (also an old one) stays as it is, a project's
  60. // id gives its CURRENT slug, anything else stays as it is and names nothing. The routes then work on the slug as before.
  61. static slugOfRef = (ref) => {
  62. if (ref == null || hlTypeName(ref) != 'String' || ref == '' || projectByAnySlug(ref) != null) { return ref }
  63. p = projectRecord(ref)
  64. return p != null ? p.slug : ref
  65. }
  66. static projectNames = () => {
  67. out = []
  68. for (p of projectRecords()) { out.push(p.slug) }
  69. return out
  70. }
  71. // the slug of a ticket's project ('' when the project is gone)
  72. static projectSlugOf = (t) => {
  73. p = projectRecord(t.project)
  74. return p != null ? p.slug : ''
  75. }
  76. static projectRowOf = (p) => {
  77. 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 }
  78. }
  79. // ---- the slug ------------------------------------------------------------------------------------
  80. // a project slug that a URL carries as is: letters, digits, . _ -
  81. static isSlugChar = (c) => {
  82. return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 46 || c == 95 || c == 45
  83. }
  84. static validProjectName = (p) => {
  85. if (p == null || hlTypeName(p) != 'String' || p.length == 0 || p.length > 100) { return false }
  86. let i = 0
  87. while (i < p.length) {
  88. if (!isSlugChar(p.charCodeAt(i))) { return false }
  89. i = i + 1
  90. }
  91. return true
  92. }
  93. // RESERVED SLUGS (ticket #25): a project's page is /<slug>, so a slug may not be the first part of a path the app
  94. // or hl:web answers itself (every explicit route of project.hl, hl:web's /__hl/… and the component modules under
  95. // /components/…), nor a name we may want for a page later. Compared in lower case. The gate checks that every
  96. // first segment of a project.hl route is in this list.
  97. static reservedSlugs = ['api' '__hl' 'components' 'shared' 'icons' 'assets' 'favicon.ico' 'robots.txt' 'login' 'login.js' 'logout' 'md-editor.js' 'connect' 'project' 'projects' 'state' 'inbox' 'new-project' 'you' 'my' 'me' 'sign-in' 'sign-out' 'settings' 'tickets' 'search' 'help' 'about' 'admin' '.' '..']
  98. static isReservedSlug = (s) => { return s != null && hlTypeName(s) == 'String' && reservedSlugs.includes(s.toLowerCase()) }
  99. static reservedError = (s) => { return { error = 'the slug ' + s + ' is reserved: /' + s + ' is a page of tickets itself' field = 'slug' } }
  100. // the slug a title proposes: lower case letters and digits, other runs become one '-'
  101. static slugFromTitle = (title) => {
  102. t = clean(title).toLowerCase()
  103. let out = ''
  104. let i = 0
  105. while (i < t.length && out.length < 60) {
  106. let c = t.charCodeAt(i)
  107. if ((c >= 48 && c <= 57) || (c >= 97 && c <= 122)) { out = out + t[i] } else if (out != '' && !out.endsWith('-')) { out = out + '-' }
  108. i = i + 1
  109. }
  110. while (out.endsWith('-')) { out = out.slice(0, out.length - 1) }
  111. return out == '' ? 'project' : out
  112. }
  113. // is this slug free? (`except` = the project that may keep it)
  114. static slugFree = (slug, except) => {
  115. for (q of projectRecords()) {
  116. if (except == null || q.id != except) {
  117. if (q.slug == slug || oldSlugsOf(q).includes(slug)) { return false }
  118. }
  119. }
  120. return true
  121. }
  122. // the slug a new project gets: the proposal, or with -2, -3 … when it is taken or reserved
  123. static uniqueSlug = (base) => {
  124. if (slugFree(base, null) && !isReservedSlug(base)) { return base }
  125. let n = 2
  126. while (!slugFree(base + '-' + n, null) && n < 10000) { n = n + 1 }
  127. return base + '-' + n
  128. }
  129. // ---- opening and changing a project ---------------------------------------------------------------
  130. // answers { error, field } or { project (row), members }. A slug is generated from the title unless one is given.
  131. static createProject = (user, title, slug, description) => {
  132. if (!userOk(user)) { return authorMissing }
  133. t = clean(title)
  134. if (t == '') { return { error = 'the title is required' field = 'title' } }
  135. if (t.length > 100) { return { error = 'the title is too long (at most 100 characters)' field = 'title' } }
  136. let s = clean(slug)
  137. if (s == '') { s = uniqueSlug(slugFromTitle(t)) }
  138. if (!validProjectName(s)) { return { error = "a slug has no spaces or slashes: letters, digits, '.', '_' and '-' only" field = 'slug' } }
  139. if (isReservedSlug(s)) { return reservedError(s) }
  140. if (!slugFree(s, null)) { return { error = 'the slug ' + s + ' is already used by a project' field = 'slug' } }
  141. id = projectsTable.put({ title = t slug = s description = clean(description) oldSlugs = '' created = now() })
  142. if (id == null) { return { error = 'could not store the project: ' + projectsTable.lastError() field = '' } }
  143. addMember(id, user.id, 'admin')
  144. return { project = projectRowOf(projectsTable.fetch(id)) members = memberRows(id) }
  145. }
  146. // an admin changes title / slug / description (null = keep). A new slug keeps the old one working.
  147. static updateProject = (projectId, actor, title, slug, description) => {
  148. p = projectRecord(projectId)
  149. if (p == null) { return { error = 'no such project' } }
  150. if (!userOk(actor)) { return authorMissing }
  151. if (!isAdminOf(p.id, actor)) { return notAdmin }
  152. if (title == null && slug == null && description == null) { return { error = 'nothing to change: give title, slug and/or description' field = '' } }
  153. t = title != null ? clean(title) : p.title
  154. if (t == '') { return { error = 'the title is required' field = 'title' } }
  155. if (t.length > 100) { return { error = 'the title is too long (at most 100 characters)' field = 'title' } }
  156. s = slug != null ? clean(slug) : p.slug
  157. if (s == '') { return { error = 'the slug is required' field = 'slug' } }
  158. if (!validProjectName(s)) { return { error = "a slug has no spaces or slashes: letters, digits, '.', '_' and '-' only" field = 'slug' } }
  159. if (s != p.slug && isReservedSlug(s)) { return reservedError(s) }
  160. if (!slugFree(s, p.id)) { return { error = 'the slug ' + s + ' is already used by another project' field = 'slug' } }
  161. d = description != null ? clean(description) : (p.description != null ? p.description : '')
  162. let olds = oldSlugsOf(p)
  163. if (s != p.slug) {
  164. kept = []
  165. for (o of olds) { if (o != s) { kept.push(o) } }
  166. kept.push(p.slug)
  167. olds = kept
  168. }
  169. projectsTable.update(p.id, merged(p, { title = t slug = s description = d oldSlugs = olds.join(',') }))
  170. return { project = projectRowOf(projectsTable.fetch(p.id)) }
  171. }
  172. // ---- members and roles ------------------------------------------------------------------------
  173. static memberRecord = (projectId, userId) => {
  174. if (projectId == null || userId == null || userId == '') { return null }
  175. all = membersTable.find('project', projectId)
  176. if (countOf(all) == 0) { return null }
  177. for (m of all) { if (m.user == userId) { return m } }
  178. return null
  179. }
  180. // the role of a user in a project ('use' | 'edit' | 'admin'), or null
  181. static roleOf = (projectId, user) => {
  182. if (user == null || user.id == null) { return null }
  183. m = memberRecord(projectId, user.id)
  184. return m == null ? null : m.role
  185. }
  186. static atLeast = (projectId, user, role) => { return rankOf(roleOf(projectId, user)) >= rankOf(role) }
  187. static isMember = (projectId, user) => { return roleOf(projectId, user) != null }
  188. static mayEditIn = (projectId, user) => { return atLeast(projectId, user, 'edit') }
  189. static isAdminOf = (projectId, user) => { return atLeast(projectId, user, 'admin') }
  190. // the member rows of a project: [{ id, user (users @id), name, role, rank, created }], the oldest first
  191. static memberRows = (projectId) => {
  192. out = []
  193. all = membersTable.find('project', projectId)
  194. if (countOf(all) == 0) { return out }
  195. 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 }) }
  196. return oldestFirst(out, 'created')
  197. }
  198. // makes `userId` a member with `role` (no permission check: the caller decided); an existing
  199. // member keeps the HIGHER of the two roles. Answers the member record.
  200. static addMember = (projectId, userId, role) => {
  201. m = memberRecord(projectId, userId)
  202. if (m != null) {
  203. if (rankOf(role) > rankOf(m.role)) { membersTable.update(m.id, merged(m, { role = role })) }
  204. return membersTable.fetch(m.id)
  205. }
  206. id = membersTable.put({ project = projectId user = userId role = role created = now() })
  207. return id == null ? null : membersTable.fetch(id)
  208. }
  209. static adminCount = (projectId) => {
  210. let n = 0
  211. for (m of memberRows(projectId)) { if (m.role == 'admin') { n = n + 1 } }
  212. return n
  213. }
  214. // an admin sets a member's role, or adds a person by the ident id / display name of a user that
  215. // has logged in here. Answers { members } or { error, forbidden? }
  216. static setMemberRole = (projectId, actor, userId, role) => {
  217. p = projectRecord(projectId)
  218. if (p == null) { return { error = 'no such project' } }
  219. if (!isAdminOf(p.id, actor)) { return notAdmin }
  220. if (!roles.includes(role)) { return { error = 'role must be one of: ' + roles.join(', ') field = 'role' } }
  221. if (userRecord(userId) == null) { return { error = 'no such user' field = 'user' } }
  222. m = memberRecord(p.id, userId)
  223. if (m != null && m.role == 'admin' && role != 'admin' && adminCount(p.id) < 2) { return { error = 'a project needs at least one admin' field = 'role' } }
  224. if (m == null) { addMember(p.id, userId, role) } else { membersTable.update(m.id, merged(m, { role = role })) }
  225. return { members = memberRows(p.id) }
  226. }
  227. // an admin removes a member (tickets.hl removeMember then unassigns their tickets). Answers { members } or { error }
  228. static removeMembership = (projectId, actor, userId) => {
  229. p = projectRecord(projectId)
  230. if (p == null) { return { error = 'no such project' } }
  231. if (!isAdminOf(p.id, actor)) { return notAdmin }
  232. m = memberRecord(p.id, userId)
  233. if (m == null) { return { error = 'not a member' field = 'user' } }
  234. if (m.role == 'admin' && adminCount(p.id) < 2) { return { error = 'a project needs at least one admin' field = 'user' } }
  235. membersTable.delete(m.id)
  236. return { members = memberRows(p.id) }
  237. }
  238. // the user a name (exact display name), an ident id (identity) or a users @id stands for, among
  239. // the members of a project when `projectId` is given; { user } | { error }
  240. static userByRef = (ref, projectId) => {
  241. r = clean(ref)
  242. if (r == '') { return { error = 'name a person' } }
  243. found = []
  244. all = userRecords()
  245. if (countOf(all) > 0) {
  246. for (u of all) {
  247. if (projectId == null || memberRecord(projectId, u.id) != null) {
  248. if (u.id == r || u.identity == r || (u.name != '' && u.name == r)) { found.push(u) }
  249. }
  250. }
  251. }
  252. if (found.length == 0) { return { error = 'no such person' + (projectId != null ? ' among the members' : '') + ': ' + r } }
  253. if (found.length > 1) { return { error = 'more than one person is called ' + r } }
  254. return { user = found[0] }
  255. }
  256. // the members a ticket can be assigned to: [{ value (users @id), label (display name) }]
  257. static assignChoices = (projectId) => {
  258. out = []
  259. for (m of memberRows(projectId)) { out.push({ value = m.user label = m.name }) }
  260. return out
  261. }
  262. // projects a user may create tickets in: [{ slug, title }] (creation order)
  263. static editableProjects = (user) => {
  264. out = []
  265. if (user == null) { return out }
  266. for (p of projectRecords()) { if (mayEditIn(p.id, user)) { out.push({ slug = p.slug title = p.title }) } }
  267. return out
  268. }
  269. // the projects a user is a member of, with the role: [{ slug, title, role, href }]
  270. static projectsOfUser = (user) => {
  271. out = []
  272. if (user == null) { return out }
  273. for (p of projectRecords()) {
  274. let r = roleOf(p.id, user)
  275. if (r != null) { out.push({ slug = p.slug title = p.title role = r href = projectHrefOf(p.slug) }) }
  276. }
  277. return out
  278. }

Branches

Latest commits

  • bb64d57cmission 012 (ticket #25 API part): every /api/projects/<x>/… route takes the project's id (12-char record id) as well as its slug, same answers byte for byte; tickets stay under their project, no global /api/tickets/<x>. gate 315/0 (old code: the 13 id checks fail), connect 60/0, live-data copy old vs new 1,484 identical, slug vs id 772 identicalmre
  • 7538b034mission 011 (ticket #25 web part): short page URLs /<slug> and /<slug>/<number>; /projects/<slug>, /projects/<slug>/<n>, /projects/<slug>/tickets/<n> and /tickets/<ref> answer 301 (current slug); slug pages moved behind hl:web's own routes after construction (they answered /__hl/* and /components/*.hl); reserved slugs refused; API paths unchanged. gate 293/0, connect 60/0, live-data copy 187/0, API old vs new: only page links differmre
  • 0369106emission 010 (code order) 4/4: README file map + import order + 'Same output' test, STATUS (entry, lessons, how to verify), LOG, report; tests/realdata-baseline.mjs + realdata-compare*.py (a cleanup answers the same on live data), tests/letcount.pymre
  • a75e0279mission 010 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (456 lets → plain declarations; Hybriel refuses a plain declaration inside a loop on its 2nd pass). gate 249/0, connect 60/0, real-data reads identical, a 50-step write sequence (API + faces) identical to the old codemre
  • 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