Skip to content

Resource identity redesign: workspace/config slugs, run numbers, invite notifications#150

Merged
martsokha merged 4 commits into
mainfrom
feat/invite-notifications
Jul 16, 2026
Merged

Resource identity redesign: workspace/config slugs, run numbers, invite notifications#150
martsokha merged 4 commits into
mainfrom
feat/invite-notifications

Conversation

@martsokha

@martsokha martsokha commented Jul 15, 2026

Copy link
Copy Markdown
Member

Reworks how workspace-owned resources are identified in the API, adopting a GitHub-shaped model — human slugs for named things, per-parent sequential numbers for high-volume append-only runs — and fixes the by-email invite flow along the way. Four commits, reviewable in order.

1. Invite by email → in-app notifications + hosted override seam

The by-email invite flow previously stored an address and delivered nothing. Now:

  • Shared create_invite(): resolves the email to an existing account and, on a hit, creates the invite and an in-app MemberInvited notification in one transaction; an unknown email is a no-op.
  • send_invite returns a uniform 200 for known and unknown emails — no account-existence oracle. Member / pending conflicts still 409.
  • BuiltinModule + CustomRoutes::exclude(): the general OSS→hosted override seam, so the hosted edition can replace an endpoint (e.g. to also send email) without an axum route-collision panic.
  • No email/SMTP/NATS in this crate — delivery stays a deployment concern.

2–3. Workspaces addressed by immutable slug, UUID off the surface

  • Workspaces get a globally-unique, immutable slug (derived from the display name or supplied). Every nested route is /workspaces/{workspaceSlug}/…, resolved by a WorkspaceContext extractor.
  • workspaceId (and the workspace's own UUID) are removed from response bodies — with no endpoint accepting a workspace UUID, exposing it was dead weight. The immutable slug is the stable, usable identity in both URL and body.

4. Config slugs + per-parent run numbers

  • Named config (connections, pipelines, policies, contexts): immutable per-workspace slug (UNIQUE(workspace_id, slug)), required at creation, duplicate → 409 (no silent suffixing). Addressed by {xSlug}; responses slug-only.
  • Runs (pipeline, connection): addressed by per-parent {runNumber}, nested under the parent — /pipelines/{slug}/runs/{runNumber} (+ detections/redactions), /connections/{slug}/runs/{runNumber} — reusing the gap-free run_number already assigned DB-side. Flat /pipeline-runs/{id} routes dropped. Aggregate lists move to /pipelines/runs/ and /connections/runs/.
  • Slug is one generic validated newtype; scope (global vs per-workspace) is a DB constraint, not a type.

OpenAPI hygiene

Declared tag set aligned to the tags handlers actually use (no empty categories; API Tokens fixed).

Verification

  • cargo check / clippy -D warnings / fmt / rustdoc -D warnings / full test suite — all clean.
  • Live boot succeeds (no route-collision panic between /pipelines/runs/ and /pipelines/{slug}/…).
  • OpenAPI: zero {xId} routes, zero UUID leaks in response bodies, runNumber is an integer param.
  • E2E: create-by-slug → run-by-number → aggregate list → 409-on-duplicate-slug; cross-tenant / unknown-slug → 404.

🤖 Generated with Claude Code

…ted overrides

The by-email invite flow previously stored an address but delivered nothing
(the "an email is sent" docs were inaccurate — this server has no email
transport). Make it actually notify existing users, and add the seam a
wrapping binary (the hosted edition) needs to replace the handler with one
that also sends email — including to users who don't yet have an account.

Invite notifications
- Extract the create sequence into `pub async fn create_invite(...) ->
  Result<InviteOutcome>`: member-dedupe, resolve the email to an account, and
  on a hit create the invite + an in-app notification (MemberInvited) in one
  transaction. An email with no account returns InviteOutcome::UnknownEmail
  and creates nothing.
- send_invite now returns a uniform 200 InviteSent for both the created and
  unknown-email cases, so the response can't be used to probe whether an
  account exists. Member/pending-invite conflicts still 409 (that membership
  is already visible to callers with InviteMembers).
- create_invite / InviteOutcome / CreatedInvite are re-exported so a wrapping
  binary can reuse the exact logic and add email on top.
- Fix the docstrings that claimed an email was sent.

Module-exclusion seam
- Add BuiltinModule + CustomRoutes::exclude(). private_routes/public_routes
  now mount each built-in module unless excluded, so a wrapper can drop a
  built-in endpoint and mount its own via add_private_routes without an axum
  route-collision panic.

Codes, token generation, accept/decline, cancel are unchanged. No email,
SMTP, or NATS in this crate — delivery stays a deployment concern.

Tests: create_invite mapping + uniform-response guarantees (unit); exclude()
frees a built-in path for a replacement, verified by booting a server that
excludes Invites and mounts a custom invite route (integration, ignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@martsokha martsokha added server API handlers, middleware, auth feat request for or implementation of a new feature labels Jul 15, 2026
martsokha and others added 2 commits July 16, 2026 15:36
…URLs

Give workspaces a human-readable slug and use it as the URL identity, so
nested routes read `/workspaces/acme-corp/connections/{id}` instead of
`/workspaces/{uuid}/connections/{id}`. The slug is only the public URL key;
the workspace UUID stays the internal key for authorization and every
workspace-scoped query, so tenant isolation is unchanged.

This matches GitHub's model — the scoped parent stays in the path even for
globally-unique child ids (e.g. check runs), and the parent is a name, not an
opaque id.

WorkspaceSlug newtype (nvisy-postgres::types)
- Validated: lowercase `[a-z0-9]` with single internal dashes, 3-32 chars.
  Invariant enforced on construction; the DB CHECK mirrors it exactly.
- `derive()` slugifies a display name (via the `slug` crate); infallible
  conversions via derive_more; validating FromStr/TryFrom stay explicit.
- `with_numeric_suffix` for collision disambiguation.

Schema / query
- Add `slug TEXT NOT NULL` + named UNIQUE / format / length constraints;
  `WorkspaceSlug` in the model; constraint enum + pg_workspace error mapping.
- `find_workspace_by_slug`; `create_workspace_with_unique_slug` retries `-2`,
  `-3`, … on a unique violation — race-safe (driven by the DB constraint, not
  a check-then-insert).

WorkspaceContext extractor
- Resolves the `{workspaceSlug}` segment to the addressed Workspace (404 on an
  unknown slug) and documents the OpenAPI path parameter. Handlers receive the
  resolved workspace and pass `workspace.id` downstream.

Routes / handlers
- Every workspace-nested route now uses `{workspaceSlug}`; child path-param
  structs drop their redundant `workspace_id` field (the extractor owns it).
  The public `/invites/code/{inviteCode}/` routes stay top-level.
- create/update workspace accept an optional explicit slug (derived from the
  display name when omitted); slug is mutable. Fix the display-name validator
  (was 3-100) to match the DB's 3-32.
- Workspace responses expose `slug`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…esponses

Make the slug the single workspace identity and remove the workspace UUID from
the API surface. Since every workspace-addressing route already takes the slug,
the UUID in response bodies was an unusable, un-addressable token — dead weight
that implied an id-lookup capability the API doesn't offer.

- Replace `workspaceId` with `workspaceSlug` in every response body (connections,
  pipelines, policies, contexts, files, webhooks, activities, invites, and the
  workspace itself). Handlers thread the resolved `workspace.slug` into the
  response constructors. Resource-owned ids stay — they remain URL-addressable.
- Make the slug immutable: it is set once at creation (explicit or derived) and
  cannot be changed. Removed from the UpdateWorkspace request DTO and the ORM
  changeset, so no path — API or internal — can mutate the slug column. An
  immutable slug is a stable, rename-proof reference, which is exactly what a
  separate UUID would otherwise provide, so exposing the UUID adds nothing.
- Drop the invite_sent_tests unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@martsokha martsokha self-assigned this Jul 16, 2026
Extend the slug model to workspace-owned config resources and address runs by
a per-parent sequential number, completing the GitHub-style identity scheme:
named things get an immutable slug, high-volume append-only runs get a number.

Generalize the slug type
- Rename WorkspaceSlug -> Slug (one reusable, validated newtype). Workspace
  slugs stay globally unique; child slugs are unique per workspace, enforced by
  each table's constraint rather than the type.

Config resources: connections, pipelines, policies, contexts
- Add `slug TEXT NOT NULL` + UNIQUE(workspace_id, slug) + format/length checks;
  the slug is immutable (absent from every Update path).
- Routes address these by `{xSlug}`; handlers resolve the slug within the
  workspace already resolved by WorkspaceContext.
- The slug is a required create field; a duplicate surfaces as 409 (no silent
  suffixing). Responses are slug-only — no surrogate UUID in the body.

Runs -> per-parent numbers
- Pipeline and connection runs are addressed as
  `/pipelines/{slug}/runs/{runNumber}` and `/connections/{slug}/runs/{runNumber}`
  (plus detections/redactions), reusing the gap-free run_number already assigned
  by a DB trigger. The flat `/pipeline-runs/{id}` / `/connection-runs/{id}`
  routes are dropped; the run response drops its UUID and its parent UUID.
- Workspace-wide aggregate lists move to `/pipelines/runs/` and
  `/connections/runs/`, joining the owning parent so each run carries its
  parent's slug.

OpenAPI: declare the tag set that handlers actually use (Pipelines, Pipeline
Runs, Policies, Contexts, Notifications, Health) and align "API Tokens" so no
declared category is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@martsokha martsokha added the postgres ORM, models, queries, migrations label Jul 16, 2026
@martsokha martsokha changed the title Invite by email: in-app notifications + module-exclusion seam Resource identity redesign: workspace/config slugs, run numbers, invite notifications Jul 16, 2026
@martsokha
martsokha merged commit 52fb337 into main Jul 16, 2026
7 checks passed
@martsokha
martsokha deleted the feat/invite-notifications branch July 16, 2026 18:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat request for or implementation of a new feature postgres ORM, models, queries, migrations server API handlers, middleware, auth

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant