Skip to content

Combined/dev image links org mgmt#197

Draft
vharmain wants to merge 43 commits into
masterfrom
combined/dev-image-links-org-mgmt
Draft

Combined/dev image links org mgmt#197
vharmain wants to merge 43 commits into
masterfrom
combined/dev-image-links-org-mgmt

Conversation

@vharmain

Copy link
Copy Markdown
Contributor

No description provided.

vharmain and others added 3 commits June 11, 2026 10:43
Sports sites can now carry an :images collection (url, alt-text, copyright,
description) edited through a new "Kuvat" tab. Images live in an external
image bank; LIPAS stores only the CC BY 4.0 metadata.

Access is gated by a new :site/edit-images privilege. The :images-manager
role grants it without :site/save-api, so its holders can only save revisions
whose diff is limited to :images; enforced in backend/core/check-permissions!
by comparing the incoming doc against the persisted one (ignoring event-date
and search-meta noise).

Initial rollout: Loimaa (city-code 430) assigns the role to designated users.
Later cities either add the privilege to an existing broader role or get a
role assignment. :images surfaces in Public API v2 (not v1, which is frozen).

Elasticsearch mapping declares :images as {:enabled false} — existing strict
indices must be recreated or have the field added before deploy, otherwise
saves with :images populated will fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fallback

