Patch notes — service-desk polish, customer portal, CRM kanbans (2026-06-14)
Branch: agent (not yet merged to main, so not deployed — main is the prod
trigger). This session was a rapid UI/feature iteration on the ported CRM/ITSM: it
finished the customer-facing ticket portal, made the conversation thread feel like a
chat app, turned the CRM boards into real drag-drop kanbans, and rebuilt the
Contacts/Companies pages. All testing was local via Docker on localhost:1316.
1 — Ticket comments: image attachments + lightbox ✅
- A comment/reply (staff and customer) can attach an image. New
img_upload(field_id)helper: a file picker that client-side resizes the image to a data URL (canvas, max 1000px, JPEG 0.82) in a hiddenname=imagefield with a live preview. Stored in the newcrm_ticket_comments.imagecolumn. Comments can now be image-only (the textarea is no longerrequired). Guard:data:image/prefix +< 900 000chars. - Thumbnails open in a click-to-zoom lightbox (shared
lightbox()helper): click the image → full size up to 92vw/92vh; click the overlay or press Esc to close. Applies to both the admin ticket page and the customer portal.
Commits cbfd898, 298790c.
2 — Ticket status colours ✅
Distinct dots for the two “waiting” states (were both amber):
| Status | Dot |
|---|---|
| Open | 🔴 red |
| Waiting (internal) | 🔵 blue (--pill-blue) |
| Waiting (customer) | 🟣 purple (--pill-purple) |
| In progress | 🟡 amber |
| Resolved | 🟢 green |
| Closed / Cancelled | ⚪ grey |
Driven by PILL_COLOR + TICKET_PILL. Commit cbfd898.
3 — Chat-style conversation thread ✅
The ticket thread is now a chat: your own messages align right, the other party’s
align left, each with their avatar (data-URL avatars hydrated from users.avatar). Works
on both sides — me = the logged-in admin on /admin/ticket, the customer on
/account/ticket. CSS .tc-thread/.tc-mine/.tc-them. Commit 298790c.
4 — /admin/tickets per-company summary boxes ✅
A row of clickable boxes above the list, one per company with unsolved tickets, each
showing open / pending / unsolved counters. Clicking a box filters the list to that
company (?company=<id>&status=all); the active box is outlined. “Pending” counts both
waiting states (waiting_internal + waiting_customer) plus blocked/Pending. Commits
298790c, 14f350b.
5 — Customer ticket portal completed ✅
- Open + reply to their own tickets at
/account/ticket?id=, with image attachments, but never internal notes (filtered server-side,internal=0). - Close ticket button (
/account/ticket/close) — sets status toclosed; scope enforced by_customer_ticket()(own ticket, or any company ticket for a company admin; another customer’s ticket → 403). - Access model: a regular customer sees only tickets where they are the requester (auto-mapped on email, staff can override); a company admin sees all their company’s tickets.
- Profile card shows the customer’s own company (e.g. Fjord Logistics), not the operating company.
- Your contacts: each contact is an expandable card revealing email (mailto) and phone (tel) so a customer can open and mail their account contacts.
Commits 2372f64, 14f350b, ad5985e, 2efe39a.
Fix — customer ticket detail returned an empty response
session_user() returns the username string; the chat-bubble change indexed it as
user["username"] → TypeError → no response on /account/ticket. Use the string
directly. Verified: own ticket 200 (bubbles + lightbox), another customer’s 403.
Commit ad5985e.
6 — Pipeline & Projects are drag-drop kanbans ✅
- Pipeline moved off the old full-reload submit to the shared
kanban_js()(backgroundfetchon drop, live column counts, no reload). Endpoint/admin/deal/move. - Projects (active view) is now a kanban by status; dragging a card persists via the
new
/admin/project/movehandler. The archived view stays a flat list.
Both reuse the same kanban_js() already powering the project task board (board declares
data-endpoint/-idfield/-statusfield/-csrf; cards .kcard[data-id]; columns
.kcol[data-status]). Commit 671f6c3.
7 — Contacts page rebuilt ✅
/admin/contacts was the generic config-driven CRUD; it’s now a bespoke page: avatar rows,
a client-side search box, and a top Add modal (add_box) with a company picker.
Each row expands to edit/delete. New handlers /admin/contact/save + /admin/contact/delete
(routed through company_post via the /admin/contact/ prefix). Commit 2efe39a.
8 — Companies: fields, search, tidier forms ✅
- New columns
org_no/phone/addressoncrm_companies(idempotentALTER+ CREATE; added to the genericcompaniesspec sosave_listpersists them on add, and to/admin/company/savefor edit). - Companies list gets a search box (name / org.no / website / phone) and shows org.no + phone on each row.
- Add + edit forms use a tidy two-column (
frow/fld) layout with placeholders.
Commit 2efe39a.
9 — Role-filtered sidebar + chrome polish ✅ (earlier in the arc)
page(..., role=…)filters the nav: a customer sees only “My profile”, a reader also “Documentation”; staff/admin see the fullADMIN_NAV— non-staff never render the admin menu (they previously saw it and hit 404s).- Secbitz-style account widget in the sidebar footer (avatar, name/role, theme toggle, sign out); dark-mode toggle in the topbar; removed the duplicate operating-company name next to the logo. Edit-profile behind a modal; avatar stored in DB; task status label Blocked → Pending.
Commits 22500da, 59fc9dd, 95d0ef4, f68e914, d6e436e, 1a6a0c8, b3e4ec8.
10 — Public tag/taxonomy pages drop the legacy rail ✅
term.html and the taxonomy term branch still rendered ed-rail.html (the old
About/Status/Working-On aside) inside a has-rail grid, while the rest of the site moved to
a full-width layout. Switched them to the same plain .wrap + .postgrid as list.html.
Verified in the built image: zero has-rail/ed-rail in /tags/**. Commit e237f5f.
11 — Customer portal: per-company support toggle + company-admin user creation ✅
- New
crm_companies.support_enabled. The customer ticket portal only appears for a company once an operator ticks Ticket support on the company overview. Off by default; asupport_backfill_v1migration enables it for companies that already had tickets so nothing regresses. - A company admin can create users locked to their own company (role forced to
customer,company_idpinned, can’t escalate to staff/admin). Site admins keep the full user form. - Customer-opened tickets are created unassigned (no assignee, no team) so they land in the triage queue rather than silently on one person.
12 — /admin/tickets: staff total, unassigned + my-team filters ✅
is_admingate: admins keep the full per-company board grid; staff get a single total counter instead of one box per company (less noise on a shared queue).- Assignee filter adds Unassigned and Assigned to my team (“in case you hold the
ticket but are off sick — easy to look up”).
myteamresolves tot.team_id IN (teams I belong to). - The service-desk boxes are a 2-up grid (
tk-boards--g2): Assigned to me, Unassigned, and Assigned to my team (only shown if you’re in a team).
13 — Teams + alarm routing ✅
- New tables
crm_teams+crm_team_members; admin manages them at/admin/teams. A ticket can carry a team (picker + chip on the ticket head), stored incrm_tickets.team_id. - Each deployment has an Alarm responsible — a person or a team
(
deployments.alert_assignee/alert_team_id, encodedu:username/t:idviaparse_responsible). When a monitor goes offline,reconcile_alertopens an incident routed to that assignee/team; staff see active alarms in an Alarm tickets box above their pipeline.
14 — Monitoring: maintenance status + public status page ✅
- Maintenance state for a deployment (purple
mon-pill--maintenance): does not trigger an alarm and clears an open alert when set. A matching ticket status maintenance uses a new pink dot (--pill-pink) so you can raise planned-work tickets. - A per-system Status slider (the existing
deployments.publiccolumn) decides what is shown publicly. - New public
/statuspage (header link next to Repositories): glass/Uptime-Kuma style, own systems only, grouped by Type (reusingASSET_TYPES), with theme toggle + profile/login button. Chrome is the sharedsite_header()module viastatus_chrome(). - Manual callout messages per system (Danger / Warning / Success / Info; “Not active”
hides it) — stored in the new
status_notestable and rendered as alert banners. - SSL ≤ 7 days to expiry auto-opens a renewal task ticket (
reconcile_ssl, deduped, auto-resolves when the cert renews).
15 — Project lifecycle ✅
- A lead that enters delivery becomes a project; complete (→ delivered) and reject (→ cancelled) remove it from the pipeline, archive hides it, and restore brings an archived project back. The company Projects tab groups Active / Completed / Rejected / Archived with restore.
- Deployment Type is a predefined dropdown (reusing
ASSET_TYPES) instead of free text.
16 — Account page: “My tasks” + service-desk grids ✅
- A My tasks box lists project tasks assigned to me (
crm_taskswhereassignee = me, not done/cancelled, project not archived) with a one-click done action. 2-column grid; placed above the ticket boxes for admins, below Service desk for staff. Replaced the old free-text personal todo. - Service desk is also a 2-up grid for visual parity.
17 — Users page: server-side filter, counters, pagination ✅
- Counter boxes (All / Staff / Customers / Company admins / Companies + disabled) from
COUNT()aggregates; role / company / status filters + sort; 100/page pagination; Clear filters. Built to survive the load-test dataset.
18 — Audit log ✅
- Logs sign-ins, failed and throttled logins, and password changes
(
auth.login/auth.login_failed/auth.login_throttled/auth.password_change) — passwords are never logged. Customer ticket actions are audited too. - Searchable/sortable audit page (
/admin/audit) with category + actor dropdowns and CSV export. Throttle limits (LOGIN_WINDOW=300/LOGIN_MAX=10per IP) and the full audited-action list are documented indocs/auth-audit-spec.md.
19 — Admin-only sections ✅
is_admin_only_path()+ADMIN_ONLY_PATHS/ADMIN_ONLY_GROUPSlock Documentation, Website and Administration to roleadmin; staff/customers can’t reach them.
20 — Company tags ✅
- Companies carry tags (
crm_tagswhereentity='company'), shown as chips on the list and folded into the search haystack — substring / mid-word search confirmed working.
21 — Header de-duplication ✅
- The marketing header is now a single
site_header(active, trailing)module backing both the admintopbar()and the publicstatus_chrome(); the public header is role-aware via/api/me(no more hardcoded “ADMIN” menu leaking to anonymous visitors).
22 — Blowfish theme fully removed ✅
- Dropped the
themes/blowfishgit submodule +.gitmodules, the deadauthor.html/sharing-links.htmlpartials, the unused JSON home output (old Blowfish search index), and Blowfish doc-link comments; fixed the.Site.Data→hugo.Datadeprecation. The site is now self-contained (./layouts+./assetsonly); RSS / sitemap / robots fall back to Hugo built-ins. Full build is warning-free and smoke-tested (all public pages, RSS, sitemap, robots,/status, and admin serve 200; zeroblowfishstrings in output). Commita2bdd27.
23 — Docs Access page scales to thousands of readers ✅
- The Docs Access page rendered every non-admin user at once (a row, a hidden form, a
folder-checkbox grid, and an N+1 grant query each) — with ~6.9k load-test accounts the
browser hung/crashed. Now: search by username, A→Z / Z→A sort, 50/page
pagination, a fixed-height scroll box (
.tbl-scroll, sticky header), and the page’s grants fetched in a single batchedIN (…)query. Loads in ~35ms with 50 rows regardless of total. Commit54d3694.
Schema changes (idempotent, in scripts/init_db.py)
crm_ticket_comments.image(data-URL attachment)crm_companies.org_no,crm_companies.phone,crm_companies.address,crm_companies.support_enabled- New tables:
crm_teams,crm_team_members,status_notes crm_tickets.team_id,crm_tickets.task_iddeployments.alert_assignee,deployments.alert_team_id- (earlier in the arc)
users.avatar,users.company_admin,crm_tickets.requester,crm_tasks.notes - Guarded backfills:
hero_seed_v1,support_backfill_v1
Files touched
| Area | Files |
|---|---|
| Admin server | server.py |
| Schema | scripts/init_db.py |
| Admin CSS | static/css/admin.css (pill colours, lightbox, chat bubbles, company boxes, search, contact cards) |
| Hugo templates | layouts/_default/term.html, layouts/_default/taxonomy.html |
New / changed routes
POST /account/ticket/close— customer closes their own ticketPOST /admin/project/move— projects kanban dragPOST /admin/contact/save,POST /admin/contact/delete— global contacts CRUDGET /admin/contacts— now the bespokecontacts_page()(was genericlist_form)GET /admin/teams— team managementGET /status— public status page;GET /api/me— role for the public headerGET /admin/audit(+ CSV export) — audit logGET /admin/docs?q=&sort=&page=— paginated/searchable docs-access readers
Reusable helpers added
img_upload(field_id)— client-resized image → data URL in a hidden fieldlightbox()— shared click-to-zoom overlay for thread images
Post-merge fixes (production, same day)
Latent SQLite→Postgres issues that only surfaced on the live deploy with real data — all fixed:
- Brand-new-instance bootstrap crashed (
relation "crm_tickets" does not exist).init_db.py’s additiveALTER TABLE crm_tickets/crm_ticket_comments ADD COLUMNran before those tables wereCREATE’d. SQLite tolerates it; Postgres doesn’t, so the first deploy to a fresh DB crash-looped → Traefik 404. Moved the forward-referencing ALTERs to the end-of-schema block; verified against a truly empty Postgres. Commit60f48fd. - Reset API token threw “Bad Gateway” (
syntax error at or near "OR"). The handler usedINSERT OR REPLACE— SQLite syntax thedb.pyshim doesn’t translate (it only doesINSERT OR IGNORE). Rewrote both occurrences (api_token reset + server_secret bootstrap) as explicitON CONFLICT(key) DO UPDATEupserts. Surfaced once the reset button moved to the reachable API settings tab. Commite5f2528. - Company Activity tab threw “Bad Gateway” (
column reference "created_at" is ambiguous). The activity query selectscreated_atwhile joiningcrm_activities a⋈crm_deals d(both have the column). SQLite silently picks one; Postgres rejects it. Qualified it asa.created_at. Hit every company’s Activity tab (self company + created ones). Commiteaadbcf.
Lesson: these all share one root cause — code written against SQLite’s lax behaviour
meeting strict Postgres, and only tripping on real data. Test init_db against an empty
Postgres (a populated DB masks ordering bugs); never use INSERT OR REPLACE (use explicit
ON CONFLICT … DO UPDATE); and qualify every column in multi-table JOIN/UNION selects.
Notes / follow-ups
- Blowfish is gone (§22) — the site no longer depends on any external theme, so the
old local-
hugorss.xmlversion mismatch no longer applies; the Docker image still pins a known-good Hugo. - The per-stage value sum (
.ksum) on the pipeline board does not live-update on drag (column counts do); it refreshes on reload. Minor, left as-is. - Open offer: “Staff with access” (
crm_company_staff) is still informational only — displayed, never enforced. Wire it to real per-company scoping if/when desired. - A full reference of capabilities and per-role controls lives in
docs/feature-set.md; the operator-facing version is the served My source of truth page (content/docs/source-of-truth.md).