Class Admin (web-primary) — org-owned classes: create, list, edit, image
Class Admin (web-primary) — org-owned classes: create, list, edit, image
Created: Sun 28 Jun 2026 15:54:05 CEST Updated: Sun 28 Jun 2026 15:54:05 CEST - Initial design from the Class Admin continuation session with Paul Document Version: 1.0 - For review Security Classification: Internal Technical Documentation Target Audience: API Engineer, Web Engineer, Product (Paul) Author: Paul Wisén (with Claude)
Problem
Community :ObjectClass nodes already exist and drive instance display (a thin instance points to its
class for name/description/image — ADR-0058), but there is no surface to manage them. The one
existing class (NmSGC72tAEr, “Påse 10x15cm”) was created and renamed via direct DB writes. This is
Slice 3 (D5) of the class display/search/admin design
(Plings-API/docs/specs/2026-06-28-class-display-search-admin-design.md).
Paul’s direction (2026-06-28): web is the primary admin surface — administering classes (and later their dependencies) wants a bigger screen than the phone. The app’s only class role is “skapa klass från objekt” (create-from-object), which is a separate, deferred session (it carries local-first write-behind/reconcile complexity). This spec covers only the web-primary slice + the API it needs.
Scope (decided this session)
- In: a web Class Admin screen to create, list, edit (name/description), and set the image of the caller’s organization’s community classes, plus the API to back it.
- Out (→ Backlog): iOS create-from-object (own session); hard-delete a class; dependency editing
(SUBCLASS_OF / properties / functional relations — the larger
class-management-consoleconsole); the class-image crop/cutout/cover pipeline (setObjectClassImagestays store-only); browsing the global public catalog of other orgs’ classes; class-aware search (D2, the main thread’s next item).
Decisions
-
D-A — Classes are organization-owned. A
:ObjectClassgains anowner_org_id, mirroring the object model (object_instances.owner_organization_id) and ADR-0050 (“the organization owns the structure”). “My classes” = classes owned by an org the caller is a member of; edits are gated on membership of the owning org (the sameorganization_membersjoinchangeObjectOwneruses). → New ADR (org-owned classes; ships with the implementation PR). -
D-B — The owner lives on the canonical Neo4j node.
owner_org_idis a property on the:ObjectClassnode (Neo4j is canonical for class identity — ADR-0057), not a separate Postgres mapping table. Authz readsowner_org_idfrom Neo4j, then checks membership in Postgres — the class resolvers already do cross-DB reads (_read_classreads the Neo4j node + the Postgres image). Rejected alternative: a Postgresclass_ownertable — keeps authz single-DB but splits class data across two DBs and adds a sync concern for no strong gain. -
D-C — Backfill the existing class.
NmSGC72tAEr→owner_org_id = 9ba2023b-117f-4346-a809-839a66b8c0d4(“Pauls” org). One-off Cypher, recorded in a migration file for the record. -
D-D — Close the
setObjectClassImageauthz gap in this slice. Now that ownership exists, upgrade its authenticated-user gate (the# TODO(class-authz)C1) to the owner-org membership gate. This clears the “Class write authorization” ROADMAP Backlog item. It stays store-only (full image pipeline remains Backlog). -
D-E — Web layout: master–detail. A two-column screen — searchable class list (left) + edit panel (right) — with a “Ny klass” action. Chosen over a card grid because it edits fast and grows into the
class-management-consoletree view (hierarchy/properties later) without a rewrite. -
D-F — No match-before-mint in v1.
createObjectClassmints unconditionally; names are human labels and duplicates are allowed (ADR-0015). Dedup (trigram/embeddings) belongs to the classification pipeline (Backlog), not the admin’s create button.
Data model
Neo4j :ObjectClass (existing props: class_ref, name, description, class_kind, visibility,
version, created_at). Add: owner_org_id: String (the owning organization’s UUID, as a string —
Neo4j carries no FK; the value is validated against Postgres organizations at write time).
- Backfill:
MATCH (c:ObjectClass {class_ref:'NmSGC72tAEr'}) SET c.owner_org_id = '9ba2023b-117f-4346-a809-839a66b8c0d4'— idempotent; recorded inPlings-API/migrations/2026-06-28-class-owner-org.cypher. - No Postgres schema change. The
object_imagespolymorphicobject_class_refcolumn (already shipped) continues to hold class images.
API surface (Plings-API, GraphQL)
A dedicated admin type so the list carries instanceCount + visibility without overloading the public
ObjectClass/PublicClass types:
type ObjectClassAdmin {
id: ID! # = class_ref (the canonical identifier)
classRef: String!
name: String!
description: String
imageUrl: String # main class image (cutout ?? cropped ?? original), or null
visibility: String # e.g. "public"
ownerOrgId: ID
instanceCount: Int! # how many INSTANCE_OF edges point here
}
-
myObjectClasses: [ObjectClassAdmin!]!(query, new) — classes whoseowner_org_idis one of the caller’s orgs. Resolver: authenticated → read the caller’s org ids from Postgres (organization_members) → one Neo4j queryMATCH (c:ObjectClass) WHERE c.owner_org_id IN $orgIdsreturning node fields +count{(:ObjectInstance)-[:INSTANCE_OF]->(c)}asinstanceCount→ batch the main images from Postgres (WHERE object_class_ref = ANY(...), coalesceprocessed_url ?? cropped_url ?? public_url). Empty list (never an error) when the caller owns none. No pagination in v1 (class counts are tiny; add keyset pagination when a catalog grows — Backlog note). -
createObjectClass(organizationId: ID!, name: String!, description: String): ObjectClassAdmin!(mutation, new) — authenticated → caller must be a member oforganizationId(reject before any write, generic message, no info leak — mirrorscreateOrganization/changeObjectOwner) → non-empty-name validation (before DB) → mint a non-cryptoclass_ref(ADR-0055: a unique stable id; reuse the same base58 short-id generator the system already uses for shortcodes/community refs) →CREATE (c:ObjectClass {class_ref, name, description, class_kind:'community', visibility:'public', owner_org_id:$organizationId, version:1, created_at: …})→ return the newObjectClassAdmin(instanceCount: 0,imageUrl: null). Image is set in a second step viasetObjectClassImage. -
updateObjectClass(classPointer: String!, name: String, description: String): ObjectClassAdmin!(mutation, new) — authenticated → load the class from Neo4j (unknown → error) → owner-org membership gate → validate non-empty name whennameis provided →SETonly the provided fields (partial update; omitted args untouched) → return the updatedObjectClassAdmin. Renames propagate to all thin instances automatically (point-to-class, ADR-0058 — no per-instance copy). -
setObjectClassImage(classPointer, imageUrl)(mutation, existing — tighten authz) — replace the authenticated-only C1 gate with the owner-org membership gate (load the class, check membership); unknown class and unauthenticated paths unchanged. Behaviour otherwise unchanged (store-only, demote-then-insert singleis_mainrow).
Shared helper: factor the “load class → assert caller is a member of its owner_org_id” check into
one function in object_class_resolvers.py, used by updateObjectClass + setObjectClassImage (and
later create’s target-org check, which is membership-only since the class doesn’t exist yet).
Error contract: mutations return GraphQL errors (raise) for UNAUTHENTICATED / not-a-member /
UNKNOWN_CLASS / empty-name, consistent with the other admin mutations (changeObjectOwner,
updateOrganization). (setObjectClassImage keeps its existing {success,error,imageId} result shape
for backward compatibility — only its authz check changes.)
Web surface (Plings-Web)
- Route: a session-protected admin route (replace the existing hardcoded mock
src/pages/admin/AdminClasses.tsxwith the real implementation; reuse the admin shell + session guard the namespace-tree view uses). - Layout (D-E, master–detail):
- Left: search box + list of
myObjectClasses(thumbnail, name,instanceCount,classRef); “Ny klass” button at the top. - Right: edit panel for the selected class — image (with “byt bild” upload →
setObjectClassImage), editable name and description (→updateObjectClasson Save), read-onlyclassRef,visibilitypill,instanceCount(“N exemplar pekar hit”). - Ny klass: a form/dialog (name + description, org = the caller’s active/only org) →
createObjectClass, then optionally set an image → selects the new class in the detail panel.
- Left: search box + list of
- GraphQL: add the three operations to
src/graphql/(e.g. a newclasses.ts); Apollo cache updates on create/edit so the list reflects changes without a full refetch. - Image upload: reuse the existing object-image upload path to get a
public_url, then callsetObjectClassImage(classPointer, imageUrl)(the mutation is store-only — one representative image). - Not in this slice: rendering instances of a class (so the web thin-instance-name fallback —
own ?? objectClass.name— is not needed here; it stays its own Backlog item). The admin shows an instance count, not instance names.
Cross-repo merge order
API first (Plings-API main → Vercel), then Web (Plings-Web dev → Vercel) — so the deployed UI
never calls a mutation/query that isn’t live yet. Backfill Cypher + the new ADR ship in the API PR.
Outflows (per the documentation working principles)
- ADR (new): “Community classes are organization-owned; owner lives on the canonical Neo4j node” (supersedes nothing; references ADR-0050/0055/0057/0058). Ships in the API implementation PR.
- Flow doc + use-case: “Administrera klasser” is a named user-facing flow → it needs a use-case
(
Plings-Web/docs/use-cases/) + a flow doc (Plings-Web/docs/flows/class-admin.md), drafted from the code and confirmed with Paul, shipped in the web PR (use-case catch, same-PR gate). - ROADMAP: move “Class write authorization” Backlog → Done (closed by D-D); add the new API items (myObjectClasses / createObjectClass / updateObjectClass) and the web slice to Done on merge; keep the deferred items (hard-delete, dependency editing, image pipeline, public-catalog browse, pagination, match-before-mint, iOS create-from-object) in Backlog.
- Schema docs: record the new
:ObjectClass.owner_org_idproperty inPlings-Web/docs/database/SCHEMA-VERIFICATION.md.
Test cases (device / in-app — Paul verifies)
- ◻️ List: the Class Admin screen lists “Påse 10x15cm” (after backfill) with “4 exemplar” and its
classRef; an org I’m not in shows none of its classes. - ◻️ Create: “Ny klass” → name + description → appears in the list immediately, owned by my org,
0 exemplar. - ◻️ Edit name: rename the class → Save → the name updates; all 4 thin instances follow (live inheritance, no frozen copies) after their class-cache refresh.
- ◻️ Edit description: edit + Save → persists; reload shows the new value.
- ◻️ Set image: upload a representative image → it becomes the class cover and the instances that inherit it show it.
- ◻️ Authz: (load-bearing) a user who is not a member of the owning org cannot edit/rename/set
the image (the mutation is rejected) and the class isn’t in their
myObjectClasses. - ◻️ Empty name: saving an empty/whitespace name is rejected before any write.
Open questions
- Active-org selection for create. A user can belong to several orgs; the web must pick which org
owns a new class. v1: if the user has one org, use it; otherwise an org picker in the “Ny klass” form
(the
organizationIdarg makes this explicit on the API). Not load-bearing — confirm during the web plan.