tickets
All repositories: gitoria
14.5 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 = () => {all = projectsTable.find(null, null)ps = []if (countOf(all) == 0) { return ps }for (p of all) { ps.push(p) }return oldestFirst(ps, 'created')}static oldSlugsOf = (p) => {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) => {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 }p = projectByAnySlug(ref)return p != null ? p : projectRecord(ref)}static projectNames = () => {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) => {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}// RESERVED SLUGS (ticket #25): a project's page is /<slug>, so a slug may not be the first part of a path the app// or hl:web answers itself (every explicit route of project.hl, hl:web's /__hl/… and the component modules under// /components/…), nor a name we may want for a page later. Compared in lower case. The gate checks that every// first segment of a project.hl route is in this list.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' '.' '..']static isReservedSlug = (s) => { return s != null && hlTypeName(s) == 'String' && reservedSlugs.includes(s.toLowerCase()) }static reservedError = (s) => { return { error = 'the slug ' + s + ' is reserved: /' + s + ' is a page of tickets itself' field = 'slug' } }// the slug a title proposes: lower case letters and digits, other runs become one '-'static slugFromTitle = (title) => {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}// the slug a new project gets: the proposal, or with -2, -3 … when it is taken or reservedstatic uniqueSlug = (base) => {if (slugFree(base, null) && !isReservedSlug(base)) { 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 }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 (isReservedSlug(s)) { return reservedError(s) }if (!slugFree(s, null)) { return { error = 'the slug ' + s + ' is already used by a project' field = 'slug' } }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) => {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 = '' } }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' } }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 (s != p.slug && isReservedSlug(s)) { return reservedError(s) }if (!slugFree(s, p.id)) { return { error = 'the slug ' + s + ' is already used by another project' field = 'slug' } }d = description != null ? clean(description) : (p.description != null ? p.description : '')let olds = oldSlugsOf(p)if (s != p.slug) {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 }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 }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) => {out = []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) => {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)}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) => {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' } }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) => {p = projectRecord(projectId)if (p == null) { return { error = 'no such project' } }if (!isAdminOf(p.id, actor)) { return notAdmin }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) => {r = clean(ref)if (r == '') { return { error = 'name a person' } }found = []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) => {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) => {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) => {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
- 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