Skip to content

feat(support-access): staff temporary workspace access + PG-backed scheduled tasks (ECHO-863)#711

Merged
ussaama merged 5 commits into
mainfrom
feat/staff-support-access
Jun 26, 2026
Merged

feat(support-access): staff temporary workspace access + PG-backed scheduled tasks (ECHO-863)#711
ussaama merged 5 commits into
mainfrom
feat/staff-support-access

Conversation

@spashii

@spashii spashii commented Jun 23, 2026

Copy link
Copy Markdown
Member

What & why

Implements the support-access flow from ECHO-863: a customer can opt in to letting dembrane staff into a workspace, and staff get a one-click 24h self-join that auto-revokes. To revoke at a definite future time durably (surviving restarts, inspectable/cancellable), this also adds a generic Postgres-backed (via Directus) scheduled_task queue and moves scheduled report generation onto it.

We chose a first-class Directus scheduled_task collection over Dramatiq delay (Redis-backed, opaque, "brokers aren't databases") or APScheduler's pickled jobstore (needs SQLAlchemy, not inspectable) because it's the only option giving staff an inspect/cancel/retry surface while staying Postgres-durable and consistent with the codebase's "everything through Directus" rule.

Backend

  • Customer toggle workspace.allow_support_access (bool). Exposed in GET/PATCH /v2/workspaces/{id}/settings, gated by settings:manage (admins/owners only).
  • Staff join POST /v2/admin/workspaces/{id}/join-support, double-gated on the is_admin staff claim AND the customer toggle. Writes an admin membership with source=staff_support and expires_at = now + 24h; re-calling extends the window. Already-real members return already_member without being given an expiry.
    • New workspace_membership.expires_at; source enum gains staff_support.
    • staff_support rows do not consume a billable seat (source != "direct"), so support access never inflates a customer's bill.
  • Generic runner task_process_scheduled_tasks (1-min tick): claims due rows via a scheduled -> processing status guard, dispatches by task_type, marks completed/failed, with a stale-claim reconciler for crash recovery. No raw SQL; handlers are idempotent so a rare double-claim is harmless.
  • Auto-revoke via a revoke_staff_support scheduled_task, plus a 15-min task_expire_staff_support_memberships catch-up sweep (belt-and-suspenders).
  • Scheduled reports migrated: create_report/update_report enqueue a generate_report task at schedule time; task_check_scheduled_reports becomes a reconciler that backfills a task for any still-scheduled report missing one (covers in-flight/legacy rows, no stranded reports).

Frontend

  • Workspace Settings → Access: "Allow dembrane staff to access this workspace for support" toggle (autosave).
  • Admin dashboard workspace actions: "Join for support (24h)" with an Open workspace link.
  • New strings translated for nl/de/fr/es/it (cs/uk fall back to English, consistent with those catalogs).

Schema / migration

  • Idempotent migration script server/scripts/add_support_access_and_scheduled_task.py (run against Directus, snapshot pulled and committed).
  • New scheduled_task collection + workspace.allow_support_access + workspace_membership.expires_at + source enum update.

Tests & review

  • Unit tests for the queue helpers (claim/reconcile/mark/cancel) and dispatch routing; endpoint tests for both gates and the create/extend/already-member branches. 15 new tests pass; no regressions in adjacent suites.
  • /security-review passed with no findings (auth/permissions change).

Notes for reviewers

  • Concurrency is handled by single-dispatcher + idempotent handlers, not row locking (deliberate, to avoid introducing raw SQL). See the module docstring in scheduled_tasks.py.
  • The it-IT strings followed informal-tu/A2 rules but are worth a native/glossary check before release.
  • The large .po line counts are messages:extract source-reference re-stamping; only the 8 new msgstr lines per locale are real content.

Test plan

  • Customer toggles support access on; staff Join succeeds; access works; toggle off -> staff Join returns 403.
  • Backdate the scheduled_task (or shorten TTL) and confirm the membership is soft-deleted and the task completes.
  • Schedule a report -> generate_report task created -> fires on time; cancel/delete cancels the pending task.
    🤖 Generated with Claude Code

…heduled tasks (ECHO-863)

Lets a customer opt in to dembrane staff support access per workspace, and gives
staff a one-click 24h self-join that auto-revokes. Adds a generic, durable,
Directus-backed scheduled_task queue for definite-future one-shot work and moves
scheduled report generation onto it.

Backend:
- workspace.allow_support_access (bool) customer toggle; gated by settings:manage
  in PATCH /v2/workspaces/{id}/settings.
- POST /v2/admin/workspaces/{id}/join-support: double-gated on is_admin AND the
  toggle. Writes an admin membership with source=staff_support and
  expires_at = now + 24h; re-calling extends. workspace_membership.expires_at
  added; source enum gains staff_support. staff_support rows do not consume a
  billable seat (source != direct).
- scheduled_task collection + unified runner (task_process_scheduled_tasks):
  claims due rows via a status guard, dispatches by task_type, marks
  completed/failed, with a stale-claim reconciler. No raw SQL; stays on Directus.
  Idempotent handlers make a rare double-claim safe.
- Auto-revoke via a revoke_staff_support scheduled_task, plus a 15-min
  task_expire_staff_support_memberships catch-up sweep.
- Scheduled reports now enqueue a generate_report scheduled_task at schedule time;
  task_check_scheduled_reports becomes a reconciler that backfills tasks for any
  still-scheduled report missing one (covers in-flight/legacy rows).

Frontend:
- Workspace Settings: "Allow dembrane staff to access this workspace for support"
  toggle (autosave).
- Admin dashboard: "Join for support (24h)" action with Open workspace link.
- i18n strings translated for nl/de/fr/es/it.

Tests: unit tests for the queue helpers + dispatch routing, and endpoint tests
for both gates and the create/extend/already-member branches. Security review
passed (no findings).
@linear

linear Bot commented Jun 23, 2026

Copy link
Copy Markdown

ECHO-863

…bserver role

  Follow-up hardening on the ECHO-863 staff support-access + observer work.

  Access & membership accounting (server):
  - Enforce expires_at at access time: membership_access_expired() and
    _get_direct_membership now deny an elapsed staff_support session, instead of
    relying only on the background revoke task / 15-min sweep (inheritance.py).
  - Exclude source=staff_support from get_effective_members so support sessions
    never count toward seats, billing, previews, or notifications; apply the same
    exclusion to the workspace member list (workspace_settings.py) and the
    member_count aggregate (workspaces.py).

  Staff support endpoints (admin.py):
  - Add GET /v2/admin/workspaces/{id}/join-support (current session status) and
    DELETE (leave early); status treats an elapsed expiry as inactive.
  - Honor the create/reactivate race result on join so a concurrent winner is
    resolved instead of scheduling a revoke against a never-inserted id.
  - Fix two pre-existing mypy errors surfaced here (membership_id typing,
    float(None) guard in the Mollie rollup).

  Revoke safety (tasks.py):
  - _revoke_staff_support_async only soft-deletes when source=staff_support, so a
    stale revoke can't strip a recycled direct membership.

  Observer role (frontend):
  - Thread the free, read-only observer role through every gate that previously
    handled only external (isOutsiderRole): org derivation in the workspace
    selector and sidebar, the org external-landing fallback, the settings
    manage-surface bounce, project create/pin gating, and the change-admin picker.
  - Block chat for read-only observers (isReadOnlyRole) in the Ask route and hide
    the Ask tab + skip its count query; external (paid) keeps chat.
  - Render observers as a locked "Observer" badge in the member list instead of a
    blank role Select.

  Workspace resolution & cache (frontend):
  - A URL-pinned workspace id now wins over the default-workspace fallback in
    useWorkspace, so deep links scope queries to the right workspace instead of
    briefly showing another workspace's data.
  - Refresh the workspaces context after creating an org / joining for support so
    the new or just-joined workspace appears without a manual refresh.

  Tests:
  - Add staff-support accounting + access-expiry tests, and join status / leave /
    concurrent-race tests.
ussaama added 2 commits June 26, 2026 16:22
  Three related membership/billing display bugs:

  - New org overview showed "no workspaces" until refresh. The overview
    reads the staleTime'd ["v2","workspaces"] cache, which the home page
    populated before the org existed; CreateOrganisationModal only refetched
    the sidebar's ["v2","workspaces-context"]. Now refetch both before
    navigating to the new org.

  - Invite modal showed seat pricing (e.g. "2 seat(s) · €1800 now") for
    observer invites. Observers are free and never consume a seat, so skip
    the cost-preview estimate entirely when role is observer, matching the
    backend's own observer short-circuit in invite_to_workspace.

  - Net-new-seat dedup counted observers as seat-holders, so an existing or
    pending observer could mask a real paid seat in the invite estimate
    (under-charge). account_active_seat_emails now reuses effective_seat_user_ids
    (already observer-aware) and account_pending_invite_emails skips observer
    invite rows. Adds TestSeatDedupExcludesObservers covering both.
@ussaama ussaama merged commit 7fcc368 into main Jun 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants