diff --git a/echo/frontend/src/components/layout/BaseLayout.tsx b/echo/frontend/src/components/layout/BaseLayout.tsx
index 8f35e1ae9..0606cc737 100644
--- a/echo/frontend/src/components/layout/BaseLayout.tsx
+++ b/echo/frontend/src/components/layout/BaseLayout.tsx
@@ -1,28 +1,54 @@
-import { Box } from "@mantine/core";
import type { PropsWithChildren } from "react";
import { Outlet } from "react-router";
+import { useAuthenticated } from "@/components/auth/hooks";
+import { AppSidebar } from "@/features/sidebar";
+import { AppBreadcrumbs } from "@/features/sidebar/breadcrumbs/AppBreadcrumbs";
import { Toaster } from "../common/Toaster";
import { ErrorBoundary } from "../error/ErrorBoundary";
-import { Header } from "./Header";
import { TransitionCurtainProvider } from "./TransitionCurtainProvider";
+const SidebarFailure = () => (
+
+);
+
export const BaseLayout = ({ children }: PropsWithChildren) => {
+ const { isAuthenticated } = useAuthenticated();
+
return (
-
-
-
-
-
+
+ {isAuthenticated ? (
+
}>
+
+
+ ) : null}
-
-
- {children}
+
+ {isAuthenticated ? : null}
+
+
+ {children}
+
-
-
+
);
};
diff --git a/echo/frontend/src/components/layout/Header.tsx b/echo/frontend/src/components/layout/Header.tsx
deleted file mode 100644
index ea0b3c4ac..000000000
--- a/echo/frontend/src/components/layout/Header.tsx
+++ /dev/null
@@ -1,492 +0,0 @@
-import { t } from "@lingui/core/macro";
-import { Trans } from "@lingui/react/macro";
-import {
- ActionIcon,
- Badge,
- Box,
- Button,
- Group,
- Menu,
- Paper,
- ScrollArea,
- Text,
- UnstyledButton,
-} from "@mantine/core";
-import * as Sentry from "@sentry/react";
-import {
- IconBug,
- IconShieldLock,
- IconCheck,
- IconChevronDown,
- IconExternalLink,
- IconLogout,
- IconMessageCircle,
- IconNotes,
- IconSettings,
- IconMail,
- IconSparkles,
- IconUsers,
-} from "@tabler/icons-react";
-import { useEffect, useState } from "react";
-import { useLocation, useParams } from "react-router";
-import {
- useAuthenticated,
- useCurrentUser,
- useLogoutMutation,
-} from "@/components/auth/hooks";
-import { isAuthPath } from "@/components/auth/utils/authPaths";
-import { useV2Me } from "@/hooks/useV2Me";
-import { useWorkspace } from "@/hooks/useWorkspace";
-import { I18nLink } from "@/components/common/i18nLink";
-import {
- COMMUNITY_SLACK_URL,
- DIRECTUS_PUBLIC_URL,
- ENABLE_ANNOUNCEMENTS,
-} from "@/config";
-import { useI18nNavigate } from "@/hooks/useI18nNavigate";
-import { useWhitelabelLogo } from "@/hooks/useWhitelabelLogo";
-import { analytics } from "@/lib/analytics";
-import { logoUrl } from "@/lib/avatar";
-import { AnalyticsEvents as events } from "@/lib/analyticsEvents";
-import { testId } from "@/lib/testUtils";
-import { TopAnnouncementBar } from "../announcement/TopAnnouncementBar";
-import { Inbox } from "../inbox/Inbox";
-import { FeedbackPortalModal } from "../common/FeedbackPortalModal";
-import { Logo } from "../common/Logo";
-import { UserAvatar } from "../common/UserAvatar";
-import { LanguagePicker } from "../language/LanguagePicker";
-import { useTransitionCurtain } from "./TransitionCurtainProvider";
-
-type HeaderViewProps = {
- isAuthenticated: boolean;
- loading: boolean;
-};
-
-function CreateFeedbackButton({ onFallback }: { onFallback: () => void }) {
- const handleClick = async () => {
- const feedback = Sentry.getFeedback();
- if (feedback) {
- const form = await feedback.createForm();
- if (form) {
- form.appendToDom();
- form.open();
- return;
- }
- }
- onFallback();
- };
-
- return (
-
} onClick={handleClick}>
-
Report an issue
-
- );
-}
-
-const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => {
- const { language } = useParams();
- const [feedbackFallbackOpen, setFeedbackFallbackOpen] = useState(false);
- const [feedbackPortalOpen, setFeedbackPortalOpen] = useState(false);
-
- const logoutMutation = useLogoutMutation();
- const { data: user } = useCurrentUser({ enabled: isAuthenticated });
- const { data: meV2 } = useV2Me({ enabled: isAuthenticated });
- const needsOnboarding = meV2?.onboarding_completed === false;
- const hasPendingInvites = meV2?.has_pending_invites === true;
- const isStaff = meV2?.is_staff === true;
- const {
- workspaceId,
- workspaceName,
- workspace,
- workspaces,
- isLoading: workspaceLoading,
- setWorkspace,
- } = useWorkspace();
- const location = useLocation();
- // Hide workspace breadcrumb on selector, create-wizard, org, and admin pages.
- const pathNoLocale = location.pathname.replace(
- /^\/[a-z]{2}(-[A-Z]{2})?(?=\/)/,
- "",
- );
- const hideWorkspaceBreadcrumb =
- pathNoLocale === "/w" ||
- pathNoLocale === "/w/" ||
- pathNoLocale.startsWith("/w/new") ||
- pathNoLocale.startsWith("/o/") ||
- pathNoLocale.startsWith("/admin") ||
- pathNoLocale.startsWith("/settings") ||
- pathNoLocale.startsWith("/onboarding") ||
- pathNoLocale.startsWith("/invites");
- const navigate = useI18nNavigate();
- const { runTransition } = useTransitionCurtain();
- const { setLogoUrl } = useWhitelabelLogo();
-
- useEffect(() => {
- if (!isAuthenticated) {
- setLogoUrl(null);
- return;
- }
-
- const insideWorkspace = !hideWorkspaceBreadcrumb;
-
- // Wait for workspace data before resolving — avoids logo flash on refresh.
- if (insideWorkspace && workspaceLoading) return;
-
- const workspaceLogo = insideWorkspace
- ? (logoUrl(workspace?.logo_url) ?? logoUrl(workspace?.org_logo_url))
- : undefined;
- const resolved =
- workspaceLogo ??
- (user?.whitelabel_logo
- ? `${DIRECTUS_PUBLIC_URL}/assets/${user.whitelabel_logo}`
- : null);
- setLogoUrl(resolved ?? null);
- }, [
- isAuthenticated,
- hideWorkspaceBreadcrumb,
- workspaceLoading,
- workspace?.logo_url,
- workspace?.org_logo_url,
- user?.whitelabel_logo,
- setLogoUrl,
- ]);
-
- let docUrl: string;
- switch (language) {
- case "nl-NL":
- docUrl = "https://docs.dembrane.com/nl-NL";
- break;
- default:
- docUrl = "https://docs.dembrane.com/en-US";
- break;
- }
-
- const handleLogout = async () => {
- if (logoutMutation.isPending) return;
-
- await runTransition({
- description: null,
- message: t`See you soon`,
- });
-
- const path = location.pathname + location.search + location.hash;
-
- await logoutMutation.mutateAsync({
- doRedirect: true,
- next: isAuthPath(location.pathname) ? undefined : path,
- });
- };
-
- const handleSettingsClick = () => {
- navigate("/settings");
- };
-
- return (
- <>
- {isAuthenticated && user && ENABLE_ANNOUNCEMENTS && (
-
- )}
-
-
-
- {/* Logo click: inside a workspace → that workspace's project
- list. Outside any workspace context → the workspace
- selector (/w). Previously fell back to /projects, which
- was the legacy dembrane home and confusing once organisations
- existed. */}
-
-
-
-
-
- {workspaceName && isAuthenticated && !hideWorkspaceBreadcrumb && (
-
- )}
-
-
- {!loading && isAuthenticated && user ? (
-
- {/* Staff shortcut — only visible when meV2.is_staff is
- true. Purple so it reads as "out-of-model" vs. the
- blue primary. Routes to /admin (billing rollup,
- at-risk, partners, upgrades). */}
- {isStaff && (
- }
- >
- Staff
-
- )}
- {/* Unified Inbox — one bell, two tabs (For you +
- Announcements). Replaces the prior split icons.
- ENABLE_ANNOUNCEMENTS still controls whether the
- broadcast channel is live, but the bell itself
- stays so personal notifications remain reachable. */}
-
-
-
- ) : (
-
-
-
- )}
-
-
-
-
{
- setFeedbackFallbackOpen(false);
- setFeedbackPortalOpen(false);
- }}
- locale={language}
- />
- >
- );
-};
-
-export const Header = () => {
- const { loading, isAuthenticated } = useAuthenticated();
- return ;
-};
-
-export { HeaderView };
diff --git a/echo/frontend/src/components/layout/PageContainer.tsx b/echo/frontend/src/components/layout/PageContainer.tsx
new file mode 100644
index 000000000..868c07edf
--- /dev/null
+++ b/echo/frontend/src/components/layout/PageContainer.tsx
@@ -0,0 +1,40 @@
+import type { CSSProperties, PropsWithChildren } from "react";
+
+interface PageContainerProps {
+ /** Page width preset. `lg` = 1024px content, `xl` = 1280px, `full` = 100%. Default `lg`. */
+ width?: "sm" | "md" | "lg" | "xl" | "full";
+ /** Reduce vertical padding for dense pages. Default `relaxed`. */
+ density?: "tight" | "relaxed";
+ className?: string;
+ style?: CSSProperties;
+}
+
+const WIDTHS = {
+ sm: 640,
+ md: 768,
+ lg: 1024,
+ xl: 1280,
+ full: undefined,
+} as const;
+
+// Single source of truth for main-content page max-width + horizontal
+// padding. Every full-page route should wrap its content in this so the
+// app feels coherent across scopes.
+export const PageContainer = ({
+ width = "lg",
+ density = "relaxed",
+ className,
+ style,
+ children,
+}: PropsWithChildren) => {
+ const maxWidth = WIDTHS[width];
+ const pyClass = density === "tight" ? "py-6" : "py-10";
+ return (
+
+ {children}
+
+ );
+};
diff --git a/echo/frontend/src/components/layout/ProjectConversationLayout.tsx b/echo/frontend/src/components/layout/ProjectConversationLayout.tsx
index 92e7492ca..2a2351d03 100644
--- a/echo/frontend/src/components/layout/ProjectConversationLayout.tsx
+++ b/echo/frontend/src/components/layout/ProjectConversationLayout.tsx
@@ -39,7 +39,7 @@ export const ProjectConversationLayout = () => {
/>
)}
; that's been
+// retired.
export const ProjectLayout = () => {
- const { sidebarWidth, setSidebarWidth, toggleSidebar } = useSidebar();
-
- const isCollapsed = false;
-
return (
-
-
-
- {
- setSidebarWidth(sidebarWidth + d.width);
- }}
- enable={{
- bottom: false,
- bottomLeft: false,
- bottomRight: false,
- left: false,
- right: !isCollapsed,
- top: false,
- topLeft: false,
- topRight: false,
- }}
- handleStyles={{
- right: {
- cursor: "col-resize",
- right: "-4px",
- width: "8px",
- },
- }}
- handleClasses={{
- right: "hover:bg-blue-500/20 transition-colors",
- }}
- >
-
-
-
- {isCollapsed && (
-
-
-
- )}
-
-
-
+
+
);
};
diff --git a/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx b/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx
index 6010d5aa4..a5fa94634 100644
--- a/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx
+++ b/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx
@@ -1,22 +1,10 @@
import { t } from "@lingui/core/macro";
-import { Trans } from "@lingui/react/macro";
-import {
- Badge,
- Box,
- Divider,
- Group,
- LoadingOverlay,
- Stack,
- Tooltip,
-} from "@mantine/core";
+import { Badge, Group, LoadingOverlay, Stack, Tooltip } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { IconLock } from "@tabler/icons-react";
import { useParams } from "react-router";
import { useProjectById } from "@/components/project/hooks";
import { testId } from "@/lib/testUtils";
-import { OngoingConversationsSummaryCard } from "../conversation/OngoingConversationsSummaryCard";
-import { OpenForParticipationSummaryCard } from "../conversation/OpenForParticipationSummaryCard";
-import { ProjectQRCode } from "../project/ProjectQRCode";
import { TabsWithRouter } from "./TabsWithRouter";
export const ProjectOverviewLayout = () => {
@@ -63,18 +51,8 @@ export const ProjectOverviewLayout = () => {
)}
)}
-
-
(
);
-export const TabsWithRouter = ({
- basePath,
- tabs,
- loading = false,
- ...rest
-}: {
- basePath: string;
- tabs: { value: string; label: string }[];
- loading?: boolean;
-} & Record) => {
- const navigate = useI18nNavigate();
- const location = useLocation();
- const params = useParams();
-
- const determineInitialTab = useCallback(() => {
- return (
- tabs.find((tab) => location.pathname.includes(`/${tab.value}`))?.value ||
- tabs[0].value
- );
- }, [tabs, location.pathname]);
-
- const [activeTab, setActiveTab] = useState(determineInitialTab());
-
- useEffect(() => {
- const newTab = determineInitialTab();
- if (newTab !== activeTab) {
- setActiveTab(newTab);
- }
- }, [determineInitialTab, activeTab]);
-
- const handleTabChange = (value: string | null) => {
- const path = basePath.replace(/:(\w+)/g, (_, param) => params[param] || "");
- navigate(`${path}/${value}`);
- setActiveTab(value ?? "");
- };
-
+// Tab strip retired — section navigation lives in the main AppSidebar.
+// Now a thin Outlet wrapper; props kept for callsite compat but ignored.
+export const TabsWithRouter = (
+ _props: {
+ basePath?: string;
+ tabs?: { value: string; label: string }[];
+ loading?: boolean;
+ } & Record,
+) => {
return (
-
-
-
- {tabs.map((tab) => (
-
- {tab.label}
-
- ))}
-
-
+
}>
diff --git a/echo/frontend/src/components/project/HostGuideDownload.tsx b/echo/frontend/src/components/project/HostGuideDownload.tsx
index 7e4b0a791..248e909d1 100644
--- a/echo/frontend/src/components/project/HostGuideDownload.tsx
+++ b/echo/frontend/src/components/project/HostGuideDownload.tsx
@@ -1,16 +1,18 @@
import { Trans } from "@lingui/react/macro";
import { Button } from "@mantine/core";
import { IconPresentation } from "@tabler/icons-react";
+import { useParams } from "react-router";
interface HostGuideDownloadProps {
project: Project;
}
export const HostGuideDownload = ({ project }: HostGuideDownloadProps) => {
+ const { workspaceId } = useParams();
const handleOpenHostGuide = () => {
if (!project) return;
// Open host guide in new tab
- const hostGuideUrl = `/projects/${project.id}/host-guide`;
+ const hostGuideUrl = `/w/${workspaceId}/projects/${project.id}/host-guide`;
window.open(hostGuideUrl, "_blank");
};
@@ -25,7 +27,7 @@ export const HostGuideDownload = ({ project }: HostGuideDownloadProps) => {
variant="outline"
maw="300px"
>
- Open Host Guide
+ Open host guide
);
};
diff --git a/echo/frontend/src/components/project/PinnedProjectCard.tsx b/echo/frontend/src/components/project/PinnedProjectCard.tsx
index 8344bf869..434f97e2a 100644
--- a/echo/frontend/src/components/project/PinnedProjectCard.tsx
+++ b/echo/frontend/src/components/project/PinnedProjectCard.tsx
@@ -12,6 +12,7 @@ import {
} from "@mantine/core";
import { IconExternalLink, IconPinFilled } from "@tabler/icons-react";
import { formatRelative } from "date-fns";
+import { useParams } from "react-router";
import { Icons } from "@/icons";
import { testId } from "@/lib/testUtils";
import { formatDurationFromHours } from "@/lib/time";
@@ -40,7 +41,8 @@ export const PinnedProjectCard = ({
isUnpinning?: boolean;
onSearchOwner?: (term: string) => void;
}) => {
- const link = `/projects/${project.id}/overview`;
+ const { workspaceId } = useParams();
+ const link = `/w/${workspaceId}/projects/${project.id}/home`;
const conversationCount =
project.conversations_count ?? project?.conversations?.length ?? 0;
const audioHours =
diff --git a/echo/frontend/src/components/project/ProjectCard.tsx b/echo/frontend/src/components/project/ProjectCard.tsx
index f5bd4aa98..5c836b352 100644
--- a/echo/frontend/src/components/project/ProjectCard.tsx
+++ b/echo/frontend/src/components/project/ProjectCard.tsx
@@ -3,6 +3,7 @@ import { ActionIcon, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { formatRelative } from "date-fns";
import type { PropsWithChildren } from "react";
+import { useParams } from "react-router";
import { Icons } from "@/icons";
import { testId } from "@/lib/testUtils";
import { I18nLink } from "../common/i18nLink";
@@ -12,7 +13,8 @@ export const ProjectCard = ({
}: PropsWithChildren<{
project: Project;
}>) => {
- const link = `/projects/${project.id}/overview`;
+ const { workspaceId } = useParams();
+ const link = `/w/${workspaceId}/projects/${project.id}/overview`;
return (
{
const deleteProjectByIdMutation = useDeleteProjectByIdMutation();
const cloneProjectByIdMutation = useCloneProjectByIdMutation();
const navigate = useI18nNavigate();
+ const { workspaceId } = useParams();
const [isCloneModalOpen, { open: openCloneModal, close: closeCloneModal }] =
useDisclosure(false);
@@ -61,7 +63,7 @@ export const ProjectDangerZone = ({ project }: { project: Project }) => {
});
if (newProjectId) {
- navigate(`/projects/${newProjectId}`);
+ navigate(`/w/${workspaceId}/projects/${newProjectId}/home`);
}
} catch (_error) {
// toast handled in mutation hook
@@ -75,7 +77,7 @@ export const ProjectDangerZone = ({ project }: { project: Project }) => {
console.warn("Analytics tracking failed:", error);
}
deleteProjectByIdMutation.mutate(project.id);
- navigate("/projects");
+ navigate(workspaceId ? `/w/${workspaceId}/home` : "/w");
};
return (
diff --git a/echo/frontend/src/components/project/ProjectExportSection.tsx b/echo/frontend/src/components/project/ProjectExportSection.tsx
index ffbedf34b..cbd7eb66e 100644
--- a/echo/frontend/src/components/project/ProjectExportSection.tsx
+++ b/echo/frontend/src/components/project/ProjectExportSection.tsx
@@ -2,8 +2,8 @@ import { Trans } from "@lingui/react/macro";
import { Button, Stack } from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { testId } from "@/lib/testUtils";
-import { ProjectSettingsSection } from "./ProjectSettingsSection";
import { HostGuideDownload } from "./HostGuideDownload";
+import { ProjectSettingsSection } from "./ProjectSettingsSection";
type ProjectExportSectionProps = {
exportLink: string;
@@ -36,7 +36,7 @@ export const ProjectExportSection = ({
variant="outline"
{...testId("project-export-transcripts-button")}
>
- Download All Transcripts
+ Download all transcripts
{project && }
diff --git a/echo/frontend/src/components/project/ProjectListItem.tsx b/echo/frontend/src/components/project/ProjectListItem.tsx
index 8cd7e49a2..ddd2fb1f6 100644
--- a/echo/frontend/src/components/project/ProjectListItem.tsx
+++ b/echo/frontend/src/components/project/ProjectListItem.tsx
@@ -4,6 +4,7 @@ import { ActionIcon, Avatar, Badge, Box, Group, Paper, Stack, Text, Tooltip } fr
import { IconLock, IconPin, IconPinFilled } from "@tabler/icons-react";
import { formatRelative } from "date-fns";
import type { PropsWithChildren } from "react";
+import { useParams } from "react-router";
import { Icons } from "@/icons";
import { avatarUrl } from "@/lib/avatar";
import { testId } from "@/lib/testUtils";
@@ -110,7 +111,8 @@ export const ProjectListItem = ({
canPin?: boolean;
onSearchOwner?: (term: string) => void;
}>) => {
- const link = `/projects/${project.id}/overview`;
+ const { workspaceId } = useParams();
+ const link = `/w/${workspaceId}/projects/${project.id}/home`;
const languageLabel = project.language
? LANGUAGE_LABELS[project.language] ?? project.language.toUpperCase()
: null;
diff --git a/echo/frontend/src/components/project/ProjectQRCode.tsx b/echo/frontend/src/components/project/ProjectQRCode.tsx
index 24cf92b46..483c41636 100644
--- a/echo/frontend/src/components/project/ProjectQRCode.tsx
+++ b/echo/frontend/src/components/project/ProjectQRCode.tsx
@@ -16,6 +16,7 @@ import {
IconPresentation,
} from "@tabler/icons-react";
import { useMemo, useRef } from "react";
+import { useParams } from "react-router";
import { PARTICIPANT_BASE_URL } from "@/config";
import { useAppPreferences } from "@/hooks/useAppPreferences";
import { testId } from "@/lib/testUtils";
@@ -80,11 +81,12 @@ export const useProjectSharingLink = (project?: Project) => {
export const ProjectQRCode = ({ project }: ProjectQRCodeProps) => {
const link = useProjectSharingLink(project);
const qrRef = useRef(null);
+ const { workspaceId } = useParams();
const handleOpenHostGuide = () => {
if (!project) return;
// Open quick start page in new tab
- const hostGuideUrl = `/projects/${project.id}/host-guide`;
+ const hostGuideUrl = `/w/${workspaceId}/projects/${project.id}/host-guide`;
window.open(hostGuideUrl, "_blank");
};
diff --git a/echo/frontend/src/components/project/ProjectSidebar.tsx b/echo/frontend/src/components/project/ProjectSidebar.tsx
deleted file mode 100644
index 11deead42..000000000
--- a/echo/frontend/src/components/project/ProjectSidebar.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-import { t } from "@lingui/core/macro";
-import { Trans } from "@lingui/react/macro";
-import {
- ActionIcon,
- Box,
- Group,
- LoadingOverlay,
- Stack,
- Title,
- Tooltip,
-} from "@mantine/core";
-import { GraphIcon, HouseIcon, QuestionIcon } from "@phosphor-icons/react";
-import { useRef } from "react";
-import { useLocation, useParams } from "react-router";
-import { useInitializeChatModeMutation } from "@/components/chat/hooks";
-import { useConversationById } from "@/components/conversation/hooks";
-import { useProjectById } from "@/components/project/hooks";
-import { useI18nNavigate } from "@/hooks/useI18nNavigate";
-
-import { testId } from "@/lib/testUtils";
-import { Breadcrumbs } from "../common/Breadcrumbs";
-import { I18nLink } from "../common/i18nLink";
-import { LogoDembrane } from "../common/Logo";
-import { NavigationButton } from "../common/NavigationButton";
-import { ReportModalNavigationButton } from "../report/ReportModalNavigationButton";
-import { useCreateChatMutation } from "./hooks";
-import { ProjectAccordion } from "./ProjectAccordion";
-import { ProjectQRCode } from "./ProjectQRCode";
-
-export const ProjectSidebar = () => {
- const { projectId, conversationId } = useParams();
- const qrCodeRef = useRef(null);
- const navigate = useI18nNavigate();
-
- const projectQuery = useProjectById({
- projectId: projectId ?? "",
- query: {
- fields: [
- "id",
- "name",
- "language",
- "is_conversation_allowed",
- "default_conversation_title",
- ],
- },
- });
- const { pathname } = useLocation();
-
- // const { isCollapsed, toggleSidebar } = useSidebarCollapsed();
-
- const conversationQuery = useConversationById({
- conversationId: conversationId ?? "",
- useQueryOpts: { enabled: !!conversationId },
- });
- const isConversationLocked = !!conversationQuery.data?.locked;
-
- const createChatMutation = useCreateChatMutation();
- const initializeModeMutation = useInitializeChatModeMutation();
-
- const handleAsk = async () => {
- if (conversationId) {
- // When clicking Ask from a conversation, create chat and go to deep_dive mode
- try {
- const chat = await createChatMutation.mutateAsync({
- conversationId: conversationId,
- navigateToNewChat: false,
- project_id: { id: projectId ?? "" },
- });
-
- if (chat?.id) {
- // Initialize deep_dive mode
- await initializeModeMutation.mutateAsync({
- chatId: chat.id,
- mode: "deep_dive",
- projectId: projectId ?? "",
- });
- navigate(`/projects/${projectId}/chats/${chat.id}`);
- }
- } catch (error) {
- console.error("Failed to create chat:", error);
- }
- } else {
- // Otherwise, navigate to mode selection
- navigate(`/projects/${projectId}/chats/new`);
- }
- };
-
- if (!projectId) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
- ),
- link: "/projects",
- },
- {
- label: (
-
-
- {projectQuery.data?.name}
-
-
- ),
- },
- ]}
- />
- {/*
-
-
-
-
-
-
- */}
- {/*
- {!isCollapsed && (
-
-
-
- )} */}
-
-
-
- }
- active={pathname.includes("chat")}
- disabled={!!conversationId && isConversationLocked}
- {...testId("sidebar-ask-button")}
- >
- Ask
-
-
-
- }
- active={pathname.includes("library")}
- {...testId("sidebar-library-button")}
- >
- Library
-
-
-
-
-
-
-
-
-
-
-
-
-
- Powered by
-
-
-
-
-
- );
-};
diff --git a/echo/frontend/src/components/project/hooks/index.ts b/echo/frontend/src/components/project/hooks/index.ts
index 71d89b746..f4ad1534e 100644
--- a/echo/frontend/src/components/project/hooks/index.ts
+++ b/echo/frontend/src/components/project/hooks/index.ts
@@ -9,6 +9,7 @@ import {
import { toast } from "@/components/common/Toaster";
import { useAddChatContextMutation } from "@/components/conversation/hooks";
import { API_BASE_URL } from "@/config";
+import { useParams } from "react-router";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import {
api,
@@ -318,6 +319,7 @@ export const useCreateChatMutation = () => {
const navigate = useI18nNavigate();
const queryClient = useQueryClient();
const addChatContextMutation = useAddChatContextMutation();
+ const { workspaceId } = useParams();
return useMutation({
mutationFn: async (payload: {
navigateToNewChat?: boolean;
@@ -345,7 +347,9 @@ export const useCreateChatMutation = () => {
const chat = (await res.json()) as { id: string };
if (payload.navigateToNewChat && chat?.id) {
- navigate(`/projects/${payload.project_id.id}/chats/${chat.id}`);
+ navigate(
+ `/w/${workspaceId}/projects/${payload.project_id.id}/chats/${chat.id}`,
+ );
}
if (payload.conversationId) {
@@ -478,6 +482,10 @@ export const useProjectById = ({
query?: Partial>;
}) => {
return useQuery({
+ // Skip the fetch when projectId hasn't resolved yet — otherwise we
+ // hammer /api/v2/projects//bff with an empty id during transient
+ // renders (sidebar mounts before scope params land).
+ enabled: !!projectId,
// BFF migration (2026-04-24): the frontend used to call Directus
// directly via readItem("project", ...), but Directus row-level
// ACL doesn't know about our v2 inheritance/sharing model — a
diff --git a/echo/frontend/src/components/quote/Quote.tsx b/echo/frontend/src/components/quote/Quote.tsx
index 4f5ea4242..274b25872 100644
--- a/echo/frontend/src/components/quote/Quote.tsx
+++ b/echo/frontend/src/components/quote/Quote.tsx
@@ -22,7 +22,7 @@ export const Quote = ({
data: AspectSegment;
className?: string;
}) => {
- const { projectId } = useParams();
+ const { projectId, workspaceId } = useParams();
const [showTranscript, setShowTranscript] = useState(false);
const { copyQuote, copied } = useCopyQuote();
@@ -172,7 +172,7 @@ export const Quote = ({
className="border-t border-gray-200 dark:border-gray-700"
>
{
const { data } = useProjectConversationCounts(projectId);
+ const { workspaceId } = useParams();
if (!data) return null;
@@ -89,7 +90,7 @@ export const ConversationStatusTable = ({
View
diff --git a/echo/frontend/src/components/report/ReportModalNavigationButton.tsx b/echo/frontend/src/components/report/ReportModalNavigationButton.tsx
index 7f7888926..dfecee08a 100644
--- a/echo/frontend/src/components/report/ReportModalNavigationButton.tsx
+++ b/echo/frontend/src/components/report/ReportModalNavigationButton.tsx
@@ -16,7 +16,7 @@ export const ReportModalNavigationButton = () => {
const navigate = useI18nNavigate();
- const { projectId } = useParams();
+ const { projectId, workspaceId } = useParams();
const { pathname } = useLocation();
const { data: projectReport, isFetching: isLoadingProjectReport } =
@@ -24,16 +24,16 @@ export const ReportModalNavigationButton = () => {
const handleClick = useCallback(() => {
if (projectReport) {
- navigate(`/projects/${projectId}/report`);
+ navigate(`/w/${workspaceId}/projects/${projectId}/report`);
} else {
open();
}
- }, [projectReport, navigate, open, projectId]);
+ }, [projectReport, navigate, open, projectId, workspaceId]);
const handleSuccess = useCallback(() => {
close();
- navigate(`/projects/${projectId}/report`);
- }, [navigate, projectId, close]);
+ navigate(`/w/${workspaceId}/projects/${projectId}/report`);
+ }, [navigate, projectId, workspaceId, close]);
return (
<>
diff --git a/echo/frontend/src/components/settings/FontSettingsCard.tsx b/echo/frontend/src/components/settings/FontSettingsCard.tsx
index a588a188c..cca96b748 100644
--- a/echo/frontend/src/components/settings/FontSettingsCard.tsx
+++ b/echo/frontend/src/components/settings/FontSettingsCard.tsx
@@ -56,9 +56,9 @@ export const FontSettingsCard = () => {
setFontFamily(newTheme);
}, 800);
- // Wait for transition to complete then navigate to projects
+ // Wait for transition to complete then navigate home.
await transitionPromise;
- navigate("/projects");
+ navigate("/");
};
return (
diff --git a/echo/frontend/src/components/settings/MyAccessCard.tsx b/echo/frontend/src/components/settings/MyAccessCard.tsx
index fc291b0dd..73e223a00 100644
--- a/echo/frontend/src/components/settings/MyAccessCard.tsx
+++ b/echo/frontend/src/components/settings/MyAccessCard.tsx
@@ -64,8 +64,8 @@ async function fetchAccess(): Promise {
export const MyAccessCard = () => {
const navigate = useI18nNavigate();
const { data, isLoading } = useQuery({
- queryKey: ["v2", "workspaces"],
queryFn: fetchAccess,
+ queryKey: ["v2", "workspaces"],
staleTime: 60_000,
});
@@ -79,7 +79,8 @@ export const MyAccessCard = () => {
if (!data) return out;
for (const ws of data.workspaces) {
const key = ws.org_id || "__orphan__";
- const organisation = data.organisations.find((t) => t.id === ws.org_id) ?? null;
+ const organisation =
+ data.organisations.find((t) => t.id === ws.org_id) ?? null;
const existing = out.get(key);
if (existing) existing.workspaces.push(ws);
else out.set(key, { organisation, workspaces: [ws] });
@@ -145,86 +146,85 @@ export const MyAccessCard = () => {
) : (
- {Array.from(byOrganisation.values()).map(({ organisation, workspaces }) => (
-
- {/* Organisation header sits flush-left so the eye reads
+ {Array.from(byOrganisation.values()).map(
+ ({ organisation, workspaces }) => (
+
+ {/* Organisation header sits flush-left so the eye reads
"organisation → workspaces" as a hierarchy. Only the
workspace rows are indented + rule'd. */}
-
-
-
- {organisation?.name ?? t`(direct workspace access)`}
-
+
+
+
+ {organisation?.name ?? t`(direct workspace access)`}
+
+ {organisation && (
+
+ {displayRole(organisation.role)}
+
+ )}
+
{organisation && (
- }
+ onClick={() => navigate(`/o/${organisation.id}`)}
>
- {displayRole(organisation.role)}
-
+ Open organisation
+
)}
- {organisation && (
- }
- onClick={() => navigate(`/o/${organisation.id}`)}
- >
- Open organisation
-
- )}
-
-
- {workspaces.map((ws) => (
-
- navigate(`/w/${ws.id}/projects`)
- }
- >
-
-
- {ws.name}
+
+ {workspaces.map((ws) => (
+ navigate(`/w/${ws.id}/home`)}
+ >
+
+
+ {ws.name}
+
+
+ {displayRole(ws.role)}
+
+
+
+
+ {" · "}
+
+ {ws.tier}
+
-
- {displayRole(ws.role)}
-
-
-
- {" · "}
-
- {ws.tier}
-
-
-
- ))}
+ ))}
+
-
- ))}
+ ),
+ )}
)}
diff --git a/echo/frontend/src/components/view/View.tsx b/echo/frontend/src/components/view/View.tsx
index 63a80cdba..0cbfff336 100644
--- a/echo/frontend/src/components/view/View.tsx
+++ b/echo/frontend/src/components/view/View.tsx
@@ -48,7 +48,7 @@ export const ViewExpandedCard = ({
data: View;
isLibraryEnabled: boolean;
}) => {
- const { projectId } = useParams();
+ const { projectId, workspaceId } = useParams();
const { copyView, copied } = useCopyView();
const [opened, { open, close }] = useDisclosure(false);
@@ -87,7 +87,7 @@ export const ViewExpandedCard = ({
)}
-
+
diff --git a/echo/frontend/src/features/sidebar/AppSidebar.tsx b/echo/frontend/src/features/sidebar/AppSidebar.tsx
new file mode 100644
index 000000000..998622013
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/AppSidebar.tsx
@@ -0,0 +1,85 @@
+import { ErrorBoundary } from "@/components/error/ErrorBoundary";
+import { ViewTransition } from "./animations/ViewTransition";
+import { HelpBlock } from "./blocks/HelpBlock";
+import { InboxBlock } from "./blocks/InboxBlock";
+import { SearchBlock } from "./blocks/SearchBlock";
+import { useRecordRecents } from "./hooks/useRecordRecents";
+import { useSidebarView } from "./hooks/useSidebarView";
+import { SidebarHeader } from "./shell/SidebarHeader";
+import { SidebarShell } from "./shell/SidebarShell";
+import { useSidebarWhitelabelLogo } from "./shell/useSidebarWhitelabelLogo";
+import { AdminHomeView } from "./views/admin/AdminHomeView";
+import { HelpView } from "./views/HelpView";
+import { InboxView } from "./views/InboxView";
+import { OrgHomeView } from "./views/org/OrgHomeView";
+import { OrgSettingsView } from "./views/org/OrgSettingsView";
+import { ProjectHomeView } from "./views/project/ProjectHomeView";
+import { ProjectSettingsView } from "./views/project/ProjectSettingsView";
+import { UserHomeView } from "./views/user/UserHomeView";
+import { UserSettingsView } from "./views/user/UserSettingsView";
+import { WorkspaceHomeView } from "./views/workspace/WorkspaceHomeView";
+import { WorkspaceSettingsView } from "./views/workspace/WorkspaceSettingsView";
+
+export const AppSidebar = () => {
+ useSidebarWhitelabelLogo();
+ useRecordRecents();
+ const { view } = useSidebarView();
+
+ const content = (() => {
+ switch (view) {
+ case "inbox":
+ return ;
+ case "help":
+ return ;
+ case "user-home":
+ return ;
+ case "user-settings":
+ return ;
+ case "org-home":
+ return ;
+ case "org-settings":
+ return ;
+ case "workspace-home":
+ return ;
+ case "workspace-settings":
+ return ;
+ case "project-home":
+ return ;
+ case "project-settings":
+ return ;
+ case "admin-home":
+ return ;
+ }
+ })();
+
+ return (
+ } footer={}>
+
+
+
+
+
+ }>{content}
+
+
+ );
+};
+
+const ViewError = () => (
+
+
This view couldn't load.
+
+
+);
diff --git a/echo/frontend/src/features/sidebar/animations/ViewTransition.tsx b/echo/frontend/src/features/sidebar/animations/ViewTransition.tsx
new file mode 100644
index 000000000..e00f4dda9
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/animations/ViewTransition.tsx
@@ -0,0 +1,44 @@
+import { motion } from "motion/react";
+import { type ReactNode, useRef } from "react";
+import { useSidebarView, viewDepth } from "../hooks/useSidebarView";
+import {
+ EASE_OUT_CUBIC,
+ TIMINGS,
+ usePrefersReducedMotion,
+ VIEW_SLIDE_PX,
+} from "./motion";
+
+interface ViewTransitionProps {
+ children: ReactNode;
+}
+
+// AnimatePresence-backed wrapper. Direction (push/pop) is derived from
+// the change in view depth — drilling in slides left, going back slides
+// right. URL is the source of truth; this just decorates the swap.
+export const ViewTransition = ({ children }: ViewTransitionProps) => {
+ const { view } = useSidebarView();
+ const prevDepthRef = useRef(viewDepth(view));
+ const direction = useRef<"push" | "pop">("push");
+ const reduced = usePrefersReducedMotion();
+
+ const currentDepth = viewDepth(view);
+ if (currentDepth > prevDepthRef.current) direction.current = "push";
+ else if (currentDepth < prevDepthRef.current) direction.current = "pop";
+ prevDepthRef.current = currentDepth;
+
+ const slide = direction.current === "push" ? VIEW_SLIDE_PX : -VIEW_SLIDE_PX;
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/animations/motion.ts b/echo/frontend/src/features/sidebar/animations/motion.ts
new file mode 100644
index 000000000..4473d5514
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/animations/motion.ts
@@ -0,0 +1,36 @@
+// All sidebar animation tuning lives here. Primitives import from this
+// module so timings and easings can be tweaked in one place. To disable
+// motion for reduced-motion users, the consumer hook returns 0 durations.
+
+export const EASE_OUT_CUBIC: [number, number, number, number] = [
+ 0.32, 0.72, 0, 1,
+];
+
+export const TIMINGS = {
+ activePill: { damping: 30, stiffness: 400, type: "spring" } as const,
+ labelFade: 0.12,
+ sectionExpand: 0.18,
+ treeStagger: 0.03,
+ viewSwap: 0.22,
+ widthSpring: { damping: 36, stiffness: 380, type: "spring" } as const,
+};
+
+export const VIEW_SLIDE_PX = 24;
+
+export const viewSwapVariants = {
+ pop: {
+ animate: { opacity: 1, x: 0 },
+ exit: { opacity: 0, x: VIEW_SLIDE_PX },
+ initial: { opacity: 0, x: -VIEW_SLIDE_PX },
+ },
+ push: {
+ animate: { opacity: 1, x: 0 },
+ exit: { opacity: 0, x: -VIEW_SLIDE_PX },
+ initial: { opacity: 0, x: VIEW_SLIDE_PX },
+ },
+} as const;
+
+export function usePrefersReducedMotion(): boolean {
+ if (typeof window === "undefined" || !window.matchMedia) return false;
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+}
diff --git a/echo/frontend/src/features/sidebar/blocks/HelpBlock.tsx b/echo/frontend/src/features/sidebar/blocks/HelpBlock.tsx
new file mode 100644
index 000000000..8bddca326
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/blocks/HelpBlock.tsx
@@ -0,0 +1,72 @@
+import { Trans } from "@lingui/react/macro";
+import {
+ ChatCircle,
+ EnvelopeSimple,
+ Note,
+ Pulse,
+ Users,
+} from "@phosphor-icons/react";
+import { useState } from "react";
+import { useParams } from "react-router";
+import { FeedbackPortalModal } from "@/components/common/FeedbackPortalModal";
+import { COMMUNITY_SLACK_URL } from "@/config";
+import { NavButton } from "../primitives/NavButton";
+import { SectionLabel } from "../primitives/SectionLabel";
+
+export const HelpBlock = () => {
+ const { language } = useParams();
+ const [feedbackOpen, setFeedbackOpen] = useState(false);
+ const docUrl =
+ language === "nl-NL"
+ ? "https://docs.dembrane.com/nl-NL"
+ : "https://docs.dembrane.com/en-US";
+
+ return (
+ <>
+
+
+ Help
+
+ Contact support}
+ icon={EnvelopeSimple}
+ external
+ onClick={() => {
+ window.location.href = "mailto:support@dembrane.com";
+ }}
+ />
+ Documentation}
+ icon={Note}
+ external
+ onClick={() => window.open(docUrl, "_blank", "noopener,noreferrer")}
+ />
+ Slack community}
+ icon={Users}
+ external
+ onClick={() =>
+ window.open(COMMUNITY_SLACK_URL, "_blank", "noopener,noreferrer")
+ }
+ />
+ System status}
+ icon={Pulse}
+ onClick={() => undefined}
+ badge={Planned}
+ disabled
+ />
+ Feedback}
+ icon={ChatCircle}
+ onClick={() => setFeedbackOpen(true)}
+ />
+
+ setFeedbackOpen(false)}
+ locale={language}
+ />
+ >
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/blocks/InboxBlock.tsx b/echo/frontend/src/features/sidebar/blocks/InboxBlock.tsx
new file mode 100644
index 000000000..07597940c
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/blocks/InboxBlock.tsx
@@ -0,0 +1,26 @@
+import { Trans } from "@lingui/react/macro";
+import { EnvelopeSimple } from "@phosphor-icons/react";
+import { useUnreadAnnouncements } from "@/components/announcement/hooks";
+import { useUnreadNotificationCount } from "@/hooks/useNotifications";
+import { useSidebarOverlayLink } from "../hooks/useSidebarOverlayLink";
+import { useSidebarView } from "../hooks/useSidebarView";
+import { NavItem } from "../primitives/NavItem";
+
+export const InboxBlock = () => {
+ const to = useSidebarOverlayLink("inbox");
+ const { view } = useSidebarView();
+ const { data: unreadNotifications = 0 } = useUnreadNotificationCount();
+ const { data: unreadAnnouncements = 0 } = useUnreadAnnouncements();
+ const total = unreadNotifications + unreadAnnouncements;
+
+ return (
+ Inbox}
+ icon={EnvelopeSimple}
+ pushes
+ active={view === "inbox"}
+ badge={total > 0 ? total : undefined}
+ />
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/blocks/SearchBlock.tsx b/echo/frontend/src/features/sidebar/blocks/SearchBlock.tsx
new file mode 100644
index 000000000..26c6cf337
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/blocks/SearchBlock.tsx
@@ -0,0 +1,231 @@
+import { Trans } from "@lingui/react/macro";
+import { Modal, TextInput, UnstyledButton } from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
+import {
+ Buildings,
+ FolderOpen,
+ Gear,
+ MagnifyingGlass,
+} from "@phosphor-icons/react";
+import { type ComponentType, useEffect, useMemo, useState } from "react";
+import { useNavigate } from "react-router";
+import { useWorkspace } from "@/hooks/useWorkspace";
+import { useRecents } from "../hooks/useRecents";
+
+interface Hit {
+ id: string;
+ icon: ComponentType<{ size?: number }>;
+ label: string;
+ subtitle?: string;
+ href: string;
+}
+
+export const SearchBlock = () => {
+ const [opened, { open, close }] = useDisclosure(false);
+ const [q, setQ] = useState("");
+ const [activeIndex, setActiveIndex] = useState(0);
+ const { workspaces } = useWorkspace();
+ const { items: recents } = useRecents();
+ const navigate = useNavigate();
+
+ // Global ⌘K / Ctrl+K — open palette anywhere.
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
+ e.preventDefault();
+ open();
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open]);
+
+ useEffect(() => {
+ if (!opened) {
+ setQ("");
+ setActiveIndex(0);
+ }
+ }, [opened]);
+
+ const hits = useMemo(() => {
+ const query = q.trim().toLowerCase();
+ const orgs = new Map();
+ for (const ws of workspaces) {
+ if (ws.org_id && !orgs.has(ws.org_id)) {
+ orgs.set(ws.org_id, { name: ws.org_name || "" });
+ }
+ }
+
+ const all: Hit[] = [];
+ for (const [id, o] of orgs) {
+ all.push({
+ href: `/o/${id}/overview`,
+ icon: Buildings,
+ id: `org-${id}`,
+ label: o.name,
+ subtitle: "Organisation",
+ });
+ }
+ for (const ws of workspaces) {
+ all.push({
+ href: `/w/${ws.id}/home`,
+ icon: FolderOpen,
+ id: `ws-${ws.id}`,
+ label: ws.name,
+ subtitle: `${ws.org_name} · Workspace`,
+ });
+ }
+ // Settings as quick shortcuts
+ const settings = [
+ { href: "/settings/account", label: "Account & security" },
+ { href: "/settings/access", label: "My access" },
+ { href: "/settings/appearance", label: "Appearance" },
+ ];
+ for (const s of settings) {
+ all.push({
+ href: s.href,
+ icon: Gear,
+ id: `setting-${s.href}`,
+ label: s.label,
+ subtitle: "Settings",
+ });
+ }
+
+ if (!query) {
+ // No query → show recents (translated to Hits) then everything
+ const recentHits: Hit[] = recents.map((r) => ({
+ href: r.href,
+ icon: r.kind === "project" ? FolderOpen : FolderOpen,
+ id: `recent-${r.kind}-${r.id}`,
+ label: r.label,
+ subtitle: r.parent ?? (r.kind === "project" ? "Project" : "Workspace"),
+ }));
+ const seen = new Set(recentHits.map((h) => h.label.toLowerCase()));
+ const rest = all.filter((h) => !seen.has(h.label.toLowerCase()));
+ return [...recentHits, ...rest].slice(0, 40);
+ }
+
+ return all
+ .filter((h) => {
+ const hay = `${h.label} ${h.subtitle ?? ""}`.toLowerCase();
+ return hay.includes(query);
+ })
+ .slice(0, 40);
+ }, [q, workspaces, recents]);
+
+ const onSelect = (hit: Hit) => {
+ close();
+ navigate(hit.href);
+ };
+
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActiveIndex((i) => Math.min(i + 1, hits.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActiveIndex((i) => Math.max(i - 1, 0));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const hit = hits[activeIndex];
+ if (hit) onSelect(hit);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ Search
+
+
+ ⌘K
+
+
+
+
+
+
+ {
+ setQ(e.currentTarget.value);
+ setActiveIndex(0);
+ }}
+ onKeyDown={onKeyDown}
+ leftSection={}
+ placeholder="Search organisations, workspaces, settings…"
+ variant="unstyled"
+ styles={{ input: { fontSize: 14 } }}
+ />
+
+
+ {hits.length === 0 ? (
+
+ No matches
+
+ ) : (
+ hits.map((hit, i) => {
+ const Icon = hit.icon;
+ const active = i === activeIndex;
+ return (
+
+ );
+ })
+ )}
+
+
+
+ >
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/blocks/SettingsBlock.tsx b/echo/frontend/src/features/sidebar/blocks/SettingsBlock.tsx
new file mode 100644
index 000000000..f1a04c2d3
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/blocks/SettingsBlock.tsx
@@ -0,0 +1,18 @@
+import { Trans } from "@lingui/react/macro";
+import { Gear } from "@phosphor-icons/react";
+import { useSidebarView } from "../hooks/useSidebarView";
+import { NavItem } from "../primitives/NavItem";
+
+export const SettingsBlock = () => {
+ const { view } = useSidebarView();
+
+ return (
+ Settings}
+ icon={Gear}
+ pushes
+ active={view === "user-settings"}
+ />
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/breadcrumbs/AppBreadcrumbs.tsx b/echo/frontend/src/features/sidebar/breadcrumbs/AppBreadcrumbs.tsx
new file mode 100644
index 000000000..681d7cfdd
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/breadcrumbs/AppBreadcrumbs.tsx
@@ -0,0 +1,235 @@
+import { CaretRight } from "@phosphor-icons/react";
+import { useMemo } from "react";
+import { useParams } from "react-router";
+import { I18nLink } from "@/components/common/i18nLink";
+import { useProjectById } from "@/components/project/hooks";
+import { useWorkspace } from "@/hooks/useWorkspace";
+import { useSidebarView } from "../hooks/useSidebarView";
+
+interface Crumb {
+ label: string;
+ href?: string;
+}
+
+const ADMIN_TAB_LABELS: Record = {
+ partners: "Partners",
+ upgrades: "Upgrades",
+ "usage-and-billing": "Usage and billing",
+};
+
+const WORKSPACE_SETTINGS_LABELS: Record = {
+ billing: "Billing",
+ danger: "Danger zone",
+ general: "General",
+ members: "Members",
+};
+
+const PROJECT_SECTION_LABELS: Record = {
+ access: "Access & sharing",
+ chats: "Ask",
+ conversation: "Conversation",
+ conversations: "Conversations",
+ export: "Export",
+ home: "Home",
+ "host-guide": "Host guide",
+ integrations: "Integrations",
+ library: "Explore",
+ overview: "Settings",
+ portal: "Portal editor",
+ "portal-editor": "Portal editor",
+ report: "Report",
+ upload: "Upload",
+};
+
+const USER_SETTINGS_LABELS: Record = {
+ access: "My access",
+ account: "Account & security",
+ appearance: "Appearance",
+ "project-defaults": "Project defaults",
+};
+
+const ORG_SECTION_LABELS: Record = {
+ billing: "Billing",
+ overview: "Overview",
+ people: "Members",
+ usage: "Usage",
+};
+
+const ORG_SETTINGS_LABELS: Record = {
+ billing: "Billing",
+ general: "General",
+ members: "Members",
+ usage: "Usage and tier",
+};
+
+// Brand: "Breadcrumbs for deep nesting only". Render only when there
+// are 2+ meaningful crumbs to show.
+export const AppBreadcrumbs = () => {
+ const { view, params } = useSidebarView();
+ const { orgId: routeOrgId, organisationId } = useParams<{
+ orgId?: string;
+ organisationId?: string;
+ }>();
+ const orgId = routeOrgId ?? organisationId;
+ const { workspaces } = useWorkspace();
+ const projectQuery = useProjectById({
+ projectId: params.projectId ?? "",
+ query: { fields: ["id", "name"] },
+ });
+
+ const workspace = useMemo(
+ () => workspaces.find((w) => w.id === params.workspaceId),
+ [workspaces, params.workspaceId],
+ );
+ const orgNameForId = useMemo(() => {
+ const id = orgId ?? params.orgId;
+ return workspaces.find((w) => w.org_id === id)?.org_name ?? null;
+ }, [workspaces, orgId, params.orgId]);
+
+ const crumbs: Crumb[] = useMemo(() => {
+ // Always start with Home so the trail is anchored to a real
+ // clickable parent.
+ const out: Crumb[] = [{ href: "/", label: "Home" }];
+ switch (view) {
+ case "inbox":
+ case "help":
+ return [];
+ case "user-home":
+ return [];
+ case "user-settings": {
+ out.push({ href: "/settings/account", label: "User settings" });
+ const section = params.section;
+ if (section && USER_SETTINGS_LABELS[section]) {
+ out.push({ label: USER_SETTINGS_LABELS[section] });
+ }
+ return out;
+ }
+ case "admin-home": {
+ out.push({
+ href: "/admin/usage-and-billing",
+ label: "Admin dashboard",
+ });
+ const section = params.section ?? "usage-and-billing";
+ if (ADMIN_TAB_LABELS[section]) {
+ out.push({ label: ADMIN_TAB_LABELS[section] });
+ }
+ return out;
+ }
+ case "org-home": {
+ const name = orgNameForId ?? "Organisation";
+ out.push({ href: `/o/${params.orgId}/overview`, label: name });
+ const section = params.section;
+ if (section && ORG_SECTION_LABELS[section]) {
+ out.push({ label: ORG_SECTION_LABELS[section] });
+ }
+ return out;
+ }
+ case "org-settings": {
+ const name = orgNameForId ?? "Organisation";
+ out.push({ href: `/o/${params.orgId}/overview`, label: name });
+ out.push({
+ href: `/o/${params.orgId}/settings/general`,
+ label: "Settings",
+ });
+ const section = params.section;
+ if (section && ORG_SETTINGS_LABELS[section]) {
+ out.push({ label: ORG_SETTINGS_LABELS[section] });
+ }
+ return out;
+ }
+ case "workspace-home":
+ return workspace ? [...out, { label: workspace.name }] : [];
+ case "workspace-settings": {
+ if (workspace) {
+ out.push({
+ href: `/w/${workspace.id}/home`,
+ label: workspace.name,
+ });
+ }
+ out.push({ label: "Settings" });
+ const section = params.section;
+ if (section && WORKSPACE_SETTINGS_LABELS[section]) {
+ out.push({ label: WORKSPACE_SETTINGS_LABELS[section] });
+ }
+ return out;
+ }
+ case "project-home": {
+ if (workspace) {
+ out.push({
+ href: `/w/${workspace.id}/home`,
+ label: workspace.name,
+ });
+ }
+ if (projectQuery.data?.name) {
+ out.push({
+ href: `/w/${params.workspaceId}/projects/${params.projectId}/home`,
+ label: projectQuery.data.name,
+ });
+ }
+ const section = params.section;
+ if (section && PROJECT_SECTION_LABELS[section]) {
+ out.push({ label: PROJECT_SECTION_LABELS[section] });
+ }
+ return out;
+ }
+ case "project-settings": {
+ if (workspace) {
+ out.push({
+ href: `/w/${workspace.id}/home`,
+ label: workspace.name,
+ });
+ }
+ if (projectQuery.data?.name) {
+ out.push({
+ href: `/w/${params.workspaceId}/projects/${params.projectId}/home`,
+ label: projectQuery.data.name,
+ });
+ }
+ out.push({ label: "Settings" });
+ const section = params.section;
+ if (section === "access") out.push({ label: "Access & sharing" });
+ else if (section === "overview") out.push({ label: "General" });
+ return out;
+ }
+ }
+ return out;
+ }, [view, params, workspace, orgNameForId, projectQuery.data?.name]);
+
+ // Brand: hide breadcrumbs when there's only one crumb.
+ if (crumbs.length < 2) return null;
+
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/hooks/useRecents.ts b/echo/frontend/src/features/sidebar/hooks/useRecents.ts
new file mode 100644
index 000000000..0b2fdf5ab
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/hooks/useRecents.ts
@@ -0,0 +1,69 @@
+import { useCallback, useEffect, useState } from "react";
+
+const KEY = "dembrane.sidebar.recents";
+const MAX = 8;
+
+export interface RecentItem {
+ kind: "workspace" | "project";
+ id: string;
+ label: string;
+ href: string;
+ // Parent label for context, e.g. workspace name on a project entry.
+ parent?: string;
+ lastVisited: number;
+}
+
+function read(): RecentItem[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = window.localStorage.getItem(KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function write(items: RecentItem[]): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(KEY, JSON.stringify(items));
+ } catch {
+ // quota / private mode — ignore
+ }
+}
+
+export function useRecents() {
+ const [items, setItems] = useState(() => read());
+
+ useEffect(() => {
+ const onStorage = (e: StorageEvent) => {
+ if (e.key !== KEY) return;
+ setItems(read());
+ };
+ window.addEventListener("storage", onStorage);
+ return () => window.removeEventListener("storage", onStorage);
+ }, []);
+
+ const record = useCallback((item: Omit) => {
+ setItems((prev) => {
+ const filtered = prev.filter(
+ (p) => !(p.kind === item.kind && p.id === item.id),
+ );
+ const next = [{ ...item, lastVisited: Date.now() }, ...filtered].slice(
+ 0,
+ MAX,
+ );
+ write(next);
+ return next;
+ });
+ }, []);
+
+ const clear = useCallback(() => {
+ setItems([]);
+ write([]);
+ }, []);
+
+ return { clear, items, record };
+}
diff --git a/echo/frontend/src/features/sidebar/hooks/useRecordRecents.ts b/echo/frontend/src/features/sidebar/hooks/useRecordRecents.ts
new file mode 100644
index 000000000..ccc9b289d
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/hooks/useRecordRecents.ts
@@ -0,0 +1,56 @@
+import { useEffect } from "react";
+import { useProjectById } from "@/components/project/hooks";
+import { useWorkspace } from "@/hooks/useWorkspace";
+import { useRecents } from "./useRecents";
+import { useSidebarView } from "./useSidebarView";
+
+// Watches the current sidebar view and records recent visits. Mounted
+// once in AppSidebar so navigation anywhere in the app feeds the
+// recents list.
+export function useRecordRecents(): void {
+ const { view, params } = useSidebarView();
+ const { workspaces } = useWorkspace();
+ const { record } = useRecents();
+
+ const projectQuery = useProjectById({
+ projectId: params.projectId ?? "",
+ query: { fields: ["id", "name"] },
+ });
+
+ // Workspace visits
+ useEffect(() => {
+ if (view !== "workspace-home") return;
+ const ws = workspaces.find((w) => w.id === params.workspaceId);
+ if (!ws) return;
+ record({
+ href: `/w/${ws.id}/home`,
+ id: ws.id,
+ kind: "workspace",
+ label: ws.name,
+ parent: ws.org_name ?? undefined,
+ });
+ }, [view, params.workspaceId, workspaces, record]);
+
+ // Project visits
+ useEffect(() => {
+ if (view !== "project-home" && view !== "project-settings") return;
+ const pid = params.projectId;
+ const name = projectQuery.data?.name;
+ if (!pid || !name) return;
+ const ws = workspaces.find((w) => w.id === params.workspaceId);
+ record({
+ href: `/w/${params.workspaceId}/projects/${pid}/home`,
+ id: pid,
+ kind: "project",
+ label: name,
+ parent: ws?.name,
+ });
+ }, [
+ view,
+ params.projectId,
+ params.workspaceId,
+ projectQuery.data?.name,
+ workspaces,
+ record,
+ ]);
+}
diff --git a/echo/frontend/src/features/sidebar/hooks/useSidebarOverlayLink.ts b/echo/frontend/src/features/sidebar/hooks/useSidebarOverlayLink.ts
new file mode 100644
index 000000000..b5c01ec2d
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/hooks/useSidebarOverlayLink.ts
@@ -0,0 +1,13 @@
+import { useMemo } from "react";
+import { useLocation } from "react-router";
+
+export function useSidebarOverlayLink(view: "inbox" | "help") {
+ const { pathname, search } = useLocation();
+
+ return useMemo(() => {
+ const params = new URLSearchParams(search);
+ params.set("sidebar", view);
+ const next = params.toString();
+ return `${pathname}${next ? `?${next}` : ""}`;
+ }, [pathname, search, view]);
+}
diff --git a/echo/frontend/src/features/sidebar/hooks/useSidebarState.ts b/echo/frontend/src/features/sidebar/hooks/useSidebarState.ts
new file mode 100644
index 000000000..f49eb2e8b
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/hooks/useSidebarState.ts
@@ -0,0 +1,142 @@
+import { useCallback, useEffect, useState } from "react";
+
+const WIDTH_KEY = "dembrane.sidebar.width";
+const EXPANDED_KEY = "dembrane.sidebar.expanded";
+const LOCAL_STATE_EVENT = "dembrane.sidebar.local-state";
+
+export const SIDEBAR_WIDTH_DEFAULT = 240;
+export const SIDEBAR_WIDTH_MIN = 180;
+export const SIDEBAR_WIDTH_MAX = 320;
+
+export interface SidebarState {
+ width: number;
+ expandedNodes: Record;
+ setWidth: (n: number) => void;
+ isNodeExpanded: (id: string) => boolean;
+ setNodeExpanded: (id: string, open: boolean) => void;
+ toggleNode: (id: string) => void;
+}
+
+function clamp(n: number, lo: number, hi: number): number {
+ return Math.min(hi, Math.max(lo, n));
+}
+
+function readLS(key: string, fallback: T): T {
+ if (typeof window === "undefined") return fallback;
+ try {
+ const raw = window.localStorage.getItem(key);
+ if (raw == null) return fallback;
+ return JSON.parse(raw) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+function writeLS(key: string, value: T): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ } catch {
+ // quota or private mode — silently ignore
+ }
+}
+
+function emitLocalStateChange(key: string, value: T): void {
+ if (typeof window === "undefined") return;
+ window.dispatchEvent(
+ new CustomEvent(LOCAL_STATE_EVENT, { detail: { key, value } }),
+ );
+}
+
+// Small inline localStorage state hook. Syncs same-page subscribers via a
+// custom event and open tabs via the storage event.
+function useLocalState(
+ key: string,
+ initial: T,
+): [T, (next: T | ((prev: T) => T)) => void] {
+ const [value, setValue] = useState(() => readLS(key, initial));
+
+ useEffect(() => {
+ const onStorage = (e: StorageEvent) => {
+ if (e.key !== key) return;
+ if (e.newValue == null) {
+ setValue(initial);
+ return;
+ }
+ try {
+ setValue(JSON.parse(e.newValue) as T);
+ } catch {
+ /* ignore */
+ }
+ };
+ const onLocalState = (e: Event) => {
+ const detail = (e as CustomEvent<{ key: string; value: T }>).detail;
+ if (detail?.key !== key) return;
+ setValue(detail.value);
+ };
+ window.addEventListener("storage", onStorage);
+ window.addEventListener(LOCAL_STATE_EVENT, onLocalState);
+ return () => {
+ window.removeEventListener("storage", onStorage);
+ window.removeEventListener(LOCAL_STATE_EVENT, onLocalState);
+ };
+ }, [key, initial]);
+
+ const set = useCallback(
+ (next: T | ((prev: T) => T)) => {
+ setValue((prev) => {
+ const resolved =
+ typeof next === "function" ? (next as (p: T) => T)(prev) : next;
+ writeLS(key, resolved);
+ queueMicrotask(() => emitLocalStateChange(key, resolved));
+ return resolved;
+ });
+ },
+ [key],
+ );
+
+ return [value, set];
+}
+
+export function useSidebarState(): SidebarState {
+ const [width, setWidthRaw] = useLocalState(
+ WIDTH_KEY,
+ SIDEBAR_WIDTH_DEFAULT,
+ );
+ const [expandedNodes, setExpandedNodes] = useLocalState<
+ Record
+ >(EXPANDED_KEY, {});
+
+ const setWidth = useCallback(
+ (n: number) => setWidthRaw(clamp(n, SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX)),
+ [setWidthRaw],
+ );
+
+ const isNodeExpanded = useCallback(
+ (id: string) => expandedNodes[id] === true,
+ [expandedNodes],
+ );
+
+ const setNodeExpanded = useCallback(
+ (id: string, open: boolean) => {
+ setExpandedNodes((prev) => ({ ...prev, [id]: open }));
+ },
+ [setExpandedNodes],
+ );
+
+ const toggleNode = useCallback(
+ (id: string) => {
+ setExpandedNodes((prev) => ({ ...prev, [id]: !prev[id] }));
+ },
+ [setExpandedNodes],
+ );
+
+ return {
+ expandedNodes,
+ isNodeExpanded,
+ setNodeExpanded,
+ setWidth,
+ toggleNode,
+ width,
+ };
+}
diff --git a/echo/frontend/src/features/sidebar/hooks/useSidebarView.ts b/echo/frontend/src/features/sidebar/hooks/useSidebarView.ts
new file mode 100644
index 000000000..820820c50
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/hooks/useSidebarView.ts
@@ -0,0 +1,199 @@
+import { useMemo } from "react";
+import { useLocation } from "react-router";
+import type { ResolvedSidebarView, SidebarViewId } from "../types";
+
+const LOCALE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
+
+function stripLocale(segments: string[]): string[] {
+ if (segments[0] && LOCALE_RE.test(segments[0])) return segments.slice(1);
+ return segments;
+}
+
+// Temporary: sidebar lives under /sidebar-preview during development.
+// Remove once the sidebar replaces production layouts.
+const PREVIEW_PREFIX = "sidebar-preview";
+
+function stripPreview(segments: string[]): string[] {
+ if (segments[0] === PREVIEW_PREFIX) return segments.slice(1);
+ return segments;
+}
+
+const OVERLAY_VIEWS = new Set(["inbox", "help"]);
+
+function withoutSidebarSearch(search: string): string {
+ const params = new URLSearchParams(search);
+ params.delete("sidebar");
+ const next = params.toString();
+ return next ? `?${next}` : "";
+}
+
+export function resolveSidebarView(
+ pathname: string,
+ search = "",
+): ResolvedSidebarView {
+ const raw = pathname.split("/").filter(Boolean);
+ const segs = stripPreview(stripLocale(raw));
+
+ const overlay = new URLSearchParams(search).get("sidebar");
+ if (overlay && OVERLAY_VIEWS.has(overlay)) {
+ const base = resolveSidebarView(pathname, withoutSidebarSearch(search));
+ if (overlay === "inbox") {
+ return {
+ backTo: `${pathname}${withoutSidebarSearch(search)}`,
+ params: base.params,
+ scope: base.scope,
+ view: "inbox",
+ };
+ }
+ return {
+ backTo: `${pathname}${withoutSidebarSearch(search)}`,
+ params: base.params,
+ scope: base.scope,
+ view: "help",
+ };
+ }
+
+ // /settings/* → UserSettings
+ if (segs[0] === "settings") {
+ return {
+ backTo: "/",
+ params: { section: segs[1] },
+ scope: "user",
+ view: "user-settings",
+ };
+ }
+
+ // /admin/* → AdminHome (staff-only surface)
+ if (segs[0] === "admin") {
+ return {
+ backTo: "/",
+ params: { section: segs[1] },
+ scope: "admin",
+ view: "admin-home",
+ };
+ }
+
+ // /o/:orgId/...
+ if (segs[0] === "o" && segs[1]) {
+ const orgId = segs[1];
+ if (segs[2] === "settings") {
+ return {
+ backTo: `/o/${orgId}/overview`,
+ params: { orgId, section: segs[3] },
+ scope: "org",
+ view: "org-settings",
+ };
+ }
+ return {
+ backTo: "/",
+ params: { orgId, section: segs[2] },
+ scope: "org",
+ view: "org-home",
+ };
+ }
+
+ // /w/:workspaceId/...
+ if (segs[0] === "w" && segs[1] && segs[1] !== "new") {
+ const workspaceId = segs[1];
+
+ // /w/:wsId/projects/:projectId/...
+ if (segs[2] === "projects" && segs[3] && segs[3] !== "new") {
+ const projectId = segs[3];
+ // Settings context: explicit /settings/ or the legacy
+ // /overview and /access pages which ARE the settings panels.
+ if (
+ segs[4] === "settings" ||
+ segs[4] === "overview" ||
+ segs[4] === "access"
+ ) {
+ return {
+ backTo: `/w/${workspaceId}/projects/${projectId}/home`,
+ params: {
+ projectId,
+ section: segs[4] === "settings" ? segs[5] : segs[4],
+ workspaceId,
+ },
+ scope: "project",
+ view: "project-settings",
+ };
+ }
+ return {
+ backTo: `/w/${workspaceId}/home`,
+ params: { projectId, section: segs[4], workspaceId },
+ scope: "project",
+ view: "project-home",
+ };
+ }
+
+ // /w/:wsId/projects/new and the retired projects index stay in the
+ // workspace layer. The content route may redirect, but the sidebar
+ // should not open a separate Projects menu anymore.
+ if (segs[2] === "projects") {
+ return {
+ backTo: "/",
+ params: { workspaceId },
+ scope: "workspace",
+ view: "workspace-home",
+ };
+ }
+
+ // /w/:wsId/settings/*
+ if (segs[2] === "settings") {
+ return {
+ backTo: `/w/${workspaceId}/home`,
+ params: { section: segs[3], workspaceId },
+ scope: "workspace",
+ view: "workspace-settings",
+ };
+ }
+
+ return {
+ backTo: "/",
+ params: { section: segs[2], workspaceId },
+ scope: "workspace",
+ view: "workspace-home",
+ };
+ }
+
+ return { backTo: null, params: {}, scope: "user", view: "user-home" };
+}
+
+export function useSidebarView(): ResolvedSidebarView {
+ const { pathname, search } = useLocation();
+ return useMemo(
+ () => resolveSidebarView(pathname, search),
+ [pathname, search],
+ );
+}
+
+export const VIEW_IDS: readonly SidebarViewId[] = [
+ "inbox",
+ "help",
+ "user-home",
+ "user-settings",
+ "org-home",
+ "org-settings",
+ "workspace-home",
+ "workspace-settings",
+ "project-home",
+ "project-settings",
+ "admin-home",
+] as const;
+
+const VIEW_DEPTH: Record = {
+ "admin-home": 1,
+ help: 9,
+ inbox: 9,
+ "org-home": 1,
+ "org-settings": 2,
+ "project-home": 4,
+ "project-settings": 5,
+ "user-home": 0,
+ "user-settings": 1,
+ "workspace-home": 2,
+ "workspace-settings": 3,
+};
+
+export function viewDepth(view: SidebarViewId): number {
+ return VIEW_DEPTH[view];
+}
diff --git a/echo/frontend/src/features/sidebar/index.ts b/echo/frontend/src/features/sidebar/index.ts
new file mode 100644
index 000000000..6fa8f6089
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/index.ts
@@ -0,0 +1,18 @@
+export { AppSidebar } from "./AppSidebar";
+export {
+ SIDEBAR_WIDTH_DEFAULT,
+ SIDEBAR_WIDTH_MAX,
+ SIDEBAR_WIDTH_MIN,
+ useSidebarState,
+} from "./hooks/useSidebarState";
+export {
+ resolveSidebarView,
+ useSidebarView,
+ VIEW_IDS,
+ viewDepth,
+} from "./hooks/useSidebarView";
+export type {
+ ResolvedSidebarView,
+ SidebarScope,
+ SidebarViewId,
+} from "./types";
diff --git a/echo/frontend/src/features/sidebar/primitives/BackButton.tsx b/echo/frontend/src/features/sidebar/primitives/BackButton.tsx
new file mode 100644
index 000000000..098075f34
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/primitives/BackButton.tsx
@@ -0,0 +1,25 @@
+import { ArrowLeft } from "@phosphor-icons/react";
+import type { ReactNode } from "react";
+import { I18nLink } from "@/components/common/i18nLink";
+
+interface BackButtonProps {
+ to: string;
+ label: ReactNode;
+}
+
+export const BackButton = ({ to, label }: BackButtonProps) => {
+ return (
+
+
+ {label}
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/primitives/NavButton.tsx b/echo/frontend/src/features/sidebar/primitives/NavButton.tsx
new file mode 100644
index 000000000..5180711a3
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/primitives/NavButton.tsx
@@ -0,0 +1,64 @@
+import { ArrowUpRight, CaretRight, type Icon } from "@phosphor-icons/react";
+import type { ReactNode } from "react";
+
+interface NavButtonProps {
+ label: ReactNode;
+ icon?: Icon;
+ onClick: () => void;
+ pushes?: boolean;
+ badge?: ReactNode;
+ destructive?: boolean;
+ disabled?: boolean;
+ external?: boolean;
+}
+
+export const NavButton = ({
+ label,
+ icon: Icon,
+ onClick,
+ pushes,
+ badge,
+ destructive,
+ disabled,
+ external,
+}: NavButtonProps) => {
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/primitives/NavItem.tsx b/echo/frontend/src/features/sidebar/primitives/NavItem.tsx
new file mode 100644
index 000000000..2b3be9c1d
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/primitives/NavItem.tsx
@@ -0,0 +1,101 @@
+import { CaretRight, type Icon } from "@phosphor-icons/react";
+import { motion } from "motion/react";
+import type { ReactNode } from "react";
+import { NavLink, useMatch, useParams, useResolvedPath } from "react-router";
+import { SUPPORTED_LANGUAGES } from "@/config";
+import { useLanguage } from "@/hooks/useLanguage";
+import { TIMINGS } from "../animations/motion";
+
+interface NavItemProps {
+ to: string;
+ label: ReactNode;
+ icon?: Icon;
+ pushes?: boolean;
+ end?: boolean;
+ badge?: ReactNode;
+ active?: boolean;
+ muted?: boolean;
+ accent?: string;
+}
+
+function useLocalePath(to: string): string {
+ const { language } = useParams<{ language?: string }>();
+ const { language: i18nLanguage } = useLanguage();
+ const finalLanguage = language ?? i18nLanguage;
+ if (
+ to.startsWith("./") ||
+ to.startsWith("../") ||
+ to === "." ||
+ to === ".."
+ ) {
+ return to;
+ }
+ const alreadyPrefixed = SUPPORTED_LANGUAGES.some(
+ (lang) => to === `/${lang}` || to.startsWith(`/${lang}/`),
+ );
+ if (alreadyPrefixed || !finalLanguage) return to;
+ return `/${finalLanguage}${to}`;
+}
+
+export const NavItem = ({
+ to,
+ label,
+ icon: Icon,
+ pushes,
+ end,
+ badge,
+ active: forcedActive,
+ muted,
+ accent,
+}: NavItemProps) => {
+ const localePath = useLocalePath(to);
+ const resolved = useResolvedPath(localePath);
+ const match = useMatch({ end: end ?? false, path: resolved.pathname });
+ const active = forcedActive ?? match != null;
+
+ return (
+
+ {active && (
+
+ )}
+
+ {Icon ? : null}
+ {label}
+
+ {badge && (
+
+ {badge}
+
+ )}
+ {pushes && (
+
+ )}
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/primitives/SectionLabel.tsx b/echo/frontend/src/features/sidebar/primitives/SectionLabel.tsx
new file mode 100644
index 000000000..1c47b5d67
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/primitives/SectionLabel.tsx
@@ -0,0 +1,16 @@
+import type { ReactNode } from "react";
+
+interface SectionLabelProps {
+ children: ReactNode;
+}
+
+export const SectionLabel = ({ children }: SectionLabelProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/primitives/ViewHeader.tsx b/echo/frontend/src/features/sidebar/primitives/ViewHeader.tsx
new file mode 100644
index 000000000..45c53a227
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/primitives/ViewHeader.tsx
@@ -0,0 +1,26 @@
+import { ArrowLeft } from "@phosphor-icons/react";
+import type { ReactNode } from "react";
+import { I18nLink } from "@/components/common/i18nLink";
+
+interface ViewHeaderProps {
+ to: string;
+ title: ReactNode;
+}
+
+export const ViewHeader = ({ to, title }: ViewHeaderProps) => {
+ return (
+
+
+ {title}
+
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/shell/ResizeHandle.tsx b/echo/frontend/src/features/sidebar/shell/ResizeHandle.tsx
new file mode 100644
index 000000000..f359cfab1
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/shell/ResizeHandle.tsx
@@ -0,0 +1,98 @@
+import { useEffect, useRef, useState } from "react";
+import {
+ SIDEBAR_WIDTH_DEFAULT,
+ SIDEBAR_WIDTH_MAX,
+ SIDEBAR_WIDTH_MIN,
+ useSidebarState,
+} from "../hooks/useSidebarState";
+
+// Invisible 4px-wide drag handle pinned to the right edge of the
+// sidebar. Drag to set width, clamped by useSidebarState. Hover state
+// gives a subtle Royal Blue accent so the affordance is discoverable
+// without taking visual weight.
+export const ResizeHandle = () => {
+ const { width, setWidth } = useSidebarState();
+ const [dragging, setDragging] = useState(false);
+ const startXRef = useRef(0);
+ const startWidthRef = useRef(width);
+
+ useEffect(() => {
+ if (!dragging) return;
+ const onMove = (e: MouseEvent) => {
+ const delta = e.clientX - startXRef.current;
+ setWidth(startWidthRef.current + delta);
+ };
+ const onUp = () => setDragging(false);
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ document.body.style.cursor = "col-resize";
+ document.body.style.userSelect = "none";
+ return () => {
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ };
+ }, [dragging, setWidth]);
+
+ const onMouseDown = (e: React.MouseEvent) => {
+ startXRef.current = e.clientX;
+ startWidthRef.current = width;
+ setDragging(true);
+ };
+
+ const onDoubleClick = () => {
+ setWidth(SIDEBAR_WIDTH_DEFAULT);
+ };
+
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ setWidth(width - 12);
+ }
+ if (e.key === "ArrowRight") {
+ e.preventDefault();
+ setWidth(width + 12);
+ }
+ if (e.key === "Home") {
+ e.preventDefault();
+ setWidth(SIDEBAR_WIDTH_MIN);
+ }
+ if (e.key === "End") {
+ e.preventDefault();
+ setWidth(SIDEBAR_WIDTH_MAX);
+ }
+ };
+
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: Resize grip needs pointer and keyboard handlers.
+ {
+ if (!dragging) {
+ (e.currentTarget as HTMLDivElement).style.backgroundColor =
+ "rgba(65, 105, 225, 0.2)";
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!dragging) {
+ (e.currentTarget as HTMLDivElement).style.backgroundColor =
+ "transparent";
+ }
+ }}
+ />
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/shell/SidebarHeader.tsx b/echo/frontend/src/features/sidebar/shell/SidebarHeader.tsx
new file mode 100644
index 000000000..c7085e611
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/shell/SidebarHeader.tsx
@@ -0,0 +1,19 @@
+import { Link } from "react-router";
+import { Logo } from "@/components/common/Logo";
+
+export const SidebarHeader = () => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/shell/SidebarShell.tsx b/echo/frontend/src/features/sidebar/shell/SidebarShell.tsx
new file mode 100644
index 000000000..94710a816
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/shell/SidebarShell.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from "react";
+import { useSidebarState } from "../hooks/useSidebarState";
+import { ResizeHandle } from "./ResizeHandle";
+
+interface SidebarShellProps {
+ children: ReactNode;
+ header?: ReactNode;
+ footer?: ReactNode;
+}
+
+// Flush-left full-height rail. Parchment background, no shadow — the
+// main content panel is the floating piece, not the sidebar.
+export const SidebarShell = ({
+ children,
+ header,
+ footer,
+}: SidebarShellProps) => {
+ const { width } = useSidebarState();
+
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/shell/UserMenu.tsx b/echo/frontend/src/features/sidebar/shell/UserMenu.tsx
new file mode 100644
index 000000000..1d41e14bf
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/shell/UserMenu.tsx
@@ -0,0 +1,215 @@
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import { Box, Group, Menu, Text, UnstyledButton } from "@mantine/core";
+import {
+ ArrowUpRight,
+ Bug,
+ ChatCircle,
+ Envelope,
+ Gear,
+ Note,
+ ShieldStar,
+ SignOut,
+ Sparkle,
+ Users,
+} from "@phosphor-icons/react";
+import * as Sentry from "@sentry/react";
+import { useState } from "react";
+import { useLocation, useParams } from "react-router";
+import {
+ useAuthenticated,
+ useCurrentUser,
+ useLogoutMutation,
+} from "@/components/auth/hooks";
+import { isAuthPath } from "@/components/auth/utils/authPaths";
+import { FeedbackPortalModal } from "@/components/common/FeedbackPortalModal";
+import { UserAvatar } from "@/components/common/UserAvatar";
+import { LanguagePicker } from "@/components/language/LanguagePicker";
+import { useTransitionCurtain } from "@/components/layout/TransitionCurtainProvider";
+import { COMMUNITY_SLACK_URL } from "@/config";
+import { useI18nNavigate } from "@/hooks/useI18nNavigate";
+import { useV2Me } from "@/hooks/useV2Me";
+
+const ReportIssueItem = ({ onFallback }: { onFallback: () => void }) => {
+ const handleClick = async () => {
+ const feedback = Sentry.getFeedback();
+ if (feedback) {
+ const form = await feedback.createForm();
+ if (form) {
+ form.appendToDom();
+ form.open();
+ return;
+ }
+ }
+ onFallback();
+ };
+ return (
+
} onClick={handleClick}>
+
Report an issue
+
+ );
+};
+
+export const UserMenu = () => {
+ const { isAuthenticated } = useAuthenticated();
+ const { data: user } = useCurrentUser({ enabled: isAuthenticated });
+ const { data: meV2 } = useV2Me({ enabled: isAuthenticated });
+ const logoutMutation = useLogoutMutation();
+ const { language } = useParams();
+ const location = useLocation();
+ const navigate = useI18nNavigate();
+ const { runTransition } = useTransitionCurtain();
+ const [feedbackOpen, setFeedbackOpen] = useState(false);
+
+ if (!isAuthenticated || !user) return null;
+
+ const needsOnboarding = meV2?.onboarding_completed === false;
+ const hasPendingInvites = meV2?.has_pending_invites === true;
+ const isStaff = meV2?.is_staff === true;
+
+ const docUrl =
+ language === "nl-NL"
+ ? "https://docs.dembrane.com/nl-NL"
+ : "https://docs.dembrane.com/en-US";
+
+ const handleLogout = async () => {
+ if (logoutMutation.isPending) return;
+ await runTransition({ description: null, message: t`See you soon` });
+ const path = location.pathname + location.search + location.hash;
+ await logoutMutation.mutateAsync({
+ doRedirect: true,
+ next: isAuthPath(location.pathname) ? undefined : path,
+ });
+ };
+
+ return (
+ <>
+
+
+
setFeedbackOpen(false)}
+ locale={language}
+ />
+ >
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/shell/useSidebarWhitelabelLogo.ts b/echo/frontend/src/features/sidebar/shell/useSidebarWhitelabelLogo.ts
new file mode 100644
index 000000000..f84a100f0
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/shell/useSidebarWhitelabelLogo.ts
@@ -0,0 +1,50 @@
+import { useEffect } from "react";
+import { useAuthenticated, useCurrentUser } from "@/components/auth/hooks";
+import { DIRECTUS_PUBLIC_URL } from "@/config";
+import { useV2Me } from "@/hooks/useV2Me";
+import { useWhitelabelLogo } from "@/hooks/useWhitelabelLogo";
+import { useWorkspace } from "@/hooks/useWorkspace";
+import { logoUrl as resolveLogoUrl } from "@/lib/avatar";
+import { useSidebarView } from "../hooks/useSidebarView";
+
+// Mirrors the resolver effect that used to live in Header.tsx. Picks
+// the workspace's logo when inside a workspace scope, falls back to the
+// user's whitelabel logo, otherwise clears.
+export function useSidebarWhitelabelLogo(): void {
+ const { isAuthenticated } = useAuthenticated();
+ const { data: user } = useCurrentUser({ enabled: isAuthenticated });
+ // touch so v2 me cache stays warm — same as old Header
+ useV2Me({ enabled: isAuthenticated });
+ const { workspace, isLoading: workspaceLoading } = useWorkspace();
+ const { scope } = useSidebarView();
+ const { setLogoUrl } = useWhitelabelLogo();
+
+ const insideWorkspace = scope === "workspace" || scope === "project";
+
+ useEffect(() => {
+ if (!isAuthenticated) {
+ setLogoUrl(null);
+ return;
+ }
+ if (insideWorkspace && workspaceLoading) return;
+
+ const workspaceLogo = insideWorkspace
+ ? (resolveLogoUrl(workspace?.logo_url) ??
+ resolveLogoUrl(workspace?.org_logo_url))
+ : undefined;
+ const resolved =
+ workspaceLogo ??
+ (user?.whitelabel_logo
+ ? `${DIRECTUS_PUBLIC_URL}/assets/${user.whitelabel_logo}`
+ : null);
+ setLogoUrl(resolved ?? null);
+ }, [
+ isAuthenticated,
+ insideWorkspace,
+ workspaceLoading,
+ workspace?.logo_url,
+ workspace?.org_logo_url,
+ user?.whitelabel_logo,
+ setLogoUrl,
+ ]);
+}
diff --git a/echo/frontend/src/features/sidebar/types.ts b/echo/frontend/src/features/sidebar/types.ts
new file mode 100644
index 000000000..e20bf008b
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/types.ts
@@ -0,0 +1,26 @@
+export type SidebarScope = "user" | "org" | "workspace" | "project" | "admin";
+
+export type SidebarViewId =
+ | "inbox"
+ | "help"
+ | "user-home"
+ | "user-settings"
+ | "org-home"
+ | "org-settings"
+ | "workspace-home"
+ | "workspace-settings"
+ | "project-home"
+ | "project-settings"
+ | "admin-home";
+
+export interface ResolvedSidebarView {
+ view: SidebarViewId;
+ scope: SidebarScope;
+ backTo: string | null;
+ params: {
+ orgId?: string;
+ workspaceId?: string;
+ projectId?: string;
+ section?: string;
+ };
+}
diff --git a/echo/frontend/src/features/sidebar/views/HelpView.tsx b/echo/frontend/src/features/sidebar/views/HelpView.tsx
new file mode 100644
index 000000000..bbf378e9a
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/HelpView.tsx
@@ -0,0 +1,74 @@
+import { Trans } from "@lingui/react/macro";
+import {
+ ChatCircle,
+ EnvelopeSimple,
+ Note,
+ Pulse,
+ Users,
+} from "@phosphor-icons/react";
+import { useState } from "react";
+import { useParams } from "react-router";
+import { FeedbackPortalModal } from "@/components/common/FeedbackPortalModal";
+import { COMMUNITY_SLACK_URL } from "@/config";
+import { useSidebarView } from "../hooks/useSidebarView";
+import { NavButton } from "../primitives/NavButton";
+import { ViewHeader } from "../primitives/ViewHeader";
+
+export const HelpView = () => {
+ const { backTo } = useSidebarView();
+ const { language } = useParams();
+ const [feedbackOpen, setFeedbackOpen] = useState(false);
+ const docUrl =
+ language === "nl-NL"
+ ? "https://docs.dembrane.com/nl-NL"
+ : "https://docs.dembrane.com/en-US";
+
+ return (
+ <>
+
+ setFeedbackOpen(false)}
+ locale={language}
+ />
+ >
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/InboxView.tsx b/echo/frontend/src/features/sidebar/views/InboxView.tsx
new file mode 100644
index 000000000..e783a0af3
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/InboxView.tsx
@@ -0,0 +1,576 @@
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import { ArrowCounterClockwise, Bell, Check } from "@phosphor-icons/react";
+import { formatRelative } from "date-fns";
+import { type ReactNode, useEffect, useState } from "react";
+import { useInView } from "react-intersection-observer";
+import {
+ useMarkAsReadMutation as useAnnouncementMarkAsReadMutation,
+ useMarkAsUnreadMutation as useAnnouncementMarkAsUnreadMutation,
+ useMarkAllAsReadMutation as useAnnouncementsMarkAllAsReadMutation,
+ useInfiniteAnnouncements,
+ useUnreadAnnouncements,
+} from "@/components/announcement/hooks";
+import {
+ type ProcessedAnnouncement,
+ useProcessedAnnouncements,
+} from "@/components/announcement/hooks/useProcessedAnnouncements";
+import { useFormatDate } from "@/components/announcement/utils/dateUtils";
+import { Markdown } from "@/components/common/Markdown";
+import { useI18nNavigate } from "@/hooks/useI18nNavigate";
+import { useLanguage } from "@/hooks/useLanguage";
+import {
+ type NotificationRow,
+ resolveNotificationHref,
+ useMarkAllNotificationsRead,
+ useMarkNotificationRead,
+ useNotifications,
+ useUnreadNotificationCount,
+} from "@/hooks/useNotifications";
+import { avatarUrl } from "@/lib/avatar";
+import { useSidebarView } from "../hooks/useSidebarView";
+import { ViewHeader } from "../primitives/ViewHeader";
+
+type Tab = "for-you" | "announcements";
+
+export const InboxView = () => {
+ const { backTo } = useSidebarView();
+ const [activeTab, setActiveTab] = useState("for-you");
+ const navigate = useI18nNavigate();
+ const { language } = useLanguage();
+
+ const { data: notifications = [], isLoading: loadingNotifs } =
+ useNotifications();
+ const { data: unreadNotifs = 0 } = useUnreadNotificationCount();
+ const markNotifRead = useMarkNotificationRead();
+ const markAllNotifsRead = useMarkAllNotificationsRead();
+
+ const { ref: loadMoreRef, inView } = useInView();
+ const {
+ data: announcementsData,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading: loadingAnnouncements,
+ } = useInfiniteAnnouncements({
+ enabled: true,
+ options: { initialLimit: 10 },
+ });
+ const { data: unreadAnnouncements = 0 } = useUnreadAnnouncements();
+ const markAnnouncementRead = useAnnouncementMarkAsReadMutation();
+ const markAnnouncementUnread = useAnnouncementMarkAsUnreadMutation();
+ const markAllAnnouncementsRead = useAnnouncementsMarkAllAsReadMutation();
+
+ const allAnnouncements =
+ announcementsData?.pages.flatMap(
+ (page) => (page as { announcements: Announcement[] }).announcements,
+ ) ?? [];
+ const processedAnnouncements = useProcessedAnnouncements(
+ allAnnouncements,
+ language,
+ );
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ const handleNotificationClick = (row: NotificationRow) => {
+ if (!row.read) markNotifRead.mutate(row.id);
+ const href = resolveNotificationHref(row);
+ if (href) navigate(href);
+ };
+
+ const handleMarkAllReadForActiveTab = () => {
+ if (activeTab === "for-you") {
+ markAllNotifsRead.mutate();
+ } else {
+ markAllAnnouncementsRead.mutate();
+ }
+ };
+
+ const markAllPending =
+ activeTab === "for-you"
+ ? markAllNotifsRead.isPending
+ : markAllAnnouncementsRead.isPending;
+
+ const markAllDisabled =
+ activeTab === "for-you" ? unreadNotifs === 0 : unreadAnnouncements === 0;
+
+ return (
+
+ );
+};
+
+interface TabButtonProps {
+ active: boolean;
+ onClick: () => void;
+ badge: number;
+ children: ReactNode;
+}
+
+const TabButton = ({ active, onClick, badge, children }: TabButtonProps) => (
+
+);
+
+interface ForYouPanelProps {
+ loading: boolean;
+ rows: NotificationRow[];
+ onRowClick: (row: NotificationRow) => void;
+ onMarkRead: (row: NotificationRow) => void;
+}
+
+const ForYouPanel = ({
+ loading,
+ rows,
+ onRowClick,
+ onMarkRead,
+}: ForYouPanelProps) => {
+ if (loading) {
+ return ;
+ }
+ if (rows.length === 0) {
+ return (
+ }
+ message={You're all caught up.}
+ />
+ );
+ }
+ return (
+
+ {rows.map((row) => (
+ -
+ onRowClick(row)}
+ onMarkRead={() => onMarkRead(row)}
+ />
+
+ ))}
+
+ );
+};
+
+interface AnnouncementsPanelProps {
+ loading: boolean;
+ announcements: ReturnType;
+ onMarkRead: (id: string) => void;
+ onMarkUnread: (id: string, activityIds: string[]) => void;
+ isFetchingNextPage: boolean;
+ loadMoreRef: (node?: Element | null) => void;
+}
+
+const AnnouncementsPanel = ({
+ loading,
+ announcements,
+ onMarkRead,
+ onMarkUnread,
+ isFetchingNextPage,
+ loadMoreRef,
+}: AnnouncementsPanelProps) => {
+ if (loading) {
+ return ;
+ }
+ if (announcements.length === 0) {
+ return (
+ Nothing from dembrane right now.} />
+ );
+ }
+ return (
+
+ {announcements.map((a) => (
+ -
+
+
+ ))}
+ {isFetchingNextPage && (
+ -
+
+
+ )}
+
+
+ );
+};
+
+const SkeletonList = () => (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+);
+
+const EmptyState = ({
+ icon,
+ message,
+}: {
+ icon?: ReactNode;
+ message: ReactNode;
+}) => (
+
+);
+
+function renderInlineMarkdown(text: string): ReactNode {
+ if (!text) return null;
+ const parts = text.split(/(\*\*[^*]+\*\*)/g);
+ return parts.map((part, i) => {
+ if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
+ return (
+ // biome-ignore lint/suspicious/noArrayIndexKey: parts array is derived from a static text split and never reorders
+
+ {part.slice(2, -2)}
+
+ );
+ }
+ return (
+ // biome-ignore lint/suspicious/noArrayIndexKey: parts array is derived from a static text split and never reorders
+ {part}
+ );
+ });
+}
+
+interface NotificationRowItemProps {
+ row: NotificationRow;
+ onClick: () => void;
+ onMarkRead: () => void;
+}
+
+const NotificationRowItem = ({
+ row,
+ onClick,
+ onMarkRead,
+}: NotificationRowItemProps) => {
+ const createdLabel = row.created_at
+ ? formatRelative(new Date(row.created_at), new Date())
+ : "";
+ const isDestructive = row.severity === "destructive";
+ const isActionRequired = row.severity === "action_required";
+
+ const unreadBg = isDestructive
+ ? "rgba(192, 57, 43, 0.05)"
+ : isActionRequired
+ ? "rgba(65, 105, 225, 0.06)"
+ : "rgba(65, 105, 225, 0.04)";
+ const borderColor = isDestructive
+ ? "rgba(192, 57, 43, 0.18)"
+ : isActionRequired
+ ? "rgba(65, 105, 225, 0.18)"
+ : "rgba(45, 45, 44, 0.07)";
+ const dotColor = isDestructive ? "#c0392b" : "#4169e1";
+
+ return (
+
+
+ {!row.read && (
+
+ )}
+
+ );
+};
+
+interface AnnouncementRowItemProps {
+ announcement: ProcessedAnnouncement;
+ onMarkRead: (id: string) => void;
+ onMarkUnread: (id: string, activityIds: string[]) => void;
+}
+
+const AnnouncementRowItem = ({
+ announcement,
+ onMarkRead,
+ onMarkUnread,
+}: AnnouncementRowItemProps) => {
+ const formatDate = useFormatDate();
+ const [expanded, setExpanded] = useState(false);
+ const isUrgent = announcement.level === "urgent";
+ const isRead = !!announcement.read;
+ const accent = isUrgent ? "#c0392b" : "#4169e1";
+
+ const unreadBg = isUrgent
+ ? "rgba(192, 57, 43, 0.05)"
+ : "rgba(65, 105, 225, 0.04)";
+ const borderColor = isUrgent
+ ? "rgba(192, 57, 43, 0.18)"
+ : "rgba(65, 105, 225, 0.18)";
+
+ const toggleRead = () => {
+ if (isRead) {
+ onMarkUnread(announcement.id, announcement.activityIds);
+ } else {
+ onMarkRead(announcement.id);
+ }
+ };
+
+ return (
+
+ {!isRead && (
+
+ )}
+
+
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/StubView.tsx b/echo/frontend/src/features/sidebar/views/StubView.tsx
new file mode 100644
index 000000000..88e82a127
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/StubView.tsx
@@ -0,0 +1,27 @@
+import { BackButton } from "../primitives/BackButton";
+
+interface StubViewProps {
+ title: string;
+ backTo: string | null;
+ backLabel?: string;
+}
+
+export const StubView = ({ title, backTo, backLabel }: StubViewProps) => {
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/admin/AdminHomeView.tsx b/echo/frontend/src/features/sidebar/views/admin/AdminHomeView.tsx
new file mode 100644
index 000000000..6a06963d8
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/admin/AdminHomeView.tsx
@@ -0,0 +1,18 @@
+import { ArrowFatLineUp, ChartBar, Handshake } from "@phosphor-icons/react";
+import { BackButton } from "../../primitives/BackButton";
+import { NavItem } from "../../primitives/NavItem";
+
+export const AdminHomeView = () => {
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/org/OrgHomeView.tsx b/echo/frontend/src/features/sidebar/views/org/OrgHomeView.tsx
new file mode 100644
index 000000000..c6ee75377
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/org/OrgHomeView.tsx
@@ -0,0 +1,153 @@
+import { Trans } from "@lingui/react/macro";
+import { Folder, Gear, House, UserPlus } from "@phosphor-icons/react";
+import { useQuery } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { useParams } from "react-router";
+import { API_BASE_URL } from "@/config";
+import { useWorkspace } from "@/hooks/useWorkspace";
+import { BackButton } from "../../primitives/BackButton";
+import { NavItem } from "../../primitives/NavItem";
+import { SectionLabel } from "../../primitives/SectionLabel";
+
+interface OrgWorkspaceRow {
+ id: string;
+ name: string;
+}
+
+async function fetchOrgWorkspaces(
+ orgId: string,
+): Promise {
+ const res = await fetch(`${API_BASE_URL}/v2/orgs/${orgId}/workspaces`, {
+ credentials: "include",
+ });
+ if (res.status === 401 || res.status === 403 || res.status === 404) {
+ return null;
+ }
+ if (!res.ok) {
+ throw new Error(`workspaces ${res.status}`);
+ }
+ return (await res.json()) as OrgWorkspaceRow[];
+}
+
+export const OrgHomeView = () => {
+ const { orgId: routeOrgId, organisationId } = useParams<{
+ orgId?: string;
+ organisationId?: string;
+ }>();
+ const orgId = routeOrgId ?? organisationId;
+ const { workspaces: myWorkspaces } = useWorkspace();
+
+ const myOrgWorkspaces = useMemo(
+ () => myWorkspaces.filter((w) => w.org_id === orgId),
+ [myWorkspaces, orgId],
+ );
+
+ const orgWsQuery = useQuery({
+ enabled: Boolean(orgId),
+ queryFn: async () => fetchOrgWorkspaces(orgId as string),
+ queryKey: ["v2", "organisation", orgId, "workspaces"],
+ retry: false,
+ staleTime: 30_000,
+ });
+
+ const isExternal =
+ orgWsQuery.data === null ||
+ (myOrgWorkspaces.length > 0 &&
+ myOrgWorkspaces.every((w) => w.role === "external"));
+
+ const orgName = myOrgWorkspaces[0]?.org_name ?? "Organisation";
+
+ const displayList = useMemo(() => {
+ if (isExternal) {
+ return myOrgWorkspaces.map((w) => ({
+ id: w.id,
+ isExternal: true,
+ name: w.name,
+ }));
+ }
+ const externalSet = new Set(
+ myOrgWorkspaces.filter((w) => w.role === "external").map((w) => w.id),
+ );
+ const full = orgWsQuery.data;
+ if (full && full.length > 0) {
+ return full.map((w) => ({
+ id: w.id,
+ isExternal: externalSet.has(w.id),
+ name: w.name,
+ }));
+ }
+ return myOrgWorkspaces.map((w) => ({
+ id: w.id,
+ isExternal: w.role === "external",
+ name: w.name,
+ }));
+ }, [isExternal, myOrgWorkspaces, orgWsQuery.data]);
+
+ if (!orgId) return null;
+ const base = `/o/${orgId}`;
+
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/org/OrgSettingsView.tsx b/echo/frontend/src/features/sidebar/views/org/OrgSettingsView.tsx
new file mode 100644
index 000000000..ac51698cf
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/org/OrgSettingsView.tsx
@@ -0,0 +1,40 @@
+import { Trans } from "@lingui/react/macro";
+import { ChartLine, CreditCard, Gear, Users } from "@phosphor-icons/react";
+import { useParams } from "react-router";
+import { BackButton } from "../../primitives/BackButton";
+import { NavItem } from "../../primitives/NavItem";
+
+export const OrgSettingsView = () => {
+ const { orgId: routeOrgId, organisationId } = useParams<{
+ orgId?: string;
+ organisationId?: string;
+ }>();
+ const orgId = routeOrgId ?? organisationId;
+ if (!orgId) return null;
+
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/project/ProjectHomeView.tsx b/echo/frontend/src/features/sidebar/views/project/ProjectHomeView.tsx
new file mode 100644
index 000000000..2e2617bc6
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/project/ProjectHomeView.tsx
@@ -0,0 +1,113 @@
+import { Trans } from "@lingui/react/macro";
+import {
+ BookOpen,
+ Broadcast,
+ ChatCircleDots,
+ ChatCircleText,
+ FileText,
+ Gear,
+ Graph,
+ House,
+ PaintBrush,
+} from "@phosphor-icons/react";
+import { useParams } from "react-router";
+import { useConversationsCountByProjectId } from "@/components/conversation/hooks";
+import { useProjectById } from "@/components/project/hooks";
+import { useWorkspace } from "@/hooks/useWorkspace";
+import { BackButton } from "../../primitives/BackButton";
+import { NavButton } from "../../primitives/NavButton";
+import { NavItem } from "../../primitives/NavItem";
+
+export const ProjectHomeView = () => {
+ const { workspaceId, projectId } = useParams<{
+ workspaceId: string;
+ projectId: string;
+ }>();
+ // Fetch by id so the project name renders even when the workspace
+ // context hasn't yet synced from the URL (saves a one-tick flash).
+ const projectQuery = useProjectById({
+ projectId: projectId ?? "",
+ query: { fields: ["id", "name"] },
+ });
+ const conversationsCountQuery = useConversationsCountByProjectId(
+ projectId ?? "",
+ );
+ const project = projectQuery.data;
+ const { workspaces } = useWorkspace();
+ const workspace = workspaces.find((w) => w.id === workspaceId);
+
+ if (!workspaceId || !projectId) return null;
+ const base = `/w/${workspaceId}/projects/${projectId}`;
+
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/project/ProjectSettingsView.tsx b/echo/frontend/src/features/sidebar/views/project/ProjectSettingsView.tsx
new file mode 100644
index 000000000..3f2a8bf29
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/project/ProjectSettingsView.tsx
@@ -0,0 +1,36 @@
+import { Trans } from "@lingui/react/macro";
+import { Gear, Plugs, UsersThree } from "@phosphor-icons/react";
+import { useParams } from "react-router";
+import { BackButton } from "../../primitives/BackButton";
+import { NavItem } from "../../primitives/NavItem";
+
+export const ProjectSettingsView = () => {
+ const { workspaceId, projectId } = useParams<{
+ workspaceId: string;
+ projectId: string;
+ }>();
+
+ if (!workspaceId || !projectId) return null;
+ const base = `/w/${workspaceId}/projects/${projectId}`;
+
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/user/UserHomeView.tsx b/echo/frontend/src/features/sidebar/views/user/UserHomeView.tsx
new file mode 100644
index 000000000..31dff6078
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/user/UserHomeView.tsx
@@ -0,0 +1,139 @@
+import { Trans } from "@lingui/react/macro";
+import {
+ Buildings,
+ FolderOpen,
+ House,
+ ShieldStar,
+ Sparkle,
+} from "@phosphor-icons/react";
+import { useMemo } from "react";
+import { useV2Me } from "@/hooks/useV2Me";
+import { useWorkspace } from "@/hooks/useWorkspace";
+import { SettingsBlock } from "../../blocks/SettingsBlock";
+import { NavItem } from "../../primitives/NavItem";
+import { SectionLabel } from "../../primitives/SectionLabel";
+
+interface OrgGroup {
+ id: string;
+ name: string;
+ workspaceCount: number;
+ isExternal: boolean;
+}
+
+function groupByOrg(
+ workspaces: ReturnType["workspaces"],
+): OrgGroup[] {
+ const map = new Map();
+ for (const ws of workspaces) {
+ if (!ws.org_id) continue;
+ const isExternal = ws.role === "external";
+ const existing = map.get(ws.org_id);
+ if (existing) {
+ existing.workspaceCount += 1;
+ if (!isExternal) existing.isExternal = false;
+ } else {
+ map.set(ws.org_id, {
+ id: ws.org_id,
+ isExternal,
+ name: ws.org_name || "Untitled organisation",
+ workspaceCount: 1,
+ });
+ }
+ }
+ return [...map.values()].sort((a, b) => {
+ if (a.isExternal !== b.isExternal) return a.isExternal ? 1 : -1;
+ return a.name.localeCompare(b.name);
+ });
+}
+
+export const UserHomeView = () => {
+ const { workspaces, isLoading } = useWorkspace();
+ const { data: meV2 } = useV2Me({ enabled: true });
+ const orgs = useMemo(() => groupByOrg(workspaces), [workspaces]);
+ const internalOrgs = useMemo(
+ () => orgs.filter((org) => !org.isExternal),
+ [orgs],
+ );
+ const externalOrgs = useMemo(
+ () => orgs.filter((org) => org.isExternal),
+ [orgs],
+ );
+ const noWorkspaces = !isLoading && workspaces.length === 0;
+ const isStaff = meV2?.is_staff === true;
+ const needsOnboarding = meV2?.onboarding_completed === false;
+ const workspacesWithoutOrg = useMemo(
+ () => workspaces.filter((w) => !w.org_id),
+ [workspaces],
+ );
+
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/user/UserSettingsView.tsx b/echo/frontend/src/features/sidebar/views/user/UserSettingsView.tsx
new file mode 100644
index 000000000..555703e50
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/user/UserSettingsView.tsx
@@ -0,0 +1,64 @@
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import {
+ Buildings,
+ Palette,
+ Scales,
+ ShieldStar,
+ SignOut,
+} from "@phosphor-icons/react";
+import { useLocation } from "react-router";
+import { useLogoutMutation } from "@/components/auth/hooks";
+import { useTransitionCurtain } from "@/components/layout/TransitionCurtainProvider";
+import { BackButton } from "../../primitives/BackButton";
+import { NavButton } from "../../primitives/NavButton";
+import { NavItem } from "../../primitives/NavItem";
+
+export const UserSettingsView = () => {
+ const logoutMutation = useLogoutMutation();
+ const location = useLocation();
+ const { runTransition } = useTransitionCurtain();
+
+ const handleLogout = async () => {
+ if (logoutMutation.isPending) return;
+ await runTransition({ description: null, message: t`See you soon` });
+ const path = location.pathname + location.search + location.hash;
+ await logoutMutation.mutateAsync({
+ doRedirect: true,
+ next: path.startsWith("/login") ? undefined : path,
+ });
+ };
+
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/workspace/WorkspaceHomeView.tsx b/echo/frontend/src/features/sidebar/views/workspace/WorkspaceHomeView.tsx
new file mode 100644
index 000000000..2917ac5ff
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/workspace/WorkspaceHomeView.tsx
@@ -0,0 +1,80 @@
+import { Trans } from "@lingui/react/macro";
+import { Gear, House, Plus, PushPin } from "@phosphor-icons/react";
+import { useMemo } from "react";
+import { useParams } from "react-router";
+import { useWorkspace } from "@/hooks/useWorkspace";
+import { useWorkspaceProjects } from "@/hooks/useWorkspaceProjects";
+import { BackButton } from "../../primitives/BackButton";
+import { NavItem } from "../../primitives/NavItem";
+import { SectionLabel } from "../../primitives/SectionLabel";
+
+export const WorkspaceHomeView = () => {
+ const { workspaceId } = useParams<{ workspaceId: string }>();
+ const { workspaces } = useWorkspace();
+ const projectsQuery = useWorkspaceProjects({ limit: 8 });
+
+ const workspace = useMemo(
+ () => workspaces.find((w) => w.id === workspaceId),
+ [workspaces, workspaceId],
+ );
+ const pinnedProjects = useMemo(
+ () => projectsQuery.data?.pages.flatMap((page) => page.pinned) ?? [],
+ [projectsQuery.data],
+ );
+
+ if (!workspaceId) return null;
+
+ const base = `/w/${workspaceId}`;
+ const backTo = workspace?.org_id ? `/o/${workspace.org_id}/overview` : "/";
+ const backLabel = workspace?.org_name ?? "Home";
+ const isExternal = workspace?.role === "external";
+ const canCreateProject = !isExternal;
+
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/features/sidebar/views/workspace/WorkspaceSettingsView.tsx b/echo/frontend/src/features/sidebar/views/workspace/WorkspaceSettingsView.tsx
new file mode 100644
index 000000000..11990c228
--- /dev/null
+++ b/echo/frontend/src/features/sidebar/views/workspace/WorkspaceSettingsView.tsx
@@ -0,0 +1,41 @@
+import { Trans } from "@lingui/react/macro";
+import { CreditCard, Gear, Users, Warning } from "@phosphor-icons/react";
+import { useParams } from "react-router";
+import { BackButton } from "../../primitives/BackButton";
+import { NavItem } from "../../primitives/NavItem";
+
+export const WorkspaceSettingsView = () => {
+ const { workspaceId } = useParams<{ workspaceId: string }>();
+
+ if (!workspaceId) return null;
+ const base = `/w/${workspaceId}/settings`;
+
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/hooks/useNotifications.ts b/echo/frontend/src/hooks/useNotifications.ts
index 6f7b4b768..959302540 100644
--- a/echo/frontend/src/hooks/useNotifications.ts
+++ b/echo/frontend/src/hooks/useNotifications.ts
@@ -63,10 +63,9 @@ async function fetchNotifications(): Promise {
}
async function fetchUnreadCount(): Promise {
- const res = await fetch(
- `${API_BASE_URL}/v2/me/notifications/unread-count`,
- { credentials: "include" },
- );
+ const res = await fetch(`${API_BASE_URL}/v2/me/notifications/unread-count`, {
+ credentials: "include",
+ });
if (!res.ok) return 0;
const body = await res.json().catch(() => ({}));
return typeof body.unread === "number" ? body.unread : 0;
@@ -74,19 +73,19 @@ async function fetchUnreadCount(): Promise {
export const useNotifications = () =>
useQuery({
- queryKey: ["v2", "notifications"],
queryFn: fetchNotifications,
- staleTime: 30_000,
+ queryKey: ["v2", "notifications"],
// Light polling so the badge stays honest without a websocket.
refetchInterval: 60_000,
+ staleTime: 30_000,
});
export const useUnreadNotificationCount = () =>
useQuery({
- queryKey: ["v2", "notifications", "unread-count"],
queryFn: fetchUnreadCount,
- staleTime: 30_000,
+ queryKey: ["v2", "notifications", "unread-count"],
refetchInterval: 60_000,
+ staleTime: 30_000,
});
export const useMarkNotificationRead = () => {
@@ -105,6 +104,9 @@ export const useMarkNotificationRead = () => {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["v2", "notifications"] });
+ queryClient.invalidateQueries({
+ queryKey: ["v2", "notifications", "unread-count"],
+ });
},
});
};
@@ -113,15 +115,18 @@ export const useMarkAllNotificationsRead = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
- const res = await fetch(
- `${API_BASE_URL}/v2/me/notifications/read-all`,
- { credentials: "include", method: "POST" },
- );
+ const res = await fetch(`${API_BASE_URL}/v2/me/notifications/read-all`, {
+ credentials: "include",
+ method: "POST",
+ });
if (!res.ok) throw new Error("Couldn't mark all as read");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["v2", "notifications"] });
+ queryClient.invalidateQueries({
+ queryKey: ["v2", "notifications", "unread-count"],
+ });
},
});
};
@@ -140,18 +145,18 @@ export function resolveNotificationHref(
const { action, refs, event_code } = row;
switch (action) {
case "NAVIGATE_WS":
- return refs.workspace_id ? `/w/${refs.workspace_id}/projects` : null;
+ return refs.workspace_id ? `/w/${refs.workspace_id}/home` : null;
case "NAVIGATE_PROJECT":
- return refs.project_id
- ? `/projects/${refs.project_id}/overview`
+ return refs.project_id && refs.workspace_id
+ ? `/w/${refs.workspace_id}/projects/${refs.project_id}/home`
: null;
case "NAVIGATE_REPORT":
- return refs.project_id && refs.report_id
- ? `/projects/${refs.project_id}/reports/${refs.report_id}`
+ return refs.project_id && refs.workspace_id
+ ? `/w/${refs.workspace_id}/projects/${refs.project_id}/report`
: null;
case "NAVIGATE_CHAT":
- return refs.project_id && refs.chat_id
- ? `/projects/${refs.project_id}/chats/${refs.chat_id}`
+ return refs.project_id && refs.chat_id && refs.workspace_id
+ ? `/w/${refs.workspace_id}/projects/${refs.project_id}/chats/${refs.chat_id}`
: null;
case "NAVIGATE_INVITE":
return "/invites";
diff --git a/echo/frontend/src/hooks/useWorkspace.ts b/echo/frontend/src/hooks/useWorkspace.ts
index c3d607fc9..8fc344bb6 100644
--- a/echo/frontend/src/hooks/useWorkspace.ts
+++ b/echo/frontend/src/hooks/useWorkspace.ts
@@ -1,6 +1,14 @@
-import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
import { API_BASE_URL } from "@/config";
+import { AUTH_CACHE_BOUNDARY_EVENT } from "@/lib/authCacheBoundary";
// ── Types ──
@@ -56,15 +64,15 @@ export interface WorkspaceContextValue {
// ── Context ──
export const WorkspaceContext = createContext({
- workspaceId: null,
- workspaceName: null,
- workspace: null,
- workspaces: [],
- isLoading: true,
+ clearWorkspace: () => {},
isError: false,
+ isLoading: true,
refetch: () => {},
setWorkspace: () => {},
- clearWorkspace: () => {},
+ workspace: null,
+ workspaceId: null,
+ workspaceName: null,
+ workspaces: [],
});
export const useWorkspace = () => useContext(WorkspaceContext);
@@ -75,6 +83,9 @@ async function fetchWorkspaces(): Promise {
const res = await fetch(`${API_BASE_URL}/v2/workspaces`, {
credentials: "include",
});
+ if (res.status === 401 || res.status === 403) {
+ return [];
+ }
if (!res.ok) {
throw new Error(`Workspaces request failed (${res.status})`);
}
@@ -89,7 +100,8 @@ const SESSION_KEY = "dembrane_ws_selected";
// Without this, a direct link to /w//projects renders the header
// with no workspace name until the next tick — the 2026-04-23 bug
// "don't see the workspace on the nav bar".
-const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+const UUID_RE =
+ /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
function readWorkspaceIdFromPath(): string | null {
if (typeof window === "undefined") return null;
const segments = window.location.pathname.split("/").filter(Boolean);
@@ -102,6 +114,15 @@ function readWorkspaceIdFromPath(): string | null {
}
export function useWorkspaceProvider(enabled: boolean): WorkspaceContextValue {
+ const [authEpoch, setAuthEpoch] = useState(0);
+
+ useEffect(() => {
+ const onAuthBoundary = () => setAuthEpoch((epoch) => epoch + 1);
+ window.addEventListener(AUTH_CACHE_BOUNDARY_EVENT, onAuthBoundary);
+ return () =>
+ window.removeEventListener(AUTH_CACHE_BOUNDARY_EVENT, onAuthBoundary);
+ }, []);
+
// Selection state — drives re-renders when user switches. Seed with
// (1) current URL's workspace id if present, (2) session, or (3) null.
// URL wins over session so deep-linking into another workspace doesn't
@@ -120,12 +141,12 @@ export function useWorkspaceProvider(enabled: boolean): WorkspaceContextValue {
isError,
refetch,
} = useQuery({
- queryKey: ["v2", "workspaces-context"],
- queryFn: fetchWorkspaces,
enabled,
- staleTime: 60_000,
+ queryFn: fetchWorkspaces,
+ queryKey: ["v2", "workspaces-context", authEpoch],
// One retry — this query wraps the entire app, so retry: false was app-wide brittle.
retry: 1,
+ staleTime: 60_000,
});
const resolved = useMemo(() => {
@@ -160,16 +181,16 @@ export function useWorkspaceProvider(enabled: boolean): WorkspaceContextValue {
}, []);
return {
- workspaceId: resolved?.id ?? null,
- workspaceName: resolved?.name ?? null,
- workspace: resolved,
- workspaces,
- isLoading,
+ clearWorkspace,
isError,
+ isLoading,
refetch: () => {
refetch();
},
setWorkspace,
- clearWorkspace,
+ workspace: resolved,
+ workspaceId: resolved?.id ?? null,
+ workspaceName: resolved?.name ?? null,
+ workspaces,
};
}
diff --git a/echo/frontend/src/lib/api.ts b/echo/frontend/src/lib/api.ts
index c5552c453..67488603d 100644
--- a/echo/frontend/src/lib/api.ts
+++ b/echo/frontend/src/lib/api.ts
@@ -1320,15 +1320,16 @@ export const listProjectReports = async (projectId: string) => {
export const getLatestProjectReport = async (projectId: string) => {
return api.get<
unknown,
- Pick<
- ProjectReport,
- | "id"
- | "status"
- | "project_id"
- | "show_portal_link"
- | "date_created"
- | "error_message"
- > | null
+ | (Pick<
+ ProjectReport,
+ | "id"
+ | "status"
+ | "project_id"
+ | "show_portal_link"
+ | "date_created"
+ | "error_message"
+ > & { title?: string | null })
+ | null
>(`/projects/${projectId}/reports/latest`);
};
diff --git a/echo/frontend/src/lib/authCacheBoundary.ts b/echo/frontend/src/lib/authCacheBoundary.ts
new file mode 100644
index 000000000..de4caa24e
--- /dev/null
+++ b/echo/frontend/src/lib/authCacheBoundary.ts
@@ -0,0 +1,6 @@
+export const AUTH_CACHE_BOUNDARY_EVENT = "dembrane.auth-cache-boundary";
+
+export function emitAuthCacheBoundary(): void {
+ if (typeof window === "undefined") return;
+ window.dispatchEvent(new Event(AUTH_CACHE_BOUNDARY_EVENT));
+}
diff --git a/echo/frontend/src/lib/roles.ts b/echo/frontend/src/lib/roles.ts
index b972158a1..a8a474cfe 100644
--- a/echo/frontend/src/lib/roles.ts
+++ b/echo/frontend/src/lib/roles.ts
@@ -24,7 +24,6 @@ export function displayRole(role: string | null | undefined): string {
return t`Member`;
case "billing":
return t`Billing`;
- case "guest":
case "external":
return t`External`;
default:
diff --git a/echo/frontend/src/routes/admin/AdminSettingsRoute.tsx b/echo/frontend/src/routes/admin/AdminSettingsRoute.tsx
index c42d02ca6..cca958e36 100644
--- a/echo/frontend/src/routes/admin/AdminSettingsRoute.tsx
+++ b/echo/frontend/src/routes/admin/AdminSettingsRoute.tsx
@@ -2915,22 +2915,13 @@ export const AdminSettingsRoute = () => {
+ {/* Tab strip retired — section navigation lives in the main
+ AppSidebar (AdminHomeView). Tabs.Panel still switches on value. */}
v && navigate(`/admin/${v}`, { replace: true })}
keepMounted={false}
>
-
-
- Usage and Billing
-
-
- Partners
-
-
- Upgrades
-
-
diff --git a/echo/frontend/src/routes/auth/Login.tsx b/echo/frontend/src/routes/auth/Login.tsx
index 2f177ae91..6895feb45 100644
--- a/echo/frontend/src/routes/auth/Login.tsx
+++ b/echo/frontend/src/routes/auth/Login.tsx
@@ -199,7 +199,7 @@ export const LoginRoute = () => {
}
// Routing:
- // - Solo user (1 workspace) → straight to projects
+ // - Solo user (1 workspace) → straight to workspace home
// - Returning multi-workspace user → last-used workspace (if still valid)
// - First-time multi-workspace user → selector
const lastUsedId = localStorage.getItem("dembrane_last_workspace_id");
@@ -207,15 +207,15 @@ export const LoginRoute = () => {
!!lastUsedId && wsList.some((w) => w.id === lastUsedId);
if (workspaceCount === 1 && firstWorkspaceId) {
- navigate(`/w/${firstWorkspaceId}/projects`);
+ navigate(`/w/${firstWorkspaceId}/home`);
} else if (lastStillValid) {
- navigate(`/w/${lastUsedId}/projects`);
+ navigate(`/w/${lastUsedId}/home`);
} else if (workspaceCount > 1 || isOrganisationAdmin) {
navigate("/w");
} else if (firstWorkspaceId) {
- navigate(`/w/${firstWorkspaceId}/projects`);
+ navigate(`/w/${firstWorkspaceId}/home`);
} else {
- navigate("/projects");
+ navigate("/w");
}
} catch (error) {
// biome-ignore lint/suspicious/noExplicitAny:
@@ -291,9 +291,7 @@ export const LoginRoute = () => {
{searchParams.get("verified") === "1" && (
-
- Your email is verified. Log in to continue.
-
+ Your email is verified. Log in to continue.
)}
@@ -376,7 +374,9 @@ export const LoginRoute = () => {
// new-password is the standard escape hatch to
// stop Chrome/Firefox auto-filling a saved
// password into this form.
- autoComplete={lockedEmail ? "new-password" : "current-password"}
+ autoComplete={
+ lockedEmail ? "new-password" : "current-password"
+ }
/>
>
)}
diff --git a/echo/frontend/src/routes/auth/VerifyEmail.tsx b/echo/frontend/src/routes/auth/VerifyEmail.tsx
index d66d600f8..4ee203225 100644
--- a/echo/frontend/src/routes/auth/VerifyEmail.tsx
+++ b/echo/frontend/src/routes/auth/VerifyEmail.tsx
@@ -112,7 +112,7 @@ export const VerifyEmailRoute = () => {
-
+ }
+ variant="default"
+ onClick={() => navigate(`${base}/upload`)}
+ >
+ Upload
+
+ }
+ variant="default"
+ onClick={() => navigate(`${base}/portal-editor`)}
+ >
+ Portal editor
+
+ }
+ variant="default"
+ onClick={() => navigate(`${base}/report`)}
+ >
+ Report
+
+
+
+
+
+
+
+ Latest conversations
+
+ navigate(`${base}/conversations`)}
+ >
+ Open all
+
+
+
+ {recentConversationsQuery.isLoading ? (
+
+
+
+
+ ) : recentConversations.length > 0 ? (
+
+ {recentConversations.slice(0, 2).map((conversation) => {
+ const tags =
+ (conversation.tags as ConversationProjectTag[] | undefined) ??
+ [];
+ return (
+
+
+
+
+
+ {conversationTitle(conversation)}
+
+
+ {conversation.created_at
+ ? new Date(
+ conversation.created_at,
+ ).toLocaleDateString()
+ : ""}
+
+
+
+ navigate(
+ `${base}/conversation/${conversation.id}/overview`,
+ )
+ }
+ >
+ Open
+
+
+
+
+ {conversation.summary?.trim() || (
+ No summary yet
+ )}
+
+
+ {tags.length > 0 && (
+
+ {tags.slice(0, 4).map((tag) => {
+ const label = tagText(tag);
+ if (!label) return null;
+ return (
+
+ {label}
+
+ );
+ })}
+
+ )}
+
+
+ );
+ })}
+
+ ) : null}
+
+
+ {report && reportTitle && (
+
+
+
+
+
+
+ Latest report
+
+
+ {report.status}
+
+
+
+ {reportTitle}
+
+ {report.date_created && (
+
+ {new Date(report.date_created).toLocaleString()}
+
+ )}
+
+ navigate(`${base}/report`)}
+ >
+ Open
+
+
+
+ )}
+
+
+ );
+};
diff --git a/echo/frontend/src/routes/project/ProjectRoutes.tsx b/echo/frontend/src/routes/project/ProjectRoutes.tsx
index fbc0b46fb..2698e8dd5 100644
--- a/echo/frontend/src/routes/project/ProjectRoutes.tsx
+++ b/echo/frontend/src/routes/project/ProjectRoutes.tsx
@@ -2,6 +2,8 @@ import { Trans } from "@lingui/react/macro";
import { Alert, Divider, LoadingOverlay, Stack } from "@mantine/core";
import { useMemo } from "react";
import { useParams } from "react-router";
+import { ProjectConversationsPanel } from "@/components/conversation/ProjectConversationsPanel";
+import { PageContainer } from "@/components/layout/PageContainer";
import {
useProjectById,
useVerificationTopicsQuery,
@@ -16,6 +18,21 @@ import { WebhookSection } from "@/components/project/webhooks/WebhookSettingsCar
import { ENABLE_WEBHOOKS } from "@/config";
import { getProjectTranscriptsLink } from "@/lib/api";
+export const ProjectConversationsRoute = () => {
+ const { projectId, workspaceId } = useParams();
+
+ if (!projectId) return null;
+
+ return (
+
+
+
+ );
+};
export const ProjectSettingsRoute = () => {
const { projectId } = useParams();
@@ -62,23 +79,6 @@ export const ProjectSettingsRoute = () => {
{projectQuery.data && (
<>
-
-
-
-
-
-
- {ENABLE_WEBHOOKS && (
- <>
-
-
- >
- )}
-
{/*
{projectId && (
<>
@@ -95,6 +95,63 @@ export const ProjectSettingsRoute = () => {
);
};
+export const ProjectUploadRoute = () => {
+ const { projectId } = useParams();
+
+ if (!projectId) return null;
+
+ return (
+
+
+
+ );
+};
+
+export const ProjectIntegrationsRoute = () => {
+ const { projectId } = useParams();
+ const query = useMemo(
+ () => ({
+ fields: ["id", "name", "is_conversation_allowed"],
+ }),
+ [],
+ );
+ const projectQuery = useProjectById({
+ projectId: projectId ?? "",
+ // @ts-expect-error narrowed fields are enough for this route
+ query,
+ });
+
+ if (!projectId) return null;
+
+ return (
+
+
+ {projectQuery.isLoading && }
+ {projectQuery.isError && (
+
+ Error loading project
+
+ )}
+ {projectQuery.data && (
+
+ )}
+
+ {ENABLE_WEBHOOKS ? (
+
+ ) : (
+
+ Webhooks are not enabled for this environment.
+
+ )}
+
+
+ );
+};
+
export const ProjectPortalSettingsRoute = () => {
const { projectId } = useParams();
const query = useMemo(
diff --git a/echo/frontend/src/routes/project/ProjectsHome.tsx b/echo/frontend/src/routes/project/ProjectsHome.tsx
index 531142c2f..e3938a9f4 100644
--- a/echo/frontend/src/routes/project/ProjectsHome.tsx
+++ b/echo/frontend/src/routes/project/ProjectsHome.tsx
@@ -103,11 +103,18 @@ export const ProjectsHomeRoute = () => {
const togglePinMutation = useTogglePinMutation();
useEffect(() => {
- if (search) {
- setSearchParams({ search });
- } else {
- setSearchParams({});
- }
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (search) {
+ next.set("search", search);
+ } else {
+ next.delete("search");
+ }
+ return next;
+ },
+ { replace: true },
+ );
}, [search, setSearchParams]);
useEffect(() => {
@@ -126,10 +133,8 @@ export const ProjectsHomeRoute = () => {
// instant POST that leaves the new project with a "New Project"
// placeholder name. See CreateProjectRoute.tsx.
posthog?.capture("project_create_started");
- const path = workspaceId
- ? `/w/${workspaceId}/projects/new`
- : "/projects/new";
- navigate(path);
+ if (!workspaceId) return;
+ navigate(`/w/${workspaceId}/projects/new`);
};
// First page has pinned + total_count; all pages have projects
diff --git a/echo/frontend/src/routes/project/chat/NewChatRoute.tsx b/echo/frontend/src/routes/project/chat/NewChatRoute.tsx
index 6f29e25d0..27a839fd3 100644
--- a/echo/frontend/src/routes/project/chat/NewChatRoute.tsx
+++ b/echo/frontend/src/routes/project/chat/NewChatRoute.tsx
@@ -13,7 +13,7 @@ import { useLanguage } from "@/hooks/useLanguage";
import type { ChatMode } from "@/lib/api";
export const NewChatRoute = () => {
- const { projectId } = useParams();
+ const { projectId, workspaceId } = useParams();
const navigate = useI18nNavigate();
const { language } = useLanguage();
const createChatMutation = useCreateChatMutation();
@@ -51,7 +51,7 @@ export const NewChatRoute = () => {
}
// Step 4: Navigate to the new chat
- navigate(`/projects/${projectId}/chats/${chat.id}`);
+ navigate(`/w/${workspaceId}/projects/${projectId}/chats/${chat.id}`);
} catch (error) {
console.error("Failed to create chat with mode:", error);
setIsInitializing(false);
diff --git a/echo/frontend/src/routes/project/chat/ProjectChatRoute.tsx b/echo/frontend/src/routes/project/chat/ProjectChatRoute.tsx
index de29eee30..526d8897a 100644
--- a/echo/frontend/src/routes/project/chat/ProjectChatRoute.tsx
+++ b/echo/frontend/src/routes/project/chat/ProjectChatRoute.tsx
@@ -8,6 +8,7 @@ import {
Divider,
Group,
LoadingOverlay,
+ Modal,
Stack,
Text,
Textarea,
@@ -18,6 +19,7 @@ import { usePostHog } from "@posthog/react";
import { ErrorBoundary } from "@sentry/react";
import {
IconAlertCircle,
+ IconListDetails,
IconRefresh,
IconSend,
IconSquare,
@@ -62,7 +64,6 @@ import {
useUserTemplates,
} from "@/components/chat/hooks/useUserTemplates";
import SourcesSearch from "@/components/chat/SourcesSearch";
-import { useProjectById } from "@/components/project/hooks";
import type { QuickAccessItem } from "@/components/chat/templateKey";
import { Templates } from "@/components/chat/templates";
import { CopyRichTextIconButton } from "@/components/common/CopyRichTextIconButton";
@@ -71,6 +72,8 @@ import { ScrollToBottomButton } from "@/components/common/ScrollToBottom";
import { toast } from "@/components/common/Toaster";
import { ConversationLinks } from "@/components/conversation/ConversationLinks";
import { useConversationsCountByProjectId } from "@/components/conversation/hooks";
+import { ProjectConversationsPanel } from "@/components/conversation/ProjectConversationsPanel";
+import { useProjectById } from "@/components/project/hooks";
import {
API_BASE_URL,
ENABLE_AGENTIC_CHAT,
@@ -316,9 +319,9 @@ const useDembraneChat = ({ chatId }: { chatId: string }) => {
};
export const ProjectChatRoute = () => {
- useDocumentTitle(t`Chat | Dembrane`);
+ useDocumentTitle(t`Chat | dembrane`);
- const { chatId, projectId } = useParams();
+ const { chatId, projectId, workspaceId: routeWorkspaceId } = useParams();
const posthog = usePostHog();
const queryClient = useQueryClient();
const chatQuery = useProjectChat(chatId ?? "");
@@ -328,6 +331,7 @@ export const ProjectChatRoute = () => {
const [saveAsTemplateContent, setSaveAsTemplateContent] = useState<
string | null
>(null);
+ const [conversationPickerOpen, setConversationPickerOpen] = useState(false);
const handleSaveAsTemplate = (content: string) => {
setSaveAsTemplateContent(content);
@@ -358,14 +362,14 @@ export const ProjectChatRoute = () => {
projectId: projectId ?? "",
query: { fields: ["id", "workspace_id"] },
});
- const workspaceId =
+ const projectWorkspaceId =
(projectForWorkspace.data as { workspace_id?: string | null } | undefined)
?.workspace_id ?? null;
const currentUserQuery = useCurrentUser();
- const userTemplatesQuery = useUserTemplates(workspaceId);
- const createUserTemplateMutation = useCreateUserTemplate(workspaceId);
- const updateUserTemplateMutation = useUpdateUserTemplate(workspaceId);
- const deleteUserTemplateMutation = useDeleteUserTemplate(workspaceId);
+ const userTemplatesQuery = useUserTemplates(projectWorkspaceId);
+ const createUserTemplateMutation = useCreateUserTemplate(projectWorkspaceId);
+ const updateUserTemplateMutation = useUpdateUserTemplate(projectWorkspaceId);
+ const deleteUserTemplateMutation = useDeleteUserTemplate(projectWorkspaceId);
const quickAccessQuery = useQuickAccessPreferences();
const saveQuickAccessMutation = useSaveQuickAccessPreferences();
const toggleAiSuggestionsMutation = useToggleAiSuggestions();
@@ -509,7 +513,7 @@ export const ProjectChatRoute = () => {
const computedChatForCopy = useMemo(() => {
const messagesList = messages.map((message) =>
// @ts-expect-error chatHistoryQuery.data is not typed
- formatMessage(message, "User", "Dembrane"),
+ formatMessage(message, "Host", "dembrane"),
);
return messagesList.join("\n\n\n\n");
}, [messages]);
@@ -645,7 +649,7 @@ export const ProjectChatRoute = () => {
content:
chatMode === "overview"
? t`Welcome to Overview Mode! I have summaries of all your conversations loaded. Ask me about patterns, themes, and insights across your data. For exact quotes, start a new chat in Specific Context mode.`
- : t`Welcome to Dembrane Chat! Use the sidebar to select resources and conversations that you want to analyse. Then, you can ask questions about the selected resources and conversations.`,
+ : t`Welcome to dembrane chat. Select the conversations you want to analyse, then ask about details, quotes, and summaries.`,
id: "init",
role: "assistant",
}}
@@ -798,7 +802,7 @@ export const ProjectChatRoute = () => {
}
chatMode={chatMode}
userTemplates={userTemplatesQuery.data ?? []}
- canCreateWorkspaceTemplate={Boolean(workspaceId)}
+ canCreateWorkspaceTemplate={Boolean(projectWorkspaceId)}
onCreateUserTemplate={(payload) =>
createUserTemplateMutation.mutateAsync(payload)
}
@@ -823,6 +827,24 @@ export const ProjectChatRoute = () => {
/>
+ {chatMode !== "overview" && (
+
+
+
+ {conversationCount} conversations selected for this chat
+
+
+ }
+ onClick={() => setConversationPickerOpen(true)}
+ {...testId("chat-select-conversations-button")}
+ >
+ Select conversations
+
+
+ )}
{chatMode !== "overview" &&
(!ENABLE_CHAT_AUTO_SELECT
? noConversationsSelected
@@ -830,11 +852,26 @@ export const ProjectChatRoute = () => {
!contextToBeAdded?.auto_select_bool) && (
}
- title={t`Please select conversations from the sidebar to proceed`}
+ title={t`Select conversations to continue`}
color="orange"
variant="light"
{...testId("chat-no-conversations-alert")}
- />
+ >
+
+
+
+ Specific Details needs at least one conversation.
+
+
+ setConversationPickerOpen(true)}
+ >
+ Choose conversations
+
+
+
)}
{contextToBeAdded && contextToBeAdded.conversations.length > 0 && (
@@ -912,7 +949,7 @@ export const ProjectChatRoute = () => {
- Dembrane is powered by AI. Please double-check responses.
+ dembrane can make mistakes. Please double-check responses.
@@ -944,13 +981,30 @@ export const ProjectChatRoute = () => {
- Dembrane is powered by AI. Please double-check responses.
+ dembrane can make mistakes. Please double-check responses.
+ setConversationPickerOpen(false)}
+ title={t`Select conversations`}
+ size="xl"
+ padding="lg"
+ >
+ {projectId && (
+
+ )}
+
);
};
diff --git a/echo/frontend/src/routes/project/library/ProjectLibraryAspect.tsx b/echo/frontend/src/routes/project/library/ProjectLibraryAspect.tsx
index daf562c46..46930b988 100644
--- a/echo/frontend/src/routes/project/library/ProjectLibraryAspect.tsx
+++ b/echo/frontend/src/routes/project/library/ProjectLibraryAspect.tsx
@@ -19,7 +19,7 @@ import { sanitizeImageUrl } from "@/lib/utils";
import { Quote } from "../../../components/quote/Quote";
export const ProjectLibraryAspect = () => {
- const { projectId, viewId, aspectId } = useParams();
+ const { projectId, viewId, aspectId, workspaceId } = useParams();
const { data: aspect, isLoading } = useAspectById(
projectId ?? "",
@@ -41,11 +41,11 @@ export const ProjectLibraryAspect = () => {
items={[
{
label: Library,
- link: `/projects/${projectId}/library`,
+ link: `/w/${workspaceId}/projects/${projectId}/library`,
},
{
label: View,
- link: `/projects/${projectId}/library/views/${viewId}`,
+ link: `/w/${workspaceId}/projects/${projectId}/library/views/${viewId}`,
},
{
label: Aspect,
diff --git a/echo/frontend/src/routes/project/library/ProjectLibraryView.tsx b/echo/frontend/src/routes/project/library/ProjectLibraryView.tsx
index 240dfa35b..9b0686182 100644
--- a/echo/frontend/src/routes/project/library/ProjectLibraryView.tsx
+++ b/echo/frontend/src/routes/project/library/ProjectLibraryView.tsx
@@ -19,7 +19,7 @@ import { useCopyView } from "@/components/view/hooks/useCopyView";
import { Icons } from "@/icons";
export const ProjectLibraryView = () => {
- const { projectId, viewId } = useParams();
+ const { projectId, viewId, workspaceId } = useParams();
const { copyView, copied } = useCopyView();
const view = useViewById(projectId ?? "", viewId ?? "");
@@ -30,7 +30,7 @@ export const ProjectLibraryView = () => {
items={[
{
label: Library,
- link: `/projects/${projectId}/library`,
+ link: `/w/${workspaceId}/projects/${projectId}/library`,
},
{
label: View,
diff --git a/echo/frontend/src/routes/settings/UserSettingsRoute.tsx b/echo/frontend/src/routes/settings/UserSettingsRoute.tsx
index aab0f1e3c..a4ae34023 100644
--- a/echo/frontend/src/routes/settings/UserSettingsRoute.tsx
+++ b/echo/frontend/src/routes/settings/UserSettingsRoute.tsx
@@ -4,26 +4,16 @@ import {
ActionIcon,
Box,
Container,
- Divider,
Group,
- NavLink,
ScrollArea,
Stack,
- Text,
Title,
} from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
-import {
- IconArrowLeft,
- IconBuildingCommunity,
- IconPalette,
- IconScale,
- IconShieldLock,
-} from "@tabler/icons-react";
+import { IconArrowLeft } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
-import { useMemo, useState } from "react";
+import { useParams } from "react-router";
import { useCurrentUser } from "@/components/auth/hooks";
-import { API_BASE_URL } from "@/config";
import { AccountSettingsCard } from "@/components/settings/AccountSettingsCard";
import { AuditLogsCard } from "@/components/settings/AuditLogsCard";
import { ChangePasswordCard } from "@/components/settings/ChangePasswordCard";
@@ -32,50 +22,27 @@ import { FontSizeSettingsCard } from "@/components/settings/FontSizeSettingsCard
import { LegalBasisSettingsCard } from "@/components/settings/LegalBasisSettingsCard";
import { MyAccessCard } from "@/components/settings/MyAccessCard";
import { TwoFactorSettingsCard } from "@/components/settings/TwoFactorSettingsCard";
-import { UserAvatar } from "@/components/common/UserAvatar";
+import { API_BASE_URL } from "@/config";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
-type SectionId =
- | "account"
- | "access"
- | "appearance"
- | "project-defaults";
-
-const SECTIONS: Array<{
- id: SectionId;
- icon: typeof IconShieldLock;
- label: () => string;
-}> = [
- {
- id: "account",
- icon: IconShieldLock,
- label: () => t`Account & Security`,
- },
- {
- id: "access",
- icon: IconBuildingCommunity,
- label: () => t`My access`,
- },
- { id: "appearance", icon: IconPalette, label: () => t`Appearance` },
- {
- id: "project-defaults",
- icon: IconScale,
- label: () => t`Project Defaults`,
- },
-];
+type SectionId = "account" | "access" | "appearance" | "project-defaults";
+
+const resolveSection = (section?: string): SectionId =>
+ section === "access" ||
+ section === "appearance" ||
+ section === "project-defaults"
+ ? section
+ : "account";
export const UserSettingsRoute = () => {
- useDocumentTitle(t`Settings | Dembrane`);
+ useDocumentTitle(t`Settings | dembrane`);
const { data: user, isLoading } = useCurrentUser();
const navigate = useI18nNavigate();
- const [activeSection, setActiveSection] = useState("account");
-
- const isTwoFactorEnabled = Boolean(user?.tfa_enabled);
+ const { section: urlSection } = useParams<{ section?: string }>();
const { data: accessData } = useQuery<{
organisations: Array<{ id: string }>;
} | null>({
- queryKey: ["v2", "workspaces"],
queryFn: async () => {
const res = await fetch(`${API_BASE_URL}/v2/workspaces`, {
credentials: "include",
@@ -83,19 +50,21 @@ export const UserSettingsRoute = () => {
if (!res.ok) return null;
return res.json();
},
+ queryKey: ["v2", "workspaces"],
staleTime: 60_000,
});
- const isExternalOnly = (accessData?.organisations.length ?? 0) === 0;
- const visibleSections = useMemo(
- () => SECTIONS.filter((s) => !(isExternalOnly && s.id === "project-defaults")),
- [isExternalOnly],
- );
+ const requestedSection = resolveSection(urlSection);
+ const isExternalOnly = (accessData?.organisations.length ?? 0) === 0;
+ const activeSection =
+ isExternalOnly && requestedSection === "project-defaults"
+ ? "account"
+ : requestedSection;
+ const isTwoFactorEnabled = Boolean(user?.tfa_enabled);
return (
- {/* Header */}
{
- {/* Two-column layout */}
-
- {/* Sidebar */}
-
-
- {/* User identity in sidebar */}
-
-
-
-
- {(user?.first_name as string) ??
- "User"}
-
-
- {user?.email ?? ""}
-
-
-
-
-
-
- {visibleSections.map((section) => (
-
- }
- active={activeSection === section.id}
- onClick={() =>
- setActiveSection(section.id)
- }
- variant="light"
- style={{ borderRadius: 8 }}
+ {/* Inner sidebar retired — section navigation lives in the main
+ AppSidebar. The page renders only the active section. */}
+
+
+ {activeSection === "account" && (
+
+
+ Account & security
+
+
+
+
+
+
+
- ))}
-
-
-
- {/* Content */}
-
-
- {activeSection === "account" && (
-
-
- Account & Security
-
-
-
-
-
-
-
-
-
-
- )}
-
- {activeSection === "access" && (
-
-
- My access
-
-
-
- )}
-
- {activeSection === "appearance" && (
-
-
- Appearance
-
-
-
-
-
- )}
-
- {activeSection === "project-defaults" && !isExternalOnly && (
-
-
- Project Defaults
-
-
-
-
- )}
-
-
-
+
+
+
+ )}
+
+ {activeSection === "access" && (
+
+
+ My access
+
+
+
+ )}
+
+ {activeSection === "appearance" && (
+
+
+ Appearance
+
+
+
+
+
+ )}
+
+ {activeSection === "project-defaults" && !isExternalOnly && (
+
+
+ Project defaults
+
+
+
+
+ )}
+
+
);
diff --git a/echo/frontend/src/routes/sidebar-preview/SidebarPreviewLayout.tsx b/echo/frontend/src/routes/sidebar-preview/SidebarPreviewLayout.tsx
new file mode 100644
index 000000000..cd27bcb32
--- /dev/null
+++ b/echo/frontend/src/routes/sidebar-preview/SidebarPreviewLayout.tsx
@@ -0,0 +1,60 @@
+import { Outlet, useLocation } from "react-router";
+import { AppSidebar } from "@/features/sidebar";
+
+// Strip locale + preview prefix to render readable breadcrumbs.
+function pathSegments(pathname: string): string[] {
+ const segs = pathname.split("/").filter(Boolean);
+ if (segs[0] && /^[a-z]{2}(-[A-Z]{2})?$/.test(segs[0])) segs.shift();
+ if (segs[0] === "sidebar-preview") segs.shift();
+ return segs;
+}
+
+const Breadcrumbs = () => {
+ const { pathname } = useLocation();
+ const segs = pathSegments(pathname);
+ if (segs.length < 2) return null;
+ return (
+
+ {segs.map((s, i) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: breadcrumb segments derived from URL path; position-stable, never reordered
+
+ {i > 0 && /}
+ {s}
+
+ ))}
+
+ );
+};
+
+export const SidebarPreviewLayout = () => {
+ return (
+
+ );
+};
diff --git a/echo/frontend/src/routes/sidebar-preview/SidebarPreviewRoute.tsx b/echo/frontend/src/routes/sidebar-preview/SidebarPreviewRoute.tsx
new file mode 100644
index 000000000..bded4f1a3
--- /dev/null
+++ b/echo/frontend/src/routes/sidebar-preview/SidebarPreviewRoute.tsx
@@ -0,0 +1,32 @@
+import { useLocation } from "react-router";
+
+export const SidebarPreviewRoute = () => {
+ const { pathname } = useLocation();
+ return (
+
+
+ Sidebar preview
+
+
+ Click around the sidebar to test view transitions, the back button,
+ and active-state animations. Current path:
+
+
+ {pathname}
+
+
+ );
+};
diff --git a/echo/frontend/src/routes/workspaces/CreateWorkspaceRoute.tsx b/echo/frontend/src/routes/workspaces/CreateWorkspaceRoute.tsx
index 0b09bdc24..05c770af5 100644
--- a/echo/frontend/src/routes/workspaces/CreateWorkspaceRoute.tsx
+++ b/echo/frontend/src/routes/workspaces/CreateWorkspaceRoute.tsx
@@ -186,17 +186,21 @@ export const CreateWorkspaceRoute = () => {
selectedTier === "pilot" ? null : billingPeriod;
const mutation = useMutation({
- mutationFn: () =>
- submitWorkspaceRequest({
+ mutationFn: () => {
+ if (!targetOrganisationId) {
+ throw new Error(t`Choose an organisation first`);
+ }
+ return submitWorkspaceRequest({
kind: "new_workspace",
- org_id: targetOrganisationId!,
+ org_id: targetOrganisationId,
proposed_billing_period: submittedBillingPeriod,
proposed_name: name.trim(),
proposed_tier: selectedTier,
proposed_visibility:
privacy === "open" ? "open_to_organisation" : "private",
requester_message: message.trim() || undefined,
- }),
+ });
+ },
onError: (error: Error) => {
toast.error(error.message);
},
@@ -401,7 +405,7 @@ export const CreateWorkspaceRoute = () => {
- navigate(`/w/${freeWorkspaceForOrg.id}/projects`)
+ navigate(`/w/${freeWorkspaceForOrg.id}/home`)
}
>
Open {freeWorkspaceForOrg.name}
diff --git a/echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx b/echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx
index 311d0839c..6c4a1958b 100644
--- a/echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx
+++ b/echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx
@@ -187,18 +187,18 @@ function WorkspaceCard({
// organisation, so repeating the organisation logo on every internal card is
// just visual noise ("see here there is no special workspace
// icon so no need to show").
- const headerLogo = (workspace.role === "external")
- ? wsLogo || organisationLogo
- : wsLogo;
+ const headerLogo =
+ workspace.role === "external" ? wsLogo || organisationLogo : wsLogo;
// Calmer meta-line: role + tier as a single dimmed string, no
// colored badges. The old design stacked two blue pills which made
// every card shout "admin! pioneer!" at once. Only the at-limit
// warning keeps color — it's the one actionable exception.
const capitalizedTier =
workspace.tier.charAt(0).toUpperCase() + workspace.tier.slice(1);
- const metaParts = (workspace.role === "external")
- ? [t`External of ${workspace.org_name}`]
- : [displayRole(workspace.role), capitalizedTier];
+ const metaParts =
+ workspace.role === "external"
+ ? [t`External of ${workspace.org_name}`]
+ : [displayRole(workspace.role), capitalizedTier];
return (
{metaParts.join(" · ")}
@@ -429,6 +429,11 @@ function AddWorkspaceCard({ organisationId }: { organisationId: string }) {
);
}
+function sortWorkspaceCards(a: Workspace, b: Workspace) {
+ if (a.is_default !== b.is_default) return a.is_default ? -1 : 1;
+ return a.name.localeCompare(b.name);
+}
+
interface OrgUsageLite {
total_audio_hours: number;
workspaces_at_cap: number;
@@ -586,8 +591,16 @@ export const WorkspaceSelectorRoute = () => {
: workspaces;
// Group by organisation (org)
- const internalWorkspaces = filtered.filter((w) => !(w.role === "external"));
- const externalWorkspaces = filtered.filter((w) => (w.role === "external"));
+ const internalWorkspaces = filtered
+ .filter((w) => !(w.role === "external"))
+ .sort(sortWorkspaceCards);
+ const externalWorkspaces = filtered
+ .filter((w) => w.role === "external")
+ .sort((a, b) => {
+ const orgCompare = a.org_name.localeCompare(b.org_name);
+ if (orgCompare !== 0) return orgCompare;
+ return sortWorkspaceCards(a, b);
+ });
// Seed groups from `organisations` first — that way a organisation with zero workspaces
// still renders a hero card + AddWorkspace affordance instead of getting
@@ -618,7 +631,7 @@ export const WorkspaceSelectorRoute = () => {
const handleSelect = (ws: Workspace) => {
setWorkspace(ws.id);
- navigate(`/w/${ws.id}/projects`);
+ navigate(`/w/${ws.id}/home`);
};
if (isLoading) {
@@ -671,49 +684,51 @@ export const WorkspaceSelectorRoute = () => {
Ask 5: organisation-level context at top). Dividers between groups
(2026-04-24 ask) so each organisation/guest section reads as a
distinct block instead of one big stream. */}
- {Array.from(orgGroups.entries()).map(([orgId, group], idx) => {
- const organisation = organisations.find((t) => t.id === orgId);
-
- return (
-
- {idx > 0 && }
- {organisation ? (
- navigate(`/o/${orgId}`)}
- />
- ) : (
-
- {group.name}
-
- )}
-
-
- {group.workspaces.map((ws) => (
- handleSelect(ws)}
- onManage={() => navigate(`/w/${ws.id}/settings`)}
+ {Array.from(orgGroups.entries())
+ .sort(([, a], [, b]) => a.name.localeCompare(b.name))
+ .map(([orgId, group], idx) => {
+ const organisation = organisations.find((t) => t.id === orgId);
+
+ return (
+
+ {idx > 0 && }
+ {organisation ? (
+ navigate(`/o/${orgId}`)}
/>
- ))}
- {pendingRequests
- .filter((r) => r.org_id === orgId)
- .map((r) => (
-
- ))}
- {(group.role === "owner" || group.role === "admin") && (
-
+ ) : (
+
+ {group.name}
+
)}
-
- {/* Matrix §6: Slack-style discovery. Renders only
+
+ {group.workspaces.map((ws) => (
+ handleSelect(ws)}
+ onManage={() => navigate(`/w/${ws.id}/settings`)}
+ />
+ ))}
+ {pendingRequests
+ .filter((r) => r.org_id === orgId)
+ .map((r) => (
+
+ ))}
+ {(group.role === "owner" || group.role === "admin") && (
+
+ )}
+
+
+ {/* Matrix §6: Slack-style discovery. Renders only
when there's something joinable/requestable and
hides itself otherwise. */}
-
-
- );
- })}
+
+
+ );
+ })}
{/* External workspaces — quieter section, individual "guest of"
labels live on each card (designer Ask 5). */}
diff --git a/echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx b/echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx
index cfbaac8e5..8044d1571 100644
--- a/echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx
+++ b/echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx
@@ -34,7 +34,7 @@ import {
} from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
-import { useParams } from "react-router";
+import { useLocation, useParams } from "react-router";
import { AccessDeniedPanel } from "@/components/common/AccessDeniedPanel";
import { ConfirmModal } from "@/components/common/ConfirmModal";
import { FetchErrorPanel } from "@/components/common/FetchErrorPanel";
@@ -263,6 +263,7 @@ export const WorkspaceSettingsRoute = () => {
"*": string;
}>();
const navigate = useI18nNavigate();
+ const { search: urlSearch } = useLocation();
const queryClient = useQueryClient();
const { data: meV2 } = useV2Me();
const { workspace: myWorkspaceSummary } = useWorkspace();
@@ -271,11 +272,11 @@ export const WorkspaceSettingsRoute = () => {
// usage, privacy, or pending invites surfaces.
const iAmExternal = myWorkspaceSummary?.role === "external";
// Externals don't have a manage surface at all (2026-04-24). Bounce
- // them back to the project list of the workspace they came in on.
+ // them back to the workspace home they came in on.
// Keep the effect above the loading gate so hook order is stable.
useEffect(() => {
if (iAmExternal && workspaceId) {
- navigate(`/w/${workspaceId}/projects`, { replace: true });
+ navigate(`/w/${workspaceId}/home`, { replace: true });
}
}, [iAmExternal, workspaceId, navigate]);
const [deleteConfirm, setDeleteConfirm] = useState("");
@@ -470,16 +471,18 @@ export const WorkspaceSettingsRoute = () => {
: defaultTab;
const setActiveTab = (value: string | null) => {
if (!value || !workspaceId) return;
- navigate(`/w/${workspaceId}/settings/${value}`, { replace: true });
+ navigate(`/w/${workspaceId}/settings/${value}${urlSearch}`, {
+ replace: true,
+ });
};
useEffect(() => {
if (!workspaceId) return;
if (segment !== activeTab) {
- navigate(`/w/${workspaceId}/settings/${activeTab}`, {
+ navigate(`/w/${workspaceId}/settings/${activeTab}${urlSearch}`, {
replace: true,
});
}
- }, [workspaceId, segment, activeTab, navigate]);
+ }, [workspaceId, segment, activeTab, navigate, urlSearch]);
// Members list order (2026-04-24): internals first — sorted by role
// (owner → admin → billing → member) — then externals at the bottom.
@@ -495,7 +498,8 @@ export const WorkspaceSettingsRoute = () => {
const sortedMembers = useMemo(() => {
if (!settings) return [];
return [...settings.members].sort((a, b) => {
- if (isExternalMember(a) !== isExternalMember(b)) return isExternalMember(a) ? 1 : -1;
+ if (isExternalMember(a) !== isExternalMember(b))
+ return isExternalMember(a) ? 1 : -1;
const ar = MEMBERS_ROLE_WEIGHT[a.role] ?? 99;
const br = MEMBERS_ROLE_WEIGHT[b.role] ?? 99;
if (ar !== br) return ar - br;
@@ -520,7 +524,8 @@ export const WorkspaceSettingsRoute = () => {
if (isExternalMember(m)) return false;
if (m.role !== "billing") return false;
}
- if (memberRoleFilter === "externals" && !isExternalMember(m)) return false;
+ if (memberRoleFilter === "externals" && !isExternalMember(m))
+ return false;
if (!q) return true;
return (
(m.display_name || "").toLowerCase().includes(q) ||
@@ -536,7 +541,8 @@ export const WorkspaceSettingsRoute = () => {
// Show Billing chip only when ≥1 billing-role member exists (mirrors hasExternalMembers).
const hasBillingMembers = useMemo(
- () => sortedMembers.some((m) => !isExternalMember(m) && m.role === "billing"),
+ () =>
+ sortedMembers.some((m) => !isExternalMember(m) && m.role === "billing"),
[sortedMembers],
);
@@ -544,7 +550,8 @@ export const WorkspaceSettingsRoute = () => {
const adminLikeCount = useMemo(
() =>
sortedMembers.filter(
- (m) => !isExternalMember(m) && (m.role === "admin" || m.role === "owner"),
+ (m) =>
+ !isExternalMember(m) && (m.role === "admin" || m.role === "owner"),
).length,
[sortedMembers],
);
@@ -580,9 +587,10 @@ export const WorkspaceSettingsRoute = () => {
/>
);
}
+ if (!workspaceId) return null;
// Externals don't have a settings surface (matrix §4). The useEffect above
- // kicks them to /projects, but that fires on the next tick — without
+ // kicks them to workspace home, but that fires on the next tick — without
// this early return the settings tabs flash briefly. Render nothing
// while the redirect resolves.
if (iAmExternal) {
@@ -657,24 +665,8 @@ export const WorkspaceSettingsRoute = () => {
and nothing to navigate. Tabs come next for everyone else. */}
{!iAmExternal && (
-
-
- General
-
-
- Members
-
-
- Usage and Tier
-
- {/* Matrix §4: delete-workspace is admin + owner. Tab
- hidden for billing and member roles. */}
- {canEditSettings && (
-
- Danger
-
- )}
-
+ {/* Tab strip hidden — the main AppSidebar drives section
+ navigation. Internal Tabs.value still wires the panels via URL. */}
@@ -693,9 +685,9 @@ export const WorkspaceSettingsRoute = () => {
- Every workspace member, including externals, counts
- as one seat. One person never takes more than one
- seat per workspace, even if they're on multiple
+ Every workspace member, including externals, counts as
+ one seat. One person never takes more than one seat
+ per workspace, even if they're on multiple
organisations.
@@ -758,13 +750,13 @@ export const WorkspaceSettingsRoute = () => {
>
@@ -854,8 +846,8 @@ export const WorkspaceSettingsRoute = () => {
seatInviteBlocked ? (
All seats are taken on this tier. Remove a member
- or external, or upgrade the workspace tier to invite
- more people.
+ or external, or upgrade the workspace tier to
+ invite more people.
) : undefined
}
diff --git a/echo/scripts/__pycache__/backfill_direct_memberships.cpython-311.pyc b/echo/scripts/__pycache__/backfill_direct_memberships.cpython-311.pyc
deleted file mode 100644
index 988c1ff71..000000000
Binary files a/echo/scripts/__pycache__/backfill_direct_memberships.cpython-311.pyc and /dev/null differ
diff --git a/echo/scripts/__pycache__/create_schema.cpython-311.pyc b/echo/scripts/__pycache__/create_schema.cpython-311.pyc
deleted file mode 100644
index 89c770020..000000000
Binary files a/echo/scripts/__pycache__/create_schema.cpython-311.pyc and /dev/null differ
diff --git a/echo/scripts/__pycache__/seed_dev.cpython-311.pyc b/echo/scripts/__pycache__/seed_dev.cpython-311.pyc
deleted file mode 100644
index 3c40db676..000000000
Binary files a/echo/scripts/__pycache__/seed_dev.cpython-311.pyc and /dev/null differ
diff --git a/echo/scripts/backfill_direct_memberships.py b/echo/scripts/backfill_direct_memberships.py
deleted file mode 100644
index af78c73b8..000000000
--- a/echo/scripts/backfill_direct_memberships.py
+++ /dev/null
@@ -1,362 +0,0 @@
-"""Backfill explicit `source='direct'` workspace_membership rows for every
-user who currently reaches a workspace only through derivation.
-
-Context: matrix v1.1 §5 + §6 retires the derivation model. After this script
-runs + the resolver simplifies, access is stored-direct-only. This script
-materialises every currently-derived user into a direct row so no one loses
-access at cutover.
-
-Two passes after the inserts land:
- 1. Simplify `inheritance.user_can_access` to a direct-row lookup.
- 2. Purge `workspace.settings.{inherit_organisation_admins, inherit_organisation_members,
- sticky_removed}` — the resolver no longer reads them.
-
-Both follow in a separate commit. This script only writes rows.
-
-STOP CONDITION per brief: dry-run default prints the proposed row count.
-Do NOT run with --apply until Sameer has signed off on the count.
-
-Usage:
- python scripts/backfill_direct_memberships.py # dry-run
- python scripts/backfill_direct_memberships.py --dry-run # explicit
- python scripts/backfill_direct_memberships.py --apply # mutate
- python scripts/backfill_direct_memberships.py --csv out.csv
-
-Environment: DIRECTUS_BASE_URL, DIRECTUS_TOKEN (falls back to
-directus/.env DIRECTUS_TOKEN line).
-"""
-
-from __future__ import annotations
-
-import argparse
-import csv
-import json
-import os
-import sys
-import uuid
-from contextlib import contextmanager
-from datetime import datetime, timezone
-from pathlib import Path
-from urllib.parse import urlencode
-
-import requests
-
-# Separate lockfile from migrate_inherited_to_derived — the two scripts
-# target different concerns and could theoretically run sequentially
-# in a cutover script; don't let one lock out the other.
-_LOCK_PATH = Path("/tmp/dembrane_backfill_direct_memberships.lock")
-
-
-@contextmanager
-def _exclusive_lock():
- if _LOCK_PATH.exists():
- raise RuntimeError(
- f"Another backfill is already running (lock: {_LOCK_PATH}). "
- "If this is stale, remove it manually after confirming no "
- "other process is running."
- )
- _LOCK_PATH.write_text(
- f"pid={os.getpid()} started={datetime.now(timezone.utc).isoformat()}"
- )
- try:
- yield
- finally:
- try:
- _LOCK_PATH.unlink()
- except FileNotFoundError:
- pass
-
-
-DIRECTUS_URL = os.environ.get("DIRECTUS_BASE_URL", "http://directus:8055")
-DIRECTUS_TOKEN = os.environ.get("DIRECTUS_TOKEN", "")
-
-if not DIRECTUS_TOKEN:
- env_path = Path(__file__).parent.parent / "directus" / ".env"
- if env_path.exists():
- for line in env_path.read_text().splitlines():
- if line.startswith("DIRECTUS_TOKEN="):
- DIRECTUS_TOKEN = line.split("=", 1)[1].strip().strip('"').strip("'")
-
-HEADERS = {
- "Authorization": f"Bearer {DIRECTUS_TOKEN}",
- "Content-Type": "application/json",
-}
-
-
-def _req(method: str, path: str, json_body: dict | None = None) -> dict | list | None:
- url = f"{DIRECTUS_URL}{path}"
- resp = requests.request(method, url, headers=HEADERS, json=json_body, timeout=60)
- if resp.status_code >= 400:
- raise RuntimeError(f"{method} {path} → {resp.status_code}: {resp.text[:500]}")
- if resp.status_code == 204 or not resp.content:
- return None
- return resp.json()
-
-
-def fetch_all(collection: str, filter_: dict, fields: list[str]) -> list[dict]:
- params = {
- "limit": -1,
- "filter": json.dumps(filter_),
- "fields": ",".join(fields),
- }
- resp = requests.get(
- f"{DIRECTUS_URL}/items/{collection}",
- headers=HEADERS,
- params=params,
- timeout=60,
- )
- if resp.status_code >= 400:
- raise RuntimeError(
- f"fetch_all {collection} failed {resp.status_code}: {resp.text[:500]}"
- )
- return resp.json().get("data", []) or []
-
-
-def _settings_of(ws: dict) -> dict:
- raw = ws.get("settings")
- return raw if isinstance(raw, dict) else {}
-
-
-def _follows_admins(ws: dict) -> bool:
- return _settings_of(ws).get("inherit_organisation_admins", True)
-
-
-def _follows_members(ws: dict) -> bool:
- return _settings_of(ws).get("inherit_organisation_members", False)
-
-
-def _sticky_user_ids(ws: dict) -> set[str]:
- raw = _settings_of(ws).get("sticky_removed") or []
- if not isinstance(raw, list):
- return set()
- return {t.get("user_id") for t in raw if isinstance(t, dict) and t.get("user_id")}
-
-
-def derive_access_for_org(
- workspaces: list[dict],
- org_memberships: list[dict],
-) -> list[dict]:
- """Given all workspaces in one org + that org's memberships, return a
- list of (workspace_id, user_id, role) triples that the current
- derivation model grants. Mirrors inheritance.user_can_access /
- get_effective_members for org+workspace pairs, without a direct row.
-
- Does not itself check for existing direct rows — caller does that.
- """
- out: list[dict] = []
-
- for ws in workspaces:
- if ws.get("deleted_at"):
- continue
- ws_id = ws["id"]
- sticky = _sticky_user_ids(ws)
- follows_admins = _follows_admins(ws)
- follows_members = _follows_members(ws)
-
- for om in org_memberships:
- if om.get("deleted_at"):
- continue
- uid = om.get("user_id")
- if not uid:
- continue
- if uid in sticky:
- continue
-
- role = om.get("role")
-
- # Organisation owners always derive admin (organisation-owner carve-out in
- # inheritance.user_can_access).
- if role == "owner":
- out.append({"workspace_id": ws_id, "user_id": uid, "role": "admin"})
- continue
-
- # Organisation admins derive admin on open workspaces only.
- if role == "admin" and follows_admins:
- out.append({"workspace_id": ws_id, "user_id": uid, "role": "admin"})
- continue
-
- # Organisation members derive member only when the workspace opts in.
- if role == "member" and follows_admins and follows_members:
- out.append({"workspace_id": ws_id, "user_id": uid, "role": "member"})
-
- return out
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--apply", action="store_true",
- help="Actually insert direct rows. Default is dry-run.")
- parser.add_argument("--dry-run", action="store_true",
- help="Explicit dry-run flag (default).")
- parser.add_argument("--csv", type=str, default=None,
- help="Write proposed rows to CSV at this path.")
- args = parser.parse_args()
- if args.apply and args.dry_run:
- print("ERROR: --apply and --dry-run are mutually exclusive")
- return 2
- dry_run = not args.apply
-
- if not DIRECTUS_TOKEN:
- print("ERROR: DIRECTUS_TOKEN not set (env or directus/.env)")
- return 2
-
- print(f"Mode: {'DRY-RUN' if dry_run else 'APPLY'}")
- print(f"Directus: {DIRECTUS_URL}")
-
- health = _req("GET", "/server/health")
- if not health:
- print("ERROR: Directus /server/health returned empty")
- return 2
- print(f"Health: {health.get('status', '?')}")
-
- script_start_iso = datetime.now(timezone.utc).isoformat()
- print(f"Started: {script_start_iso}")
- # Idempotency: re-runs dedupe via existing direct rows; no time cutoff
- # needed since a row created mid-run will be picked up on the next run
- # (which is still a no-op if it's already direct).
-
- # 1. Fetch all active workspaces.
- workspaces = fetch_all(
- "workspace",
- {"deleted_at": {"_null": True}},
- ["id", "name", "org_id", "settings", "deleted_at"],
- )
- print(f"\nActive workspaces in scope: {len(workspaces)}")
-
- by_org: dict[str, list[dict]] = {}
- for ws in workspaces:
- oid = ws.get("org_id")
- if not oid:
- continue
- by_org.setdefault(oid, []).append(ws)
-
- # 2. Fetch all active org_memberships (chunk by org for clarity).
- all_om = fetch_all(
- "org_membership",
- {"deleted_at": {"_null": True}},
- ["id", "org_id", "user_id", "role", "deleted_at"],
- )
- om_by_org: dict[str, list[dict]] = {}
- for om in all_om:
- om_by_org.setdefault(om.get("org_id") or "", []).append(om)
- print(f"Active org_memberships: {len(all_om)} across {len(om_by_org)} orgs")
-
- # 3. Fetch all current direct rows — so we can dedupe proposals.
- direct_rows = fetch_all(
- "workspace_membership",
- {
- "source": {"_eq": "direct"},
- "deleted_at": {"_null": True},
- },
- ["workspace_id", "user_id", "role"],
- )
- direct_key = {(r["workspace_id"], r["user_id"]) for r in direct_rows}
- print(f"Existing direct rows: {len(direct_rows)}")
-
- # 4. Compute derivations per org + propose rows that have no direct.
- proposals: list[dict] = []
- per_org_summary: list[tuple[str, int, int]] = [] # (org_id, ws_count, propose_count)
- per_ws_summary: list[tuple[str, str, int]] = [] # (ws_id, ws_name, propose_count)
-
- for org_id, org_workspaces in by_org.items():
- om_list = om_by_org.get(org_id, [])
- derived = derive_access_for_org(org_workspaces, om_list)
-
- org_propose = 0
- per_ws_counts: dict[str, int] = {}
- for d in derived:
- key = (d["workspace_id"], d["user_id"])
- if key in direct_key:
- continue # direct wins, no-op
- proposals.append({
- "workspace_id": d["workspace_id"],
- "user_id": d["user_id"],
- "role": d["role"],
- "org_id": org_id,
- })
- org_propose += 1
- per_ws_counts[d["workspace_id"]] = per_ws_counts.get(d["workspace_id"], 0) + 1
-
- per_org_summary.append((org_id, len(org_workspaces), org_propose))
- for ws in org_workspaces:
- if ws["id"] in per_ws_counts:
- per_ws_summary.append((ws["id"], ws.get("name") or "(no name)",
- per_ws_counts[ws["id"]]))
-
- # 5. Summary.
- print(f"\n=== Proposal summary ===")
- print(f"Proposed INSERT rows: {len(proposals)}")
- print(f"Affected orgs: {sum(1 for _, _, n in per_org_summary if n > 0)}")
- print(f"Affected workspaces: {len(per_ws_summary)}")
-
- if per_org_summary:
- print("\nPer-org:")
- for oid, ws_count, n in sorted(per_org_summary, key=lambda x: -x[2])[:20]:
- print(f" org={oid[:8]} workspaces={ws_count:3d} proposed={n}")
-
- if per_ws_summary:
- print("\nTop workspaces by proposed rows:")
- for ws_id, ws_name, n in sorted(per_ws_summary, key=lambda x: -x[2])[:15]:
- print(f" ws={ws_id[:8]} name={ws_name[:40]:40s} proposed={n}")
-
- if args.csv:
- with open(args.csv, "w", newline="") as f:
- w = csv.DictWriter(
- f, fieldnames=["org_id", "workspace_id", "user_id", "role"]
- )
- w.writeheader()
- w.writerows(proposals)
- print(f"\nCSV written: {args.csv} ({len(proposals)} rows)")
-
- if dry_run:
- print("\nDry-run — no changes written.")
- print("Paste the proposal summary into 04-QUESTIONS-FOR-SAMEER.md")
- print("and wait for Sameer's sign-off before running with --apply.")
- return 0
-
- if not proposals:
- print("\nNothing to apply.")
- return 0
-
- # Lock only for the mutating portion.
- try:
- lock_ctx = _exclusive_lock()
- lock_ctx.__enter__()
- except RuntimeError as exc:
- print(f"ERROR: {exc}")
- return 2
-
- errors = 0
- written = 0
- try:
- for p in proposals:
- try:
- _req(
- "POST",
- "/items/workspace_membership",
- {
- "id": str(uuid.uuid4()),
- "workspace_id": p["workspace_id"],
- "user_id": p["user_id"],
- "role": p["role"],
- "source": "direct",
- },
- )
- written += 1
- except Exception as e:
- errors += 1
- print(
- f" FAIL ws={p['workspace_id'][:8]} "
- f"user={p['user_id'][:8]}: {e}"
- )
- print(f"\nWrote {written}/{len(proposals)} direct rows. Errors: {errors}")
- finally:
- lock_ctx.__exit__(None, None, None)
-
- if errors:
- return 1
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/echo/scripts/create_schema.py b/echo/scripts/create_schema.py
deleted file mode 100644
index f1615f54f..000000000
--- a/echo/scripts/create_schema.py
+++ /dev/null
@@ -1,2021 +0,0 @@
-"""
-Create / extend Directus schema collections via the Directus API.
-
-Usage:
- python scripts/create_schema.py --step 1 # app_user only (test)
- python scripts/create_schema.py --step 2 # org + org_membership
- python scripts/create_schema.py --step 3 # workspace + workspace_membership
- python scripts/create_schema.py --step 4 # workspace_invite + project_membership
- python scripts/create_schema.py --step 5 # (removed)
- python scripts/create_schema.py --step 6 # add fields to project
- python scripts/create_schema.py --step 7 # add deleted_at to existing collections
- python scripts/create_schema.py --step 8 # remove legacy chat collection
- python scripts/create_schema.py --step 9-16 # notifications, visibility, downgrade, etc.
- python scripts/create_schema.py --step 17 # conversation.is_over_cap
- python scripts/create_schema.py --step 18 # workspace_request collection
- python scripts/create_schema.py --step 19 # workspace.tier_expires_at
- python scripts/create_schema.py --step 20 # workspace.pre_warning_sent
- python scripts/create_schema.py --step 21 # workspace discount fields
- python scripts/create_schema.py --step 22 # workspace_request billing period columns
- python scripts/create_schema.py --step all # everything
-
-Requires DIRECTUS_TOKEN and DIRECTUS_BASE_URL env vars (reads from directus/.env).
-"""
-
-import argparse
-import json
-import os
-import sys
-
-import requests
-
-# ---------------------------------------------------------------------------
-# Config
-# ---------------------------------------------------------------------------
-
-DIRECTUS_URL = os.environ.get("DIRECTUS_BASE_URL", "http://directus:8055")
-DIRECTUS_TOKEN = os.environ.get("DIRECTUS_TOKEN", "")
-
-if not DIRECTUS_TOKEN:
- # Try reading from directus/.env
- env_path = os.path.join(os.path.dirname(__file__), "..", "directus", ".env")
- if os.path.exists(env_path):
- with open(env_path) as f:
- for line in f:
- line = line.strip()
- if line.startswith("DIRECTUS_TOKEN="):
- DIRECTUS_TOKEN = line.split("=", 1)[1].strip().strip('"').strip("'")
-
-HEADERS = {
- "Authorization": f"Bearer {DIRECTUS_TOKEN}",
- "Content-Type": "application/json",
-}
-
-
-def api(method, path, data=None):
- """Make a Directus API call. Returns response JSON or raises on error."""
- url = f"{DIRECTUS_URL}{path}"
- resp = requests.request(method, url, headers=HEADERS, json=data, timeout=30)
- if resp.status_code >= 400:
- print(f" ERROR {resp.status_code}: {resp.text[:500]}")
- return None
- if resp.status_code == 204:
- return {}
- return resp.json()
-
-
-def collection_exists(name):
- """Check if a collection already exists."""
- resp = requests.get(
- f"{DIRECTUS_URL}/collections/{name}", headers=HEADERS, timeout=10
- )
- return resp.status_code == 200
-
-
-def field_exists(collection, field):
- """Check if a field already exists on a collection."""
- resp = requests.get(
- f"{DIRECTUS_URL}/fields/{collection}/{field}", headers=HEADERS, timeout=10
- )
- return resp.status_code == 200
-
-
-def create_collection(name, fields, meta=None):
- """Create a collection with fields. Skips if already exists."""
- if collection_exists(name):
- print(f" SKIP {name} (already exists)")
- return True
-
- payload = {
- "collection": name,
- "meta": meta or {},
- "schema": {},
- "fields": fields,
- }
- print(f" Creating collection: {name}")
- result = api("POST", "/collections", payload)
- if result:
- print(f" OK {name} created")
- return True
- return False
-
-
-def add_field(collection, field_name, field_def):
- """Add a field to an existing collection. Skips if already exists."""
- if field_exists(collection, field_name):
- print(f" SKIP {collection}.{field_name} (already exists)")
- return True
-
- payload = {"field": field_name, **field_def}
- print(f" Adding field: {collection}.{field_name}")
- result = api("POST", f"/fields/{collection}", payload)
- if result:
- print(f" OK {collection}.{field_name} added")
- return True
- return False
-
-
-def create_relation(collection, field, related_collection, meta=None, schema=None):
- """Create a M2O relation."""
- payload = {
- "collection": collection,
- "field": field,
- "related_collection": related_collection,
- }
- if meta:
- payload["meta"] = meta
- if schema:
- payload["schema"] = schema
-
- print(f" Creating relation: {collection}.{field} -> {related_collection}")
- result = api("POST", "/relations", payload)
- if result:
- print(f" OK relation created")
- return True
- return False
-
-
-# ---------------------------------------------------------------------------
-# Field definitions (reusable)
-# ---------------------------------------------------------------------------
-
-def pk_uuid():
- return {
- "field": "id",
- "type": "uuid",
- "schema": {"is_primary_key": True, "has_auto_increment": False},
- "meta": {"special": ["uuid"], "interface": "input", "readonly": True, "hidden": True},
- }
-
-
-def timestamp_created():
- return {
- "type": "timestamp",
- "schema": {"is_nullable": True, "default_value": "CURRENT_TIMESTAMP"},
- "meta": {"special": ["date-created"], "interface": "datetime", "readonly": True,
- "width": "half"},
- }
-
-
-def timestamp_updated():
- return {
- "type": "timestamp",
- "schema": {"is_nullable": True, "default_value": "CURRENT_TIMESTAMP"},
- "meta": {"special": ["date-updated"], "interface": "datetime", "readonly": True,
- "width": "half"},
- }
-
-
-def deleted_at_field():
- return {
- "type": "timestamp",
- "schema": {"is_nullable": True, "default_value": None},
- "meta": {"interface": "datetime", "width": "half", "note": "Soft delete timestamp"},
- }
-
-
-# ---------------------------------------------------------------------------
-# Step 1: app_user
-# ---------------------------------------------------------------------------
-
-def step_1_app_user():
- print("\n=== Step 1: app_user ===")
-
- fields = [
- pk_uuid(),
- {
- "field": "directus_user_id",
- "type": "uuid",
- "schema": {"is_nullable": True, "is_unique": True},
- "meta": {"interface": "input", "note": "Maps to directus_users.id"},
- },
- {
- "field": "email",
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"},
- },
- {
- "field": "display_name",
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"},
- },
- {
- "field": "created_at",
- **timestamp_created(),
- },
- {
- "field": "updated_at",
- **timestamp_updated(),
- },
- ]
-
- meta = {
- "accountability": "all",
- "display_template": "{{display_name}}",
- }
-
- ok = create_collection("app_user", fields, meta)
- if not ok:
- return False
-
- # Note: directus_user_id is a logical FK to directus_users but we do NOT
- # create a Directus relation for it. This is intentional — app_user is our
- # indirection layer and we don't want Directus managing this relationship.
-
- return True
-
-
-# ---------------------------------------------------------------------------
-# Step 2: org + org_membership
-# ---------------------------------------------------------------------------
-
-def step_2_org():
- print("\n=== Step 2: org + org_membership ===")
-
- # --- org ---
- org_fields = [
- pk_uuid(),
- {
- "field": "name",
- "type": "string",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "logo_url",
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"},
- },
- {
- "field": "created_by",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "FK to app_user.id"},
- },
- {
- "field": "deleted_at",
- **deleted_at_field(),
- },
- {
- "field": "created_at",
- **timestamp_created(),
- },
- {
- "field": "updated_at",
- **timestamp_updated(),
- },
- ]
-
- ok = create_collection("org", org_fields, {
- "accountability": "all",
- "display_template": "{{name}}",
- })
- if not ok:
- return False
-
- # Relation: org.created_by -> app_user
- create_relation("org", "created_by", "app_user", schema={"on_delete": "SET NULL"})
-
- # --- org_membership ---
- om_fields = [
- pk_uuid(),
- {
- "field": "org_id",
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "user_id",
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "role",
- "type": "string",
- "schema": {"is_nullable": False},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Owner", "value": "owner"},
- {"text": "Admin", "value": "admin"},
- {"text": "Member", "value": "member"},
- ]},
- "required": True,
- },
- },
- {
- "field": "custom_policies",
- "type": "json",
- "schema": {"is_nullable": True, "default_value": "[]"},
- "meta": {"interface": "input-code", "options": {"language": "json"},
- "note": "Extra policies beyond role preset. Usually empty."},
- },
- {
- "field": "deleted_at",
- **deleted_at_field(),
- },
- {
- "field": "created_at",
- **timestamp_created(),
- },
- {
- "field": "updated_at",
- **timestamp_updated(),
- },
- ]
-
- ok = create_collection("org_membership", om_fields, {
- "accountability": "all",
- })
- if not ok:
- return False
-
- # Relations
- create_relation("org_membership", "org_id", "org", schema={"on_delete": "CASCADE"})
- create_relation("org_membership", "user_id", "app_user", schema={"on_delete": "CASCADE"})
-
- return True
-
-
-# ---------------------------------------------------------------------------
-# Step 3: workspace + workspace_membership
-# ---------------------------------------------------------------------------
-
-def step_3_workspace():
- print("\n=== Step 3: workspace + workspace_membership ===")
-
- # --- workspace ---
- ws_fields = [
- pk_uuid(),
- {
- "field": "org_id",
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "name",
- "type": "string",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "description",
- "type": "text",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input-multiline"},
- },
- {
- "field": "logo_url",
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "Override org logo"},
- },
- {
- "field": "tier",
- "type": "string",
- "schema": {"is_nullable": False, "default_value": "free"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Free", "value": "free"},
- {"text": "Pilot", "value": "pilot"},
- {"text": "Pioneer", "value": "pioneer"},
- {"text": "Innovator", "value": "innovator"},
- {"text": "Changemaker", "value": "changemaker"},
- {"text": "Guardian", "value": "guardian"},
- ]},
- },
- },
- {
- "field": "billed_to_workspace_id",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "Partner billing. NULL = org pays."},
- },
- {
- "field": "is_default",
- "type": "boolean",
- "schema": {"is_nullable": False, "default_value": False},
- "meta": {"interface": "boolean"},
- },
- {
- "field": "legal_basis",
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Consent", "value": "consent"},
- {"text": "Client-managed", "value": "client-managed"},
- {"text": "Dembrane Events", "value": "dembrane-events"},
- ]},
- },
- },
- {
- "field": "privacy_policy_url",
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"},
- },
- {
- "field": "settings",
- "type": "json",
- "schema": {"is_nullable": True, "default_value": "{}"},
- "meta": {"interface": "input-code", "options": {"language": "json"},
- "note": "Feature flags, limits"},
- },
- {
- "field": "deleted_at",
- **deleted_at_field(),
- },
- {
- "field": "created_by",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "FK to app_user.id"},
- },
- {
- "field": "created_at",
- **timestamp_created(),
- },
- {
- "field": "updated_at",
- **timestamp_updated(),
- },
- ]
-
- ok = create_collection("workspace", ws_fields, {
- "accountability": "all",
- "display_template": "{{name}}",
- })
- if not ok:
- return False
-
- # Relations
- create_relation("workspace", "org_id", "org", schema={"on_delete": "CASCADE"})
- create_relation("workspace", "created_by", "app_user", schema={"on_delete": "SET NULL"})
- create_relation("workspace", "billed_to_workspace_id", "workspace",
- schema={"on_delete": "SET NULL"})
-
- # --- workspace_membership ---
- wm_fields = [
- pk_uuid(),
- {
- "field": "workspace_id",
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "user_id",
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "role",
- "type": "string",
- "schema": {"is_nullable": False},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Owner", "value": "owner"},
- {"text": "Admin", "value": "admin"},
- {"text": "Member", "value": "member"},
- {"text": "Billing", "value": "billing"},
- {"text": "External", "value": "external"},
- ]},
- "required": True,
- },
- },
- {
- "field": "custom_policies",
- "type": "json",
- "schema": {"is_nullable": True, "default_value": "[]"},
- "meta": {"interface": "input-code", "options": {"language": "json"},
- "note": "Extra policies beyond role preset. Usually empty."},
- },
- {
- "field": "source",
- "type": "string",
- "schema": {"is_nullable": False, "default_value": "direct"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Direct", "value": "direct"},
- {"text": "Inherited", "value": "inherited"},
- ]},
- "note": "direct = explicitly invited. inherited = auto-added from org role.",
- },
- },
- {
- "field": "deleted_at",
- **deleted_at_field(),
- },
- {
- "field": "created_at",
- **timestamp_created(),
- },
- {
- "field": "updated_at",
- **timestamp_updated(),
- },
- ]
-
- ok = create_collection("workspace_membership", wm_fields, {
- "accountability": "all",
- })
- if not ok:
- return False
-
- create_relation("workspace_membership", "workspace_id", "workspace",
- schema={"on_delete": "CASCADE"})
- create_relation("workspace_membership", "user_id", "app_user",
- schema={"on_delete": "CASCADE"})
-
- return True
-
-
-# ---------------------------------------------------------------------------
-# Step 4: workspace_invite + project_membership
-# ---------------------------------------------------------------------------
-
-def step_4_invite_and_project_membership():
- print("\n=== Step 4: workspace_invite + project_membership ===")
-
- # --- workspace_invite ---
- wi_fields = [
- pk_uuid(),
- {
- "field": "workspace_id",
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "email",
- "type": "string",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "role",
- "type": "string",
- "schema": {"is_nullable": False},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Admin", "value": "admin"},
- {"text": "Member", "value": "member"},
- {"text": "Billing", "value": "billing"},
- {"text": "External", "value": "external"},
- ]},
- "required": True,
- "note": "Role to assign on acceptance",
- },
- },
- {
- "field": "invited_by",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "FK to app_user.id"},
- },
- {
- "field": "token",
- "type": "string",
- "schema": {"is_nullable": False, "is_unique": True},
- "meta": {"interface": "input", "note": "secrets.token_urlsafe(32)"},
- },
- {
- "field": "expires_at",
- "type": "timestamp",
- "schema": {"is_nullable": False},
- "meta": {"interface": "datetime", "note": "7 days from creation"},
- },
- {
- "field": "accepted_at",
- "type": "timestamp",
- "schema": {"is_nullable": True},
- "meta": {"interface": "datetime"},
- },
- {
- "field": "created_at",
- **timestamp_created(),
- },
- ]
-
- ok = create_collection("workspace_invite", wi_fields, {
- "accountability": "all",
- })
- if not ok:
- return False
-
- create_relation("workspace_invite", "workspace_id", "workspace",
- schema={"on_delete": "CASCADE"})
- create_relation("workspace_invite", "invited_by", "app_user",
- schema={"on_delete": "SET NULL"})
-
- # --- project_membership ---
- pm_fields = [
- pk_uuid(),
- {
- "field": "project_id",
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "user_id",
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "role",
- "type": "string",
- "schema": {"is_nullable": False, "default_value": "editor"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Editor", "value": "editor"},
- {"text": "Viewer", "value": "viewer"},
- ]},
- },
- },
- {
- "field": "custom_policies",
- "type": "json",
- "schema": {"is_nullable": True, "default_value": "[]"},
- "meta": {"interface": "input-code", "options": {"language": "json"},
- "note": "Extra policies beyond role preset. Usually empty."},
- },
- {
- "field": "granted_by",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "FK to app_user.id"},
- },
- {
- "field": "created_at",
- **timestamp_created(),
- },
- ]
-
- ok = create_collection("project_membership", pm_fields, {
- "accountability": "all",
- })
- if not ok:
- return False
-
- create_relation("project_membership", "project_id", "project",
- schema={"on_delete": "CASCADE"})
- create_relation("project_membership", "user_id", "app_user",
- schema={"on_delete": "CASCADE"})
- create_relation("project_membership", "granted_by", "app_user",
- schema={"on_delete": "SET NULL"})
-
- return True
-
-
-# ---------------------------------------------------------------------------
-# Step 5: usage_event
-# ---------------------------------------------------------------------------
-
-def step_5_usage_event():
- print("\n=== Step 5: usage_event ===")
-
- fields = [
- pk_uuid(),
- {
- "field": "trace_id",
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "Request correlation ID"},
- },
- {
- "field": "org_id",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "Reference only, no FK constraint"},
- },
- {
- "field": "workspace_id",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "Reference only, no FK constraint"},
- },
- {
- "field": "project_id",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "Reference only, no FK constraint"},
- },
- {
- "field": "user_id",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "Reference only, no FK constraint"},
- },
- {
- "field": "event_type",
- "type": "string",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True},
- },
- {
- "field": "event_data",
- "type": "json",
- "schema": {"is_nullable": True, "default_value": "{}"},
- "meta": {"interface": "input-code", "options": {"language": "json"},
- "note": "Always include \"v\": 1 for schema versioning"},
- },
- {
- "field": "created_at",
- **timestamp_created(),
- },
- ]
-
- ok = create_collection("usage_event", fields, {
- "accountability": "all",
- "note": "Append-only. Never updated. Never deleted.",
- })
-
- # No FK relations — these are reference-only UUID fields
- return ok
-
-
-# ---------------------------------------------------------------------------
-# Step 6: Add fields to project
-# ---------------------------------------------------------------------------
-
-def step_6_project_fields():
- print("\n=== Step 6: Add fields to project ===")
-
- add_field("project", "workspace_id", {
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "FK to workspace. NULL during migration."},
- })
-
- # Create the relation for workspace_id
- if not field_exists("project", "workspace_id"):
- pass # field creation failed, skip relation
- else:
- create_relation("project", "workspace_id", "workspace",
- schema={"on_delete": "SET NULL"})
-
- add_field("project", "visibility", {
- "type": "string",
- "schema": {"is_nullable": False, "default_value": "workspace"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Workspace", "value": "workspace"},
- {"text": "Private", "value": "private"},
- ]},
- "note": "workspace = visible to all workspace members. private = explicit sharing.",
- },
- })
-
- add_field("project", "deleted_at", {
- **deleted_at_field(),
- })
-
- return True
-
-
-# ---------------------------------------------------------------------------
-# Step 7: Add deleted_at to existing collections
-# ---------------------------------------------------------------------------
-
-def step_7_deleted_at():
- print("\n=== Step 7: Add deleted_at to existing collections ===")
-
- for collection in ["conversation", "project_chat", "project_report"]:
- add_field(collection, "deleted_at", {
- **deleted_at_field(),
- })
-
- return True
-
-
-# ---------------------------------------------------------------------------
-# Step 8: Remove legacy chat collection
-# ---------------------------------------------------------------------------
-
-def step_8_remove_chat():
- print("\n=== Step 8: Remove legacy chat collection ===")
-
- if not collection_exists("chat"):
- print(" SKIP chat (already removed)")
- return True
-
- # Verify it's empty first
- resp = api("GET", "/items/chat?limit=0&meta=total_count")
- if resp and resp.get("meta", {}).get("total_count", 0) > 0:
- count = resp["meta"]["total_count"]
- print(f" ABORT: chat collection has {count} rows! Not safe to remove.")
- return False
-
- print(" Confirmed: chat collection is empty")
- print(" Deleting chat collection...")
- result = api("DELETE", "/collections/chat")
- if result is not None:
- print(" OK chat collection removed")
- return True
- return False
-
-
-# ---------------------------------------------------------------------------
-# Step 9: Notifications (inbox) — flat per-recipient rows
-# ---------------------------------------------------------------------------
-
-def step_9_notifications():
- """Per-user notifications — one row per (event, recipient).
-
- The announcement pattern (parent + translations + activity) was
- rejected here because fan-out is almost always 1–3 people and pre-
- rendering N locales per row wastes more than we'd save on string
- dedupe. Client-side Lingui catalogs render text from `event_code +
- params` when that migration lands; for now title/message are plain
- English strings written at emit time.
-
- Severity is server-derived from the event_code — client renders
- styling from severity, never from event_code.
-
- Scope is the denormalized breadcrumb ("Org › Workspace › Project")
- computed once at emit time; a later rename correctly preserves the
- historical breadcrumb instead of mutating past notifications.
-
- Note on channels: in-app only for now. A future email digest or
- Slack bridge reads the same rows rather than having its own store.
- """
- print("\n=== Step 9: notification (flat per-recipient) ===")
-
- notification_fields = [
- pk_uuid(),
- {
- "field": "audience_user_id", "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True,
- "note": "FK to app_user — the recipient."},
- },
- {
- "field": "actor_user_id", "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input",
- "note": "FK to app_user — who triggered the event."},
- },
- {
- "field": "event_code", "type": "string",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True,
- "note": "Machine enum. WORKSPACE_ADDED, INVITE_ACCEPTED, REPORT_READY, etc."},
- },
- {
- "field": "severity", "type": "string",
- "schema": {"is_nullable": False, "default_value": "info"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Info", "value": "info"},
- {"text": "Action required", "value": "action_required"},
- {"text": "Destructive", "value": "destructive"},
- ]},
- "note": "Server-derived from event_code. Controls row styling.",
- },
- },
- {
- "field": "action", "type": "string",
- "schema": {"is_nullable": False, "default_value": "NONE"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "None", "value": "NONE"},
- {"text": "Navigate to workspace", "value": "NAVIGATE_WS"},
- {"text": "Navigate to project", "value": "NAVIGATE_PROJECT"},
- {"text": "Navigate to report", "value": "NAVIGATE_REPORT"},
- {"text": "Navigate to chat", "value": "NAVIGATE_CHAT"},
- {"text": "Navigate to invite", "value": "NAVIGATE_INVITE"},
- {"text": "Navigate to organisation settings", "value": "NAVIGATE_ORGANISATION_SETTINGS"},
- {"text": "Navigate to workspace settings", "value": "NAVIGATE_WORKSPACE_SETTINGS"},
- ]},
- "note": "Codified nav target. UI resolves the URL from ref_* fields.",
- },
- },
- {"field": "title", "type": "string",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True,
- "note": "Server-rendered headline. Plain text."}},
- {"field": "message", "type": "text",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input-multiline",
- "note": "Optional body. Markdown allowed."}},
- {"field": "scope", "type": "string",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input",
- "note": "Breadcrumb: 'Org › Workspace › Project'. Frozen at emit time."}},
- {"field": "params", "type": "json",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input-code", "options": {"language": "JSON"},
- "note": "Event-specific params for future client-rendered i18n."}},
- {"field": "ref_org_id", "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"}},
- {"field": "ref_workspace_id", "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"}},
- {"field": "ref_project_id", "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"}},
- {"field": "ref_chat_id", "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"}},
- {"field": "ref_report_id", "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"}},
- {"field": "ref_conversation_id", "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"}},
- {"field": "ref_invite_id", "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input"}},
- {"field": "read_at", "type": "timestamp",
- "schema": {"is_nullable": True},
- "meta": {"interface": "datetime",
- "note": "When the recipient marked this read. Null = unread."}},
- {"field": "expires_at", "type": "timestamp",
- "schema": {"is_nullable": True},
- "meta": {"interface": "datetime",
- "note": "Hide from inbox after this timestamp."}},
- {"field": "created_at", **timestamp_created()},
- {"field": "updated_at", **timestamp_updated()},
- ]
- if not create_collection("notification", notification_fields, {
- "accountability": "all",
- "display_template": "{{event_code}} → {{audience_user_id}}",
- }):
- return False
-
- create_relation("notification", "audience_user_id", "app_user",
- schema={"on_delete": "CASCADE"})
- create_relation("notification", "actor_user_id", "app_user",
- schema={"on_delete": "SET NULL"})
- create_relation("notification", "ref_org_id", "org",
- schema={"on_delete": "SET NULL"})
- create_relation("notification", "ref_workspace_id", "workspace",
- schema={"on_delete": "SET NULL"})
- create_relation("notification", "ref_project_id", "project",
- schema={"on_delete": "SET NULL"})
- create_relation("notification", "ref_chat_id", "project_chat",
- schema={"on_delete": "SET NULL"})
- # Skipped: project_report.id is bigInteger but ref_report_id is uuid.
- # Directus rejects the FK outright (column-type mismatch). We store
- # the report id as an opaque string at the application layer and
- # accept the absence of referential-integrity for this one link.
- # If project_report is ever re-keyed to uuid, uncomment this.
- # create_relation("notification", "ref_report_id", "project_report",
- # schema={"on_delete": "SET NULL"})
- create_relation("notification", "ref_conversation_id", "conversation",
- schema={"on_delete": "SET NULL"})
- create_relation("notification", "ref_invite_id", "workspace_invite",
- schema={"on_delete": "SET NULL"})
-
- return True
-
-
-# ---------------------------------------------------------------------------
-# Main
-# ---------------------------------------------------------------------------
-
-def step_10_workspace_visibility():
- """Add workspace.visibility enum (open_to_organisation | private).
-
- Matrix v1.1 §6 replaces the two-boolean inherit_organisation_admins /
- inherit_organisation_members model with a single visibility enum. This step:
-
- 1. Adds the column (nullable for transition).
- 2. Backfills existing rows from settings.inherit_organisation_admins:
- inherit_organisation_admins == True → 'open_to_organisation' (default)
- inherit_organisation_admins == False → 'private'
-
- What it does NOT do (those happen after the backfill_direct_memberships
- script runs --apply in prod):
- - Drop settings.inherit_organisation_admins / inherit_organisation_members flags.
- - Drop settings.sticky_removed tombstones.
- - Simplify inheritance.user_can_access to direct-only.
-
- Idempotent — rerunning only backfills rows still NULL.
- """
- print("\n=== Step 10: workspace.visibility enum ===")
-
- add_field("workspace", "visibility", {
- "type": "string",
- "schema": {"is_nullable": True, "default_value": "open_to_organisation"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Open to organisation", "value": "open_to_organisation"},
- {"text": "Private", "value": "private"},
- ]},
- "note": (
- "Matrix v1.1 §6. open_to_organisation = discoverable by organisation admins "
- "(join) and members (request access). private = visible only "
- "to organisation admins in discovery. Innovator+ tier to create."
- ),
- },
- })
-
- # Backfill from existing settings flags for any workspace still NULL.
- # Batched paging handled by fetch-all behavior.
- resp = api(
- "GET",
- "/items/workspace"
- "?fields=id,settings,visibility,deleted_at"
- "&filter[visibility][_null]=true"
- "&filter[deleted_at][_null]=true"
- "&limit=-1",
- )
- if not resp:
- print(" WARN: could not fetch workspaces to backfill")
- return True
- rows = resp.get("data") or []
- print(f" Backfilling {len(rows)} workspaces with NULL visibility")
-
- fixed = 0
- failed = 0
- for row in rows:
- settings = row.get("settings") or {}
- if not isinstance(settings, dict):
- settings = {}
- follows_admins = settings.get("inherit_organisation_admins", True)
- visibility = "open_to_organisation" if follows_admins else "private"
- result = api(
- "PATCH",
- f"/items/workspace/{row['id']}",
- {"visibility": visibility},
- )
- if result is not None:
- fixed += 1
- else:
- failed += 1
- print(f" Backfilled {fixed}/{len(rows)} (errors: {failed})")
-
- return True
-
-
-def step_11_downgrade_tracking():
- """Add workspace.downgraded_at + downgraded_from_tier for the 7-day
- post-downgrade banner (matrix v1.1 §3).
-
- Rules:
- - Set both on tier downgrade; clear both on tier upgrade.
- - Frontend renders the banner until
- downgraded_at + 7 days < now()
- OR until the admin dismisses it (dismissal state lives in a
- per-user settings key, not on the workspace).
-
- Idempotent.
- """
- print("\n=== Step 11: workspace downgrade tracking ===")
-
- add_field("workspace", "downgraded_at", {
- "type": "timestamp",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "datetime",
- "note": (
- "Set when a staff tier change lowered this workspace's tier. "
- "Frontend renders the post-downgrade banner for 7 days from "
- "this timestamp (matrix v1.1 §3)."
- ),
- },
- })
-
- add_field("workspace", "downgraded_from_tier", {
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "input",
- "note": (
- "The tier the workspace was on BEFORE the downgrade. Used "
- "so the banner can say 'downgraded from X to Y' without "
- "guessing. Cleared on next upgrade."
- ),
- },
- })
-
- return True
-
-
-def step_12_access_requests():
- """Create the access_request collection for Slack-style discovery
- (matrix v1.1 §6).
-
- Flow: a organisation member clicks "Request access" on an open workspace →
- writes a pending row here → audience (workspace admins + organisation admins)
- is notified → admin approves (writes a workspace_membership direct
- row + marks request approved) or rejects (marks request rejected;
- no notification to requester per matrix §6 "silent rejection").
-
- Fields are deliberately lean: the workshop question about adding a
- user-provided "reason" text is deferred — add post-release if abuse
- patterns demand it.
-
- Idempotent.
- """
- print("\n=== Step 12: access_request collection ===")
-
- if not collection_exists("access_request"):
- print(" Creating access_request collection...")
- # Directus auto-creates an integer PK if `fields` is omitted.
- # Pass the PK explicitly so we get uuid from the start; trying
- # to alter it afterwards via add_field silently noops.
- api("POST", "/collections", {
- "collection": "access_request",
- "meta": {
- "icon": "meeting_room",
- "note": (
- "Pending join requests from organisation members on open-to-organisation "
- "workspaces. Matrix v1.1 §6 Slack-style discovery."
- ),
- "display_template": "{{user_id}} → {{workspace_id}} ({{status}})",
- "sort_field": "requested_at",
- },
- "schema": {},
- "fields": [
- {
- "field": "id",
- "type": "uuid",
- "schema": {
- "is_primary_key": True,
- "has_auto_increment": False,
- "is_nullable": False,
- },
- "meta": {
- "hidden": True,
- "readonly": True,
- "interface": "input",
- "special": ["uuid"],
- },
- }
- ],
- })
- print(" OK access_request collection created")
-
- add_field("access_request", "workspace_id", {
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input"},
- })
- create_relation("access_request", "workspace_id", "workspace",
- schema={"on_delete": "CASCADE"})
-
- add_field("access_request", "user_id", {
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input"},
- })
- create_relation("access_request", "user_id", "app_user",
- schema={"on_delete": "CASCADE"})
-
- add_field("access_request", "status", {
- "type": "string",
- "schema": {"is_nullable": False, "default_value": "pending"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Pending", "value": "pending"},
- {"text": "Approved", "value": "approved"},
- {"text": "Rejected", "value": "rejected"},
- ]},
- },
- })
-
- add_field("access_request", "requested_at", {
- "type": "timestamp",
- "schema": {"is_nullable": False, "default_value": "now()"},
- "meta": {"interface": "datetime", "readonly": True},
- })
-
- add_field("access_request", "actioned_at", {
- "type": "timestamp",
- "schema": {"is_nullable": True},
- "meta": {"interface": "datetime"},
- })
-
- add_field("access_request", "actioned_by", {
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "app_user.id of the approver/rejecter"},
- })
- create_relation("access_request", "actioned_by", "app_user",
- schema={"on_delete": "SET NULL"})
-
- add_field("access_request", "deleted_at", {
- **deleted_at_field(),
- })
-
- return True
-
-
-def step_13_partner_model():
- """Matrix §10 partner-client model.
-
- Adds two nullable FKs on workspace + a referral_ledger collection.
- billed_to_team_id tracks which organisation pays the subscription (partner
- pre-handoff, client post-handoff). effective_client_team_id is
- set when there's a client distinct from the paying organisation.
-
- The referral ledger records partner kickback agreements (20%
- default, per-workspace, optional expiry).
-
- Idempotent.
- """
- print("\n=== Step 13: partner-client model ===")
-
- # Workspace fields.
- add_field("workspace", "billed_to_team_id", {
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "input",
- "note": (
- "FK to org. Which organisation pays the subscription. NULL for "
- "pre-migration workspaces. Partner-owned workspaces point "
- "here pre-handoff."
- ),
- },
- })
- create_relation("workspace", "billed_to_team_id", "org",
- schema={"on_delete": "SET NULL"})
-
- add_field("workspace", "effective_client_team_id", {
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "input",
- "note": (
- "FK to org. The client the workspace is for, when "
- "different from billed_to_team_id (partner-client "
- "arrangement). Set on handoff completion."
- ),
- },
- })
- create_relation("workspace", "effective_client_team_id", "org",
- schema={"on_delete": "SET NULL"})
-
- # Handoff state on workspace — one workspace in handoff at a time.
- add_field("workspace", "handoff_status", {
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Pending (client accept)", "value": "pending"},
- {"text": "Completed", "value": "completed"},
- ]},
- "note": (
- "Matrix §10. Set 'pending' when partner initiates; "
- "cleared on client accept (and effective_client_team_id "
- "flips)."
- ),
- },
- })
-
- add_field("workspace", "handoff_target_team_id", {
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "input",
- "note": (
- "Target client organisation during a pending handoff. Cleared on "
- "accept."
- ),
- },
- })
- create_relation("workspace", "handoff_target_team_id", "org",
- schema={"on_delete": "SET NULL"})
-
- # Referral ledger collection.
- if not collection_exists("referral_ledger"):
- print(" Creating referral_ledger collection...")
- api("POST", "/collections", {
- "collection": "referral_ledger",
- "meta": {
- "icon": "account_balance",
- "note": (
- "Matrix §10. Partner kickback agreements per workspace. "
- "Staff edits; partners read via GET /v2/orgs/:id/"
- "referral-ledger."
- ),
- "display_template":
- "{{partner_team_id}} → {{workspace_id}} ({{partner_kickback_percent}}%)",
- "sort_field": "starts_at",
- },
- "schema": {},
- })
- print(" OK referral_ledger collection created")
-
- add_field("referral_ledger", "id", {
- "type": "uuid",
- "schema": {"is_primary_key": True, "has_auto_increment": False, "is_nullable": False},
- "meta": {"hidden": True, "readonly": True, "interface": "input", "special": ["uuid"]},
- })
-
- add_field("referral_ledger", "workspace_id", {
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input"},
- })
- create_relation("referral_ledger", "workspace_id", "workspace",
- schema={"on_delete": "CASCADE"})
-
- add_field("referral_ledger", "partner_team_id", {
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "note": "The organisation receiving the kickback."},
- })
- create_relation("referral_ledger", "partner_team_id", "org",
- schema={"on_delete": "CASCADE"})
-
- add_field("referral_ledger", "partner_kickback_percent", {
- "type": "integer",
- "schema": {"is_nullable": False, "default_value": 20},
- "meta": {"interface": "input", "note": "Default 20% per matrix §10."},
- })
-
- add_field("referral_ledger", "starts_at", {
- "type": "timestamp",
- "schema": {"is_nullable": False, "default_value": "now()"},
- "meta": {"interface": "datetime"},
- })
-
- add_field("referral_ledger", "expires_at", {
- "type": "timestamp",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "datetime",
- "note": "Optional. NULL = no expiry; set per deal or globally later.",
- },
- })
-
- add_field("referral_ledger", "notes", {
- "type": "text",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input-multiline"},
- })
-
- add_field("referral_ledger", "created_by_staff_id", {
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input", "note": "app_user.id of the staff creator"},
- })
- create_relation("referral_ledger", "created_by_staff_id", "app_user",
- schema={"on_delete": "SET NULL"})
-
- add_field("referral_ledger", "deleted_at", {
- **deleted_at_field(),
- })
-
- return True
-
-
-def step_14_kickback_extensions():
- """Matrix §10 kickback: round out `referral_ledger` with the three
- fields step 13 didn't carry.
-
- - `from_org_id`: client org (owner of the workspace at the time the
- agreement was written). Denormalized so the ledger doesn't need
- to join back through workspace to answer "what are we earning
- from Client X across all their workspaces?" Snapshotted at
- creation; does not update if workspace ownership moves later —
- a handoff would produce a new ledger row.
- - `to_organisation_discount_percent`: optional parallel benefit — the
- partner's own subscription gets N% off. Null = no discount.
- Independent of `partner_kickback_percent`.
- - `eur_cap_kickback`: optional cap on total lifetime kickback in
- euros. Null = uncapped. Payout side checks this before cutting
- a cheque; the product doesn't enforce it (invoicing is manual
- at this stage).
-
- Idempotent.
- """
- print("\n=== Step 14: kickback extensions on referral_ledger ===")
-
- if not collection_exists("referral_ledger"):
- print(" referral_ledger missing — run step 13 first")
- return False
-
- add_field("referral_ledger", "from_org_id", {
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "input",
- "note": (
- "FK to org — the client whose workspace bill is being "
- "shared. Denormalized from workspace.org_id at the time "
- "the agreement was written; stays stable across handoffs."
- ),
- },
- })
- create_relation("referral_ledger", "from_org_id", "org",
- schema={"on_delete": "SET NULL"})
-
- add_field("referral_ledger", "to_organisation_discount_percent", {
- "type": "integer",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "input",
- "note": (
- "Optional. Discount % applied to the partner organisation's own "
- "subscription. Independent of the kickback percent. "
- "Null = no discount."
- ),
- },
- })
-
- add_field("referral_ledger", "eur_cap_kickback", {
- "type": "decimal",
- "schema": {
- "is_nullable": True,
- "numeric_precision": 12,
- "numeric_scale": 2,
- },
- "meta": {
- "interface": "input",
- "note": (
- "Optional lifetime cap on kickback paid out under this "
- "agreement, in euros. Null = uncapped. Enforced on the "
- "payout side; not by the product."
- ),
- },
- })
-
- return True
-
-
-def step_15_prompt_template_workspace_scope():
- """Scope prompt_template to workspaces.
-
- Pre-matrix, prompt_template was a per-user collection: every row was
- keyed by user_created, and there was no concept of a shared library.
- Matrix v1.1 §4 says members of a workspace collaborate on the chat
- surface — so a template written by one member should be reusable by
- another. To get there without breaking existing rows:
-
- - Add workspace_id (nullable UUID FK) so a template can live in a
- workspace instead of being tied only to a user.
- - Add scope (string, default 'user') so the backend filter is
- explicit: 'user' rows are private to user_created, 'workspace'
- rows are shared with anyone in workspace_id.
- - Leave existing rows untouched — they'll stay scope='user' and
- keep behaving exactly as before.
-
- Role gating is enforced at the endpoint layer (template.py):
- admin/owner/member can create/update scope='workspace' templates;
- is_external guests cannot (they can still read + create
- scope='user' templates).
- """
- if not collection_exists("prompt_template"):
- print(" prompt_template missing — nothing to migrate")
- return False
-
- add_field("prompt_template", "workspace_id", {
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "input",
- "note": (
- "Optional FK to workspace. When set alongside "
- "scope='workspace', the template is visible to every "
- "workspace member. NULL = user-private template."
- ),
- },
- })
- create_relation("prompt_template", "workspace_id", "workspace",
- schema={"on_delete": "CASCADE"})
-
- add_field("prompt_template", "scope", {
- "type": "string",
- "schema": {"is_nullable": False, "default_value": "user"},
- "meta": {
- "interface": "select-dropdown",
- "options": {
- "choices": [
- {"text": "User (private)", "value": "user"},
- {"text": "Workspace (shared)", "value": "workspace"},
- ],
- },
- "note": (
- "Controls visibility. 'user' = private to user_created "
- "(legacy behavior). 'workspace' = shared with everyone "
- "in workspace_id."
- ),
- },
- })
-
- return True
-
-
-def step_16_access_request_uuid_pk():
- """Convert access_request.id from integer → uuid.
-
- The original step_12 declared id as uuid, but the `POST /collections`
- call auto-created the table with an integer auto-increment PK, and
- the subsequent `add_field id type=uuid` doesn't alter an existing
- PK column (Directus noops on conflicting shape). Everything else in
- the schema is UUID, so access_request is the odd one out and the
- backend has to str()-cast int ids to match Pydantic types.
-
- This step migrates via Postgres SQL because the Directus REST API
- can't change a PK column type. Strategy:
- 1. Rename current int column to id_old.
- 2. Add new uuid column `id` with uuid_generate_v4() default.
- 3. Backfill: id = gen_random_uuid() for every existing row.
- 4. Drop id_old + its PK constraint.
- 5. Add PK on new id.
-
- Assumes access_request rows are ephemeral (pending join requests
- that get resolved in hours/days). Losing the integer ids is fine —
- any in-flight notifications reference the request by id-in-payload,
- not by FK, and the UX re-queries by (workspace_id, user_id, status).
-
- Idempotent: checks the current column type first.
- """
- print("\n=== Step 16: access_request.id integer → uuid ===")
- if not collection_exists("access_request"):
- print(" access_request missing — run step 12 first")
- return False
-
- # Ask Directus what the current type is.
- current = api("GET", "/fields/access_request/id")
- if not current:
- print(" ERROR: couldn't read access_request.id field")
- return False
- current_type = (current.get("data") or {}).get("type")
- if current_type == "uuid":
- print(" already uuid — nothing to do")
- return True
- if current_type not in ("integer", "bigInteger"):
- print(f" unexpected current type {current_type!r}; aborting")
- return False
-
- # Directus doesn't expose raw SQL. We drop + recreate the collection.
- # access_request has no inbound FKs (verified via snapshot). The
- # outbound FKs (workspace_id / user_id / actioned_by) live on this
- # table and get recreated by step 12 below.
- print(" dropping access_request collection…")
- api("DELETE", "/collections/access_request")
- print(" recreating with uuid pk via step 12…")
- return step_12_access_requests()
-
-
-def step_17_conversation_is_over_cap():
- """Add conversation.is_over_cap boolean for over-cap stamping (ADR 0001).
-
- Durable accounting stamp set once at conversation finish. True iff the
- workspace's tier disallows overage AND the workspace was at or past its
- hour cap before this conversation started. Never recomputed retroactively.
-
- Idempotent.
- """
- print("\n=== Step 17: conversation.is_over_cap ===")
-
- add_field("conversation", "is_over_cap", {
- "type": "boolean",
- "schema": {"is_nullable": False, "default_value": False},
- "meta": {
- "interface": "boolean",
- "readonly": True,
- "note": (
- "ADR 0001. Durable stamp set at finish. True = workspace was "
- "at/past its lifetime cap before this conversation started. "
- "The live UI lock is computed from this + current tier."
- ),
- },
- })
-
- return True
-
-
-def step_18_workspace_request():
- """Create the workspace_request collection (Slice 08).
-
- Unified collection for new-workspace and tier-upgrade requests.
- Staff review at /admin/upgrades; requesters see read-only rows.
-
- Schema trimmed per grilling session: decided_at + decided_by replace
- separate approved_at/approved_by/denied_at/denied_by. No
- proposed_inherit_organisation_admins (always true). No
- proposed_type_discount or proposed_percent_discount (discounts are
- staff-granted only).
-
- Idempotent.
- """
- print("\n=== Step 18: workspace_request collection ===")
-
- wr_fields = [
- pk_uuid(),
- {
- "field": "kind",
- "type": "string",
- "schema": {"is_nullable": False},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "New workspace", "value": "new_workspace"},
- {"text": "Tier upgrade", "value": "tier_upgrade"},
- ]},
- "required": True,
- },
- },
- {
- "field": "status",
- "type": "string",
- "schema": {"is_nullable": False, "default_value": "pending"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Pending", "value": "pending"},
- {"text": "Approved", "value": "approved"},
- {"text": "Denied", "value": "denied"},
- ]},
- },
- },
- {
- "field": "requested_by",
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True,
- "note": "FK to app_user — the submitter."},
- },
- {
- "field": "org_id",
- "type": "uuid",
- "schema": {"is_nullable": False},
- "meta": {"interface": "input", "required": True,
- "note": "Target org for new_workspace; existing org for tier_upgrade."},
- },
- {
- "field": "workspace_id",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input",
- "note": "Set for tier_upgrade; null for new_workspace until approved."},
- },
- {
- "field": "proposed_name",
- "type": "string",
- "schema": {"is_nullable": True, "max_length": 100},
- "meta": {"interface": "input",
- "note": "Only for new_workspace."},
- },
- {
- "field": "proposed_tier",
- "type": "string",
- "schema": {"is_nullable": False, "default_value": "innovator"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Pilot", "value": "pilot"},
- {"text": "Pioneer", "value": "pioneer"},
- {"text": "Innovator", "value": "innovator"},
- {"text": "Changemaker", "value": "changemaker"},
- {"text": "Guardian", "value": "guardian"},
- ]},
- },
- },
- {
- "field": "proposed_visibility",
- "type": "string",
- "schema": {"is_nullable": False, "default_value": "open_to_organisation"},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Open to organisation", "value": "open_to_organisation"},
- {"text": "Private", "value": "private"},
- ]},
- },
- },
- {
- "field": "requester_message",
- "type": "text",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input-multiline",
- "note": "Free text from requester, max 1000 chars."},
- },
- {
- "field": "granted_tier",
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Free", "value": "free"},
- {"text": "Pilot", "value": "pilot"},
- {"text": "Pioneer", "value": "pioneer"},
- {"text": "Innovator", "value": "innovator"},
- {"text": "Changemaker", "value": "changemaker"},
- {"text": "Guardian", "value": "guardian"},
- ]},
- "note": "What staff actually granted (may differ from proposed).",
- },
- },
- {
- "field": "granted_tier_expires_at",
- "type": "timestamp",
- "schema": {"is_nullable": True},
- "meta": {"interface": "datetime",
- "note": "Optional expiry on the granted tier."},
- },
- {
- "field": "granted_type_discount",
- "type": "string",
- "schema": {"is_nullable": True},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Scholarship", "value": "scholarship"},
- {"text": "Staff discount", "value": "staff_discount"},
- ]},
- },
- },
- {
- "field": "granted_percent_discount",
- "type": "integer",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input",
- "note": "0-100. Applied at tier subscription price only."},
- },
- {
- "field": "resulting_workspace_id",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input",
- "note": "Points to created (new_workspace) or upgraded (tier_upgrade) workspace."},
- },
- {
- "field": "decided_at",
- "type": "timestamp",
- "schema": {"is_nullable": True},
- "meta": {"interface": "datetime",
- "note": "When staff approved or denied."},
- },
- {
- "field": "decided_by",
- "type": "uuid",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input",
- "note": "FK to app_user — the staff member who decided."},
- },
- {
- "field": "denial_reason",
- "type": "text",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input-multiline",
- "note": "Required on deny; shown to requester."},
- },
- {
- "field": "staff_notes",
- "type": "text",
- "schema": {"is_nullable": True},
- "meta": {"interface": "input-multiline",
- "note": "Internal staff notes. Never shown to requester. Field-level locked to staff role."},
- },
- {"field": "created_at", **timestamp_created()},
- {"field": "updated_at", **timestamp_updated()},
- ]
-
- ok = create_collection("workspace_request", wr_fields, {
- "accountability": "all",
- "display_template": "{{kind}} — {{status}} ({{proposed_tier}})",
- "sort_field": "created_at",
- })
- if not ok:
- return False
-
- create_relation("workspace_request", "requested_by", "app_user",
- schema={"on_delete": "CASCADE"})
- create_relation("workspace_request", "org_id", "org",
- schema={"on_delete": "CASCADE"})
- create_relation("workspace_request", "workspace_id", "workspace",
- schema={"on_delete": "SET NULL"})
- create_relation("workspace_request", "resulting_workspace_id", "workspace",
- schema={"on_delete": "SET NULL"})
- create_relation("workspace_request", "decided_by", "app_user",
- schema={"on_delete": "SET NULL"})
-
- return True
-
-
-def step_19_workspace_tier_expires_at():
- """Add workspace.tier_expires_at nullable timestamp (Slice 15).
-
- Staff-writable. When set and elapsed, the hourly cron downgrades
- the workspace to free. NULL means no auto-expiry.
-
- Idempotent.
- """
- print("\n=== Step 19: workspace.tier_expires_at ===")
-
- add_field("workspace", "tier_expires_at", {
- "type": "timestamp",
- "schema": {"is_nullable": True, "default_value": None},
- "meta": {
- "interface": "datetime",
- "note": (
- "Optional tier expiry. Staff sets at approval time. "
- "Hourly cron downgrades to free when elapsed."
- ),
- },
- })
-
- return True
-
-
-def step_20_workspace_pre_warning_sent():
- """Add workspace.pre_warning_sent boolean (Slice 16).
-
- Deduplicates the 3-day tier-expiry pre-warning email. Reset to
- false whenever staff changes tier_expires_at.
-
- Idempotent.
- """
- print("\n=== Step 20: workspace.pre_warning_sent ===")
-
- add_field("workspace", "pre_warning_sent", {
- "type": "boolean",
- "schema": {"is_nullable": False, "default_value": False},
- "meta": {
- "interface": "boolean",
- "note": (
- "Dedup flag for 3-day tier-expiry pre-warning email. "
- "Reset to false when tier_expires_at changes."
- ),
- },
- })
-
- return True
-
-
-def step_21_workspace_discount_fields():
- """Add workspace.type_discount and workspace.percent_discount (Slice 19).
-
- Staff-writable, members read-only. Descriptive metadata only — no code
- path computes a price using these fields.
-
- Idempotent.
- """
- print("\n=== Step 21: workspace.type_discount + workspace.percent_discount ===")
-
- add_field("workspace", "type_discount", {
- "type": "string",
- "schema": {"is_nullable": True, "default_value": None},
- "meta": {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Scholarship", "value": "scholarship"},
- {"text": "Staff discount", "value": "staff_discount"},
- ]},
- "note": (
- "Categorical discount label. Staff write, members read. "
- "Descriptive only — not enforced by any billing code path."
- ),
- },
- })
-
- add_field("workspace", "percent_discount", {
- "type": "integer",
- "schema": {"is_nullable": True, "default_value": None},
- "meta": {
- "interface": "input",
- "note": (
- "0-100. Applied at tier subscription price only (descriptive). "
- "Does NOT discount overage, add-on seats, or à la carte items."
- ),
- },
- })
-
- return True
-
-
-def step_22_workspace_request_billing_period():
- """Add proposed_billing_period + approved_billing_period to workspace_request.
-
- Two nullable enum columns ('annual' | 'monthly' | null) capturing the
- cadence the requester picked and the cadence staff actually granted.
- Splitting proposed vs approved keeps the audit trail honest when staff
- overrides the requested cadence (see docs/adr/0002-billing-period-toggle.md).
-
- Idempotent.
- """
- print("\n=== Step 22: workspace_request billing period columns ===")
-
- cadence_field = {
- "interface": "select-dropdown",
- "options": {"choices": [
- {"text": "Annual", "value": "annual"},
- {"text": "Monthly", "value": "monthly"},
- ]},
- }
-
- ok1 = add_field("workspace_request", "proposed_billing_period", {
- "type": "string",
- "schema": {"is_nullable": True, "default_value": None},
- "meta": {
- **cadence_field,
- "note": (
- "Cadence the requester picked. Required for pioneer+, null for "
- "pilot/free. Never mutated post-submit."
- ),
- },
- })
- ok2 = add_field("workspace_request", "approved_billing_period", {
- "type": "string",
- "schema": {"is_nullable": True, "default_value": None},
- "meta": {
- **cadence_field,
- "note": (
- "Cadence staff actually granted. May differ from proposed "
- "(divergence drives extra copy in the approval email)."
- ),
- },
- })
- return ok1 and ok2
-
-
-STEPS = {
- "1": ("app_user", step_1_app_user),
- "2": ("org + org_membership", step_2_org),
- "3": ("workspace + workspace_membership", step_3_workspace),
- "4": ("workspace_invite + project_membership", step_4_invite_and_project_membership),
- "5": ("usage_event", step_5_usage_event),
- "6": ("project fields (workspace_id, visibility, deleted_at)", step_6_project_fields),
- "7": ("deleted_at on conversation, project_chat, project_report", step_7_deleted_at),
- "8": ("remove legacy chat", step_8_remove_chat),
- "9": ("notifications trio (inbox)", step_9_notifications),
- "10": ("workspace.visibility enum + backfill", step_10_workspace_visibility),
- "11": ("workspace downgrade tracking", step_11_downgrade_tracking),
- "12": ("access_request collection", step_12_access_requests),
- "13": ("partner-client model (§10)", step_13_partner_model),
- "14": ("kickback extensions on referral_ledger", step_14_kickback_extensions),
- "15": ("prompt_template workspace scope", step_15_prompt_template_workspace_scope),
- "16": ("access_request.id integer → uuid", step_16_access_request_uuid_pk),
- "17": ("conversation.is_over_cap stamp", step_17_conversation_is_over_cap),
- "18": ("workspace_request collection", step_18_workspace_request),
- "19": ("workspace.tier_expires_at field", step_19_workspace_tier_expires_at),
- "20": ("workspace.pre_warning_sent flag", step_20_workspace_pre_warning_sent),
- "21": ("workspace discount fields (type_discount, percent_discount)", step_21_workspace_discount_fields),
- "22": ("workspace_request billing period columns", step_22_workspace_request_billing_period),
-}
-
-
-def main():
- parser = argparse.ArgumentParser(description="Create workspace schema in Directus")
- parser.add_argument("--step", required=True,
- help="Step number (1-22) or 'all'")
- args = parser.parse_args()
-
- # Verify connection
- print(f"Directus URL: {DIRECTUS_URL}")
- health = api("GET", "/server/health")
- if not health:
- print("ERROR: Cannot connect to Directus")
- sys.exit(1)
- print("Directus is healthy\n")
-
- if args.step == "all":
- steps_to_run = list(STEPS.keys())
- else:
- steps_to_run = [args.step]
-
- for step_num in steps_to_run:
- if step_num not in STEPS:
- print(f"ERROR: Unknown step {step_num}. Valid: 1-22 or 'all'")
- sys.exit(1)
-
- name, fn = STEPS[step_num]
- print(f"{'='*60}")
- print(f"Step {step_num}: {name}")
- print(f"{'='*60}")
-
- ok = fn()
- if not ok:
- print(f"\nStep {step_num} FAILED. Stopping.")
- sys.exit(1)
-
- print(f"Step {step_num} complete.\n")
-
- print("\nAll requested steps complete.")
- print("Next: run 'cd directus && bash sync.sh pull' to capture schema changes.")
-
-
-if __name__ == "__main__":
- main()
diff --git a/echo/scripts/create_user_pin_schema.py b/echo/scripts/create_user_pin_schema.py
deleted file mode 100644
index 3ff2a367f..000000000
--- a/echo/scripts/create_user_pin_schema.py
+++ /dev/null
@@ -1,158 +0,0 @@
-"""
-Create user_project_pin junction table for per-user project pinning.
-
-Currently pin_order is a global field on the project row — when one user pins
-a project, it's pinned for EVERYONE in the workspace. This is wrong for
-multi-user workspaces.
-
-After this migration:
-- pin_order on project row is deprecated (keep for backward compat)
-- user_project_pin junction table stores per-user pins
-- Endpoints read/write this table instead
-
-Run once: python scripts/create_user_pin_schema.py
-"""
-
-import sys
-import os
-
-# Add server to path so we can import dembrane
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "server"))
-
-from dembrane.directus import DirectusClient
-from dembrane.settings import get_settings
-
-settings = get_settings()
-client = DirectusClient(
- base_url=settings.directus.base_url,
- token=settings.directus.token,
-)
-
-
-def collection_exists(name: str) -> bool:
- try:
- result = client._request("GET", f"/collections/{name}")
- return bool(result.get("data"))
- except Exception:
- return False
-
-
-def field_exists(collection: str, field: str) -> bool:
- try:
- result = client._request("GET", f"/fields/{collection}/{field}")
- return bool(result.get("data"))
- except Exception:
- return False
-
-
-def main():
- print("Creating user_project_pin junction table...")
-
- if collection_exists("user_project_pin"):
- print(" ⚠ Collection already exists, skipping creation")
- else:
- # Create collection
- client._request("POST", "/collections", json={
- "collection": "user_project_pin",
- "meta": {
- "collection": "user_project_pin",
- "icon": "push_pin",
- "note": "Per-user project pinning — prevents collision in multi-user workspaces",
- "singleton": False,
- "hidden": False,
- },
- "schema": {"name": "user_project_pin"},
- "fields": [
- {
- "field": "id",
- "type": "uuid",
- "meta": {"interface": "input", "readonly": True, "hidden": True, "special": ["uuid"]},
- "schema": {"is_primary_key": True, "has_auto_increment": False, "default_value": None},
- },
- {
- "field": "user_id",
- "type": "uuid",
- "meta": {
- "interface": "select-dropdown-m2o",
- "special": ["m2o"],
- "options": {"template": "{{display_name}}"},
- },
- "schema": {"is_nullable": False},
- },
- {
- "field": "project_id",
- "type": "uuid",
- "meta": {
- "interface": "select-dropdown-m2o",
- "special": ["m2o"],
- "options": {"template": "{{name}}"},
- },
- "schema": {"is_nullable": False},
- },
- {
- "field": "workspace_id",
- "type": "uuid",
- "meta": {
- "interface": "select-dropdown-m2o",
- "special": ["m2o"],
- "note": "Denormalized for fast scoped queries",
- },
- "schema": {"is_nullable": True},
- },
- {
- "field": "pin_order",
- "type": "integer",
- "meta": {
- "interface": "input",
- "note": "1, 2, or 3 — display order of pinned projects",
- },
- "schema": {"is_nullable": False, "default_value": 1},
- },
- {
- "field": "created_at",
- "type": "timestamp",
- "meta": {
- "interface": "datetime",
- "readonly": True,
- "hidden": True,
- "special": ["date-created"],
- },
- "schema": {"is_nullable": False},
- },
- ],
- })
- print(" ✓ Collection created")
-
- # Create relations
- for field, related, field_name in [
- ("user_id", "app_user", "pins"),
- ("project_id", "project", "user_pins"),
- ("workspace_id", "workspace", None),
- ]:
- try:
- client._request("POST", "/relations", json={
- "collection": "user_project_pin",
- "field": field,
- "related_collection": related,
- "meta": {
- "one_field": field_name,
- "sort_field": None,
- "one_deselect_action": "nullify",
- },
- "schema": {
- "on_delete": "CASCADE" if field != "workspace_id" else "SET NULL",
- },
- })
- print(f" ✓ Relation {field} → {related}")
- except Exception as e:
- if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
- print(f" ⚠ Relation {field} → {related} already exists")
- else:
- print(f" ✗ Relation {field} → {related} failed: {e}")
-
- print("\nDone. Run Directus sync to snapshot the schema:")
- print(" cd directus && bash sync.sh -u http://directus:8055 -e admin@dembrane.com -p admin pull")
-
-
-if __name__ == "__main__":
- main()
diff --git a/echo/scripts/estimate_recorded_hours.py b/echo/scripts/estimate_recorded_hours.py
new file mode 100644
index 000000000..0cf243dfa
--- /dev/null
+++ b/echo/scripts/estimate_recorded_hours.py
@@ -0,0 +1,218 @@
+"""
+Estimate total hours recorded across all conversations for the last 30 days
+and the last 6 months.
+
+Usage:
+ DIRECTUS_URL=https://... \
+ DIRECTUS_EMAIL=... \
+ DIRECTUS_PASSWORD=... \
+ python scripts/estimate_recorded_hours.py
+
+Designed to be gentle on a live Directus:
+ - Small batches with a sleep between requests.
+ - Only the strictly needed fields are requested (id, project_id, created_at, duration).
+ - Excludes conversations whose project is owned by an Administrator
+ (filter[project_id][directus_user_id][role][name][_neq] = Administrator).
+
+Reports: actual `conversation.duration` summed up (no estimation),
+plus distinct project count and conversation count for each window.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+import time
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, List, Optional
+
+import requests
+
+# Gentle defaults — overridable via env
+BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "100"))
+SLEEP_BETWEEN_BATCHES = float(os.environ.get("SLEEP_BETWEEN_BATCHES", "0.4"))
+
+# Exclude conversations whose project is owned by an Administrator.
+EXCLUDE_ADMIN_FILTER = {
+ "filter[project_id][directus_user_id][role][name][_neq]": "Administrator",
+}
+
+TOTAL_STEPS = 4
+
+
+# ---------------------------------------------------------------------------
+# Progress UI
+# ---------------------------------------------------------------------------
+
+def step(n: int, msg: str) -> None:
+ print(f"\n[Step {n}/{TOTAL_STEPS}] {msg}", file=sys.stderr, flush=True)
+
+
+def progress_bar(current: int, total: int, prefix: str = "", width: int = 30) -> None:
+ total = max(total, 1)
+ pct = min(current / total, 1.0)
+ filled = int(width * pct)
+ bar = "█" * filled + "░" * (width - filled)
+ line = f"\r {prefix} [{bar}] {current}/{total} ({pct * 100:5.1f}%)"
+ print(line, end="", flush=True, file=sys.stderr)
+ if current >= total:
+ print("", file=sys.stderr)
+
+
+# ---------------------------------------------------------------------------
+# Directus helpers
+# ---------------------------------------------------------------------------
+
+def login(base_url: str, email: str, password: str) -> str:
+ r = requests.post(
+ f"{base_url.rstrip('/')}/auth/login",
+ json={"email": email, "password": password},
+ timeout=60,
+ )
+ r.raise_for_status()
+ return r.json()["data"]["access_token"]
+
+
+def count_conversations(base_url: str, token: str, since: datetime) -> int:
+ """Server-side count — single cheap aggregate query."""
+ r = requests.get(
+ f"{base_url.rstrip('/')}/items/conversation",
+ params={
+ "aggregate[count]": "id",
+ "filter[created_at][_gte]": since.isoformat(),
+ **EXCLUDE_ADMIN_FILTER,
+ },
+ headers={"Authorization": f"Bearer {token}"},
+ timeout=60,
+ )
+ r.raise_for_status()
+ data = r.json().get("data", [])
+ if not data:
+ return 0
+ count_val = data[0].get("count")
+ if isinstance(count_val, dict):
+ count_val = count_val.get("id") or next(iter(count_val.values()), 0)
+ return int(count_val or 0)
+
+
+def fetch_metadata(
+ base_url: str, token: str, since: datetime, expected_total: int
+) -> List[Dict[str, Any]]:
+ """Pass 1: id, created_at, duration, chunk count. No transcripts (cheap)."""
+ headers = {"Authorization": f"Bearer {token}"}
+ out: List[Dict[str, Any]] = []
+ offset = 0
+
+ while True:
+ r = requests.get(
+ f"{base_url.rstrip('/')}/items/conversation",
+ params={
+ "fields": "id,project_id,created_at,duration",
+ "filter[created_at][_gte]": since.isoformat(),
+ "sort": "created_at",
+ "limit": BATCH_SIZE,
+ "offset": offset,
+ **EXCLUDE_ADMIN_FILTER,
+ },
+ headers=headers,
+ timeout=120,
+ )
+ r.raise_for_status()
+ batch = r.json().get("data", [])
+ if not batch:
+ break
+ out.extend(batch)
+ progress_bar(min(len(out), expected_total), expected_total, prefix="metadata")
+ if len(batch) < BATCH_SIZE:
+ break
+ offset += BATCH_SIZE
+ time.sleep(SLEEP_BETWEEN_BATCHES)
+
+ # ensure bar shows complete
+ progress_bar(len(out), max(len(out), expected_total), prefix="metadata")
+ return out
+
+
+def _project_id(row: Dict[str, Any]) -> str:
+ p = row.get("project_id")
+ if isinstance(p, dict):
+ return str(p.get("id", ""))
+ return str(p or "")
+
+
+def summarise(label: str, rows: List[Dict[str, Any]]) -> None:
+ actual = 0
+ projects = set()
+ for r in rows:
+ d = r.get("duration")
+ if d is not None and d > 0:
+ actual += int(d)
+ pid = _project_id(r)
+ if pid:
+ projects.add(pid)
+
+ print(f"\n=== {label} ===")
+ print(f" Conversations: {len(rows):>10,}")
+ print(f" Projects: {len(projects):>10,}")
+ print(f" Actual hours: {actual / 3600:>10,.1f}")
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def main() -> int:
+ url = os.environ.get("DIRECTUS_URL")
+ email = os.environ.get("DIRECTUS_EMAIL")
+ password = os.environ.get("DIRECTUS_PASSWORD")
+ if not (url and email and password):
+ print(
+ "Missing env vars. Set DIRECTUS_URL, DIRECTUS_EMAIL, DIRECTUS_PASSWORD.",
+ file=sys.stderr,
+ )
+ return 1
+
+ now = datetime.now(timezone.utc)
+ cutoff_1m = now - timedelta(days=30)
+ cutoff_6m = now - timedelta(days=30 * 6)
+ cutoff_1y = now - timedelta(days=365)
+
+ print(
+ f"Config: BATCH_SIZE={BATCH_SIZE}, SLEEP={SLEEP_BETWEEN_BATCHES}s",
+ file=sys.stderr,
+ )
+
+ step(1, f"Logging in to {url} as {email}...")
+ token = login(url, email, password)
+
+ step(2, f"Counting conversations since {cutoff_1y.date()} (server aggregate)...")
+ total = count_conversations(url, token, cutoff_1y)
+ print(f" → {total:,} conversations in 1-year window", file=sys.stderr)
+
+ if total == 0:
+ print("\nNothing to report.", file=sys.stderr)
+ return 0
+
+ step(3, f"Fetching conversation metadata (id, project, duration) in batches of {BATCH_SIZE}...")
+ rows = fetch_metadata(url, token, cutoff_1y, expected_total=total)
+ print(f" → fetched {len(rows):,} rows", file=sys.stderr)
+
+ step(4, "Computing hours...")
+
+ def _in_window(r: Dict[str, Any], cutoff: datetime) -> bool:
+ created = r.get("created_at")
+ if not created:
+ return False
+ return datetime.fromisoformat(created.replace("Z", "+00:00")) >= cutoff
+
+ last_1m = [r for r in rows if _in_window(r, cutoff_1m)]
+ last_6m = [r for r in rows if _in_window(r, cutoff_6m)]
+
+ summarise(f"Last 1 month (since {cutoff_1m.date()})", last_1m)
+ summarise(f"Last 6 months (since {cutoff_6m.date()})", last_6m)
+ summarise(f"Last 1 year (since {cutoff_1y.date()})", rows)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/echo/scripts/lock_directus_permissions.py b/echo/scripts/lock_directus_permissions.py
deleted file mode 100644
index 5a71cba62..000000000
--- a/echo/scripts/lock_directus_permissions.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""
-Lock down Directus permissions on project-scoped collections.
-
-Every frontend read/write for these collections now goes through
-/v2/bff/* (see server/dembrane/api/v2/bff/_access.py). Directus's own
-row-level ACL doesn't know about the v2 inheritance / sharing model,
-so keeping non-admin permissions on these tables was at best
-redundant and at worst dangerously permissive (a 403 on a BFF route
-but open access if someone went to the raw Directus API).
-
-This script deletes every permission on a fixed set of collections
-for any policy OTHER than the built-in administrator policy. The
-admin token + `async_directus` + the BFF layer continue to work
-because they attach to the admin policy.
-
-Usage:
- DIRECTUS_TOKEN=... DIRECTUS_BASE_URL=http://directus:8055 \\
- python scripts/lock_directus_permissions.py [--dry-run]
-
-After running with --dry-run to confirm the target set, run without
---dry-run, then refresh the sync snapshot:
-
- cd directus && bash sync.sh -u http://directus:8055 \\
- -e admin@dembrane.com -p admin pull
-
-…and commit the resulting snapshot diff so future `sync.sh apply`
-runs don't re-grant the removed permissions.
-"""
-
-from __future__ import annotations
-
-import argparse
-import os
-import sys
-from typing import Any
-
-import requests
-
-TARGET_COLLECTIONS = [
- "project",
- "conversation",
- "conversation_chunk",
- "conversation_segment",
- "conversation_project_tag",
- "project_chat",
- "project_chat_message",
- "project_chat_conversation",
- "project_report",
- "project_report_metric",
- "project_tag",
- "project_analysis_run",
- "project_agentic_run_event",
- "view",
- "aspect",
- "aspect_segment",
-]
-
-DIRECTUS_URL = os.environ.get("DIRECTUS_BASE_URL", "http://directus:8055")
-DIRECTUS_TOKEN = os.environ.get("DIRECTUS_TOKEN", "")
-if not DIRECTUS_TOKEN:
- env_path = os.path.join(
- os.path.dirname(__file__), "..", "directus", ".env"
- )
- if os.path.exists(env_path):
- with open(env_path) as f:
- for line in f:
- line = line.strip()
- if line.startswith("DIRECTUS_TOKEN="):
- DIRECTUS_TOKEN = (
- line.split("=", 1)[1].strip().strip('"').strip("'")
- )
-
-
-HEADERS = {
- "Authorization": f"Bearer {DIRECTUS_TOKEN}",
- "Content-Type": "application/json",
-}
-
-
-def api(method: str, path: str, **kwargs: Any) -> requests.Response:
- return requests.request(
- method, f"{DIRECTUS_URL}{path}", headers=HEADERS, timeout=30, **kwargs
- )
-
-
-def find_admin_policy_id() -> str | None:
- """Resolve the Administrator policy id.
-
- The _sync_default_admin_policy sync-id is stable across envs but the
- DB id isn't — we look up by `admin_access = true` which is only
- ever set on the built-in admin policy.
- """
- resp = api(
- "GET",
- "/policies",
- params={
- "filter[admin_access][_eq]": "true",
- "fields": "id,name",
- "limit": -1,
- },
- )
- resp.raise_for_status()
- data = resp.json().get("data") or []
- if not data:
- return None
- # Prefer the built-in name if there are multiple admin-flagged rows.
- for row in data:
- if row.get("name") == "Administrator":
- return row["id"]
- return data[0].get("id")
-
-
-def list_permissions() -> list[dict]:
- """Return every non-admin permission on our target collections."""
- admin_id = find_admin_policy_id()
- if not admin_id:
- print(" ! couldn't find the Administrator policy id; refusing to run.")
- return []
-
- out: list[dict] = []
- for col in TARGET_COLLECTIONS:
- resp = api(
- "GET",
- "/permissions",
- params={
- "filter[collection][_eq]": col,
- "fields": "id,action,collection,policy",
- "limit": -1,
- },
- )
- if resp.status_code == 404:
- continue
- resp.raise_for_status()
- for row in resp.json().get("data") or []:
- policy = row.get("policy")
- if policy and policy != admin_id:
- out.append(row)
- return out
-
-
-def delete_permission(perm_id: int) -> bool:
- resp = api("DELETE", f"/permissions/{perm_id}")
- if resp.status_code not in (200, 204):
- print(f" ! failed to delete permission {perm_id}: {resp.status_code} {resp.text[:200]}")
- return False
- return True
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument(
- "--dry-run",
- action="store_true",
- help="List what would be removed without deleting.",
- )
- args = parser.parse_args()
-
- if not DIRECTUS_TOKEN:
- print("Set DIRECTUS_TOKEN (or populate directus/.env).")
- return 2
-
- targets = list_permissions()
- if not targets:
- print("Nothing to remove — target collections already admin-only.")
- return 0
-
- print(f"{'Dry run — ' if args.dry_run else ''}{len(targets)} non-admin permissions on target collections:")
- by_col: dict[str, list[str]] = {}
- for row in targets:
- by_col.setdefault(row["collection"], []).append(
- f"{row['action']} · policy={row['policy']}"
- )
- for col in sorted(by_col):
- print(f"\n {col}")
- for line in by_col[col]:
- print(f" - {line}")
-
- if args.dry_run:
- print("\n(no changes made)")
- return 0
-
- print("\nDeleting…")
- ok = 0
- failed = 0
- for row in targets:
- if delete_permission(row["id"]):
- ok += 1
- else:
- failed += 1
- print(f"\nDone. removed={ok} failed={failed}.")
- if ok > 0:
- print(
- "\nNext: refresh the sync snapshot so future `sync.sh apply` doesn't regrant.\n"
- " cd directus && bash sync.sh pull\n"
- " git add directus/sync/ && git commit -m 'chore(directus): lock project-scoped collections admin-only'"
- )
- return 0 if failed == 0 else 1
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/echo/scripts/matrix_smoke.py b/echo/scripts/matrix_smoke.py
deleted file mode 100644
index 1fedd73b7..000000000
--- a/echo/scripts/matrix_smoke.py
+++ /dev/null
@@ -1,425 +0,0 @@
-#!/usr/bin/env python
-"""Matrix v1.1 conformance smoke test.
-
-Checks that the live code + Directus schema implement the claims in
-`docs/workspaces-validate/matrix.md`. Prints pass / fail per section.
-
-What this does NOT do: run the app end-to-end. It reads server
-modules + queries Directus. Run the server's v2 smoke test
-(`scripts/smoke_test_v2.py`) separately for live-endpoint coverage.
-
-Usage:
- python scripts/matrix_smoke.py
- python scripts/matrix_smoke.py --directus http://directus:8055 --token admin
-"""
-from __future__ import annotations
-
-import argparse
-import importlib
-import os
-import sys
-from dataclasses import dataclass, field
-from typing import Any
-
-import requests
-
-DEFAULT_DIRECTUS = os.environ.get("DIRECTUS_URL", "http://directus:8055")
-DEFAULT_TOKEN = os.environ.get("DIRECTUS_TOKEN", "admin")
-
-# Add server to sys.path so we can import dembrane.* directly.
-ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-sys.path.insert(0, os.path.join(ROOT, "server"))
-
-
-@dataclass
-class Check:
- section: str
- title: str
- passed: bool
- detail: str = ""
-
-
-@dataclass
-class Report:
- checks: list[Check] = field(default_factory=list)
-
- def add(self, section: str, title: str, passed: bool, detail: str = "") -> None:
- self.checks.append(Check(section, title, passed, detail))
-
- def summary(self) -> tuple[int, int]:
- ok = sum(1 for c in self.checks if c.passed)
- return ok, len(self.checks)
-
- def print(self) -> None:
- by_section: dict[str, list[Check]] = {}
- for c in self.checks:
- by_section.setdefault(c.section, []).append(c)
- for section, items in by_section.items():
- print(f"\n§ {section}")
- for c in items:
- mark = "✓" if c.passed else "✗"
- line = f" {mark} {c.title}"
- if c.detail:
- line += f" — {c.detail}"
- print(line)
- ok, total = self.summary()
- print(f"\n{ok}/{total} passed")
-
-
-def directus(url: str, token: str, path: str) -> dict | None:
- try:
- res = requests.get(
- f"{url}{path}",
- headers={"Authorization": f"Bearer {token}"},
- timeout=10,
- )
- if not res.ok:
- return None
- return res.json()
- except Exception:
- return None
-
-
-def check_tier_capacity(report: Report) -> None:
- """Section 1: tier capacity matrix."""
- try:
- mod = importlib.import_module("dembrane.tier_capacity")
- except Exception as e:
- report.add("1. Tier capacity", "import tier_capacity", False, str(e))
- return
-
- get = mod.get_capacity
- expected = {
- # (hours, seats, hour_overage_eur, seat_overage_eur, hard_block)
- "free": (1, 1, None, None, False),
- "pilot": (10, 2, None, None, False),
- "pioneer": (25, 3, 5.0, 25.0, False),
- "innovator": (50, 10, 4.0, 30.0, False),
- "changemaker": (100, 20, 3.0, 60.0, False),
- "guardian": (None, None, None, None, False),
- }
- for tier, (hours, seats, hour_over, seat_over, block) in expected.items():
- cap = get(tier)
- if cap is None:
- report.add(
- "1. Tier capacity", f"{tier}: capacity defined", False, "get_capacity returned None"
- )
- continue
- report.add(
- "1. Tier capacity",
- f"{tier}: included_hours={hours}",
- cap.included_hours == hours,
- f"got {cap.included_hours}" if cap.included_hours != hours else "",
- )
- report.add(
- "1. Tier capacity",
- f"{tier}: included_seats={seats}",
- cap.included_seats == seats,
- f"got {cap.included_seats}" if cap.included_seats != seats else "",
- )
- report.add(
- "1. Tier capacity",
- f"{tier}: hard_block_on_hours={block}",
- cap.hard_block_on_hours == block,
- f"got {cap.hard_block_on_hours}"
- if cap.hard_block_on_hours != block
- else "",
- )
-
-
-def check_role_policies(report: Report) -> None:
- """Section 4 (workspace) + 5 (organisation) role x capability."""
- try:
- from dembrane.policies import ORG_ROLE_PRESETS, WORKSPACE_ROLE_PRESETS
- except Exception as e:
- report.add("4. Roles", "import policies", False, str(e))
- return
-
- # Matrix §4 workspace role expectations (spot-checked)
- ws_expect = {
- "member": {
- "has": ["project:create", "project:read", "project:update",
- "conversation:delete", "chat:use", "report:generate",
- "workspace:view_usage"],
- "hasnt": ["project:delete", "project:share", "member:invite",
- "settings:manage", "workspace:view_invoices"],
- },
- "admin": {
- "has": ["project:create", "project:delete", "project:share",
- "member:invite", "member:manage", "settings:manage",
- "workspace:view_usage", "workspace:view_invoices",
- "upgrade:request"],
- "hasnt": [],
- },
- "billing": {
- "has": ["workspace:view_usage", "workspace:view_invoices",
- "workspace:update_payment", "upgrade:request"],
- "hasnt": ["project:create", "member:invite", "settings:manage"],
- },
- }
- for role, spec in ws_expect.items():
- preset = set(WORKSPACE_ROLE_PRESETS.get(role) or [])
- for p in spec["has"]:
- report.add(
- "4. Workspace roles",
- f"{role} has {p}",
- p in preset,
- "missing" if p not in preset else "",
- )
- for p in spec["hasnt"]:
- report.add(
- "4. Workspace roles",
- f"{role} does NOT have {p}",
- p not in preset,
- "present (should not be)" if p in preset else "",
- )
-
- # Matrix §5 organisation role expectations
- organisation_expect = {
- "member": {
- "has": ["org:view"],
- "hasnt": ["org:create_workspace", "org:manage_users"],
- },
- "admin": {
- "has": ["org:view", "org:create_workspace", "org:manage_users",
- "org:view_all_workspaces", "org:view_usage"],
- "hasnt": [],
- },
- "billing": {
- "has": ["org:view", "org:view_all_workspaces", "org:view_usage",
- "org:view_invoices", "org:update_payment"],
- "hasnt": ["org:create_workspace", "org:manage_users"],
- },
- }
- for role, spec in organisation_expect.items():
- preset = set(ORG_ROLE_PRESETS.get(role) or [])
- for p in spec["has"]:
- report.add(
- "5. Organisation roles",
- f"{role} has {p}",
- p in preset,
- "missing" if p not in preset else "",
- )
- for p in spec["hasnt"]:
- report.add(
- "5. Organisation roles",
- f"{role} does NOT have {p}",
- p not in preset,
- "present (should not be)" if p in preset else "",
- )
-
-
-def check_tier_gates(report: Report) -> None:
- """Sections 2 + 3: tier-gated capabilities."""
- try:
- from dembrane.policies import TIER_REQUIRED_FOR_POLICY
- except Exception as e:
- report.add("2. Tier gates", "import policies", False, str(e))
- return
-
- expected = {
- "project:set_private": "innovator",
- "workspace:set_private": "innovator",
- "project:share": "innovator",
- "workspace:export": "innovator",
- "workspace:whitelabel": "changemaker",
- "workspace:api_access": "changemaker",
- "workspace:webhooks": "changemaker",
- }
- for policy, tier in expected.items():
- got = TIER_REQUIRED_FOR_POLICY.get(policy)
- report.add(
- "2. Tier gates",
- f"{policy} gated at {tier}",
- got == tier,
- f"got {got}" if got != tier else "",
- )
-
-
-def check_visibility_enum(url: str, token: str, report: Report) -> None:
- """Section 6: workspace visibility."""
- f = directus(url, token, "/fields/workspace/visibility")
- if not f:
- report.add("6. Visibility", "workspace.visibility exists", False, "field missing")
- return
- data = f.get("data") or {}
- choices = (
- (data.get("meta") or {}).get("options") or {}
- ).get("choices") or []
- values = [c.get("value") for c in choices if isinstance(c, dict)]
- want = {"open_to_organisation", "private"}
- got = set(values)
- report.add(
- "6. Visibility",
- "enum is {open_to_organisation, private}",
- want == got,
- f"got {sorted(got)}" if want != got else "",
- )
-
-
-def check_access_request_schema(url: str, token: str, report: Report) -> None:
- """Section 6: Slack-style discovery infra."""
- ar = directus(url, token, "/collections/access_request")
- present = bool(ar and (ar.get("data") or {}).get("collection") == "access_request")
- report.add("6. Discovery", "access_request collection exists", present)
- if not present:
- return
- idf = directus(url, token, "/fields/access_request/id")
- id_type = ((idf or {}).get("data") or {}).get("type")
- report.add(
- "6. Discovery",
- "access_request.id is uuid",
- id_type == "uuid",
- f"got {id_type}" if id_type != "uuid" else "",
- )
- # Required fields
- for fname in ("workspace_id", "user_id", "status"):
- r = directus(url, token, f"/fields/access_request/{fname}")
- report.add(
- "6. Discovery",
- f"access_request.{fname} exists",
- bool(r),
- )
-
-
-def check_seats_unified(report: Report) -> None:
- """Section 7: seat counting includes guests in the unified pool."""
- try:
- with open(os.path.join(ROOT, "server", "dembrane", "seat_capacity.py")) as f:
- src = f.read()
- except Exception as e:
- report.add("7. Seats", "read seat_capacity.py", False, str(e))
- return
- # Unified model: externals (role='external') share the seat pool.
- ok = ("external" in src and "seats_used" in src)
- report.add(
- "7. Seats",
- "seat_capacity.py unifies externals into seat pool (role='external' + seats_used)",
- ok,
- "no unified seat logic found" if not ok else "",
- )
-
-
-def check_hours_meter(report: Report) -> None:
- """Section 8: hours derived from conversation duration."""
- path = os.path.join(ROOT, "server", "dembrane", "api", "v2", "workspaces.py")
- try:
- with open(path) as f:
- src = f.read()
- except Exception as e:
- report.add("8. Hours", "read workspaces.py", False, str(e))
- return
- ok = "duration" in src and "audio_hours" in src
- report.add(
- "8. Hours",
- "workspaces.py usage derives audio_hours from conversation duration",
- ok,
- )
- # Calendar-month reset: search for cycle_start derivation
- ok_cycle = ("first day" in src.lower()) or ("replace(day=1" in src) or ("cycle_start" in src)
- report.add(
- "8. Hours",
- "cycle reset aligned to calendar month",
- ok_cycle,
- )
-
-
-def check_pilot_hard_block(report: Report) -> None:
- """Section 8: pilot hard block enforcement exists in middleware."""
- path = os.path.join(ROOT, "server", "dembrane", "api", "v2", "middleware.py")
- try:
- with open(path) as f:
- src = f.read()
- except Exception as e:
- report.add("8. Pilot block", "read middleware.py", False, str(e))
- return
- ok = "require_no_pilot_block" in src
- report.add(
- "8. Pilot block",
- "require_no_pilot_block middleware exists",
- ok,
- )
-
-
-def check_upgrade_inbox(report: Report) -> None:
- """Section 11: upgrade requests go through workspace_request collection."""
- ws_requests_path = os.path.join(
- ROOT, "server", "dembrane", "api", "v2", "workspace_requests.py"
- )
- try:
- with open(ws_requests_path) as f:
- src = f.read()
- except Exception as e:
- report.add("11. Upgrade flow", "read workspace_requests.py", False, str(e))
- return
- has_endpoint = "workspace-requests" in src or "workspace_requests" in src
- has_kinds = "new_workspace" in src and "tier_upgrade" in src
- report.add(
- "11. Upgrade flow",
- "workspace_requests.py handles new_workspace + tier_upgrade kinds",
- has_endpoint and has_kinds,
- "" if has_endpoint and has_kinds else "missing request kinds",
- )
-
-
-def check_partner_model(url: str, token: str, report: Report) -> None:
- """Section 10: partner fields + referral_ledger."""
- for f in ("billed_to_team_id", "effective_client_team_id"):
- r = directus(url, token, f"/fields/workspace/{f}")
- report.add(
- "10. Partner model",
- f"workspace.{f} exists",
- bool(r),
- )
- rl = directus(url, token, "/collections/referral_ledger")
- report.add(
- "10. Partner model",
- "referral_ledger collection exists",
- bool(rl and (rl.get("data") or {}).get("collection") == "referral_ledger"),
- )
- for f in ("workspace_id", "partner_team_id", "partner_kickback_percent",
- "starts_at", "expires_at"):
- r = directus(url, token, f"/fields/referral_ledger/{f}")
- report.add(
- "10. Partner model",
- f"referral_ledger.{f} exists",
- bool(r),
- )
-
-
-def check_notification_schema(url: str, token: str, report: Report) -> None:
- """Inbox: notification collection present (MEMBERSHIP_REQUESTED target)."""
- n = directus(url, token, "/collections/notification")
- report.add(
- "Inbox",
- "notification collection exists",
- bool(n and (n.get("data") or {}).get("collection") == "notification"),
- )
-
-
-def main() -> int:
- ap = argparse.ArgumentParser()
- ap.add_argument("--directus", default=DEFAULT_DIRECTUS)
- ap.add_argument("--token", default=DEFAULT_TOKEN)
- args = ap.parse_args()
-
- report = Report()
- print(f"Checking matrix v1.1 against {args.directus}…")
- check_tier_capacity(report)
- check_tier_gates(report)
- check_role_policies(report)
- check_visibility_enum(args.directus, args.token, report)
- check_access_request_schema(args.directus, args.token, report)
- check_seats_unified(report)
- check_hours_meter(report)
- check_pilot_hard_block(report)
- check_upgrade_inbox(report)
- check_partner_model(args.directus, args.token, report)
- check_notification_schema(args.directus, args.token, report)
- report.print()
- ok, total = report.summary()
- return 0 if ok == total else 1
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/echo/scripts/migrate_inherited_to_derived.py b/echo/scripts/migrate_inherited_to_derived.py
deleted file mode 100644
index 89a5e7001..000000000
--- a/echo/scripts/migrate_inherited_to_derived.py
+++ /dev/null
@@ -1,319 +0,0 @@
-"""Migrate legacy `source='inherited'` workspace_membership rows to the
-derived-inheritance model.
-
-Context: pre-commit `94cf40d` the platform materialized inherited access as
-workspace_membership rows with `source='inherited'`. The derived model
-(docs/workspaces/inheritance-rules.md) treats inheritance as a query-time
-derivation from org_membership + workspace.settings. **Invariant #5: no
-`source='inherited'` rows with `deleted_at IS NULL` after migration.**
-
-Two classes of legacy rows to handle:
-
- 1. Live inherited rows (`source='inherited' AND deleted_at IS NULL`):
- archive via soft-delete. Derived access takes over immediately for
- any user still deserving it.
-
- 2. Soft-deleted inherited rows (`source='inherited' AND deleted_at IS
- NOT NULL`): these represent "workspace admin removed this organisation
- admin". Without a tombstone, derivation would silently re-grant
- access. Convert each to a `sticky_removed` entry on
- `workspace.settings`.
-
-Usage:
- python scripts/migrate_inherited_to_derived.py --dry-run
- python scripts/migrate_inherited_to_derived.py --apply
-
-Dry-run is the default; --apply required to actually mutate. Script is
-idempotent — rerunning after partial success is safe.
-"""
-
-from __future__ import annotations
-
-import argparse
-import os
-import sys
-from contextlib import contextmanager
-from datetime import datetime, timezone
-from pathlib import Path
-
-import requests
-
-# Simple filesystem lock to block concurrent --apply runs. `--apply`
-# against the same Directus instance from two shells would race the
-# read-modify-write of workspace.settings.sticky_removed (round-2 audit,
-# Red-organisation H2). This guard isn't distributed — it's per-host. Good
-# enough for single-operator migrations; if we ever run from a CI
-# runner + a human shell at the same moment, this breaks down and we'd
-# need a Directus-level flag.
-_LOCK_PATH = Path("/tmp/dembrane_migrate_inherited.lock")
-
-
-@contextmanager
-def _exclusive_lock():
- if _LOCK_PATH.exists():
- raise RuntimeError(
- f"Another migration is already running (lock: {_LOCK_PATH}). "
- "If this is stale, remove it manually after confirming no other "
- "process is running."
- )
- _LOCK_PATH.write_text(
- f"pid={os.getpid()} started={datetime.now(timezone.utc).isoformat()}"
- )
- try:
- yield
- finally:
- try:
- _LOCK_PATH.unlink()
- except FileNotFoundError:
- pass
-
-DIRECTUS_URL = os.environ.get("DIRECTUS_BASE_URL", "http://directus:8055")
-DIRECTUS_TOKEN = os.environ.get("DIRECTUS_TOKEN", "")
-
-if not DIRECTUS_TOKEN:
- env_path = Path(__file__).parent.parent / "directus" / ".env"
- if env_path.exists():
- for line in env_path.read_text().splitlines():
- if line.startswith("DIRECTUS_TOKEN="):
- DIRECTUS_TOKEN = line.split("=", 1)[1].strip().strip('"').strip("'")
-
-HEADERS = {
- "Authorization": f"Bearer {DIRECTUS_TOKEN}",
- "Content-Type": "application/json",
-}
-
-
-def api(method: str, path: str, json_body: dict | None = None) -> dict | list | None:
- url = f"{DIRECTUS_URL}{path}"
- resp = requests.request(method, url, headers=HEADERS, json=json_body, timeout=60)
- if resp.status_code >= 400:
- raise RuntimeError(f"{method} {path} → {resp.status_code}: {resp.text[:500]}")
- if resp.status_code == 204 or not resp.content:
- return None
- return resp.json()
-
-
-def fetch_all(collection: str, query: dict) -> list[dict]:
- """Fetch items with a query. Paginates by bumping offset if limit=-1
- is rejected (unlikely at current scale but defensive)."""
- from urllib.parse import urlencode
- q = dict(query)
- q.setdefault("limit", -1)
- encoded = urlencode({"fields": ",".join(q.pop("fields", ["*"]))})
- # Use the POST alternative via ?search body is not supported — stick to GET
- params: dict = {"limit": q.get("limit", -1)}
- # Serialize filter as filter[...]= form. For simplicity use Directus's
- # JSON filter param.
- import json as _json
- if "filter" in q:
- params["filter"] = _json.dumps(q["filter"])
- if "fields" in query:
- params["fields"] = ",".join(query["fields"])
- resp = requests.get(
- f"{DIRECTUS_URL}/items/{collection}",
- headers=HEADERS,
- params=params,
- timeout=60,
- )
- if resp.status_code >= 400:
- raise RuntimeError(
- f"fetch_all {collection} failed {resp.status_code}: {resp.text[:500]}"
- )
- data = resp.json().get("data", [])
- return data
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument(
- "--apply", action="store_true",
- help="Actually mutate data. Default is dry-run.",
- )
- args = parser.parse_args()
- dry_run = not args.apply
-
- if not DIRECTUS_TOKEN:
- print("ERROR: DIRECTUS_TOKEN not set (env or directus/.env)")
- return 2
-
- print(f"Mode: {'DRY-RUN' if dry_run else 'APPLY'}")
- print(f"Directus: {DIRECTUS_URL}")
-
- health = api("GET", "/server/health")
- if not health:
- print("ERROR: Directus /server/health returned empty")
- return 2
- print(f"Health: {health.get('status', '?')}")
-
- # Anchor timestamp. Any row soft-deleted AFTER this instant was archived
- # by this run — we must not count those as "pre-existing soft-deletes"
- # when building tombstones on a re-run. Fixes the round-2 audit finding
- # where a second --apply run would tombstone users whose access should
- # continue via derivation.
- script_start_iso = datetime.now(timezone.utc).isoformat()
- print(f"Cutoff: pre-existing soft-deletes must have deleted_at < {script_start_iso}")
-
- # 1. Live inherited rows → archive by soft-delete.
- live_inherited = fetch_all(
- "workspace_membership",
- {
- "filter": {
- "source": {"_eq": "inherited"},
- "deleted_at": {"_null": True},
- },
- "fields": ["id", "workspace_id", "user_id", "role"],
- },
- )
- print(f"\nLive source='inherited' rows to archive: {len(live_inherited)}")
- for row in live_inherited[:10]:
- print(f" - ws={row['workspace_id'][:8]} user={row['user_id'][:8]} role={row['role']}")
- if len(live_inherited) > 10:
- print(f" … and {len(live_inherited) - 10} more")
-
- # 2. Soft-deleted inherited rows → convert to sticky tombstones.
- # ONLY rows soft-deleted BEFORE this run started — these are the
- # pre-existing "workspace admin revoked this organisation admin" tombstones.
- # Rows soft-deleted after script_start_iso are our own archive output
- # and must NOT be converted (derivation should continue granting them).
- soft_inherited = fetch_all(
- "workspace_membership",
- {
- "filter": {
- "source": {"_eq": "inherited"},
- "deleted_at": {"_nnull": True, "_lt": script_start_iso},
- },
- "fields": ["id", "workspace_id", "user_id", "deleted_at"],
- },
- )
- print(
- f"\nSoft-deleted source='inherited' rows to tombstone: {len(soft_inherited)}"
- )
-
- # Group by workspace so we write settings once per workspace.
- by_ws: dict[str, list[dict]] = {}
- for row in soft_inherited:
- ws_id = row["workspace_id"]
- by_ws.setdefault(ws_id, []).append(row)
-
- print(f" affects {len(by_ws)} workspaces")
- for ws_id, rows in list(by_ws.items())[:5]:
- print(f" - ws={ws_id[:8]}: {len(rows)} tombstone(s)")
- if len(by_ws) > 5:
- print(f" … and {len(by_ws) - 5} more workspaces")
-
- if dry_run:
- print("\nDry-run — no changes written. Re-run with --apply to mutate.")
- return 0
-
- # Acquire the per-host lock for the mutating portion only. Dry-run
- # never writes; it's safe to run in parallel with --apply.
- try:
- lock_ctx = _exclusive_lock()
- lock_ctx.__enter__()
- except RuntimeError as exc:
- print(f"ERROR: {exc}")
- return 2
-
- now_iso = datetime.now(timezone.utc).isoformat()
-
- # --- Apply archive of live rows ---
- errors = 0
- for row in live_inherited:
- try:
- api(
- "PATCH",
- f"/items/workspace_membership/{row['id']}",
- {"deleted_at": now_iso},
- )
- except Exception as e:
- errors += 1
- print(f" FAIL archive {row['id']}: {e}")
- print(f"\nArchived {len(live_inherited) - errors}/{len(live_inherited)} live rows.")
-
- # --- Apply tombstone conversion ---
- ws_errors = 0
- for ws_id, rows in by_ws.items():
- try:
- ws = api("GET", f"/items/workspace/{ws_id}")
- if not ws:
- ws_errors += 1
- print(f" SKIP ws={ws_id[:8]}: not found")
- continue
- workspace = ws.get("data") or ws
- settings = workspace.get("settings") or {}
- if not isinstance(settings, dict):
- settings = {}
-
- # Defensive: settings.sticky_removed should be a list of dicts.
- # If it's been manually edited or corrupted (string, dict, etc)
- # reset to [] rather than crashing the whole migration.
- raw_tombs = settings.get("sticky_removed")
- if not isinstance(raw_tombs, list):
- existing_tombstones = []
- else:
- existing_tombstones = [
- t for t in raw_tombs if isinstance(t, dict)
- ]
- existing_user_ids = {t.get("user_id") for t in existing_tombstones}
-
- added = 0
- for row in rows:
- uid = row["user_id"]
- if uid in existing_user_ids:
- continue
- existing_tombstones.append(
- {
- "user_id": uid,
- "removed_at": row.get("deleted_at") or now_iso,
- "removed_by": "migrate_inherited_to_derived",
- }
- )
- existing_user_ids.add(uid)
- added += 1
-
- if added == 0:
- continue
-
- new_settings = {**settings, "sticky_removed": existing_tombstones}
- api(
- "PATCH",
- f"/items/workspace/{ws_id}",
- {"settings": new_settings},
- )
- print(f" OK ws={ws_id[:8]}: +{added} tombstones")
- except Exception as e:
- ws_errors += 1
- print(f" FAIL ws={ws_id[:8]}: {e}")
-
- print(
- f"\nTombstoned {len(by_ws) - ws_errors}/{len(by_ws)} workspaces. "
- f"Row archive errors: {errors}. Tombstone errors: {ws_errors}."
- )
-
- # --- Post-verify invariant #5 ---
- remaining = fetch_all(
- "workspace_membership",
- {
- "filter": {
- "source": {"_eq": "inherited"},
- "deleted_at": {"_null": True},
- },
- "fields": ["id"],
- },
- )
- try:
- if remaining:
- print(
- f"\n⚠️ Invariant violated: {len(remaining)} live source='inherited' rows "
- "still exist. Re-run the script; if they persist investigate manually."
- )
- return 1
-
- print("\n✓ Invariant #5 holds: no live source='inherited' rows remain.")
- return 0
- finally:
- lock_ctx.__exit__(None, None, None)
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/echo/scripts/preseed/example.yaml b/echo/scripts/preseed/example.yaml
deleted file mode 100644
index 1eb1a7c37..000000000
--- a/echo/scripts/preseed/example.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-# Example pre-seed configuration for a partner org.
-#
-# Usage:
-# python scripts/preseed_workspace.py --config scripts/preseed/example.yaml --dry-run
-# python scripts/preseed_workspace.py --config scripts/preseed/example.yaml
-#
-# - owner: gets org ownership + workspace ownership
-# - admins: get org admin + inherited workspace admin
-# - members: get org member + direct workspace member
-#
-# Users who don't exist in Directus will be created with an invite email.
-
-org:
- name: "Example Consultancy"
- owner: owner@example.com
- admins:
- - admin1@example.com
- - admin2@example.com
- members:
- - member1@example.com
-
-workspaces:
- - name: "Client Alpha"
- tier: pioneer
- include_projects: true # move owner+admin+member projects here
-
- - name: "Client Beta"
- tier: pioneer
- include_projects: false # empty workspace, projects added manually
diff --git a/echo/scripts/preseed_workspace.py b/echo/scripts/preseed_workspace.py
deleted file mode 100644
index ee5a8dd56..000000000
--- a/echo/scripts/preseed_workspace.py
+++ /dev/null
@@ -1,368 +0,0 @@
-"""
-Pre-seed workspaces for existing clients.
-
-Creates orgs, workspaces, and invites users. Handles both existing
-Directus users (immediate setup) and new users (creates Directus
-account with invite email).
-
-Usage:
- python scripts/preseed_workspace.py --config preseed/example.yaml
- python scripts/preseed_workspace.py --config preseed/example.yaml --dry-run
-
-YAML config format:
-
- org:
- name: "Fiets Consulting"
- admins:
- - foo@fiets.nl
- member:
- - bar@fiets.nl
- - bin@fiets.nl
-
- workspaces:
- - name: "Client Alpha"
- tier: pioneer
- include_projects: true # if true, then move admin+member projects here
- admins:
- - foo@fiets.nl
- members:
- - bin@fiets.nl
- external_members:
- - baz@leets.nl
-
-
- - name: "Client Beta"
- tier: pioneer
- include_projects: false # empty workspace
-"""
-
-import argparse
-import sys
-from logging import getLogger, basicConfig, INFO
-from pathlib import Path
-
-import yaml
-
-# Add server to path so we can import dembrane modules
-sys.path.insert(0, str(Path(__file__).parent.parent / "server"))
-
-logger = getLogger("preseed")
-
-
-def load_config(path: str) -> dict:
- with open(path) as f:
- return yaml.safe_load(f)
-
-
-def find_directus_user_by_email(client, email: str) -> dict | None:
- """Find a Directus user by email. Returns None if not found."""
- users = client.get_users(
- {"query": {"filter": {"email": {"_eq": email}}, "fields": ["id", "email", "first_name", "last_name"], "limit": 1}}
- )
- if isinstance(users, list) and len(users) > 0:
- return users[0]
- return None
-
-
-def find_or_create_directus_user(client, email: str, role_id: str, dry_run: bool) -> dict | None:
- """Find existing Directus user or create one with invite."""
- existing = find_directus_user_by_email(client, email)
- if existing:
- logger.info(f" Found existing Directus user: {email} (id: {existing['id']})")
- return existing
-
- if dry_run:
- logger.info(f" WOULD create Directus user + send invite: {email}")
- return None
-
- logger.info(f" Creating Directus user + sending invite: {email}")
- result = client.post("/users", json={
- "email": email,
- "role": role_id,
- "status": "invited",
- })
- user = result.get("data", result)
- logger.info(f" Created Directus user: {email} (id: {user.get('id')})")
- return user
-
-
-def find_or_create_app_user(client, directus_user_id: str, email: str, display_name: str, dry_run: bool) -> dict | None:
- """Find existing app_user or create one."""
- items = client.get_items("app_user", {"query": {"filter": {"directus_user_id": {"_eq": directus_user_id}}, "limit": 1}})
- if isinstance(items, list) and len(items) > 0:
- logger.info(f" Found existing app_user for {email}")
- return items[0]
-
- if dry_run:
- logger.info(f" WOULD create app_user for {email}")
- return None
-
- from dembrane.utils import generate_uuid
- app_user_id = generate_uuid()
- result = client.create_item("app_user", {
- "id": app_user_id,
- "directus_user_id": directus_user_id,
- "email": email,
- "display_name": display_name,
- })
- app_user = result["data"]
- logger.info(f" Created app_user: {app_user['id']} for {email}")
- return app_user
-
-
-def run_preseed(config: dict, dry_run: bool = True):
- from dembrane.directus import create_directus_client
- from dembrane.utils import generate_uuid
- from dembrane.settings import get_settings
-
- settings = get_settings()
- client = create_directus_client(token=settings.directus.token)
-
- org_config = config["org"]
- workspace_configs = config.get("workspaces", [])
-
- # Get the Basic User role ID for new user creation
- basic_user_role_id = None
- try:
- import requests
- resp = requests.get(
- f"{settings.directus.base_url}/roles?fields=id,name",
- headers={"Authorization": f"Bearer {settings.directus.token}"},
- timeout=10,
- )
- for role in resp.json().get("data", []):
- if role["name"] == "Basic User":
- basic_user_role_id = role["id"]
- break
- except Exception as e:
- logger.error(f"Failed to fetch roles: {e}")
- return
-
- if not basic_user_role_id:
- logger.error("Could not find 'Basic User' role")
- return
-
- logger.info(f"{'DRY RUN — ' if dry_run else ''}Pre-seeding org: {org_config['name']}")
-
- # ── Collect all emails ──
- all_emails = {
- org_config["owner"]: "owner",
- }
- for email in org_config.get("admins", []):
- all_emails[email] = "admin"
- for email in org_config.get("members", []):
- if email not in all_emails:
- all_emails[email] = "member"
-
- # ── Ensure all users exist in Directus + app_user ──
- app_users: dict[str, dict] = {} # email -> app_user record
-
- for email, role in all_emails.items():
- logger.info(f"\nProcessing user: {email} (org role: {role})")
-
- directus_user = find_or_create_directus_user(client, email, basic_user_role_id, dry_run)
- if not directus_user:
- if dry_run:
- app_users[email] = {"id": f"", "email": email}
- continue
-
- first = directus_user.get("first_name") or ""
- last = directus_user.get("last_name") or ""
- display_name = f"{first} {last}".strip() or email
-
- app_user = find_or_create_app_user(
- client, directus_user["id"], email, display_name, dry_run
- )
- if app_user:
- app_users[email] = app_user
- elif dry_run:
- app_users[email] = {"id": f"", "email": email}
-
- owner_email = org_config["owner"]
- owner_app_user = app_users.get(owner_email)
- if not owner_app_user:
- logger.error(f"Owner {owner_email} could not be resolved. Aborting.")
- return
-
- # ── Create org ──
- logger.info(f"\nCreating org: {org_config['name']}")
-
- existing_orgs = client.get_items("org_membership", {
- "query": {"filter": {"user_id": {"_eq": owner_app_user["id"]}, "role": {"_eq": "owner"}, "deleted_at": {"_null": True}}, "fields": ["org_id"], "limit": 1}
- })
-
- if isinstance(existing_orgs, list) and len(existing_orgs) > 0:
- org_id = existing_orgs[0]["org_id"]
- logger.info(f" Org already exists: {org_id}")
- elif dry_run:
- org_id = ""
- logger.info(f" WOULD create org: {org_config['name']}")
- else:
- org_id = generate_uuid()
- client.create_item("org", {
- "id": org_id,
- "name": org_config["name"],
- "created_by": owner_app_user["id"],
- })
- client.create_item("org_membership", {
- "id": generate_uuid(),
- "org_id": org_id,
- "user_id": owner_app_user["id"],
- "role": "owner",
- })
- logger.info(f" Created org: {org_id}")
-
- # Add org admins/members
- for email, role in all_emails.items():
- if email == owner_email:
- continue
- app_user = app_users.get(email)
- if not app_user or app_user["id"].startswith(" 0:
- logger.info(f" {email} already org member, skipping")
- continue
-
- if dry_run:
- logger.info(f" WOULD add {email} as org {role}")
- else:
- client.create_item("org_membership", {
- "id": generate_uuid(),
- "org_id": org_id,
- "user_id": app_user["id"],
- "role": role,
- })
- logger.info(f" Added {email} as org {role}")
-
- # ── Create workspaces ──
- for ws_config in workspace_configs:
- ws_name = ws_config["name"]
- ws_tier = ws_config.get("tier", "pioneer")
- include_projects = ws_config.get("include_projects", False)
-
- logger.info(f"\nCreating workspace: {ws_name} (tier: {ws_tier})")
-
- # Check if workspace already exists
- existing_ws = client.get_items("workspace", {
- "query": {"filter": {"org_id": {"_eq": org_id}, "name": {"_eq": ws_name}, "deleted_at": {"_null": True}}, "limit": 1}
- })
-
- if isinstance(existing_ws, list) and len(existing_ws) > 0:
- ws_id = existing_ws[0]["id"]
- logger.info(f" Workspace already exists: {ws_id}")
- elif dry_run:
- ws_id = f""
- logger.info(f" WOULD create workspace: {ws_name}")
- else:
- ws_id = generate_uuid()
- client.create_item("workspace", {
- "id": ws_id,
- "org_id": org_id,
- "name": ws_name,
- "tier": ws_tier,
- "is_default": False,
- "created_by": owner_app_user["id"],
- })
- logger.info(f" Created workspace: {ws_id}")
-
- # Add workspace memberships for org admins/owner (inherited)
- for email, org_role in all_emails.items():
- if org_role not in ("owner", "admin"):
- continue
- app_user = app_users.get(email)
- if not app_user or app_user["id"].startswith(" 0:
- continue
-
- ws_role = "owner" if email == owner_email else "admin"
- if dry_run:
- logger.info(f" WOULD add {email} as workspace {ws_role} (inherited)")
- else:
- client.create_item("workspace_membership", {
- "id": generate_uuid(),
- "workspace_id": ws_id,
- "user_id": app_user["id"],
- "role": ws_role,
- "source": "inherited",
- })
- logger.info(f" Added {email} as workspace {ws_role} (inherited)")
-
- # Add workspace members (direct)
- for email, org_role in all_emails.items():
- if org_role != "member":
- continue
- app_user = app_users.get(email)
- if not app_user or app_user["id"].startswith(" 0:
- continue
-
- if dry_run:
- logger.info(f" WOULD add {email} as workspace member (direct)")
- else:
- client.create_item("workspace_membership", {
- "id": generate_uuid(),
- "workspace_id": ws_id,
- "user_id": app_user["id"],
- "role": "member",
- "source": "direct",
- })
- logger.info(f" Added {email} as workspace member (direct)")
-
- # Move projects if requested
- if include_projects and not dry_run:
- for email in all_emails:
- app_user = app_users.get(email)
- if not app_user or app_user["id"].startswith(" bool:
- try:
- client.get(f"/fields/{collection}/{field}")
- return True
- except Exception:
- return False
-
-
-def main() -> int:
- if not field_exists("workspace_invite", "token"):
- print("Field workspace_invite.token already removed — nothing to do")
- return 0
-
- print("Deleting field workspace_invite.token...")
- client.delete("/fields/workspace_invite/token")
- print(" ✓ Field deleted")
-
- print("\nDone. Pull Directus schema to snapshot:")
- print(" cd directus && bash sync.sh -u http://directus:8055 "
- "-e admin@dembrane.com -p admin pull")
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/echo/scripts/seed_dev.py b/echo/scripts/seed_dev.py
deleted file mode 100644
index 2135bbb4a..000000000
--- a/echo/scripts/seed_dev.py
+++ /dev/null
@@ -1,780 +0,0 @@
-"""Reset + seed dev Directus with a wide range of toy examples.
-
-Covers the scenarios the workspaces release needs to demo:
-- Multiple organisations at different tiers
-- Workspaces across pilot → changemaker, including at-cap + approaching
-- Role diversity (admin / billing / member + external guest)
-- Pending access requests + workspace invites
-- A downgraded workspace (7-day banner state)
-- Partner handoff in flight + a completed referral ledger entry
-- Conversations with durations to make hour meters realistic
-
-**Preserves** admin@dembrane.com + their existing org. Everything else
-in the test collections is soft-deleted before seeding.
-
-Usage:
- python scripts/seed_dev.py # dry-run by default
- python scripts/seed_dev.py --reset # reset only
- python scripts/seed_dev.py --seed # seed only
- python scripts/seed_dev.py --all # reset then seed [DESTRUCTIVE]
-
-The --all flag is the intended "give me a fresh demo environment"
-entry point. All seeded accounts use the same dev password (below).
-"""
-
-from __future__ import annotations
-
-import argparse
-import json
-import os
-import random
-import sys
-import uuid
-from datetime import datetime, timedelta, timezone
-from pathlib import Path
-from typing import Any, Optional
-
-import requests
-
-DIRECTUS_URL = os.environ.get("DIRECTUS_BASE_URL", "http://directus:8055")
-ADMIN_EMAIL = os.environ.get("SEED_ADMIN_EMAIL", "admin@dembrane.com")
-ADMIN_PASSWORD = os.environ.get("SEED_ADMIN_PASSWORD", "admin")
-SEED_USER_PASSWORD = os.environ.get("SEED_USER_PASSWORD", "demo1234")
-
-BASIC_USER_ROLE_ID = "bcdd7430-2456-4feb-930c-0c9eee30a7e1"
-ADMIN_POLICY_ID = "c1071295-984a-4985-95db-a1c8064a28e6"
-
-# Directus 11: admin_access=true on a policy bypasses most collection
-# permissions, BUT newly-created collections still need explicit
-# permission rows before item writes land. Grant them up-front on collections
-# the seed writes to.
-COLLECTIONS_NEEDING_ADMIN_PERMS = [
- "referral_ledger",
- "access_request",
- "workspace_invite",
- "notification",
-]
-
-# Collections we wipe (soft-delete where deleted_at exists, hard-delete
-# otherwise). Order matters for FK cascades — deepest first.
-RESET_SOFT = [
- "workspace_invite",
- "access_request",
- "referral_ledger",
- "project_membership",
- "workspace_membership",
- "project",
- "workspace",
- "org_membership",
- "org",
-]
-
-# Hard-delete app_user rows created by seeds (detected via email pattern).
-SEED_USER_EMAIL_PATTERNS = (
- "@seed.dembrane.dev",
-)
-
-
-# ── HTTP helpers ──────────────────────────────────────────────────────
-
-
-def login() -> str:
- resp = requests.post(
- f"{DIRECTUS_URL}/auth/login",
- json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
- timeout=15,
- )
- resp.raise_for_status()
- return resp.json()["data"]["access_token"]
-
-
-def api(
- session: requests.Session,
- method: str,
- path: str,
- json_body: Optional[dict | list] = None,
- params: Optional[dict] = None,
-) -> Any:
- url = f"{DIRECTUS_URL}{path}"
- resp = session.request(method, url, json=json_body, params=params, timeout=30)
- if resp.status_code >= 400:
- raise RuntimeError(f"{method} {path} → {resp.status_code}: {resp.text[:500]}")
- if resp.status_code == 204 or not resp.content:
- return None
- return resp.json()
-
-
-def fetch_all(
- session: requests.Session,
- collection: str,
- filter_: Optional[dict] = None,
- fields: Optional[list[str]] = None,
-) -> list[dict]:
- params = {"limit": -1}
- if filter_:
- params["filter"] = json.dumps(filter_)
- if fields:
- params["fields"] = ",".join(fields)
- r = session.get(
- f"{DIRECTUS_URL}/items/{collection}", params=params, timeout=30
- )
- if r.status_code >= 400:
- raise RuntimeError(f"fetch_all {collection} {r.status_code}: {r.text[:300]}")
- return r.json().get("data", []) or []
-
-
-# ── Reset ─────────────────────────────────────────────────────────────
-
-
-def reset_seed_data(session: requests.Session, dry_run: bool) -> None:
- """Soft-delete everything in RESET_SOFT. Skip any rows owned by the
- admin user so we don't nuke the operator's own organisation."""
- print("=== RESET ===")
-
- # Resolve admin's app_user + home org so we preserve them.
- admin_app = fetch_all(
- session, "app_user", {"email": {"_eq": ADMIN_EMAIL}}, ["id"]
- )
- admin_app_user_id = admin_app[0]["id"] if admin_app else None
-
- preserve_org_ids: set[str] = set()
- if admin_app_user_id:
- admin_orgs = fetch_all(
- session,
- "org_membership",
- {"user_id": {"_eq": admin_app_user_id}, "role": {"_eq": "owner"}},
- ["org_id"],
- )
- preserve_org_ids = {m["org_id"] for m in admin_orgs if m.get("org_id")}
- print(f"Preserving admin orgs: {[o[:8] for o in preserve_org_ids] or 'NONE'}")
-
- now_iso = datetime.now(timezone.utc).isoformat()
-
- # Workspaces in preserved orgs are kept. Everything else soft-deleted.
- # We soft-delete children first to keep referential sanity.
-
- # Workspaces to delete — not in preserved orgs.
- all_workspaces = fetch_all(
- session, "workspace", {"deleted_at": {"_null": True}},
- ["id", "org_id", "name"],
- )
- ws_to_delete = [
- w for w in all_workspaces
- if (w.get("org_id") or "") not in preserve_org_ids
- ]
- print(f"Workspaces to soft-delete: {len(ws_to_delete)}")
-
- ws_ids_to_delete = {w["id"] for w in ws_to_delete}
-
- def soft_delete_where(collection: str, filter_: dict) -> int:
- try:
- rows = fetch_all(session, collection, filter_, ["id"])
- except RuntimeError:
- return 0
- if not rows:
- return 0
- if dry_run:
- return len(rows)
- for r in rows:
- try:
- api(session, "PATCH", f"/items/{collection}/{r['id']}",
- {"deleted_at": now_iso})
- except Exception as e:
- print(f" FAIL soft-delete {collection}/{r['id']}: {e}")
- return len(rows)
-
- def hard_delete_where(collection: str, filter_: dict) -> int:
- try:
- rows = fetch_all(session, collection, filter_, ["id"])
- except RuntimeError:
- return 0
- if not rows:
- return 0
- if dry_run:
- return len(rows)
- for r in rows:
- try:
- api(session, "DELETE", f"/items/{collection}/{r['id']}")
- except Exception as e:
- print(f" FAIL hard-delete {collection}/{r['id']}: {e}")
- return len(rows)
-
- # Children first. Some collections lack deleted_at — hard-delete
- # those. workspace_invite + access_request + project_membership are
- # seed-scope; no history to preserve.
- if ws_ids_to_delete:
- wid_list = list(ws_ids_to_delete)
- for coll in ("workspace_invite", "access_request", "project_membership"):
- n = hard_delete_where(coll, {"workspace_id": {"_in": wid_list}})
- print(f" {coll}: {n} rows (hard)")
- n = soft_delete_where("workspace_membership", {
- "workspace_id": {"_in": wid_list},
- "deleted_at": {"_null": True},
- })
- print(f" workspace_membership: {n} rows (soft)")
-
- # Referral ledger — reset everything (small, no cross-org preservation needed).
- n = soft_delete_where("referral_ledger", {"deleted_at": {"_null": True}})
- print(f" referral_ledger: {n} rows")
- # Also hard-delete in case the soft column isn't read-permitted.
- n_hard = hard_delete_where("referral_ledger", {})
- if n_hard:
- print(f" referral_ledger: {n_hard} rows (hard fallback)")
-
- # Conversations in doomed workspaces — find via project_id join.
- if ws_ids_to_delete:
- doomed_projects = fetch_all(
- session, "project",
- {"workspace_id": {"_in": list(ws_ids_to_delete)}, "deleted_at": {"_null": True}},
- ["id"],
- )
- if doomed_projects:
- pids = [p["id"] for p in doomed_projects]
- n = soft_delete_where(
- "conversation",
- {"project_id": {"_in": pids}, "deleted_at": {"_null": True}},
- )
- print(f" conversation: {n} rows")
- n = soft_delete_where(
- "project",
- {"id": {"_in": pids}, "deleted_at": {"_null": True}},
- )
- print(f" project: {n} rows")
-
- # Workspaces.
- if ws_ids_to_delete:
- for wid in ws_ids_to_delete:
- if dry_run:
- continue
- try:
- api(session, "PATCH", f"/items/workspace/{wid}",
- {"deleted_at": now_iso})
- except Exception as e:
- print(f" FAIL soft-delete workspace/{wid}: {e}")
- print(f" workspace: {len(ws_ids_to_delete)} rows")
-
- # Orgs (not preserved) — soft-delete + their org_memberships.
- all_orgs = fetch_all(session, "org", {"deleted_at": {"_null": True}}, ["id"])
- orgs_to_delete = [o for o in all_orgs if o["id"] not in preserve_org_ids]
- if orgs_to_delete:
- oids = [o["id"] for o in orgs_to_delete]
- n = soft_delete_where(
- "org_membership",
- {"org_id": {"_in": oids}, "deleted_at": {"_null": True}},
- )
- print(f" org_membership: {n} rows")
- if not dry_run:
- for oid in oids:
- try:
- api(session, "PATCH", f"/items/org/{oid}",
- {"deleted_at": now_iso})
- except Exception as e:
- print(f" FAIL soft-delete org/{oid}: {e}")
- print(f" org: {len(oids)} rows")
-
- # Seed users — hard-delete by email suffix.
- for pattern in SEED_USER_EMAIL_PATTERNS:
- seeded = fetch_all(
- session, "app_user",
- {"email": {"_ends_with": pattern}},
- ["id", "email", "directus_user_id"],
- )
- if not seeded:
- continue
- print(f" app_user ({pattern}): {len(seeded)} rows")
- if dry_run:
- continue
- for u in seeded:
- try:
- api(session, "DELETE", f"/items/app_user/{u['id']}")
- except Exception as e:
- print(f" FAIL delete app_user/{u['id']}: {e}")
- du = u.get("directus_user_id")
- if du:
- try:
- api(session, "DELETE", f"/users/{du}")
- except Exception as e:
- print(f" FAIL delete directus user/{du}: {e}")
-
-
-# ── Create helpers ────────────────────────────────────────────────────
-
-
-def new_uuid() -> str:
- return str(uuid.uuid4())
-
-
-def ensure_admin_permissions(session: requests.Session) -> None:
- """Grant Administrator policy full CRUD on seed-touched collections.
-
- Idempotent — skips when a permission row already exists for the
- (collection, action, policy) triple. Directus 11 doesn't auto-grant
- admin access on newly created collections; without this, our seed
- POSTs 403 even with admin_access=true.
- """
- for collection in COLLECTIONS_NEEDING_ADMIN_PERMS:
- # Check if the collection exists first — skip silently if it doesn't
- # (shared helper used by tests + future collections).
- try:
- api(session, "GET", f"/collections/{collection}")
- except RuntimeError:
- continue
- for action in ("create", "read", "update", "delete"):
- existing = session.get(
- f"{DIRECTUS_URL}/permissions",
- params={
- "filter": json.dumps({
- "collection": {"_eq": collection},
- "action": {"_eq": action},
- "policy": {"_eq": ADMIN_POLICY_ID},
- }),
- "limit": 1,
- },
- timeout=15,
- )
- rows = existing.json().get("data", []) if existing.ok else []
- if rows:
- continue
- try:
- api(session, "POST", "/permissions", {
- "collection": collection,
- "action": action,
- "policy": ADMIN_POLICY_ID,
- "fields": ["*"],
- "permissions": {},
- "validation": {},
- "presets": None,
- })
- except RuntimeError as e:
- print(f" WARN grant {collection}/{action}: {e}")
-
-
-def create_directus_user(
- session: requests.Session, email: str, display_name: str
-) -> tuple[str, str]:
- """Returns (directus_user_id, app_user_id). Password is SEED_USER_PASSWORD."""
- parts = display_name.split(" ", 1)
- first = parts[0]
- last = parts[1] if len(parts) > 1 else ""
-
- # directus_users is served via /users, not /items/directus_users.
- r = session.get(
- f"{DIRECTUS_URL}/users",
- params={"filter": json.dumps({"email": {"_eq": email}}), "limit": 1},
- timeout=15,
- )
- existing = r.json().get("data", []) if r.ok else []
-
- if existing:
- du_id = existing[0]["id"]
- else:
- res = api(session, "POST", "/users", {
- "email": email,
- "password": SEED_USER_PASSWORD,
- "first_name": first,
- "last_name": last,
- "role": BASIC_USER_ROLE_ID,
- "status": "active",
- })
- du_id = res["data"]["id"]
-
- app_existing = fetch_all(
- session, "app_user",
- {"directus_user_id": {"_eq": du_id}},
- ["id"],
- )
- if app_existing:
- return du_id, app_existing[0]["id"]
-
- app_id = new_uuid()
- api(session, "POST", "/items/app_user", {
- "id": app_id,
- "directus_user_id": du_id,
- "email": email,
- "display_name": display_name,
- })
- return du_id, app_id
-
-
-def create_org(session: requests.Session, name: str) -> str:
- oid = new_uuid()
- api(session, "POST", "/items/org", {
- "id": oid,
- "name": name,
- })
- return oid
-
-
-def add_org_member(
- session: requests.Session, org_id: str, user_id: str, role: str
-) -> None:
- api(session, "POST", "/items/org_membership", {
- "id": new_uuid(),
- "org_id": org_id,
- "user_id": user_id,
- "role": role,
- })
-
-
-def create_workspace(
- session: requests.Session,
- org_id: str,
- name: str,
- tier: str = "pioneer",
- visibility: str = "open_to_organisation",
- downgraded_at: Optional[str] = None,
- downgraded_from_tier: Optional[str] = None,
-) -> str:
- wid = new_uuid()
- body = {
- "id": wid,
- "org_id": org_id,
- "name": name,
- "tier": tier,
- "visibility": visibility,
- "is_default": False,
- "billed_to_team_id": org_id,
- }
- if downgraded_at:
- body["downgraded_at"] = downgraded_at
- if downgraded_from_tier:
- body["downgraded_from_tier"] = downgraded_from_tier
- api(session, "POST", "/items/workspace", body)
- return wid
-
-
-def add_workspace_member(
- session: requests.Session,
- workspace_id: str,
- user_id: str,
- role: str,
-) -> None:
- api(session, "POST", "/items/workspace_membership", {
- "id": new_uuid(),
- "workspace_id": workspace_id,
- "user_id": user_id,
- "role": role,
- "source": "direct",
- })
-
-
-def create_project(
- session: requests.Session,
- workspace_id: str,
- name: str,
- directus_user_id: str,
- visibility: str = "workspace",
- language: str = "en",
-) -> str:
- pid = new_uuid()
- api(session, "POST", "/items/project", {
- "id": pid,
- "workspace_id": workspace_id,
- "name": name,
- "language": language,
- "visibility": visibility,
- "directus_user_id": directus_user_id,
- "is_conversation_allowed": True,
- })
- return pid
-
-
-def create_conversations(
- session: requests.Session,
- project_id: str,
- durations_seconds: list[int],
- created_recent: bool = True,
-) -> None:
- """Create conversation rows with given durations. created_at defaults
- to 'recent' (this calendar month) so the hour meter picks them up."""
- now = datetime.now(timezone.utc)
- # Spread across the current month for realism.
- month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
- for i, dur in enumerate(durations_seconds):
- ts = (
- month_start + timedelta(
- days=random.randint(0, max(1, (now - month_start).days)),
- hours=random.randint(0, 23),
- )
- ) if created_recent else (now - timedelta(days=120))
- api(session, "POST", "/items/conversation", {
- "id": new_uuid(),
- "project_id": project_id,
- "participant_name": f"Participant {i+1}",
- "duration": dur,
- "created_at": ts.isoformat(),
- })
-
-
-def create_access_request(
- session: requests.Session, workspace_id: str, user_id: str
-) -> None:
- # access_request.id is auto-int (Directus auto-created before our
- # add_field specified UUID; the add_field was a no-op).
- api(session, "POST", "/items/access_request", {
- "workspace_id": workspace_id,
- "user_id": user_id,
- "status": "pending",
- })
-
-
-def create_workspace_invite(
- session: requests.Session,
- workspace_id: str,
- email: str,
- invited_by_user_id: str,
- role: str = "member",
-) -> None:
- expires = (datetime.now(timezone.utc) + timedelta(days=14)).isoformat()
- api(session, "POST", "/items/workspace_invite", {
- "id": new_uuid(),
- "workspace_id": workspace_id,
- "email": email,
- "role": role,
- "invited_by_user_id": invited_by_user_id,
- "expires_at": expires,
- # HMAC token_hash would normally be set — skip for seed (invite
- # is surface-tested; not accepted via seed).
- "token_hash": f"seed-{new_uuid()[:16]}",
- })
-
-
-def create_referral_ledger_entry(
- session: requests.Session,
- workspace_id: str,
- partner_team_id: str,
- staff_id: Optional[str],
- percent: int = 20,
- notes: Optional[str] = None,
-) -> None:
- # referral_ledger.id is auto-int (same story as access_request).
- api(session, "POST", "/items/referral_ledger", {
- "workspace_id": workspace_id,
- "partner_team_id": partner_team_id,
- "partner_kickback_percent": percent,
- "notes": notes,
- "created_by_staff_id": staff_id,
- })
-
-
-# ── Seed ──────────────────────────────────────────────────────────────
-
-
-def seed(session: requests.Session, dry_run: bool) -> None:
- print("\n=== SEED ===")
- if dry_run:
- print("(dry-run — no writes)")
- return
-
- ensure_admin_permissions(session)
-
- # Users
- users: dict[str, tuple[str, str]] = {} # email → (du_id, app_id)
- def mk(email: str, name: str) -> None:
- du, au = create_directus_user(session, email, name)
- users[email] = (du, au)
- print(f" user {email} → app={au[:8]}")
-
- mk("anna@seed.dembrane.dev", "Anna Bakker")
- mk("ben@seed.dembrane.dev", "Ben Cortez")
- mk("cara@seed.dembrane.dev", "Cara Dubois")
- mk("dan@seed.dembrane.dev", "Dan Eriksen")
- mk("emma@seed.dembrane.dev","Emma Friedman")
- mk("finn@seed.dembrane.dev", "Finn Garcia")
- mk("grace@seed.dembrane.dev","Grace Hughes")
- mk("hank@seed.dembrane.dev","Hank Irving")
-
- def au(email: str) -> str: return users[email][1]
- def du(email: str) -> str: return users[email][0]
-
- # Admin app_user id (preserved from reset).
- admin_rows = fetch_all(
- session, "app_user", {"email": {"_eq": ADMIN_EMAIL}}, ["id"]
- )
- admin_app_id = admin_rows[0]["id"] if admin_rows else None
-
- # Organisations
- acme = create_org(session, "Acme Research")
- add_org_member(session, acme, au("anna@seed.dembrane.dev"), "owner")
- add_org_member(session, acme, au("ben@seed.dembrane.dev"), "admin")
- add_org_member(session, acme, au("cara@seed.dembrane.dev"), "member")
- add_org_member(session, acme, au("dan@seed.dembrane.dev"), "billing")
- print(f" organisation Acme Research → {acme[:8]}")
-
- partner = create_org(session, "Partner Consulting")
- add_org_member(session, partner, au("emma@seed.dembrane.dev"), "owner")
- print(f" organisation Partner Consulting → {partner[:8]}")
-
- alpha = create_org(session, "Alpha Inc")
- add_org_member(session, alpha, au("hank@seed.dembrane.dev"), "owner")
- print(f" organisation Alpha Inc → {alpha[:8]}")
-
- studio = create_org(session, "Solo Studio")
- add_org_member(session, studio, au("finn@seed.dembrane.dev"), "owner")
- print(f" organisation Solo Studio → {studio[:8]}")
-
- # ─ Workspaces ─
-
- # Acme default — pioneer, healthy
- acme_default = create_workspace(session, acme, "Default", tier="pioneer")
- add_workspace_member(session, acme_default, au("anna@seed.dembrane.dev"), "owner")
- add_workspace_member(session, acme_default, au("ben@seed.dembrane.dev"), "admin")
- add_workspace_member(session, acme_default, au("cara@seed.dembrane.dev"), "member")
- add_workspace_member(session, acme_default, au("dan@seed.dembrane.dev"), "billing")
- p1 = create_project(session, acme_default, "Kickoff Interviews",
- du("anna@seed.dembrane.dev"))
- create_conversations(session, p1, [1200, 1500, 2100, 1800]) # ~1.8h
- print(f" workspace Acme / Default → healthy pioneer")
-
- # Acme Q1 Discovery — pioneer approaching cap (25h included, we put ~22h)
- acme_q1 = create_workspace(session, acme, "Q1 Discovery", tier="pioneer")
- add_workspace_member(session, acme_q1, au("anna@seed.dembrane.dev"), "owner")
- add_workspace_member(session, acme_q1, au("ben@seed.dembrane.dev"), "admin")
- p2 = create_project(session, acme_q1, "Customer Panel",
- du("anna@seed.dembrane.dev"))
- create_conversations(session, p2, [3600] * 22 + [600]) # ~22.2h → >80%
- print(f" workspace Acme / Q1 Discovery → approaching pioneer limit")
-
- # Acme Privacy Research — innovator, private
- acme_private = create_workspace(session, acme, "Privacy Research",
- tier="innovator", visibility="private")
- add_workspace_member(session, acme_private, au("anna@seed.dembrane.dev"), "owner")
- p3 = create_project(session, acme_private, "Legal Framework",
- du("anna@seed.dembrane.dev"), visibility="private")
- create_conversations(session, p3, [2400, 3000])
- print(f" workspace Acme / Privacy Research → private innovator")
-
- # Acme Whitelabel — changemaker, downgraded 3 days ago (banner live)
- three_days_ago = (datetime.now(timezone.utc) - timedelta(days=3)).isoformat()
- acme_whitelabel = create_workspace(
- session, acme, "Whitelabel Project",
- tier="innovator", downgraded_at=three_days_ago,
- downgraded_from_tier="changemaker",
- )
- add_workspace_member(session, acme_whitelabel, au("anna@seed.dembrane.dev"), "owner")
- add_workspace_member(session, acme_whitelabel, au("ben@seed.dembrane.dev"), "admin")
- create_project(session, acme_whitelabel, "Brand Rollout",
- du("anna@seed.dembrane.dev"))
- print(f" workspace Acme / Whitelabel → just-downgraded innovator")
-
- # Partner Client Alpha — handoff in flight, pioneer
- partner_alpha = create_workspace(session, partner, "Client Alpha",
- tier="pioneer")
- add_workspace_member(session, partner_alpha, au("emma@seed.dembrane.dev"), "owner")
- # Set handoff pending to Alpha Inc.
- api(session, "PATCH", f"/items/workspace/{partner_alpha}", {
- "handoff_status": "pending",
- "handoff_target_team_id": alpha,
- })
- pa = create_project(session, partner_alpha, "Discovery Q4",
- du("emma@seed.dembrane.dev"))
- create_conversations(session, pa, [1800, 2400, 3000, 2100])
- print(f" workspace Partner / Client Alpha → handoff pending → Alpha Inc")
-
- # Partner Client Beta — already handed off (completed)
- partner_beta = create_workspace(session, partner, "Client Beta",
- tier="innovator")
- add_workspace_member(session, partner_beta, au("emma@seed.dembrane.dev"), "owner")
- add_workspace_member(session, partner_beta, au("hank@seed.dembrane.dev"),
- "admin")
- # Mark as already handed off.
- api(session, "PATCH", f"/items/workspace/{partner_beta}", {
- "handoff_status": "completed",
- "effective_client_team_id": alpha,
- # billed_to stays with partner for this demo (like they kept billing).
- })
- create_project(session, partner_beta, "Analytics Rebuild",
- du("emma@seed.dembrane.dev"))
- print(f" workspace Partner / Client Beta → handoff completed")
-
- # Referral ledger — partner earns kickback on Client Alpha
- create_referral_ledger_entry(
- session, partner_alpha, partner, admin_app_id,
- percent=20, notes="Standard partner agreement",
- )
- create_referral_ledger_entry(
- session, partner_beta, partner, admin_app_id,
- percent=20, notes="Handed off Q4 — kickback continues",
- )
- print(f" referral_ledger: 2 entries under Partner Consulting")
-
- # Solo Studio — pilot AT cap
- solo_trial = create_workspace(session, studio, "Trial Run", tier="pilot")
- add_workspace_member(session, solo_trial, au("finn@seed.dembrane.dev"), "owner")
- p_solo = create_project(session, solo_trial, "First Engagement",
- du("finn@seed.dembrane.dev"))
- # Pilot cap = 10 hours = 36000s. Fill to just over.
- create_conversations(session, p_solo, [3600] * 10 + [1200])
- print(f" workspace Solo / Trial Run → pilot AT cap (hard block active)")
-
- # External on Acme Default
- add_workspace_member(
- session, acme_default, au("grace@seed.dembrane.dev"),
- "external",
- )
- print(" external grace@seed.dembrane.dev on Acme/Default")
-
- # Pending access request — cara requesting access to Acme Q1 Discovery
- # (she's organisation member; workspace is open).
- # Q1 Discovery doesn't have cara as member yet — perfect scenario.
- # Actually she was added to Default earlier; for Q1 she's not a member.
- create_access_request(session, acme_q1, au("cara@seed.dembrane.dev"))
- print(f" access_request: cara → Acme/Q1 Discovery (pending)")
-
- # Pending workspace invite — new email, not yet a user.
- create_workspace_invite(
- session, acme_default,
- email="frank@seed.dembrane.dev",
- invited_by_user_id=au("anna@seed.dembrane.dev"),
- )
- print(f" workspace_invite: frank@seed.dembrane.dev → Acme/Default (pending)")
-
- print("\nSeed complete.")
- print("Login as any demo user with the configured SEED_USER_PASSWORD (value not printed).")
-
-
-def finn_suffix(oid: str) -> str:
- return oid[:8]
-
-
-# ── main ──────────────────────────────────────────────────────────────
-
-
-def main() -> int:
- p = argparse.ArgumentParser(description=__doc__)
- p.add_argument("--reset", action="store_true", help="Reset seed data.")
- p.add_argument("--seed", action="store_true", help="Write seed data.")
- p.add_argument("--all", action="store_true", help="Reset then seed.")
- p.add_argument("--apply", action="store_true",
- help="Actually mutate. Default dry-run.")
- args = p.parse_args()
-
- if not (args.reset or args.seed or args.all):
- p.print_help()
- return 2
-
- do_reset = args.reset or args.all
- do_seed = args.seed or args.all
- dry_run = not args.apply
-
- print(f"Directus: {DIRECTUS_URL}")
- print(f"Mode: {'DRY-RUN' if dry_run else 'APPLY'}")
- print(f"Reset={do_reset} Seed={do_seed}")
-
- token = login()
- session = requests.Session()
- session.headers.update({"Authorization": f"Bearer {token}"})
-
- if do_reset:
- reset_seed_data(session, dry_run)
- if do_seed:
- if dry_run:
- print("\n(dry-run: would seed but skipping writes)")
- else:
- seed(session, dry_run=False)
-
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/echo/scripts/seed_project_conversations.py b/echo/scripts/seed_project_conversations.py
deleted file mode 100644
index 147036551..000000000
--- a/echo/scripts/seed_project_conversations.py
+++ /dev/null
@@ -1,178 +0,0 @@
-"""
-Seed fake conversations into a project so usage numbers populate.
-
-Usage:
- python scripts/seed_project_conversations.py \
- --project-id ae0c0523-a148-4d39-a9d4-b963decf144d
-
-Creates 1 "Plenary" conversation at 1 hour + 10 "Round Table N" conversations
-at 1 hour each for a total of 11 hours of audio. `duration` is stored in
-seconds (matches workspaces._get_workspace_usage).
-
-Requires DIRECTUS_TOKEN + DIRECTUS_BASE_URL env vars (falls back to
-directus/.env if unset). Idempotent: re-running skips any conversation
-whose title already exists in the project so you don't double-count.
-"""
-
-from __future__ import annotations
-
-import argparse
-import os
-import sys
-import uuid
-from datetime import datetime, timezone
-
-import requests
-
-DIRECTUS_URL = os.environ.get("DIRECTUS_BASE_URL", "http://directus:8055")
-DIRECTUS_TOKEN = os.environ.get("DIRECTUS_TOKEN", "")
-
-if not DIRECTUS_TOKEN:
- env_path = os.path.join(os.path.dirname(__file__), "..", "directus", ".env")
- if os.path.exists(env_path):
- with open(env_path) as f:
- for line in f:
- line = line.strip()
- if line.startswith("DIRECTUS_TOKEN="):
- DIRECTUS_TOKEN = line.split("=", 1)[1].strip().strip('"').strip("'")
-
-HEADERS = {
- "Authorization": f"Bearer {DIRECTUS_TOKEN}",
- "Content-Type": "application/json",
-}
-
-
-def api(method: str, path: str, data: dict | None = None, params: dict | None = None) -> dict | None:
- url = f"{DIRECTUS_URL}{path}"
- resp = requests.request(method, url, headers=HEADERS, json=data, params=params, timeout=30)
- if resp.status_code >= 400:
- print(f" ERROR {resp.status_code} on {method} {path}: {resp.text[:500]}")
- return None
- if resp.status_code == 204:
- return {}
- return resp.json()
-
-
-def ensure_project_exists(project_id: str) -> dict | None:
- data = api("GET", f"/items/project/{project_id}")
- if not data or not isinstance(data, dict) or "data" not in data:
- return None
- return data["data"]
-
-
-def existing_titles(project_id: str) -> set[str]:
- """Read current conversation titles for idempotency."""
- resp = api(
- "GET",
- "/items/conversation",
- params={
- "filter[project_id][_eq]": project_id,
- "filter[deleted_at][_null]": "true",
- "fields": "title",
- "limit": -1,
- },
- )
- if not resp or "data" not in resp:
- return set()
- return {(r.get("title") or "").strip() for r in resp["data"] if r.get("title")}
-
-
-def create_conversation(
- *,
- project_id: str,
- title: str,
- duration_seconds: float,
- created_at_iso: str,
-) -> bool:
- payload = {
- "id": str(uuid.uuid4()),
- "project_id": project_id,
- "title": title,
- "duration": duration_seconds,
- "source": "PORTAL_AUDIO",
- "is_finished": True,
- "is_all_chunks_transcribed": True,
- "is_audio_processing_finished": True,
- # Created_at is auto-filled server-side, but we can override so a
- # freshly-seeded set lines up with "this month".
- "created_at": created_at_iso,
- }
- resp = api("POST", "/items/conversation", data=payload)
- if resp is None:
- return False
- return True
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--project-id", required=True, help="Target project UUID.")
- parser.add_argument(
- "--plenary-hours",
- type=float,
- default=1.0,
- help="Duration of the Plenary conversation (hours). Default 1.",
- )
- parser.add_argument(
- "--round-tables",
- type=int,
- default=10,
- help="Number of 'Round Table N' conversations to create. Default 10.",
- )
- parser.add_argument(
- "--round-table-hours",
- type=float,
- default=1.0,
- help="Duration of each round table (hours). Default 1.",
- )
- args = parser.parse_args()
-
- if not DIRECTUS_TOKEN:
- print("Set DIRECTUS_TOKEN (or populate directus/.env).")
- return 2
-
- project = ensure_project_exists(args.project_id)
- if not project:
- print(f"Project {args.project_id} not found or fetch failed.")
- return 2
- print(f"Project: {project.get('name', args.project_id)}")
-
- have = existing_titles(args.project_id)
- now_iso = datetime.now(timezone.utc).isoformat()
-
- conversations: list[tuple[str, float]] = [
- (f"Plenary - {int(args.plenary_hours)}hr", args.plenary_hours * 3600),
- ]
- for i in range(1, args.round_tables + 1):
- conversations.append((f"Round Table {i}", args.round_table_hours * 3600))
-
- created = 0
- skipped = 0
- failed = 0
- for title, duration_s in conversations:
- if title in have:
- print(f" skip {title} (already exists)")
- skipped += 1
- continue
- ok = create_conversation(
- project_id=args.project_id,
- title=title,
- duration_seconds=duration_s,
- created_at_iso=now_iso,
- )
- if ok:
- print(f" add {title} ({duration_s / 3600:.1f}h)")
- created += 1
- else:
- print(f" FAIL {title}")
- failed += 1
-
- total_hours = sum(d for _, d in conversations) / 3600
- print(
- f"\nDone. created={created} skipped={skipped} failed={failed}. "
- f"Target total: {total_hours:.1f}h across {len(conversations)} conversations."
- )
- return 0 if failed == 0 else 1
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/echo/scripts/smoke_test_v2.py b/echo/scripts/smoke_test_v2.py
deleted file mode 100644
index 2338b8de1..000000000
--- a/echo/scripts/smoke_test_v2.py
+++ /dev/null
@@ -1,263 +0,0 @@
-#!/usr/bin/env python
-"""v2 API smoke test.
-
-Hits every authenticated GET endpoint plus safe-ish POST/PATCH paths
-against the local stack, logged in as a seeded user. Each call is
-classified as pass / 4xx / 5xx / skipped.
-
-Usage:
- python scripts/smoke_test_v2.py
- python scripts/smoke_test_v2.py --user anna@seed.dembrane.dev
- python scripts/smoke_test_v2.py --api http://localhost:8000 --verbose
-
-The server must be running. Seeded data must exist (run
-scripts/seed_dev.py --all first if you get zero workspaces/orgs).
-"""
-from __future__ import annotations
-
-import argparse
-import json
-import sys
-from dataclasses import dataclass, field
-from typing import Any, Optional
-
-import requests
-
-DEFAULT_API = "http://localhost:8000"
-DEFAULT_DIRECTUS = "http://directus:8055"
-DEFAULT_USER = "anna@seed.dembrane.dev"
-DEFAULT_PASSWORD = "demo1234"
-
-
-@dataclass
-class TestResult:
- method: str
- path: str
- status: int
- notes: str = ""
- body_preview: str = ""
-
-
-@dataclass
-class Report:
- passed: list[TestResult] = field(default_factory=list)
- client_err: list[TestResult] = field(default_factory=list)
- server_err: list[TestResult] = field(default_factory=list)
- skipped: list[TestResult] = field(default_factory=list)
-
-
-def login(
- session: requests.Session,
- directus_url: str,
- email: str,
- password: str,
-) -> None:
- """Login via Directus in session mode.
-
- Our FastAPI trusts the `directus_session_token` cookie that Directus
- sets on mode=session logins. Directus is on the same host:port as
- our FastAPI through the nginx/devcontainer setup; requests.Session
- carries the cookie across the two hostnames iff they resolve to the
- same cookie jar, so we set it explicitly.
- """
- res = session.post(
- f"{directus_url}/auth/login",
- json={"email": email, "password": password, "mode": "session"},
- timeout=10,
- )
- res.raise_for_status()
- # Directus sets the cookie under its host; propagate it so the
- # FastAPI session check sees it on its own host.
- for cookie in session.cookies:
- if cookie.name == "directus_session_token":
- return
- # Fallback — pull token from body and set manually.
- body = res.json().get("data") or {}
- token = body.get("access_token")
- if token:
- session.cookies.set("directus_session_token", token)
-
-
-def probe(
- session: requests.Session,
- api: str,
- method: str,
- path: str,
- *,
- body: Any = None,
- notes: str = "",
- report: Report,
- verbose: bool = False,
-) -> TestResult:
- url = f"{api}/api{path}"
- try:
- res = session.request(method, url, json=body, timeout=20)
- except Exception as e:
- tr = TestResult(method, path, 0, f"exception: {e}", "")
- report.server_err.append(tr)
- if verbose:
- print(f" EXC {method} {path} → {e}")
- return tr
-
- status = res.status_code
- preview = ""
- try:
- raw = res.json()
- preview = json.dumps(raw)[:240]
- except Exception:
- preview = res.text[:240]
-
- tr = TestResult(method, path, status, notes, preview)
- if 200 <= status < 300:
- report.passed.append(tr)
- elif 400 <= status < 500:
- report.client_err.append(tr)
- elif 500 <= status:
- report.server_err.append(tr)
- else:
- report.skipped.append(tr)
-
- if verbose:
- bucket = (
- "OK" if 200 <= status < 300
- else "4xx" if 400 <= status < 500
- else "5xx" if status >= 500
- else "??"
- )
- print(f" {bucket} {status} {method} {path}")
- if status >= 500 or (verbose and status >= 400):
- print(f" {preview[:200]}")
-
- return tr
-
-
-def first_of(data: Any, key: str, default: str | None = None) -> str | None:
- if isinstance(data, dict) and data.get(key):
- return data[key]
- if isinstance(data, list) and data and isinstance(data[0], dict) and data[0].get(key):
- return data[0][key]
- if isinstance(data, dict) and isinstance(data.get("workspaces"), list):
- return first_of(data["workspaces"], key, default)
- if isinstance(data, dict) and isinstance(data.get("organisations"), list):
- return first_of(data["organisations"], key, default)
- return default
-
-
-def main() -> int:
- ap = argparse.ArgumentParser()
- ap.add_argument("--api", default=DEFAULT_API)
- ap.add_argument("--directus", default=DEFAULT_DIRECTUS)
- ap.add_argument("--user", default=DEFAULT_USER)
- ap.add_argument("--password", default=DEFAULT_PASSWORD)
- ap.add_argument("--verbose", "-v", action="store_true")
- args = ap.parse_args()
-
- session = requests.Session()
- print(f"Logging in as {args.user} via {args.directus}…", end=" ")
- try:
- login(session, args.directus, args.user, args.password)
- print("ok")
- except Exception as e:
- print(f"FAILED: {e}")
- return 1
-
- report = Report()
- def P(*a, **k): # noqa: N802
- return probe(session, args.api, *a, report=report, verbose=args.verbose, **k)
-
- # ── /v2/me ──
- me = P("GET", "/v2/me")
- my_user_id = None
- try:
- body = session.get(f"{args.api}/api/v2/me", timeout=10).json()
- my_user_id = body.get("id")
- except Exception:
- pass
-
- P("GET", "/v2/me/invites")
-
- # ── /v2/workspaces ──
- ws_list_tr = P("GET", "/v2/workspaces")
- workspace_id = None
- org_id = None
- try:
- body = session.get(f"{args.api}/api/v2/workspaces", timeout=10).json()
- workspace_id = first_of(body.get("workspaces"), "id")
- org_id = first_of(body.get("workspaces"), "org_id") or first_of(body.get("organisations"), "id")
- except Exception:
- pass
-
- P("GET", "/v2/workspaces/tier-capacities")
-
- if workspace_id:
- P("GET", f"/v2/workspaces/{workspace_id}/settings")
- P("GET", f"/v2/workspaces/{workspace_id}/usage")
- P("GET", f"/v2/workspaces/{workspace_id}/tier/preview-downgrade?target_tier=pioneer")
- P("GET", f"/v2/workspaces/{workspace_id}/projects")
- # Access requests list (admin-only; may 403 for non-admin)
- P("GET", f"/v2/workspaces/{workspace_id}/access-requests")
- # upgrade request is destructive (sends email) but rate-limited & idempotent-ish;
- # skip in smoke to not spam.
- else:
- report.skipped.append(TestResult("GET", "/v2/workspaces/:id/settings", 0, "no workspace"))
-
- # ── /v2/orgs ──
- P("GET", "/v2/orgs")
- if org_id:
- P("GET", f"/v2/orgs/{org_id}")
- P("GET", f"/v2/orgs/{org_id}/members")
- P("GET", f"/v2/orgs/{org_id}/workspaces")
- P("GET", f"/v2/orgs/{org_id}/usage")
- P("GET", f"/v2/orgs/{org_id}/projects")
- else:
- report.skipped.append(TestResult("GET", "/v2/orgs/:id", 0, "no org"))
-
- # ── /v2/notifications ──
- P("GET", "/v2/notifications")
- P("GET", "/v2/notifications/unread-count")
-
- # ── /v2/projects ──
- project_id = None
- if workspace_id:
- try:
- body = session.get(
- f"{args.api}/api/v2/workspaces/{workspace_id}/projects", timeout=10
- ).json()
- items = body if isinstance(body, list) else body.get("projects") or body.get("items")
- if items:
- project_id = items[0].get("id")
- except Exception:
- pass
- if project_id:
- P("GET", f"/v2/projects/{project_id}")
- P("GET", f"/v2/projects/{project_id}/members")
-
- # ── /templates (v1 — still in use) ──
- P("GET", "/templates/prompt-templates")
- if workspace_id:
- P("GET", f"/templates/prompt-templates?workspace_id={workspace_id}")
- P("GET", "/templates/quick-access")
-
- # ── Report ──
- print()
- print(f" pass: {len(report.passed)}")
- print(f" 4xx: {len(report.client_err)}")
- print(f" 5xx: {len(report.server_err)}")
- print(f" skip: {len(report.skipped)}")
-
- if report.server_err:
- print("\n5xx detail:")
- for tr in report.server_err:
- print(f" {tr.status} {tr.method} {tr.path}")
- print(f" {tr.body_preview[:200]}")
- if report.client_err and args.verbose:
- print("\n4xx detail:")
- for tr in report.client_err:
- print(f" {tr.status} {tr.method} {tr.path}")
- print(f" {tr.body_preview[:200]}")
-
- return 1 if report.server_err else 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/echo/server/dembrane/api/project.py b/echo/server/dembrane/api/project.py
index f4ca05031..6925c222d 100644
--- a/echo/server/dembrane/api/project.py
+++ b/echo/server/dembrane/api/project.py
@@ -884,13 +884,21 @@ async def get_latest_report(
"show_portal_link",
"date_created",
"error_message",
+ "content",
],
"sort": ["-date_created"],
"limit": 1,
}
},
)
- return reports[0] if reports else None
+ if not reports:
+ return None
+ report = reports[0]
+ return {
+ **report,
+ "content": None,
+ "title": _extract_report_title(report.get("content")),
+ }
class UpdateReportRequestBodySchema(BaseModel):
diff --git a/echo/server/dembrane/api/v2/bff/conversations.py b/echo/server/dembrane/api/v2/bff/conversations.py
index 7a647ca90..aa0ba58cd 100644
--- a/echo/server/dembrane/api/v2/bff/conversations.py
+++ b/echo/server/dembrane/api/v2/bff/conversations.py
@@ -99,6 +99,22 @@ async def list_conversations(
),
limit: int = Query(1000, ge=1, le=1000),
offset: int = Query(0, ge=0),
+ sort: Literal[
+ "-created_at",
+ "created_at",
+ "-participant_name",
+ "participant_name",
+ "-duration",
+ "duration",
+ "-updated_at",
+ "updated_at",
+ ] = Query("-created_at"),
+ tag_ids: Optional[str] = Query(
+ None,
+ description="Comma-separated project_tag ids. Match any.",
+ ),
+ verified_only: bool = Query(False),
+ search_text: Optional[str] = Query(None),
transcript_required: bool = Query(
False,
description="Only return conversations that have at least one chunk with transcript text.",
@@ -120,6 +136,17 @@ async def list_conversations(
if src_list:
conv_filter["source"] = {"_in": src_list}
+ tids = [x.strip() for x in (tag_ids or "").split(",") if x.strip()]
+ if tids:
+ conv_filter["tags"] = {
+ "_some": {"project_tag_id": {"id": {"_in": tids}}},
+ }
+
+ if verified_only:
+ conv_filter["conversation_artifacts"] = {
+ "_some": {"approved_at": {"_nnull": True}},
+ }
+
if fields is None:
field_list: list[str] = list(_CONVERSATION_DEFAULT_FIELDS)
elif fields.strip() == "*":
@@ -129,18 +156,20 @@ async def list_conversations(
if "id" not in field_list:
field_list.insert(0, "id")
+ query_dict: dict = {
+ "filter": conv_filter,
+ "fields": field_list,
+ "sort": [sort],
+ "limit": limit,
+ "offset": offset,
+ }
+ if search_text and search_text.strip():
+ query_dict["search"] = search_text.strip()
+
convs = (
await async_directus.get_items(
"conversation",
- {
- "query": {
- "filter": conv_filter,
- "fields": field_list,
- "sort": ["-updated_at"],
- "limit": limit,
- "offset": offset,
- }
- },
+ {"query": query_dict},
)
or []
)
@@ -180,9 +209,32 @@ async def list_conversations(
for conv in convs:
_enrich_conversation(conv, tier)
- if convs and (include_chunks or include_tags):
+ if convs:
conv_ids = [c["id"] for c in convs]
+ artifacts = (
+ await async_directus.get_items(
+ "conversation_artifact",
+ {
+ "query": {
+ "filter": {"conversation_id": {"_in": conv_ids}},
+ "fields": ["id", "conversation_id", "approved_at", "key", "topic_label"],
+ "sort": ["-approved_at", "-date_created"],
+ "limit": -1,
+ }
+ },
+ )
+ or []
+ )
+ artifact_map: dict[str, list[dict]] = {}
+ if isinstance(artifacts, list):
+ for artifact in artifacts:
+ cid = artifact.get("conversation_id")
+ if cid:
+ artifact_map.setdefault(cid, []).append(artifact)
+ for conv in convs:
+ conv["conversation_artifacts"] = artifact_map.get(conv["id"], [])
+
if include_chunks:
chunks = (
await async_directus.get_items(
@@ -253,18 +305,39 @@ async def list_conversations(
async def count_conversations(
auth: DependencyDirectusSession,
project_id: str = Query(...),
+ tag_ids: Optional[str] = Query(
+ None,
+ description="Comma-separated project_tag ids. Match any.",
+ ),
+ verified_only: bool = Query(False),
+ search_text: Optional[str] = Query(None),
) -> dict:
"""Count of conversations in a project (deleted_at is null)."""
access = await resolve_project_access(project_id, auth)
access.require("conversation:read")
+ filt: dict = filter_exclude_deleted({"project_id": {"_eq": project_id}})
+
+ tids = [x.strip() for x in (tag_ids or "").split(",") if x.strip()]
+ if tids:
+ filt["tags"] = {
+ "_some": {"project_tag_id": {"id": {"_in": tids}}},
+ }
+
+ if verified_only:
+ filt["conversation_artifacts"] = {
+ "_some": {"approved_at": {"_nnull": True}},
+ }
+
+ query_dict: dict = {
+ "aggregate": {"count": "id"},
+ "filter": filt,
+ }
+ if search_text and search_text.strip():
+ query_dict["search"] = search_text.strip()
+
agg = await async_directus.get_items(
"conversation",
- {
- "query": {
- "aggregate": {"count": "id"},
- "filter": filter_exclude_deleted({"project_id": {"_eq": project_id}}),
- }
- },
+ {"query": query_dict},
)
if isinstance(agg, list) and agg:
return {"count": int((agg[0].get("count") or {}).get("id", 0) or 0)}
@@ -361,13 +434,36 @@ async def count_remaining_conversations(
"_some": {"approved_at": {"_nnull": True}},
}
- query: dict = {"aggregate": {"countDistinct": ["id"]}, "filter": filt}
+ query: dict = {"fields": ["id"], "filter": filt, "limit": -1}
if search_text and search_text.strip():
query["search"] = search_text.strip()
- agg = await async_directus.get_items("conversation", {"query": query})
+ candidates = await async_directus.get_items("conversation", {"query": query})
+ if not isinstance(candidates, list) or not candidates:
+ return {"count": 0}
+
+ candidate_ids = [row["id"] for row in candidates if row.get("id")]
+ if not candidate_ids:
+ return {"count": 0}
+
+ # Match the select-all backend: empty conversations are skipped because
+ # they do not add useful context. Counting chunks separately keeps this
+ # endpoint aligned with that behavior without relying on relationship
+ # aggregate semantics in Directus.
+ agg = await async_directus.get_items(
+ "conversation_chunk",
+ {
+ "query": {
+ "aggregate": {"countDistinct": ["conversation_id"]},
+ "filter": {
+ "conversation_id": {"_in": candidate_ids},
+ "transcript": {"_nempty": True},
+ },
+ }
+ },
+ )
if isinstance(agg, list) and agg:
- val = (agg[0].get("countDistinct") or {}).get("id", 0) or 0
+ val = (agg[0].get("countDistinct") or {}).get("conversation_id", 0) or 0
try:
return {"count": int(val)}
except (TypeError, ValueError):
diff --git a/echo/server/dembrane/api/v2/middleware.py b/echo/server/dembrane/api/v2/middleware.py
index f1cb1cf35..052b79695 100644
--- a/echo/server/dembrane/api/v2/middleware.py
+++ b/echo/server/dembrane/api/v2/middleware.py
@@ -123,6 +123,25 @@ 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/orgs.py b/echo/server/dembrane/api/v2/orgs.py
index 8be06c274..ea56cdc9d 100644
--- a/echo/server/dembrane/api/v2/orgs.py
+++ b/echo/server/dembrane/api/v2/orgs.py
@@ -996,32 +996,72 @@ async def list_organisation_workspaces(
org_id: str,
auth: DependencyDirectusSession,
) -> list[OrgWorkspaceSummary]:
- """Every workspace in the organisation, visible to any organisation member.
-
- A organisation owner's "see all my workspaces" answer — the selector only
- shows workspaces the caller is a member of (direct or derived), but
- organisation owners may want a roster view including workspaces they haven't
- joined directly. Anyone in the organisation can read this.
+ """Workspaces in the organisation that the caller can see.
+
+ - Org admin/owner: every workspace in the org.
+ - Org member: every non-private workspace in the org.
+ - Guest (no org membership, but has at least one workspace_membership in
+ the org): just the workspaces they're a direct member of. Counts and
+ tier still populated so the response model stays uniform — the
+ sidebar uses id/name only, but other surfaces want the rest.
"""
app_user = await get_app_user_or_raise(auth.user_id)
- caller_role = await _require_org_role(org_id, app_user["id"], minimum="member")
+
+ # Resolve the caller's org role without raising — a guest with workspace
+ # access in this org has no org_membership but should still see "the
+ # basics" (which workspaces they're in). 5xx and other unexpected
+ # failures bubble up.
+ caller_role: str | None = None
+ try:
+ caller_role = await _require_org_role(org_id, app_user["id"], minimum="member")
+ except HTTPException as e:
+ if e.status_code != 403:
+ raise
caller_is_manager = caller_role in ("admin", "owner")
+ is_org_member = caller_role is not None
+
+ ws_filter: dict = {
+ "org_id": {"_eq": org_id},
+ "deleted_at": {"_null": True},
+ }
+
+ if not is_org_member:
+ # Guest path: restrict to workspaces the caller is a direct member
+ # of (workspace_membership row). If none, they have no view here.
+ membership_rows = (
+ await async_directus.get_items(
+ "workspace_membership",
+ {
+ "query": {
+ "filter": {
+ "user_id": {"_eq": app_user["id"]},
+ "deleted_at": {"_null": True},
+ },
+ "fields": ["workspace_id"],
+ "limit": -1,
+ }
+ },
+ )
+ or []
+ )
+ if not isinstance(membership_rows, list):
+ membership_rows = []
+ accessible_ids = [
+ r["workspace_id"] for r in membership_rows if r.get("workspace_id")
+ ]
+ if not accessible_ids:
+ raise HTTPException(status_code=403, detail="No access to this organisation")
+ ws_filter["id"] = {"_in": accessible_ids}
# Pull settings.inherit_organisation_admins explicitly (sub-field projection)
- # so we don't need to send the whole JSON (also avoids accidentally
- # exposing sticky_removed tombstones to the client — the response model
- # drops them, but belt-and-braces). Counts come from separate aggregates
- # because the workspace collection doesn't declare O2M aliases for
- # projects/members.
+ # so we don't need to send the whole JSON. Counts come from separate
+ # aggregates because the workspace collection doesn't declare O2M aliases.
workspaces = (
await async_directus.get_items(
"workspace",
{
"query": {
- "filter": {
- "org_id": {"_eq": org_id},
- "deleted_at": {"_null": True},
- },
+ "filter": ws_filter,
"fields": [
"id",
"name",
@@ -1088,12 +1128,14 @@ async def list_organisation_workspaces(
# Hide private workspaces from non-admin organisation members — the whole
# point of a private workspace is that organisation admins can't see it,
# advertising its name + tier in a organisation-scoped list contradicts that.
- # Admins/owners still see the full roster (they're the audience).
+ # Admins/owners still see the full roster. Guests already came in via
+ # direct membership so we don't filter them here (they ARE the audience
+ # for those private workspaces).
out: list[OrgWorkspaceSummary] = []
for ws in workspaces:
settings = ws.get("settings") if isinstance(ws.get("settings"), dict) else {}
is_private = (settings or {}).get("inherit_organisation_admins") is False
- if is_private and not caller_is_manager:
+ if is_org_member and is_private and not caller_is_manager:
continue
# Cap-blocked flags. Compute lazily — only call get_effective_members
diff --git a/echo/server/dembrane/api/v2/workspaces.py b/echo/server/dembrane/api/v2/workspaces.py
index b35942fa5..d6c0f12ca 100644
--- a/echo/server/dembrane/api/v2/workspaces.py
+++ b/echo/server/dembrane/api/v2/workspaces.py
@@ -198,6 +198,9 @@ async def list_workspaces(
)
if not isinstance(org_membership_data, list):
org_membership_data = []
+ internal_org_ids = {
+ om["org_id"] for om in org_membership_data if om.get("org_id")
+ }
if len(memberships) == 0 and len(org_membership_data) == 0:
return WorkspaceListResponse(workspaces=[], organisations=[])
@@ -340,6 +343,13 @@ async def _get_workspace_aggregates(
for (membership, ws), (project_count, member_count, usage, previews) in zip(
valid_memberships, all_aggregates, strict=True
):
+ org_id = ws.get("org_id", "")
+ raw_role = membership.get("role", "")
+ source = membership.get("source", "")
+ is_external_access = raw_role == "external" or (
+ source == "direct" and org_id not in internal_org_ids
+ )
+ role = "external" if is_external_access else raw_role
# Fill in matrix §8 cap signals on the usage object so card-level
# rendering doesn't need to join tier → cap client-side.
tier = ws.get("tier") or ""
@@ -362,13 +372,13 @@ async def _get_workspace_aggregates(
WorkspaceSummary(
id=ws["id"],
name=ws.get("name", ""),
- org_id=ws.get("org_id", ""),
- org_name=org_map.get(ws.get("org_id", ""), ""),
- role=membership.get("role", ""),
+ org_id=org_id,
+ org_name=org_map.get(org_id, ""),
+ role=role,
is_default=ws.get("is_default", False),
tier=ws.get("tier", "pioneer"),
logo_url=ws.get("logo_url"),
- org_logo_url=org_logo_map.get(ws.get("org_id", "")),
+ org_logo_url=org_logo_map.get(org_id),
project_count=project_count,
member_count=member_count,
members_preview=previews,