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-console console); the class-image crop/cutout/cover pipeline (setObjectClassImage stays 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 :ObjectClass gains an owner_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 same organization_members join changeObjectOwner uses). → New ADR (org-owned classes; ships with the implementation PR).

  • D-B — The owner lives on the canonical Neo4j node. owner_org_id is a property on the :ObjectClass node (Neo4j is canonical for class identity — ADR-0057), not a separate Postgres mapping table. Authz reads owner_org_id from Neo4j, then checks membership in Postgres — the class resolvers already do cross-DB reads (_read_class reads the Neo4j node + the Postgres image). Rejected alternative: a Postgres class_owner table — 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. NmSGC72tAErowner_org_id = 9ba2023b-117f-4346-a809-839a66b8c0d4 (“Pauls” org). One-off Cypher, recorded in a migration file for the record.

  • D-D — Close the setObjectClassImage authz 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-console tree view (hierarchy/properties later) without a rewrite.

  • D-F — No match-before-mint in v1. createObjectClass mints 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 in Plings-API/migrations/2026-06-28-class-owner-org.cypher.
  • No Postgres schema change. The object_images polymorphic object_class_ref column (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
}
  1. myObjectClasses: [ObjectClassAdmin!]! (query, new) — classes whose owner_org_id is one of the caller’s orgs. Resolver: authenticated → read the caller’s org ids from Postgres (organization_members) → one Neo4j query MATCH (c:ObjectClass) WHERE c.owner_org_id IN $orgIds returning node fields + count{(:ObjectInstance)-[:INSTANCE_OF]->(c)} as instanceCount → batch the main images from Postgres (WHERE object_class_ref = ANY(...), coalesce processed_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).

  2. createObjectClass(organizationId: ID!, name: String!, description: String): ObjectClassAdmin! (mutation, new) — authenticated → caller must be a member of organizationId (reject before any write, generic message, no info leak — mirrors createOrganization/changeObjectOwner) → non-empty-name validation (before DB) → mint a non-crypto class_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 new ObjectClassAdmin (instanceCount: 0, imageUrl: null). Image is set in a second step via setObjectClassImage.

  3. 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 when name is provided → SET only the provided fields (partial update; omitted args untouched) → return the updated ObjectClassAdmin. Renames propagate to all thin instances automatically (point-to-class, ADR-0058 — no per-instance copy).

  4. 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 single is_main row).

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.tsx with 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 (→ updateObjectClass on Save), read-only classRef, visibility pill, 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.
  • GraphQL: add the three operations to src/graphql/ (e.g. a new classes.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 call setObjectClassImage(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_id property in Plings-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 organizationId arg makes this explicit on the API). Not load-bearing — confirm during the web plan.