Tjue
Ideas
-
What to build
One-time production cutover. The live DB has accumulated spam-derived
companiesrows and staleemail_threadsdata; ADR-0014 explicitly calls this out as a one-time wipe ("we have not got anything worth keeping in inbox/lead/companies yet"). Future schema changes must preserve.Sequence on the prod box (as
tjue, withTJUE_DBset):- Stop the service.
- Take a fresh sqlite snapshot (defence-in-depth — restoreable if cutover goes sideways).
- Apply:
DELETE FROM emails; DELETE FROM email_threads; DELETE FROM companies;— note:leadsis dropped by the migration in #50, no manual delete needed there. - Deploy the updated binary (Woodpecker pipeline as usual; do NOT bypass — see project memory).
- Service restart; migrations apply on
db.Open(#43–#50 migrations roll up clean against the wiped state). - Verify post-deploy:
/admin/sporis empty (no orphan Threads)./admin/firmais empty.- Submitting the public form populates Spor with one orphan Thread.
- Sending an SMTP test from a known-Kunde domain (will be empty initially since we wiped Firmas, so this is N/A until the team manually creates the first real Kunde).
Documentation step: add a short note to
deploy/(RUNBOOK or similar) capturing the cutover steps and the "future migrations must preserve" rule from ADR-0014.Acceptance criteria
- [ ] Pre-cutover sqlite snapshot is taken and stored alongside the deploy logs.
- [ ]
companies,email_threads,emailsare wiped on prod before migration apply. - [ ] Woodpecker pipeline is used for the deploy (no local-build + scp, no
--no-verify). - [ ] Post-deploy verification checklist (Spor empty, Firma empty, form submission works) is run and documented.
- [ ]
deploy/RUNBOOK.md(or equivalent) gains a section on the one-time wipe, citing ADR-0014, with the explicit note that subsequent schema changes must preserve. - [ ] No regressions in the public landing page,
/admin/login, or Tavla / Oppgaver / Dokument / Leikegrind (those tables are unaffected, but smoke-test).
Type
HITL — manual SQL on production, manual verification, deploy coordination.
Blocked by
- #50 (all code changes must be merged to main before cutover)
-
What to build
Collapse the three Store types in
internal/companies/(*companies.Store,*companies.EmailStore,*companies.LeadStore) into a single unified*companies.Storewith methods grouped by topic across files. ADR-0013 records the rationale.File layout after collapse:
store.go— constructor and Company methods (CreateCompany,FindOrCreateCompany,FindCompanyByID, etc.)store_threads.go— Thread methods (UpsertThread,ThreadByKey,ThreadByID,SetThreadStatus,SetThreadCompanyID,ThreadsByCompany, etc.)store_messages.go— Message methods (InsertInbound, etc.)store_leads.go— Lead methods (CreateLead,FindLeadByID,LeadsByCompany, etc.)
Method renames to disambiguate where collisions exist between the former packages — entity-prefix naming for ambiguous names:
Create,FindByID,Update,List⇒CreateCompany/CreateLead,FindCompanyByID/FindLeadByID,UpdateCompany/UpdateLead,ListCompanies/ListLeads. Methods that are already entity-specific (UpsertThread,InsertInbound, etc.) keep their names.Cleanup:
- Delete
parseSQLiteTimeduplication (same helper in former companies and email packages today). - All handlers take a single
*companies.Store;cmd/tjue/main.goconstructs one Store.
Acceptance criteria
- [ ] One
*companies.Storestruct; no other Store types ininternal/companies/ - [ ] Method-file grouping in place (
store.go,store_threads.go,store_messages.go,store_leads.go) - [ ] All handlers refactored to take one
*companies.Store - [ ]
cmd/tjue/main.goconstructs exactly one*companies.Store - [ ] Duplicated helpers (
parseSQLiteTime) collapsed - [ ]
go build ./...succeeds - [ ]
go test ./...passes - [ ] No user-visible behavior changes
Blocked by
- #37
History
-
Summary
Full implementation of the Spor-replaces-Innboks plan, all 8 slices in one PR. Branch contains 9 squashed commits — one for the plan/docs (ADR-0014 + CONTEXT.md) and one per implementation issue (#43–#50). Each implementation commit was individually code-reviewed by a subagent and amended with the recommended fixes.
Why
Spam on the live site has been creating real `companies` rows via the auto-creating `FindOrCreate` derivation path, polluting the Firma directory. The fix is at the schema layer (only-deliberate Firma creation), not the UI layer.
Commits in this PR (newest first)
- `ff7df9d` Drop leads table; reshape dashboard; retire notify_new_lead (closes #50)
- `5cdde04` Innboks retirement: thread URL becomes /admin/traad/{key} (closes #49)
- `c9cda11` Firma detail: Stadfest button + confirmation badge + Trådar section (closes #48)
- `0bbc043` Form submissions synthesise Threads; drop Firma field (closes #47)
- `8cdea7f` Promote: orphan Thread → Firma with orgnr + confirmed_at (closes #46)
- `9cbddd5` Spam: soft-delete with 30s revert + background purge (closes #45)
- `05fd890` Spor lists orphan Threads with Lukk verb (closes #44)
- `5fa53a3` Match-only inbound derivation: stop auto-creating Firmas (closes #43)
- `1c96081` Plan: Spor replaces Innboks; Firma is deliberate-only (ADR-0014 + CONTEXT.md)
Domain shifts (locked in by ADR-0014)
- Firma rows are created only by deliberate admin action. Inbound pipeline is match-only (`FindByHostname`); never inserts.
- Lead = Firma with `confirmed_at IS NULL`. Confirmed Kunde = `confirmed_at IS NOT NULL`.
- Spor = orphan-Thread tray. Three verbs on the Thread detail page: Promoter (creates/links Firma), Lukk (close), Spam (soft-delete + 30s revert + background purge).
- Innboks retired. Thread detail lives at canonical `/admin/traad/{key}`; compose only at `/admin/firma/{id}/ny`.
- Public form synthesises a Thread instead of writing to `leads`. `leads` table dropped.
Schema changes
- `0014_email_threads_deleted_at.sql` — soft-delete column for Spam.
- `0015_companies_confirmed_at_and_orgnr.sql` — `confirmed_at` (Lead/Kunde state) + `org_number` (Brønnøysund mod-11, partial UNIQUE-when-set).
- `0016_drop_leads.sql` — DROP TABLE leads.
- `0017_users_drop_notify_new_lead.sql` — DROP COLUMN notify_new_lead.
Production cutover (separately tracked)
This PR is code-only. Issue #51 covers the one-time HITL production cutover: stop service → snapshot DB → `DELETE FROM emails; DELETE FROM email_threads; DELETE FROM companies;` → deploy → restart → verify Spor + Firma empty + form submission populates Spor. ADR-0014 documents this as a one-time concession; future schema changes must preserve.
Test plan
- [x] `go test ./...` green across every package after each commit.
- [x] `go build ./cmd/tjue` clean after each commit.
- [x] `go vet ./...` clean.
- [x] No remaining references to `leads` table, `Lead` struct, `LeadAdminHandler`, `LeadsByCompany`, `notify_new_lead`, `/admin/innboks/`, `admin_innboks.html`, `admin.html`, or `lead_row.html`.
- [ ] Manual smoke after deploy: form submission → orphan Thread in Spor; SMTP from unmatched domain → no `companies` row; Promote orphan → Firma with `confirmed_at IS NULL`; Stadfest → badge flips to "Kunde sidan {date}"; Spam → snackbar + Undo within 30s.
Closed issues
Closes #43, #44, #45, #46, #47, #48, #49, #50. Issue #51 (production cutover) remains open and is HITL.
🤖 Generated with Claude Code
-
Closes #37.
Slice #37 of the consolidation per ADR-0013, blocked-by #39 (slice #36) and #40 (legacy column drop). Moves all of `internal/leads/` into `internal/companies/` as part of the flat package, deleting the leads↔companies adapter shells (`leads.CompanyResolver`, `companies.LeadAggregator`, `companies.LeadRow`) and the `leadAggregator` adapter in `cmd/tjue/main.go`.
Two commits, squashed on merge:
- Fold internal/leads into internal/companies, delete adapter shells — the move + shell deletion. Renames to resolve collisions: `leads.Store` → `companies.LeadStore`, `leads.New` → `companies.NewLeadStore`, `leads.AdminHandler` → `companies.LeadAdminHandler`, `leads.PublicHandler` → `companies.PublicLeadHandler`, `leads.ErrNotFound` → `companies.ErrLeadNotFound`. The duplicate `Renderer` / `ShellFunc` declarations and the `rowScanner` interface deleted (shared with the firma_http.go versions).
- Review fix for #37 — `LeadAdminHandler.LeadStore` → `Store` (field name only; type stays `*LeadStore` until slice #38). Mirrors the slice #36 review fix on InnboksHandler / EmailInboundHandler.
Slice #38 (collapse Store types into one `*companies.Store`) follows.
Test plan
- [ ] `go build ./...` passes
- [ ] `go test ./...` passes
- [ ] Public lead-form submission (`POST /api/leads`) still creates a Lead with the FK-resolved Company
- [ ] Admin Spor (`/admin/spor`) lists Leads, mark-handled + notes still work
- [ ] Admin Firma (`/admin/firma`) detail page renders associated Leads via `Store.LeadsByCompany` directly (no aggregator)
-
Closes #36.
Slice #36 of the consolidation per ADR-0013. Moves all of
internal/email/(plus theinternal/companies/deriveandinternal/companies/providerssub-packages) intointernal/companies/as one flat package, deleting the four adapter shells (email.CompanyResolver,email.CompanyDirectory,email.CompanyRef,companies.ThreadAggregator/ThreadRow) and the correspondingcompanyResolver,companyDirectory,threadAggregatortypes incmd/tjue/main.go.This branch carries three commits which forge will squash on merge:
- Fold internal/email into internal/companies, delete adapter shells — the actual move + shell deletion.
- added adr — ADR-0013 + CONTEXT.md updates the slice references (Provider flagged ambiguity, refreshed Company entity description). Pulled in here because slice #36's commit message references ADR-0013, and `drop-legacy-leads-company` (which carried these locally) is being held back as its own integration.
- Review fixes for #36 — collapse the duplicate `parseTimeStr` / `parseSQLiteTime` helper (the rationale for keeping them separate evaporated with the package merge); rename handler field `EmailStore *EmailStore` → `Store *EmailStore` so call sites read `h.Store.UpsertThread(...)` instead of `h.EmailStore.UpsertThread(...)`.
Slices #37 (fold leads) and #38 (collapse Store types) follow on stacked branches and will land separately.
Test plan
- [ ] `go build ./...` passes
- [ ] `go test ./...` passes (verified locally; Woodpecker confirms)
- [ ] Admin Innboks (`/admin/innboks`) loads, list + thread + reply + status surfaces unchanged
- [ ] Admin Firma (`/admin/firma`) detail page renders associated Threads
- [ ] Manual-associate Firma POST (`/admin/innboks/{key}/firma`) still links a Thread to a Company
- [ ] Inbound webhook (`POST /api/email/inbound`) creates Thread + auto-derives Company for org-domain senders, leaves `company_id` null for personal-provider senders
-
Closes #35.
Drops the deprecated free-text `leads.company` column. The Lead's organisation lives on the FK `company_id` since #33; the legacy column was retained as a backfill aid and is no longer read or written.
Pulled out of the original `drop-legacy-leads-company` branch as its own integration so slice #37 (fold leads into companies) has a clean prerequisite to land on. The ADR-0013 / CONTEXT.md changes that originally rode along were already pulled into #39 via cherry-pick.
Test plan
- [ ] `go build ./...` passes
- [ ] `go test ./...` passes
- [ ] Migration applies cleanly on existing prod data (Woodpecker preview before merging)
-
Closes #27. Slice 4 of PRD #23.
Summary
Gives admins a way to fix Threads that came in from personal-email providers (or had derivation failures) and have
company_id IS NULL. The Innboks list flags those Threads with aManglar firmabadge in the row trail, and the Thread detail page surfaces a manual-associate form (datalist of existing Companies + an inline create-new form). Both paths end withemail.SetThreadCompanyID.Changes
internal/email/associate.go— newPOST /admin/innboks/{key}/firmahandler. Accepts eithercompany_id=<n>(existing) orname/hostname(new). Empty input → 400 with Nynorsk message; unknown id → 400; missing thread → 404.internal/email/store.go—ThreadSummarygains aNeedsCompanyflag (true exactly whenCompanyID == 0) so templates can key on a positive predicate.internal/email/http.go—Handlergains aCompanies CompanyDirectoryfield (list + find-by-id + find-or-create); thread detail data carriesNeedsCompanyandCompanyChoices. New chi route registered inRoutes().internal/web/templates/admin_innboks.html— row trail renders<span class="row__firma row__firma--needs">Manglar firma</span>when the thread is unassigned.internal/web/templates/admin_innboks_thread.html— adds afirma-picksection under the header with two stacked forms: existing-Company datalist picker (small inline JS turns a typed name into the company_id) + a create-new form (name required, hostname optional).internal/web/static/css/tjue.css—.row__firma--needs,.thread__sub-flag,.firma-pickstyles in keeping with the existing mono/uppercase trail aesthetic.cmd/tjue/main.go—companyDirectoryadapter (mirrors the existingcompanyResolver) wires*companies.Storeintoemail.Handler.Companies. Translatescompanies.ErrInvalid→email.ErrInvalidCompanyandcompanies.ErrNotFound→email.ErrCompanyNotFound.
Test plan
- [x]
go test ./...clean - [x]
go vet ./...clean - [x]
go build ./cmd/tjueclean - [x] Integration: existing Company associate (
TestAssociate_ExistingCompanyByID) - [x] Integration: inline-create + associate (
TestAssociate_CreatesNewCompanyAndLinks) - [x] Validation: empty form → 400 (
TestAssociate_RejectsEmptyForm) - [x] Validation: stale id → 400 (
TestAssociate_RejectsUnknownCompanyID) - [x] Validation: missing thread → 404 (
TestAssociate_NotFoundForMissingThread) - [x] List view surfaces
NeedsCompany(TestList_FlagsThreadsWithoutCompanyAsNeedingAssignment) - [x] Detail view renders choices when unassigned, hides them when assigned
- [x] Template smoke: list shows badge, detail renders picker form
Decisions
- Affordance: a small mono-uppercase
Manglar firmabadge in the same trail slot the linked-Firma anchor occupies, with a dashed underline to read as "incomplete" without shouting. Mirrors the existing.row__firmarhythm so the inbox doesn't gain a new visual layer just for this case. - Endpoint shape:
POST /admin/innboks/{key}/firma. Slug is Nynorsk (firma) consistent with/admin/firma, and lives under the existing{key}namespace alongside/svar,/status. One endpoint handles both "pick existing" and "create new" so the template doesn't need a router-aware mode flag — the handler branches on whethercompany_idis present. - Typeahead: pure HTML
<datalist>plus a tiny inline JS that resolves the typed name back to the hiddencompany_id. No htmx swap, no fetched JSON: the directory is small (PRD #23 estimates tens to low hundreds of Companies) and the page already renders the full list asCompanyChoices. If the directory grows past what<datalist>handles comfortably, the upgrade path is htmx- a
/admin/firma/searchendpoint, but not v1.
- a
- Validation: server-side only, with Nynorsk messages
(
Namn må fyllast ut.etc.). Returns plain 400 — the Innboks thread page doesn't have an in-form error renderer like/admin/firma'sformData, and adding one for this fallback flow would be over-engineering. Admins re-submit; the typeahead preserves their typing in the URL bar via re-navigation. - Interface boundary:
email.CompanyDirectoryis a small, email-package-local interface;cmd/tjue/main.goadapts*companies.Storeto it (same pattern as the existingcompanyResolverfor inbound). Keeps the email package free of aninternal/companiesimport.
-
Closes #28. Slice 5 of PRD #23.
What changed
- Detail page
/admin/firma/{id}now shows the Company's metadata, a Spor section with associated Leads (sorted bycreated_at DESC), and an Innboks section with associated email Threads (sorted bylast_activity_at DESC). Both have Nynorsk empty-state copy. The edit form is collapsed behind a<details>so the page lands on the aggregation view, not the form. - Index page
/admin/firmais now sorted by most-recent activity descending —MAX(latest Lead created_at, latest Thread last_activity_at, Company created_at)— instead of alphabetical. Brand-new empty Companies fall back to their owncreated_atso they don't disappear to the bottom of the list.
Where aggregation lives
Path (a) per the brief: each store owns its own SQL.
leads.Store.ByCompany(ctx, id) []Lead— sortedcreated_at DESC.email.Store.ThreadsByCompany(ctx, id) []ThreadSummary— sortedlast_activity_at DESC. Reuses the same correlated-subquery shape asListThreadsso the row template stays compatible.companies.Store.ListByActivity(ctx) []CompanyWithActivity— single query, correlated subqueries onleadsandemail_threads, scalarMAX()(which ignores NULL in SQLite). No N+1.
The
companies.HandlerconsumesLeadAggregatorandThreadAggregatorinterfaces so the package stays free ofinternal/leadsandinternal/emailimports —cmd/tjue/main.gowires thin adapters that map domain types to smallLeadRow/ThreadRowview structs.Empty states
- No Leads → "Inga spor for dette firmaet."
- No Threads → "Ingen trådar for dette firmaet."
Both render via the existing
.emptystyle.Tests
internal/leads:TestByCompany,TestByCompany_Empty.internal/email:TestThreadsByCompany,TestThreadsByCompany_Empty.internal/companies:TestListByActivity(Acme/Beta/Gamma/Delta scenario covering all four "has Leads / has Threads / has both / has neither" combinations),TestListByActivity_Empty,TestListByActivity_ExposesLastActivity. Handler tests with fake aggregators verify the handler plumbs Leads/Threads through to the template payload in order, and that the index uses the activity-sorted method.internal/web:TestAdminCompanyRendersLeadsAndThreadsandTestAdminCompanyRendersEmptyStatesexercise the live templates via the real renderer and assert on rendered HTML — slice acceptance ("integration test verifies aggregation correctness").
Design
internal/web/design/preview/admin-firma.html(new) — index with activity timestamps.internal/web/design/preview/admin-firma-detail.html(new) — detail page mock with Spor + Innboks sections.internal/web/design/specs/2026-05-02-firma-detail-aggregation.md— spec covering reglar, datakonvensjon, og preview-mockar.internal/web/static/css/tjue.css— small additions:.firma-head*(serif h1 + mono host meta),.firma-edit(collapsed<details>summary). Otherwise reuses existing.section-head,.list,.row,.empty.
Out of scope (per brief)
- No pagination — v1 renders everything.
- Detail page is read-only aggregation; Lead/Thread edits stay in their canonical surfaces.
- Manual-associate UI for unassigned Threads is slice 4's concern (parallel agent).
Pre-PR checks
go test ./...— cleango vet ./...— cleango build ./cmd/tjue— clean
- Detail page
-
Closes #26. Slice 3 of PRD #23.
Summary
- Adds
email_threads.company_id(nullable FK) via migration0012c_threads_company_id.sqlwith a pure-SQL best-effort backfill that parses each existing thread'scanonical_key, extracts the first sender's hostname, skips personal-provider domains, and INSERT-OR-IGNOREs a Company row anchored at that hostname. - New pure-function packages
internal/companies/providers(curated list +IsPersonalProvider) andinternal/companies/derive(DeriveCompanyHostname(email, providers) -> (host, ok)). No DB, no I/O — every edge case lives in table tests. - Inbound pipeline (
internal/email/inbound.go) now derives the sender's hostname andFindOrCreates the Company on Thread upsert. Personal-provider senders and malformed addresses leavecompany_idnull without erroring. - Innboks renders a Firma link to
/admin/firma/{id}on threads that havecompany_idset — both on the list row trail and in the thread header. - Migration runner now tracks applied migrations by filename (with backwards-compatible upgrade from the old integer-version-only schema). Without this, alpha-suffixed siblings like
0012aand0012cboth parsed to version 12 and the second was silently skipped.
Decisions
- Subdomain:
team.acme.comstaysteam.acme.com. Collapsing to apex needs a public-suffix list — too heavy for v1. Documented inderive.go. - Personal providers: static curated list (international + Norwegian webmail/ISPs). The SQL backfill embeds the same list inline as a
TEMP TABLE; both list locations have aMUST stay in synccomment. - Backfill in pure SQL rather than in Go through the migration runner — the existing runner is SQL-only by design, and the operation cleanly expresses as
WITH first_addr AS (…) SELECT … INSERT OR IGNORE companies … UPDATE email_threads. emailpackage stays free of an import oninternal/companies. ACompanyResolverinterface inemailand a tinycompanyResolveradapter incmd/tjue/main.gokeep the boundary clean.
Test plan
- [x]
go test ./...clean - [x]
go vet ./...clean - [x]
go build ./cmd/tjueclean - [x]
providers: case-insensitive matches, organisation-domain rejection, empty/whitespace, near-miss - [x]
derive: happy path, subdomain, plus-addressing, uppercase host, leading/trailing whitespace, malformed (no @, multiple @, empty, missing local/host), personal-provider rejection, near-miss, Norwegian providers, nil providers set - [x] Inbound integration:
joe@acme.comends up on a Thread linked to a Company;joe@gmail.comends up on a Thread withcompany_idnull - [x] Migration: alpha-suffix migrations both apply; upgrade from integer-only
schema_migrations; thread backfill respects existing Company rows and dedupes on hostname; personal-provider senders stay null - [ ] Manual smoke: live inbound from an org address shows the Firma badge in
/admin/innboksand a link in the thread header
- Adds
-
Closes #25. Slice 2 of PRD #23.
Summary
- Migration
0012b_leads_company_id.sqladds a nullableleads.company_idFK referencingcompanies(id)and backfills it for every existing Lead with a non-empty trimmedcompanytext. The legacy free-text column stays in place for one migration cycle (parallel write per PRD #23). - The public
POST /api/leadshandler now requires Company. Empty submissions return a Nynorsk validation message ("Firma må fyllast ut."). Valid submissions callcompanies.FindOrCreate(name, "")and store the resultingcompany_idalongside the legacy text. Repeated submissions for the same Company reuse the same row. - Admin Lead list and detail (the
lead_rowpartial used for both listings and HTMX row updates) and the dashboard's "Opne spor" block now render the title as a link to/admin/firma/{id}whencompany_idis set; otherwise they fall back to the legacycompanytext. - The migration runner now tracks applied state by filename so alpha-suffixed siblings (
0012a/0012b) don't collide on the parsed integer. Existing prod DBs with integer-onlyschema_migrationsrows are backfilled in place; the legacyversioncolumn stays populated for rollback safety. - Public form templates (Norwegian + English) drop "(valgfritt)" / "(optional)" from the Firma/Company label and add
required. Preview mocks (admin-spor,admin-dashboard,landing) track the change. New design spec2026-05-02-spor-firma-link.mddocuments the rules.
Test plan
- [x]
go test ./... - [x]
go vet ./... - [x]
go build ./cmd/tjue - [x] Migration backfill test: distinct trimmed names produce one Company each, dupes share a row, empty/whitespace-only Lead rows stay unlinked, pre-existing Companies are reused.
- [x] Upgrade test: simulated legacy DB (integer-only
schema_migrationsthrough0012a) cleanly applies0012bon top. - [x] Handler test: empty Company returns the Nynorsk error fragment with
class="error"; valid submission populatesCompanyID; three submissions with the same Company name share the sameCompanyID(idempotency). - [x] Renderer test:
lead_rowemitshref="/admin/firma/{id}"whenCompanyIDis set; falls back to plain text when nil.
- Migration
-
Follow-up to PR #29. Aligns the new Firma surface with the existing Nynorsk admin URL style (
innboks,oppgaver,dokument,leikegrind,tavla,spor) and pins the convention in CLAUDE.md so future agents don't have to reverse-engineer it from the codebase.Changes
/admin/companies→/admin/firma(route mount, redirects, template links/forms)- Shell active-key
selskap→firma(matches the displayed label "Firma") - CLAUDE.md: explicit rule — Nynorsk URLs and UI copy, English code and identifiers
Code-side names stay English:
internal/companiespackage,Companystruct,companiestable,companiesStore/companiesHandlervariables.Test plan
- [x] go test ./...
- [x] go vet ./...
- [x] go build ./cmd/tjue
- [ ] Manual passkey smoke at /admin/firma
-
Closes #24. First slice of PRD #23.
Summary
- New
companiestable (0012a_companies.sql):id,name UNIQUE NOT NULL,hostname UNIQUE NULLABLE, timestamps. Indexed on both keys. - New
internal/companiesdeep-module store:Create,FindOrCreate,FindByID,FindByName,FindByHostname,List,Update. SQL is fully encapsulated; no*sql.DBexposed in the API. - Admin UI at
/admin/companies(list, new, detail/edit) + nav entry "Firma". - Norwegian validation copy: blank name ->
Namn må fyllast ut; UNIQUE conflict (name or hostname) ->Det finst alt eit firma med dette namnet eller vertsnamnet. FindOrCreatematches by hostname first (stable identity anchor), so admin renames don't break later derivations.- Out of scope (per PRD #23 / issue #24):
leads.company_id,email_threads.company_id, derive helpers, personal-provider list.
Test plan
- [x]
go test ./... - [x]
go vet ./... - [x]
go build ./cmd/tjue - [x] Store tests (temp sqlite) cover
FindOrCreateidempotency, name uniqueness, hostname uniqueness, blank-hostname coexistence,Listordering (case-insensitive), and update conflicts. - [x] Renderer test extended with the three new templates so embed/parse stays green.
- [ ] Manual smoke: list -> new -> edit at
/admin/companieswith a passkey login.
- New
-
Summary
- Stinamn input + Lagre stinamn button + autosave status share a single row above the editor (.editor__topline).
- Save button hidden until `slugify(input)` differs from the current slug; mirrors server-side Slugify in JS.
- Reusable `.flash-ok` confirmation animation: brief `--ok` (#4F8A4F) box-shadow outline that survives the redirect via sessionStorage and only fires when the new URL actually has the new slug. Pattern doc: `internal/web/design/specs/2026-04-29-flash-ok.md`.
- CI: skip the test step on preview pushes; tests still run on main and tags.
Test plan
- [x] Hard-refresh editor; type a new slug, click Lagre stinamn → redirect, brief green outline on the input.
- [x] Type a colliding slug → re-render with error, no flash.
- [x] Type a slug that slugifies to the existing one → button stays hidden.
- [x] CI on preview is now ~test-step faster.
🤖 Generated with Claude Code
-
Summary
- Settable URL slug ("stinamn") on documents — at creation and via rename on the editor page.
- New
Store.SetSlug+ErrSlugTaken/ErrInvalidSlug.Store.Createaccepts an explicitrequestedSlug. POST /admin/dokument/{slug}/stinamnrename route; new-doc form + editor form re-render on collision/invalid input with state preserved. Old slug 404s after rename.
Spec: `docs/superpowers/specs/2026-04-28-document-stinamn-design.md` Plan: `docs/superpowers/plans/2026-04-28-document-stinamn.md` Design spec: `internal/web/design/specs/2026-04-28-stinamn-form.md`
Test plan
- [x] `go test ./...` clean
- [x] `go build ./cmd/tjue` clean
- [ ] New doc with empty stinamn → derived slug
- [ ] New doc with custom stinamn → that slug
- [ ] New doc with colliding stinamn → re-render with body + slug preserved + error
- [ ] Editor rename to fresh slug → redirect; old URL 404s
- [ ] Editor rename to colliding slug → re-render with error
- [ ] Body autosave still works after rename
🤖 Generated with Claude Code