Skip to content

Give Bluehex a surface to own the service catalogue, and to promote into it #78

Description

@davidtaing

TL;DR

  • The problem — Bluehex owns the vocabulary the directory filters on, and nothing lets Bluehex edit it or see what practitioners are writing in the custom-service box.
  • The fix — admin CRUD over service_catalogue (retire with active = false, never delete — the FK is on delete restrict), a report ranking custom labels by distinct practitioners, and one atomic promote action. Unblocked since the services schema landed in practitioner_services: what a practitioner offers, capped at three #90.
  • To decide — how similar counts as "similar" in that report; start at normalised exact match and argue for anything fuzzier. Not a moderation queue — a custom service is not a proposal, so nothing is pending, approved or rejected; states, notifications or a rejection path are the signal this has drifted.
Full context — the reasoning, the constraints and what has already been ruled out. This is the part a coding agent should read.

Bluehex owns the vocabulary the directory filters on, and nothing lets Bluehex edit it. This is that surface.

Binding design: docs/spec/profile-and-credentials.md § "Superseded: the closed set is stable keys, and a practitioner may add their own", and CONTEXT.md § "What a practitioner offers". Decided in #76.

What this is not

It is not a moderation queue, and building it as one would undo the model it serves. A practitioner writing a custom service is not proposing an addition to the catalogue — they are describing their own business on their own profile, and it renders immediately. Nothing is submitted, so nothing is pending, nothing is approved and nothing is ever rejected. CONTEXT.md puts it directly: do not call a custom service a "proposal", because treating it as a request implies a queue that does not exist.

Promotion is Bluehex reading evidence, not Bluehex answering requests. Same outcome for the practitioner, none of the machinery, and no implied promise about response time. If this ticket grows states, notifications or a rejection path, that is the signal it has drifted.

Scope

Catalogue CRUD over service_catalogue — add an entry, correct a label, retire one with active = false, set sort_order. Retire rather than delete: the FK from practitioner_services is on delete restrict precisely so a catalogue row cannot be removed out from under somebody's profile. A retired entry still renders on profiles that hold it and drops out of the picker.

A promotion report. Custom services are the rows in practitioner_services where catalogue_id is null, ranked by how many distinct practitioners wrote something similar — distinct practitioners rather than row count, because the signal that justifies a filter chip is "several people sell this", not "one person listed it three times". This is the screen an admin opens when curious, and it is the whole reason the catalogue can learn what the market sells rather than what Bluehex guessed.

How similar is "similar" is the one open design question here. Exact match is cheap and will under-report; normalising case and collapsing whitespace is nearly as cheap and catches most of it; anything fuzzier is a rabbit hole and should be argued for before it is written. Start at normalised exact match.

A promote action, admin-only and atomic.

The trap: promotion has two modes and only one of them can re-point

Promoting to a new catalogue entry is the easy case: insert the service_catalogue row, then update public.practitioner_services set catalogue_id = <new>, label = null for the matching rows.

Merging a custom label into an existing catalogue entry is the case that breaks. If a practitioner already holds the target catalogue row and a custom label that means the same thing — which the spec explicitly permits and calls "an admin sees it during review, promotion fixes it permanently" — then re-pointing violates unique (practitioner_id, catalogue_id). The merge path has to delete the custom row rather than re-point it, for exactly those practitioners and only those.

So the promote action is: for each matching custom row, re-point it if the practitioner does not already hold the target entry, delete it if they do. Both modes in one transaction — a half-applied promotion leaves the directory with duplicate chips and no record of what went wrong.

Two smaller things that follow:

  • It runs as bluehex_admin, never the service role. See docs/adr/0001-admins-are-a-postgres-role.md. The service role bypasses RLS outright and would make every policy on these tables decorative.
  • updated_at moves on every re-pointed row, via practitioner_services_set_updated_at. That is correct and expected — the row now means something different — but it will look like practitioner activity in any query that reads updated_at as "when did they last edit this", so do not build one that does.

Out of scope

Depends on

service_catalogue and practitioner_services existing, with their grants, policies, the practitioner_services_cap trigger and set_updated_at. There is no issue for that yet — the services schema landed in the spec via #76 and has not been cut as a ticket. This is blocked until it is.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: dbPostgres schema, migrations, RLS, queriesarea: uiReact routes, components, stylingenhancementNew feature or requesthelp wantedExtra attention is neededhitlNeeds a human (decision/design/review)size: MA weekend for someone new to this codebase

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions