feat(support-access): staff temporary workspace access + PG-backed scheduled tasks (ECHO-863)#711
Merged
Merged
Conversation
…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).
…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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_taskqueue and moves scheduled report generation onto it.We chose a first-class Directus
scheduled_taskcollection over Dramatiqdelay(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
workspace.allow_support_access(bool). Exposed inGET/PATCH /v2/workspaces/{id}/settings, gated bysettings:manage(admins/owners only).POST /v2/admin/workspaces/{id}/join-support, double-gated on theis_adminstaff claim AND the customer toggle. Writes an admin membership withsource=staff_supportandexpires_at = now + 24h; re-calling extends the window. Already-real members returnalready_memberwithout being given an expiry.workspace_membership.expires_at;sourceenum gainsstaff_support.staff_supportrows do not consume a billable seat (source != "direct"), so support access never inflates a customer's bill.task_process_scheduled_tasks(1-min tick): claims due rows via ascheduled -> processingstatus guard, dispatches bytask_type, markscompleted/failed, with a stale-claim reconciler for crash recovery. No raw SQL; handlers are idempotent so a rare double-claim is harmless.revoke_staff_supportscheduled_task, plus a 15-mintask_expire_staff_support_membershipscatch-up sweep (belt-and-suspenders).create_report/update_reportenqueue agenerate_reporttask at schedule time;task_check_scheduled_reportsbecomes a reconciler that backfills a task for any still-scheduled report missing one (covers in-flight/legacy rows, no stranded reports).Frontend
Schema / migration
server/scripts/add_support_access_and_scheduled_task.py(run against Directus, snapshot pulled and committed).scheduled_taskcollection +workspace.allow_support_access+workspace_membership.expires_at+sourceenum update.Tests & review
/security-reviewpassed with no findings (auth/permissions change).Notes for reviewers
scheduled_tasks.py..poline counts aremessages:extractsource-reference re-stamping; only the 8 newmsgstrlines per locale are real content.Test plan
scheduled_task(or shorten TTL) and confirm the membership is soft-deleted and the task completes.generate_reporttask created -> fires on time; cancel/delete cancels the pending task.🤖 Generated with Claude Code