Per the external image-links requirement spec ("Täydentävä
vaatimusmäärittely"), a bare URL is no longer accepted:

- :alt-text and :copyright (source/owner) are now required with at least
  one non-empty translation; enforced in the malli schema (validates the
  internal save API and surfaces in v2 OpenAPI) and in the edit dialog,
  whose save button stays disabled until URL + alt-text + copyright are
  filled.
- Image URLs must be https:// — http images would be blocked as mixed
  content on the https-served LIPAS anyway. Single source of truth in
  lipas.schema.sports-sites.images/valid-url?, shared by schema and UI.
- Failed image loads (broken links) no longer render the browser's
  broken-image icon: the dialog preview and the hover popper swap in a
  neutral text placeholder via on-error.
- Schema descriptions and docs/site-images.md now spell out the API
  consumer obligations: embed/hotlink images from the source URL, no
  caching or re-hosting, so source-side takedowns (GDPR/copyright)
  propagate immediately. Updating the official API Terms of Use remains a
  legal/process task outside this repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Previously :site/save-api holders (e.g. city-managers) could write :images
through the regular save endpoint even though the UI never offered it to
them — pilot containment relied on the UI alone. check-permissions! now
enforces both directions of the diff against the persisted revision:

- :images changed (or present on a new site) -> :site/edit-images required
- anything else changed -> :site/save-api required, as before

Saves that merely round-trip unchanged images (the UI posts the full
document) need no images privilege, so regular editing of sites that
already have images keeps working. Rolling the feature out to a new
municipality remains a pure role assignment: grant :images-manager (on top
of existing editor rights) with the municipality's city-code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vharmain vharmain added the lipas-dev Deploy this PR to lipas-dev label Jun 11, 2026
Two bugs hid persisted images from the UI even though the API returned
them:

- The ::display-site sub builds its map field-by-field and never included
  :images, so the read-only tab always rendered an empty table — and
  view-images? gating made the tab invisible to users without
  :site/edit-images even when the site had images.
- The images editor caches its table state from :value once at mount
  (r/with-let), so entering edit mode after viewing the empty read-only
  tab kept the stale empty state. Key the component by lipas-id, mode and
  value so state re-derives whenever the source changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vharmain and others added 22 commits June 13, 2026 10:01
Connect organizations to the data they produce and make org state immutable
and auditable, without touching the per-request auth path or the matcher core.

P1 — Immutable org store + ownership link:
- `org` becomes an append-only revision table + `org_current` view + GIN index
  (migration 20260604120000), seeded from legacy orgs with members backfilled
  from account roles. `org.clj` rewritten to revisions; membership lives in the
  org document.
- Site `:owner-org-id`/`:edit-grants` (document) + `acting_org_id` revision
  column (20260604120001); ES indexes `search-meta.owner-org-id`/`editor-org-ids`.
- Owner-enum hard-lock on org-owned sites.

P2 — Org-derived permissions:
- New `:org-editor` role; `select-role` `:org-id` uses set-intersection (a site
  can have several editor orgs); `site-roles-context` emits the editor-org set;
  `wrap-es-query` gains an org branch.
- `derive-org-roles`/`derive-user-org-roles` project membership -> roles at
  login (auth/enrich-org-roles), never persisted. Structural ceiling: only
  catalog templates expand.

P3 — Self-service:
- Catalog edit (lipas-admin), a separate catalog-validated org-admin invite
  endpoint with a custom org-invitation email, member role/template/remove ops,
  cross-org site edit-grants, and a requested->approved take-over workflow
  (org_takeover_request, 20260604120002 + lipas.backend.org-takeover).

No invitation table: invites reuse add-user! + the existing magic link. All
migrations are reversible; verified via REPL and the test harness (roles, org
incl. take-over + 4 permission-bound safety tests, handler, ptv, enrichment,
search-mapping) with no regressions. UI deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the opt-in org-management UI on top of the P1–P3 backend, plus the
three read endpoints the dashboard phases need.

Frontend (lipas.ui.org + site page):
- ::can? capability sub gating every new control by the UX-plan matrix
- tab restructure: Overview / Members / Roles & templates / Our sites /
  PTV / History
- Members: unified invite-member (email + org-role + catalog template
  multi-select), member table on :org-role/:templates with tooltip chips
  and inline role/template edits
- Roles & templates: read-only catalog for org-admins, editable for
  lipas-admin
- Overview: type + ownership-rule (lipas-admin only)
- Our sites: owned/editable filters, per-site "who can edit" drawer,
  cross-org grants, claim-ownership, folded-in bulk-ops
- History timeline; site-page "Editing rights" tab (org-owned sites only)
- orgs list: enriched cards + lipas-admin take-over approval queue
- surface :owner-org-id/:edit-grants via ::display-site

Backend (thin reads):
- GET /orgs/:id/sites?filter=owned|editable (core/org-sites, ES terms)
- GET /sites/:lipas-id/editors (core/site-editors)
- GET /orgs/:id/history (org/get-history, revision diffs)

Tests: org-sites/site-editors/history added to org-test (all green;
roles-test + handler-test unaffected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part of the ongoing feat/org-management work. This commit captures the
current branch state and adds the following on top:

Permission-ceiling hardening (lipas-admin only, enforced + tested):
- Role-template catalog: strict malli schema (every spec must name a real,
  catalog-assignable role) replaces the previous :map-of :keyword :any on
  /actions/update-org-role-templates; org/validate-catalog! guards REPL and
  internal callers too. FE sanitizes half-filled specs before saving.
- /actions/update-org now pins :type and :ownership to current values unless
  the caller has :users/manage, so an org-admin can edit name/contact/PTV but
  cannot widen their own take-over ceiling (marshall re-defaulting made a plain
  key-drop unsafe, hence pin-to-current).
- HTTP-level authz tests: catalog endpoint (admin 200 / org-admin 403 /
  regular 403) and org-admin :type/:ownership edits are no-ops while
  lipas-admin's persist.

Roles & templates editor:
- Surfaces per-template member-assignment counts and warns before saving away
  a template that members still depend on.

Member-invite UX (single email-based flow for all admins):
- New org-scoped POST /actions/check-is-existing-user returning ONLY a boolean
  (GDPR-safe; gated [org-scope-from-body :org/manage], no directory exposure,
  no email harvesting). Drives inline "existing user / new invite" status and
  an adaptive button label.
- invite-org-member! now emails in both branches: magic-link invitation for
  new accounts, "added to organization" notification for existing ones.
- Dropped the lipas-admin-only directory autocomplete + redundant :user-id
  path (the source of the duplicate "Sähköposti" label); result toast is
  accurate via the response's :new-account?.

Also includes in-progress org-management surfaces carried on this branch
(bulk operations, takeover, dashboard, shared role-spec editor) and the
design/spec docs under docs/.

Verified: cljs compiles clean; roles/org/handler test namespaces green;
new endpoint + both email paths exercised via REPL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sports-site form (Omistajuus):
- Owner-org autocomplete on new sites for owner-capable users (pre-filled
  when exactly one ownable org), and on any site for lipas-admins; clearing
  it falls back to the legacy Omistaja select. Non-admins can't change
  ownership on existing sites (take-over flow only).
- Selecting an org locks :owner to the org-type enum (org-type->owner moved
  to lipas.data.owners so FE + backend share it).
- Backend gate: POST /sports-sites authorizes a claimed :owner-org-id via
  owner-org-assignment-authorized? (requires :site/create-edit on that org);
  legacy saves untouched.

Org view:
- History tab is now admin-only (lipas-admin or org-admin): FE :org/view-history
  capability + org-history route tightened to :org/manage.
- Audit-log diffs resolve member user-ids to emails (not raw UUIDs).
- Terminology: Kohteemme -> Kohteet, Oikeudet ja omistus -> Käyttöoikeudet
  (en/se aligned); Kohteet is now the first and default tab, with its data
  loaded on org open.

Tests: owner-org assignment authz, history authz (member -> 403), and
member-email resolution in the audit log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Org instructions (member guidance):
- New "Ohjeet" tab (first), localized fi/se/en shown as language sub-tabs;
  org-admin/lipas-admin edit, members read-only. Stored in the org payload
  under :instructions; added to the malli org schema, marshall/unmarshall, and
  the update-org! whitelist (members/catalog still preserved). Audit diff
  records "Ohjeet päivitetty". New capability :org/edit-instructions.

Who-can-edit panel (Kohteet tab) redesign:
- Replaced the confusing "Muokkausoikeus myönnetty:" stack with one scannable
  list answering "who can edit this site", each row tagged by reason
  (Omistaja / Jaettu+revoke / Aktiviteetti / Suora oikeus), plus a clear
  "share edit access" action. New i18n keys; old grantee/legacy labels retired.

Legacy direct-permission users (Q2, design-spec §6 step 4):
- core/site-editors now populates :legacy-users. db/users-with-permissions-matching
  pre-filters accounts via jsonb containment on account.permissions (both
  city-code/city_code key spellings), then the exact check-privilege confirms;
  admins excluded. GIN index account_permissions_gin (jsonb_path_ops) added by
  migration to keep the selective path fast. Verified in REPL: candidate result
  == exhaustive naive scan, single-digit-ms.

Tests: org-instructions round-trip + org-admin route; site-editors legacy-users
(direct user found, admin excluded, candidate == naive scan).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each access-reason chip (Omistaja / Jaettu / Aktiviteetti / Suora oikeus) now
explains in a tooltip how that access is granted. Wrapped the chip in a MUI
Tooltip (revoke action on the shared chip still works). New i18n keys in fi/en/se.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge the three working docs (design-spec, ux-plan, ux-followup) into a single
docs/wip/org-management.md reflecting what's actually built on this branch:
site-form ownership UI + owner-enum lock + assignment authz, org instructions
(Ohjeet) tab, terminology + tab order, admin-only history with UUID→email
resolution, the who-can-edit redesign, and the legacy-editor lookup (candidate
pre-filter + GIN index). Records endpoints, capabilities, verification (incl.
the legacy-editor REPL benchmark), and open items (default tab, GDPR email
decision pending PM/DPO).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface each site's per-revision edit history (timestamp + editor email)
in the org Kohteet expand drawer, alongside the existing who-can-edit
list. Useful for the org members maintaining the data.

- Lightweight per-revision query (event-date/author-id/status, no
  documents) so it stays cheap on long-lived sites; authors resolved to
  emails in one batched account lookup (mirrors org/get-history).
- New POST /actions/site-edit-history, visible to any authenticated user
  (org members included), like /actions/site-editors.
- Lazy-fetched on row expand, cached per lipas-id; display capped to the
  50 most recent revisions with the true total in the header.

Emails are shown to all org members for now; swapping to username/reveal
is a one-line change once PM/DPO decides (see org-management.md §10.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the future enhancement discussed: org-admins currently have no
reachable claim trigger (both entry points are lipas-admin-only), and
claim scope is fixed to the org's ownership rule. Captures the two
follow-ups reusing the existing org_takeover_request state machine:
surface the org-admin bulk-request trigger, and per-site claims for
wrong-owner corrections / private->municipality transfers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ptv-organizations migration (20250707) called org/create-org, which on
this branch was changed to append to the new org revision schema
(org_id/document). That schema isn't created until the later org-event-log
migration (20260604120000), so a from-scratch migration run (CI, fresh
deploy) failed at "column org_id of relation org does not exist". Existing
DBs were unaffected only because the migration was already marked complete.

Freeze the migration to insert directly into the single-row org schema that
existed when it was written (a plain sql/insert!, matching the old
create-org). The org-event-log migration folds these seeded rows into the
new revision schema. Verified end-to-end: full migration chain now runs
clean on a fresh database, org_current shows all three PTV orgs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Naming: the read/query org endpoints were noun-only and didn't read as
actions. Rename them to verb-first, consistent with the command endpoints
(create-/update-/grant-/approve-/…), updating handler routes and every FE
call site + test:

  current-user-orgs      -> get-current-user-orgs
  all-orgs               -> get-all-orgs
  org-members            -> get-org-members
  org-sites              -> get-org-sites
  org-sites-for-bulk     -> get-org-sites-for-bulk
  org-history            -> get-org-history
  org-takeover-preview   -> preview-org-takeover
  org-takeover-requests  -> list-org-takeover-requests
  site-editors           -> get-site-editors
  site-edit-history      -> get-site-edit-history

jdbc: convert the new account-name resolver in core to next.jdbc
(execute! + as-unqualified-kebab-maps) instead of legacy clojure.java.jdbc.

org-takeover's approve! still uses clojure.java.jdbc for its write txn on
purpose: it shares the re-entrant with-db-transaction of
db/upsert-sports-site! (same pattern as bulk-ops mass-update); switching it
to next.jdbc would break that nested-transaction reuse.

Verified: bb test-ns org-test + bulk-operations-test green (39 tests, 171
assertions); CLJS compiles clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Määritetyt roolit" → "Käyttöoikeudet", mirroring the Käyttöoikeudet tab's
existing translations across all three locales (se "Åtkomsträttigheter",
en "Access rights") for consistent terminology.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Native-speaker feedback: "lunastaa" reads wrong for claiming sites into an
org. Switch all Finnish claim/take-over strings to the "siirrä omistukseen"
(transfer to ownership) phrasing, short forms; request noun -> "siirtopyyntö".
Covers the i18n labels and the two hardcoded toasts in org events.

Swedish was inconsistent (mixed "anspråk" and "ta över"); unify on "överför"
(transfer) to match. English ("claim"/"take-over") left as-is per request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the agreed simplification and its refactor plan. Downstream both fields
already reduce to the same flat {:role :org-id} set matched by one rule, so the
split only ever existed at the assignment layer; a single per-member :roles list
(drawn from #{:admin} ∪ catalog keys) is simpler for admins to reason about.

- §3.2/§3.5/§4.1: unified model — one :roles list; reserved engine :admin role
  (→ org-admin, not catalog-stored, so catalog edits can't strip admins);
  :org/member baseline conferred by membership itself.
- §2.5/§6.2/§6.6/§6.7/§7/§8/§9: bounded delegation, members UI, endpoints,
  migration and audit updated to the unified model.
- §13: concrete refactor checklist by layer + self-contained reshape migration
  (no org/* calls, per §8's from-scratch lesson) + sequencing.
- Also refreshed §7/§6.8 endpoint names to the verb-prefixed forms from the
  earlier naming review (get-/preview-/list-), which the doc still listed as nouns.

No code change yet — design doc only; still pre-production so safe to do.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plan described the invite form's collapse but not the new-member-specific
decisions the unification forces. Make them explicit in §6.6 + §13:

- roles select defaults to empty = plain member (the old "member" default goes
  away; baseline is implicit), and :roles [] is a valid submission
- :admin is grantable at invite time (first-admin creation in one step)
- the existence probe + account creation + magic-link email path is unchanged;
  only the role payload collapses to :roles
- §13.8: invite test cases for empty roles and admin

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Granular, resumable implementation plan (file paths, current→new transforms,
sequencing, REPL/test verification) for the §13 refactor. Self-contained
companion to org-management.md so the work can be picked up from a clean context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the org-member authority split (an admin/member :org-role plus a
:templates data-grant list) into a single :roles list per member, drawn from
#{"admin"} ∪ the org's role-template catalog keys.

- org.clj: "admin" is a reserved engine role (reserved-roles, not in the
  deletable catalog) projecting org-admin; org-user is the membership baseline
  projected unconditionally (membership ⟺ :org/member). member->roles expands the
  single list via reserved + catalog, dropping anything else (the ceiling).
  validate-assignment! checks :roles ⊆ #{admin}∪catalog (ex-type
  :roles-outside-catalog); set-member-org-role!/set-member-templates! merged into
  set-member-roles!; get-org-users and the history diff switch to :roles.
- schema/org.cljc: members entry -> {:user-id … :roles [..]}.
- handler.clj: invite body takes :roles; merged endpoint
  /actions/set-org-member-roles; map :roles-outside-catalog/:invalid-catalog -> 400.
- migration 20260608120000-org-roles-unify: self-contained jsonb reshape (no
  org/* calls, per the ptv-organizations from-scratch lesson), idempotent —
  members already in :roles shape are skipped so a re-run never wipes them to [].
- FE org views/events: one "Roolit" multi-select (Ylläpitäjä + catalog) replaces
  the org-role dropdown + templates select; ::set-member-roles. i18n
  :roles/:role-admin/:grants-admin in fi/se/en.
- tests: org-test fixtures/endpoints -> :roles, plus new cases for baseline,
  reserved admin, ceiling, catalog-cannot-strip-admin, the merged endpoint, and
  invite :roles []/["admin"].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…in UI

Org-scoped roles (org-admin/org-user/org-editor) now come ONLY from org
membership, projected at login. Direct account roles remain the default plane
for everything else (admin, type/city/site/ptv/floorball/activities/itrs/analysis
managers) — those have no org concept and stay account-level. An org's existence
is itself the opt-in to org management; no separate flag.

- roles.cljc: org-admin/org-user -> :assignable false (org-editor already was),
  so they no longer appear in the admin role editor. :assignable has no backend
  consumers; this only gates the FE dropdown.
- migration 20260608130000-org-roles-account-cleanup: strips org-admin/org-user/
  org-editor from account.permissions.roles (now redundant with membership).
  Self-contained (no org/* calls). Defensive: an org role is dropped only when
  current membership covers all its org-ids; any uncovered one is kept + logged,
  so access can never be silently revoked. Idempotent; down rebuilds from
  membership. Safe because org-event-log backfilled membership for every account
  org role (verified: all 48 tuples reproduced, no missing/empty org-ids,
  flattened effective access identical across down/up for all 36 affected users).
- Remove dead alpha org admin UI: org-dialog + add-user-to-org-dialog and their
  orphan subs; org-select + the :org-id context case in the shared role editor;
  the now-unused react require. Kept admin ::orgs/::org/::get-orgs — they still
  feed org-name display in user/subs context-value-name.
- Plane labels: admin permissions card -> "Suorat käyttöoikeudet" (+ pointer to
  Organisaatiot); org members tab -> :members-plane-note (fi/se/en).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Centralize "who may change a site's :owner-org-id and :edit-grants" in the core
layer as pure predicates, authorized against the STORED revision — never the
submitted body. Fixes a privilege-escalation hole (#18) and a revoke asymmetry
(#19) found in branch review.

Root cause: the generic save path authorized edits using site-roles-context of
the request body (whose :org-id = owner-org-id ∪ edit-grants), so an org-editor
could grant themselves :site/save-api on any site by injecting their org into
:edit-grants. And revoke-site-edit lacked the ownership check grant-site-edit had.

- core.clj: new predicates lipas-admin?, owns-site-org?,
  ownership-change-authorized? (new-site claim by an org you can create-edit on;
  existing-site change is lipas-admin only), edit-grant-change-authorized?
  (lipas-admin or owning-org admin).
- upsert-sports-site!: authorize content edits against the stored site; reject
  unauthorized changes to :owner-org-id/:edit-grants. Permission-before-existence
  ordering preserved (a posted-but-missing lipas-id still 403s before 404).
- grant-site-edit!/revoke-site-edit!: both gate via edit-grant-change-authorized!,
  so the two endpoints share one rule; removed the bespoke handler-side check and
  the redundant owner-org-assignment-authorized? middleware gate (it couldn't see
  stored state and falsely rejected legacy city/type editors of org-owned sites).
- tests: exhaustive predicate unit tests (business-logic-test) + handler
  regression tests for the escalation and grant/revoke authz (org-test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the branch review; fixes the remaining medium/low items.

Backend:
- update-org: pin :ptv-data to current for non-admins (org-admins could otherwise
  overwrite PTV org-id/creds via the details endpoint, bypassing the
  :users/manage-only PTV config route). [#20]
- org-takeover approve!/deny!: require a 'requested' status and flip it with a
  guarded UPDATE (WHERE status='requested') so a decided request can't be
  re-approved and concurrent approvals can't double-claim. New
  :invalid-takeover-state → 409 mapping. [#23]
- invite-org-member!: email send is now best-effort (try/catch, logged) and
  returns :email-sent? — a delivery failure no longer fails an already-committed
  membership change. [#24]
- validate-catalog!: reject a context-scoped role spec missing its required
  context key (e.g. ptv-manager without :city-code), which would project an
  org-wide grant; present-but-empty is fine (matches nothing). [#29]
- org-event-log migration: require lipas.backend.db.utils for jsonb (de)serialize,
  matching its sibling migrations (from-scratch safety). [#22]

Frontend:
- editing-rights-panel: also dispatch ::get-site-edit-history so the edit-history
  section on the sports-site tab is populated (was always empty). [#21]
- org events ::get-user-orgs/::get-org-users: :on-failure ::todo → ::failure
  (user-visible error); removed the dead ::todo/::TODO handlers. [#25]
- roles editor: :clearOnBlue → :clearOnBlur typo. [#26]
- remove dead our-sites subs/events orphaned by the bulk-ops rewrite
  (::our-sites, ::our-sites-filter, ::org-editable-sites, ::set-our-sites-filter,
  the :editable fetch). [#28]

Tests: catalog missing/empty-context cases; takeover re-approve/deny rejection.
108 backend tests, 400 assertions green; cljs compiles clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the consolidated design doc in line with what's now on the branch:

- Role unification is implemented (banner + §13 reframed from "plan" to a "DONE"
  record); org-scoped roles are membership-only (:assignable false, §3.5).
- New §4.5 documents the ownership/edit-grant business rule (core predicates
  authorized against the stored site) — the post-review security hardening.
- §6.10 rewritten: save authz is in core; the "deferred caveat" is resolved.
  §10 item 8 marked resolved.
- §8 lists the org-roles-unify and org-roles-account-cleanup migrations; account
  org-roles are now stripped (not a pending follow-up).
- §6.7 documents catalog required-context validation; §6.2 notes the relabelled
  "Suorat käyttöoikeudet" plane and removed alpha add-user-to-org dialog.
- §7 endpoints: save row (all authz in core), grant/revoke (owning-org rule),
  takeover approve/deny state guard (409). §11.4 lists the added tests.
- §10 gains the org-site result-cap limitation (tracked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each org card in the Organisaatiot list now shows how many sites the org
owns, via a single ES terms aggregation over search-meta.owner-org-id
(core/org-owned-site-counts) merged into the get-current-user-orgs
response as :site-count. The agg is exact (single-shard) and cheap
(~1ms, independent of site count), so it folds into the list load with
no new endpoint.

Tests: org-owned-site-counts-test (agg correctness, grants excluded,
matches org-sites :total) and current-user-orgs-site-count-test
(endpoint annotation, 0 default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vharmain and others added 15 commits June 13, 2026 10:01
The org-added email (existing-account branch of invite-org-member!) now
carries a one-click magic login link instead of a plain login-page URL —
the invitee lands already authenticated, with a fresh token already
carrying the new org role. Mirrors send-permissions-updated-email!, which
already mints a magic link for the analogous "permissions changed" event.

The link domain is env-specific by construction (frontend login-url from
window origin, backend-validated against the LIPAS domain whitelist), so
no domain handling is needed here. Token reuses the standard 7-day
magic-link lifetime; tighten later across the board if desired.

Test: org-admin-invite-existing-account-magic-link-test asserts the
token-bearing link in both plain and HTML bodies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catalog grant text and the role form now describe roles and their
privileges from i18n data instead of hardcoded per-role cases:

- role-grant-text looks up a role's :role-descriptions entry, falling back
  to its translated name, so no catalog role ever renders as "Tuntematon
  oikeus" (previously only org-editor/ptv/activities were handled; city-,
  type-, site-manager etc. fell through).
- role-spec-editor now lists the selected role's privileges inline
  ("Sisältää käyttöoikeudet:"), driven by a new :privilege-descriptions
  i18n map covering all privileges — so a LIPAS admin no longer has to
  remember what each role grants. Shared primitive, so it appears in both
  the org catalog editor and the admin user-management form.
- :site/save-api description reworded to "Osittaisten muutosten
  tallentaminen (esim. aktiviteetit, salibandy, ITRS)" — it is the
  aspect-editor save grant, not a duplicate of create-edit.
- Renamed the "Reittien lisäys" preset to "Tyyppi" (key :routes -> :type);
  it narrows on facility types. No org used the old key.
- Removed the now-unused :grants-org-editor/-ptv/-activity/-unknown keys.

Privilege keys are namespaced (:site/create-edit); tongue drops a
namespaced leaf's namespace, so the i18n map munges "/" -> "." and the
lookup mirrors it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sanitize-catalog now collapses exact-duplicate role-specs (distinct)
alongside dropping half-filled rows, so a template can never carry the
same grant twice (it would render twice and grant nothing extra) — the
gunk seen on the Laukaa "Muokkaaja" template.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The save-endpoint gate (check-permissions!) now accepts :site/create-edit
OR :site/save-api, so a general editor needs no separate save-api grant.
Removed :site/save-api from the `basic` privilege set; it remains only on
the aspect-specific editor roles (activities/floorball/itrs) that may
persist a partial edit WITHOUT being general editors.

Behavior is unchanged: the OR admits exactly the same users as before
(verified — city-manager still saves via create-edit, activities-manager
still via save-api, scoping and denial intact). No migration needed:
privileges are derived from role keys at check-time, never persisted
(confirmed across all 2346 accounts incl. legacy permissions — zero store
any expanded privilege).

Tests green: roles-test, business-logic-test, org-test, handler-test,
api v1/v2 handler-test, bulk-operations-test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- organizations.md: replace the outdated, too-technical pre-branch doc with
  a high-level overview of org management as it works now (two permission
  planes, role catalog as ceiling, self-describing roles, ownership +
  shared editing, claims, members, history, PTV).
- org-management.md: drop the plan-vs-implemented / DONE framing and the
  §13 change-record; write the spec as a present-state description.
  Correct to current code: reserved :admin role lives outside the catalog
  (membership view-baseline + reserved admin are the only non-catalog
  authorities); lipas.roles/basic no longer carries :site/save-api (save
  gate accepts create-edit OR save-api); add self-describing role/privilege
  descriptions and catalog dedup/sanitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes all 32 findings from the multi-agent code review (2026-06-09) plus
one found in live testing (F33). Per-finding details, test evidence, and
remaining follow-ups: docs/wip/pr193-review-findings.md.

Security / authorization:
- Save path checks :site/create-edit against stored AND submitted docs
  (scope escape closed); server carries :owner-org-id/:edit-grants
  forward when absent; :acting-org-id no longer forgeable via save
- Bulk contact updates authorize against DB state and route through
  core/upsert-sports-site! (single enforcement point, no ES clobber)
- Edit-history author emails gated behind :users/manage
- Org-admin field ceiling moved into the org business layer

Functional:
- Inviting an existing member returns 409 instead of wiping their roles
- Org members with catalog-projected roles can create sites in the UI
- Who-can-edit drawer evaluates catalogs through the roles engine
- Catalog-granted PTV managers receive audit notifications
- Owner org name denormalized into ES search-meta and shown to all
  viewers; "other"-type orgs can own sites; fresh event-date on
  grant/takeover revisions; members tab works on fresh orgs
- Org members see the org sites list read-only; bulk actions gated by
  :site/create-edit; list-fetch errors rendered instead of swallowed

Efficiency / cleanup:
- Takeover approve! batch-loads revisions; owned-site count uses a
  count-only ES query; zero-org users skip the counts aggregation;
  deduped member upsert, uuid coercers, account-name resolver, org
  emails, admin list subs; deleted legacy test-only member path and
  dead org UI events; backend normalizes catalog role-specs

Tests: ~26 new regression tests, each verified to fail on the old
behavior. Full suite: 523 tests, 11835 assertions, 0 failures.

DEPLOY NOTE: requires a full Elasticsearch reindex (strict mapping
gained search-meta.owner-org-name; saves fail without it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ownership-rule queries in lipas.backend.org-takeover build :->> / :->
HoneySQL forms, but the pg-ops registration that makes those render as
infix operators was only loaded as a side effect of lipas.wfs.core. In
processes that never load wfs (the deployed server), HoneySQL fell back
to function-call rendering — ->>(document, 'name') — which PostgreSQL
parses as an operator on a record: "operator does not exist: ->> record".
Dev REPL and the test suite load broadly, masking the load-order
dependency; reproduced via the uberjar with only the takeover ns loaded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- F34: site-roles-context derives :activity from the site's type
  (by-type-code) unioned with document UTP keys, so activities-managers
  can bootstrap UTP data on fresh typed sites and their orgs appear in
  the who-can-edit drawer (reviewer case: Bikeland on cycling routes)
- F35: org type "sports-federation" renamed to "association"
  (fi "Yhdistys"); idempotent migration rewrites existing org revisions
- F36: org Kohteet list refetches on every page/tab entry instead of
  serving cached app-db state (new sites appear without browser refresh;
  ~1s ES indexing lag remains inherent)
- F37: "Organisaatiot" navbar crumb is always a link back to the org
  list (navbar sub-page header now links for parameterized routes)
- F38: GDPR - site edit history shows timestamp + coarse role label
  (Yllapito/Kunta/Organisaatio/Muu, derived at read time) to non-admins;
  person identifiers only for :users/manage

Gate: full suite 526 tests / 11828 assertions / 0 failures, cljs
compile clean, browser smoke 5/5 PASS. Tracker updated
(docs/wip/pr193-review-findings.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
site-editors gains :legacy-activity-users: individuals whose direct
roles grant :activity/edit but not :site/create-edit (previously they
could edit a site's UTP data — including typed-but-empty sites since
F34 — while staying invisible in the transparency view). The drawer
tags them with the same Aktiviteetti label as activity-editor orgs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PM request (Uuvi case): an association's estate is enumerated, not
rule-shaped — the rule narrows the candidate pool and a checkbox picker
draws the exact boundary. Spec: docs/organizations.md "Claiming a
curated subset".

- Requests snapshot the claimed lipas-ids (curated subset validated
  against the rule's matches → 400 :invalid-selection otherwise; full
  match list when no subset given) and approve! applies EXACTLY the
  stored set — closing the drift where approval re-ran the rule and
  silently claimed sites that appeared after the request. No migration:
  the existing org_takeover_request.lipas_ids column is repurposed.
- Already-owned skip, idempotency, owner-enum lock unchanged; sites
  deleted between request and approval are skipped and reported.
- Preview dialog: per-site checkboxes (default all checked), header
  select-all/none with indeterminate state, name filter, "N / M
  valittu" summary, confirm disabled on empty selection; the approval
  queue shows the stored snapshot read-only. Stepper claim button
  disabled while the dialog is open (click-through hardening).
- 6 new integration tests incl. empirically-proven drift closure.

Full suite: 532 tests / 11869 assertions / 0 failures. Browser smoke
5/5 PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The org Muutoshistoria resolved revision authors with emails
unconditionally — an org admin saw LIPAS staff members' personal
emails. get-history now branches like site edit history (F38):
:users/manage callers keep :author-name (email); org admins get
:author-role, the coarse current-role label rendered with the same
i18n keys. Member references inside change summaries keep emails in
both modes — they are the org's own members, already visible to every
member on the Jäsenet tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The deftranslations macro read EDN files invisibly to shadow-cljs, so
editing a translation file did not invalidate the cached compilation
output of lipas.i18n.fi/se/en and release builds shipped stale
translations. The ^:dev/always hint sat on the macro namespace, which
shadow's cache check never consults for the consuming namespaces.

Read EDN through shadow.resource/slurp-resource when expanding for
CLJS, registering each file as a compilation input of the consuming
namespace so both release and watch builds recompile exactly when an
EDN file changes. The JVM path keeps plain slurp and resolves nothing
from shadow, so the backend never loads the CLJS compiler.

Also drop the now-redundant ^:dev/always and remove five ice-hall
top-level keys whose EDN files were deleted long ago (resurrected by
a merge), silencing the missing-file warnings on every build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release (Luovu omistajuudesta): an org admin can give up ownership of
selected owned sites — authority-shedding, so self-service with no
approval queue. New ownership-change-authorized? arm (owner-org admin
may clear, never gain), preview-release/release! mirroring approve!
(one tx, one ES bulk, acting_org_id, skip+report non-owned), endpoints
preview-org-release/release-org-sites (:org/manage), and a selection-bar
action + impact dialog in Kohteet. Release clears :edit-grants with the
owner and offers an optional :owner relabel (the org-type lock vanishes),
making release → re-claim the v1 transfer path.

Contested claims: take-over previews (live-rule and stored-request) now
carry current-owner-org-id/-name per row; the claim dialog flags such
rows with a warning chip naming the current owner plus a bold summary
bullet, so a claim that transfers sites away from another org is visible
to the requester and approver. Decision: visibility suffices, no separate
consent flow (spec §10.8).

Both close-dialog events now keep the preview through MUI's exit fade
(clearing it mid-fade flipped the still-mounted content to the loading
state, reading as a phantom reopen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fetch-grid runs a geo_shape filter with no sort, so Elasticsearch
returns the matching grid cells in arbitrary (segment-layout-dependent)
order. The test asserted on (first result), which flaked when the
order flipped between which of the two in-radius cells came first
(passed locally / on most CI runs, failed on others with the adjacent
250mN667150E38600 cell first).

Assert the full set of returned grd_ids instead - deterministic,
order-independent, and stronger (verifies both seeded cells are
returned within the radius).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fresh throwaway integration branch off the rebased feat/org-management
(now on latest master: gis rewrite, s3 removal, test-infra speedups,
diversity flake fix) with feat/site-image-links woven in for lipas-dev.

Semantic merge decisions (re-applied from the prior combined merge,
since they live only in the combined branch, not either feature):
- core.clj: org-management's upsert-sports-site! flow is the skeleton;
  image-links rules woven in: 4-arity check-permissions! (extra
  :site/edit-images OR branch when images-only?) + separate
  check-image-permissions! (any :images change needs :site/edit-images)
  + images-only-diff?. `stored` fetched RAW via db/get-sports-site (not
  core/get-sports-site) so the images-only diff isn't poisoned by
  enrich-activities adding :geometries.
- map/views.cljs: editing-rights tab = :value 7, images tab = :value 8.
- subs.cljs: surface both org ownership keys and :images on the site page.
- test_utils/fix-generated-site strips :images (like :owner-org-id /
  :edit-grants) so generated images don't make non-images-role saves
  flaky by seed.

Throwaway: never merge to master; review happens on the two feature PRs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vharmain vharmain force-pushed the combined/dev-image-links-org-mgmt branch from d3891cb to 210c405 Compare June 13, 2026 08:16
The canonical lipas-id schema was [:int {:min 0}] (allowed 0), while the
jobs payload schema required pos-int?. lipas-ids come from a Postgres
sequence and are always >= 1; every save enqueues an analysis job
validated as pos-int?, so a site with lipas-id 0 could never be saved.
The looser front gate let mg/generate produce :lipas-id 0, which passed
the sports-site schema but failed the job schema at enqueue time -> 500
(seed-dependent flake in HTTP-save tests, e.g. the images-manager test).

- Tighten the canonical schema to [:int {:min 1}].
- Repoint the ad-hoc [:lipas-id :int] / [:vector :int] / [:set [:int
  {:min 0}]] usages at the canonical #'sports-sites-schema/lipas-id
  (jobs payload, bulk-operations, ptv handler/workbench, ptv schema,
  users schema, frontend map route) instead of re-spelling it.
- Update lipas-id-test: 0 is now invalid, matching the contract the jobs
  layer always enforced.

Two deliberate exceptions:
- ptv/ai.clj keeps inline :int: it feeds malli.json-schema/transform for
  Gemini structured output, where a var ref becomes a JSON-schema $ref
  that Gemini's schema subset doesn't support (verified).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 53ee6cd)
deftranslations expands concurrently for lipas.i18n.fi/se/en under
shadow-cljs's parallel release compile. Each expansion did a bare
(requiring-resolve 'shadow.resource/slurp-resource), so on a cold build
(no warm cache) the calls raced on the half-loaded shadow.resource
namespace and threw "Attempting to call unbound fn:
#'shadow.resource/slurp-resource", failing the CI "Build frontend" job.

It only surfaced on this branch (+ combined): master/image-links use the
old plain-slurp deftranslations with no shadow.resource dependency, and
a warm local cache or a compilation order where shadow.resource loads
first (combined's extra namespaces) hides the race. Reproduced reliably
locally with a cold .shadow-cljs cache.

Resolve the var once through a delay so the require runs a single,
serialized time. The delay never derefs on the JVM (the macro takes the
plain-slurp branch there), so the backend never loads shadow/cljs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit fe10402)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lipas-dev Deploy this PR to lipas-dev

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant