← Back

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 hidden name=image field with a live preview. Stored in the new crm_ticket_comments.image column. Comments can now be image-only (the textarea is no longer required). Guard: data:image/ prefix + < 900 000 chars.
  • 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):

StatusDot
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 to closed; 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() (background fetch on 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/move handler. 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 / address on crm_companies (idempotent ALTER + CREATE; added to the generic companies spec so save_list persists them on add, and to /admin/company/save for 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 full ADMIN_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 BlockedPending.

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; a support_backfill_v1 migration 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_id pinned, 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_admin gate: 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”). myteam resolves to t.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 in crm_tickets.team_id.
  • Each deployment has an Alarm responsible — a person or a team (deployments.alert_assignee / alert_team_id, encoded u:username / t:id via parse_responsible). When a monitor goes offline, reconcile_alert opens 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.public column) decides what is shown publicly.
  • New public /status page (header link next to Repositories): glass/Uptime-Kuma style, own systems only, grouped by Type (reusing ASSET_TYPES), with theme toggle + profile/login button. Chrome is the shared site_header() module via status_chrome().
  • Manual callout messages per system (Danger / Warning / Success / Info; “Not active” hides it) — stored in the new status_notes table 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_tasks where assignee = 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=10 per IP) and the full audited-action list are documented in docs/auth-audit-spec.md.

19 — Admin-only sections ✅

  • is_admin_only_path() + ADMIN_ONLY_PATHS / ADMIN_ONLY_GROUPS lock Documentation, Website and Administration to role admin; staff/customers can’t reach them.

20 — Company tags ✅

  • Companies carry tags (crm_tags where entity='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 admin topbar() and the public status_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/blowfish git submodule + .gitmodules, the dead author.html / sharing-links.html partials, the unused JSON home output (old Blowfish search index), and Blowfish doc-link comments; fixed the .Site.Datahugo.Data deprecation. The site is now self-contained (./layouts + ./assets only); 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; zero blowfish strings in output). Commit a2bdd27.

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 batched IN (…) query. Loads in ~35ms with 50 rows regardless of total. Commit 54d3694.

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_id
  • deployments.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

AreaFiles
Admin serverserver.py
Schemascripts/init_db.py
Admin CSSstatic/css/admin.css (pill colours, lightbox, chat bubbles, company boxes, search, contact cards)
Hugo templateslayouts/_default/term.html, layouts/_default/taxonomy.html

New / changed routes

  • POST /account/ticket/close — customer closes their own ticket
  • POST /admin/project/move — projects kanban drag
  • POST /admin/contact/save, POST /admin/contact/delete — global contacts CRUD
  • GET /admin/contacts — now the bespoke contacts_page() (was generic list_form)
  • GET /admin/teams — team management
  • GET /status — public status page; GET /api/me — role for the public header
  • GET /admin/audit (+ CSV export) — audit log
  • GET /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 field
  • lightbox() — 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 additive ALTER TABLE crm_tickets/crm_ticket_comments ADD COLUMN ran before those tables were CREATE’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. Commit 60f48fd.
  • Reset API token threw “Bad Gateway” (syntax error at or near "OR"). The handler used INSERT OR REPLACE — SQLite syntax the db.py shim doesn’t translate (it only does INSERT OR IGNORE). Rewrote both occurrences (api_token reset + server_secret bootstrap) as explicit ON CONFLICT(key) DO UPDATE upserts. Surfaced once the reset button moved to the reachable API settings tab. Commit e5f2528.
  • Company Activity tab threw “Bad Gateway” (column reference "created_at" is ambiguous). The activity query selects created_at while joining crm_activities acrm_deals d (both have the column). SQLite silently picks one; Postgres rejects it. Qualified it as a.created_at. Hit every company’s Activity tab (self company + created ones). Commit eaadbcf.

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-hugo rss.xml version 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).