tickets
All repositories: gitoria
13.4 KB
// lib/projects.hl — PROJECTS AND THEIR MEMBERS (ticket #20, 2026-09-26). Statics only, the server realm.// Two hl:mpackdb tables (storage/mpackdb/, UUID keys — creator's convention):// 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).//// 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// ANY logged-in user with a display name opens a project and is its first admin; a project always keeps one admin.// 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.// Removing a member also unassigns their tickets: that is tickets.hl removeMember (it owns the tickets table).import { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { storageDir, countOf, first, merged, clean, localStamp, oldestFirst, projectHrefOf, settingsHrefOf } from './util.hl'import { nameOfUser, userRecord, userRecords } from './users.hl'static projectsTable = new MPackDB(file = storageDir + '/projects.db', primaryKey = '@id', indexes = ['!slug'])static membersTable = new MPackDB(file = storageDir + '/members.db', primaryKey = '@id', indexes = ['project', 'user'])static roles = ['use' 'edit' 'admin']static rankOf = (role) => { return role == 'admin' ? 3 : (role == 'edit' ? 2 : (role == 'use' ? 1 : 0)) }// the refusals a write answers (`forbidden = true` → the API answers 403)static notAdmin = { error = 'only an admin of the project can do that' field = '' forbidden = true }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' }// ---- finding a project ---------------------------------------------------------------------------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) }return oldestFirst(ps, 'created')}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}// the slug of a ticket's project ('' when the project is gone)static projectSlugOf = (t) => {let p = projectRecord(t.project)return p != null ? p.slug : ''}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 = localStamp(p.created) createdMs = p.created }}// ---- the slug ------------------------------------------------------------------------------------// 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}// 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}// ---- opening and changing a project ---------------------------------------------------------------// answers { error, field } or { project (row), members }. 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)) }}// ---- 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 }) }return oldestFirst(out, 'created')}// 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}// 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. 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) }}// an admin removes a member (tickets.hl removeMember then unassigns their tickets). Answers { members } or { error }static removeMembership = (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)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 = userRecords()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}
Branches
- mainmain branch
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