diff --git a/echo/AGENTS.md b/echo/AGENTS.md index cc8c8031..9c66edc0 100644 --- a/echo/AGENTS.md +++ b/echo/AGENTS.md @@ -145,11 +145,9 @@ Which group powers which feature is non-obvious — don't downgrade silently. **Never hand-write Directus sync/snapshot JSON files.** To create or modify collections: 1. Write an idempotent Python script that uses the Directus REST API (`POST /collections`, `POST /fields`, `POST /relations`) with the admin token. Check `collection_exists()` / `field_exists()` before creating -2. Run it step-by-step to verify each change +2. Run it step-by-step to verify each change against a local Directus 3. Pull the schema: `cd directus && bash sync.sh -u http://directus:8055 -e admin@dembrane.com -p admin pull` -4. Commit the snapshot JSON - -See `scripts/create_schema.py` for the established pattern. +4. Commit the snapshot JSON under `directus/sync/snapshot/` — that is the source of truth; the one-shot migration script does not need to be committed ### Python DirectusClient diff --git a/echo/frontend/src/features/sidebar/views/user/UserSettingsView.tsx b/echo/frontend/src/features/sidebar/views/user/UserSettingsView.tsx index 555703e5..206ee54a 100644 --- a/echo/frontend/src/features/sidebar/views/user/UserSettingsView.tsx +++ b/echo/frontend/src/features/sidebar/views/user/UserSettingsView.tsx @@ -1,15 +1,17 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { - Buildings, - Palette, - Scales, - ShieldStar, - SignOut, + BuildingsIcon, + PaletteIcon, + ScalesIcon, + ShieldStarIcon, + SignOutIcon, } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; import { useLocation } from "react-router"; import { useLogoutMutation } from "@/components/auth/hooks"; import { useTransitionCurtain } from "@/components/layout/TransitionCurtainProvider"; +import { API_BASE_URL } from "@/config"; import { BackButton } from "../../primitives/BackButton"; import { NavButton } from "../../primitives/NavButton"; import { NavItem } from "../../primitives/NavItem"; @@ -19,6 +21,22 @@ export const UserSettingsView = () => { const location = useLocation(); const { runTransition } = useTransitionCurtain(); + const { data: accessData } = useQuery<{ + organisations: Array<{ id: string }>; + } | null>({ + queryFn: async () => { + const res = await fetch(`${API_BASE_URL}/v2/workspaces`, { + credentials: "include", + }); + if (!res.ok) return null; + return res.json(); + }, + queryKey: ["v2", "workspaces"], + staleTime: 60_000, + }); + const isExternalOnly = + accessData != null ? accessData.organisations.length === 0 : false; + const handleLogout = async () => { if (logoutMutation.isPending) return; await runTransition({ description: null, message: t`See you soon` }); @@ -35,26 +53,28 @@ export const UserSettingsView = () => { Account & security} - icon={ShieldStar} + icon={ShieldStarIcon} /> My access} - icon={Buildings} + icon={BuildingsIcon} /> Appearance} - icon={Palette} - /> - Project defaults} - icon={Scales} + icon={PaletteIcon} /> + {!isExternalOnly && ( + Project defaults} + icon={ScalesIcon} + /> + )} Log out} - icon={SignOut} + icon={SignOutIcon} onClick={handleLogout} disabled={logoutMutation.isPending} destructive diff --git a/echo/server/dembrane/api/v2/middleware.py b/echo/server/dembrane/api/v2/middleware.py index 052b7969..f1cb1cf3 100644 --- a/echo/server/dembrane/api/v2/middleware.py +++ b/echo/server/dembrane/api/v2/middleware.py @@ -123,25 +123,6 @@ async def list_projects(ctx: WorkspaceContext = Depends(get_workspace_context)): from dembrane.policies import _normalize_legacy_role normalized_role = _normalize_legacy_role(role) or role - if source == "direct" and normalized_role != "external": - org_id = workspace.get("org_id") - if org_id: - org_rows = await async_directus.get_items( - "org_membership", - { - "query": { - "filter": { - "org_id": {"_eq": org_id}, - "user_id": {"_eq": app_user_id}, - "deleted_at": {"_null": True}, - }, - "fields": ["id"], - "limit": 1, - } - }, - ) - if not isinstance(org_rows, list) or len(org_rows) == 0: - normalized_role = "external" return WorkspaceContext( workspace_id=workspace_id, diff --git a/echo/server/dembrane/api/v2/project_sharing.py b/echo/server/dembrane/api/v2/project_sharing.py index dd8db04e..34b31b9e 100644 --- a/echo/server/dembrane/api/v2/project_sharing.py +++ b/echo/server/dembrane/api/v2/project_sharing.py @@ -389,6 +389,7 @@ async def change_project_share_role( message=f"You're now a **{body.role}** on this project.", action="NAVIGATE_PROJECT", ref_project_id=project_id, + ref_workspace_id=project.get("workspace_id"), ) return {"status": "updated", "role": body.role} diff --git a/echo/server/dembrane/tasks.py b/echo/server/dembrane/tasks.py index 029ea76b..eb438f96 100644 --- a/echo/server/dembrane/tasks.py +++ b/echo/server/dembrane/tasks.py @@ -1437,6 +1437,7 @@ def progress_callback(event_type: str, message: str, detail: Optional[dict] = No action="NAVIGATE_REPORT", ref_project_id=project_id, ref_report_id=report_id_str, + ref_workspace_id=(project_row or {}).get("workspace_id"), ) except Exception as e: logger.warning(f"Failed to emit REPORT_READY notification: {e}") @@ -1467,6 +1468,7 @@ def progress_callback(event_type: str, message: str, detail: Optional[dict] = No from dembrane.notifications import emit_sync with directus_client_context() as client: report_row = client.get_item("project_report", report_id_str) + project_row = client.get_item("project", project_id) if project_id else None report_data = (report_row or {}).get("data") or report_row or {} creator_directus_id = report_data.get("user_created") if creator_directus_id: @@ -1480,6 +1482,7 @@ def progress_callback(event_type: str, message: str, detail: Optional[dict] = No action="NAVIGATE_REPORT", ref_project_id=project_id, ref_report_id=report_id_str, + ref_workspace_id=(project_row or {}).get("workspace_id"), ) except Exception as notif_err: logger.warning(f"Failed to emit REPORT_FAILED: {notif_err}") diff --git a/echo/server/tests/test_discount_fields.py b/echo/server/tests/test_discount_fields.py index cd752396..1f8a1db7 100644 --- a/echo/server/tests/test_discount_fields.py +++ b/echo/server/tests/test_discount_fields.py @@ -10,12 +10,9 @@ - _create_workspace_for_request writes discount fields (already covered in Slice 10, but verified structurally here) - _upgrade_workspace_for_request writes discount fields (same) -- create_schema step_21 is registered and callable - No code path computes a price using discount fields (grep guard) """ -import os -import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -456,39 +453,6 @@ async def test_query_includes_discount_fields(self): assert "percent_discount" in fields -# ── Schema step_21 registration ── - - -class TestSchemaStep21: - @pytest.fixture(autouse=True) - def _add_scripts_to_path(self): - scripts_dir = os.path.join(os.path.dirname(__file__), "..", "..", "scripts") - scripts_dir = os.path.abspath(scripts_dir) - if scripts_dir not in sys.path: - sys.path.insert(0, scripts_dir) - yield - if scripts_dir in sys.path: - sys.path.remove(scripts_dir) - - def test_step_21_in_steps(self): - from create_schema import STEPS - - assert "21" in STEPS - name, fn = STEPS["21"] - assert "discount" in name.lower() - - def test_step_21_callable(self): - from create_schema import STEPS - - _, fn = STEPS["21"] - assert callable(fn) - - def test_step_21_function_name(self): - from create_schema import step_21_workspace_discount_fields - - assert step_21_workspace_discount_fields.__name__ == "step_21_workspace_discount_fields" - - # ── Approve helpers write discount fields (structural) ── diff --git a/echo/server/tests/test_tier_expiry.py b/echo/server/tests/test_tier_expiry.py index 8c31675d..e6332b96 100644 --- a/echo/server/tests/test_tier_expiry.py +++ b/echo/server/tests/test_tier_expiry.py @@ -483,33 +483,6 @@ def test_expire_tier_job_runs_hourly(self): assert str(minute_field) == "0" -# ── Schema step 19 ────────────────────────────────────────────────── - - -class TestSchemaStep19: - """Structural check that step_19 is wired in STEPS and is callable.""" - - def test_step_19_in_source(self): - src_path = os.path.join( - os.path.dirname(__file__), "..", "..", "scripts", "create_schema.py" - ) - with open(src_path) as f: - source = f.read() - - assert "step_19_workspace_tier_expires_at" in source - assert '"19"' in source - - def test_step_19_adds_tier_expires_at(self): - src_path = os.path.join( - os.path.dirname(__file__), "..", "..", "scripts", "create_schema.py" - ) - with open(src_path) as f: - source = f.read() - - assert '"tier_expires_at"' in source - assert '"timestamp"' in source - - # ── Email template existence ───────────────────────────────────────── diff --git a/echo/server/tests/test_tier_expiry_prewarning.py b/echo/server/tests/test_tier_expiry_prewarning.py index 1495c1fe..4db9d947 100644 --- a/echo/server/tests/test_tier_expiry_prewarning.py +++ b/echo/server/tests/test_tier_expiry_prewarning.py @@ -18,7 +18,6 @@ from __future__ import annotations import os -import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -595,36 +594,3 @@ def test_prewarning_job_target(self): assert "task_send_tier_expiry_prewarning" in str(job.func_ref) -# ── Schema step 20 ─────────────────────────────────────────────────── - - -class TestSchemaStep20: - """Structural check for the pre_warning_sent field definition.""" - - @pytest.fixture(autouse=True) - def _add_scripts_to_path(self): - scripts_dir = os.path.join(os.path.dirname(__file__), "..", "..", "scripts") - scripts_dir = os.path.abspath(scripts_dir) - if scripts_dir not in sys.path: - sys.path.insert(0, scripts_dir) - yield - if scripts_dir in sys.path: - sys.path.remove(scripts_dir) - - def test_step_20_in_steps(self): - from create_schema import STEPS - - assert "20" in STEPS - name, fn = STEPS["20"] - assert "pre_warning_sent" in name - - def test_step_20_callable(self): - from create_schema import STEPS - - _, fn = STEPS["20"] - assert callable(fn) - - def test_step_20_function_name(self): - from create_schema import step_20_workspace_pre_warning_sent - - assert step_20_workspace_pre_warning_sent.__name__ == "step_20_workspace_pre_warning_sent" diff --git a/echo/server/tests/test_workspace_requests.py b/echo/server/tests/test_workspace_requests.py index 42f4fbbb..2c372987 100644 --- a/echo/server/tests/test_workspace_requests.py +++ b/echo/server/tests/test_workspace_requests.py @@ -389,33 +389,6 @@ async def test_pioneer_with_cadence_accepted(self, cadence: str): assert row["proposed_billing_period"] == cadence -# ── Schema script step_18 ──────────────────────────────────────────── - - -class TestSchemaStep18: - """Structural checks that the schema step function exists and has the right shape.""" - - def _load_schema_module(self): - import os - import importlib.util - script_path = os.path.join( - os.path.dirname(__file__), "..", "..", "scripts", "create_schema.py" - ) - spec = importlib.util.spec_from_file_location("create_schema", script_path) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - def test_step_registered(self): - mod = self._load_schema_module() - assert "18" in mod.STEPS - assert mod.STEPS["18"][0] == "workspace_request collection" - - def test_step_function_callable(self): - mod = self._load_schema_module() - assert callable(mod.step_18_workspace_request) - - # ── Row creation payload ─────────────────────────────────────────────