diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx index 286c5b4..c004375 100644 --- a/src/client/components/Sidebar.tsx +++ b/src/client/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; -import { ChevronsUpDown, X } from "lucide-react"; +import { X } from "lucide-react"; import { getProjectNavGroups } from "@/client/navigation/items"; +import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher"; interface SidebarProps { projectId: string; @@ -29,15 +30,11 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) { {/* Project picker */}
-
- -
+
{/* Navigation */} diff --git a/src/client/features/gsc/GscReEngagementModal.tsx b/src/client/features/gsc/GscReEngagementModal.tsx index 6d3511e..76a3217 100644 --- a/src/client/features/gsc/GscReEngagementModal.tsx +++ b/src/client/features/gsc/GscReEngagementModal.tsx @@ -87,10 +87,10 @@ export function GscReEngagementModal({ // screen, and on return they'll either have a grant (which suppresses this // anyway) or have abandoned it — neither case should re-nag. persistDismiss(); - // Land them on the project's Integrations page so they can pick a property + // Land them on the project's settings page so they can pick a property // right after granting access (the grant alone has no property bound yet). const callbackURL = projectId - ? `${window.location.origin}/p/${projectId}/integrations` + ? `${window.location.origin}/p/${projectId}/settings#search-console` : window.location.href; void startGscLink(callbackURL); } diff --git a/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx b/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx index 68fc37a..e2b776d 100644 --- a/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx +++ b/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx @@ -13,19 +13,20 @@ import { listGscSites, setGscSite, } from "@/serverFunctions/gsc"; -import { getOrCreateDefaultProject } from "@/serverFunctions/projects"; +import { getProjects } from "@/serverFunctions/projects"; /** * Onboarding step for connecting Google Search Console: link the account-level - * OAuth grant, then bind a verified property to the user's default project — + * OAuth grant, then bind a verified property to the user's first project — * the same binding the project's Integrations page does — so it's done in one * place. */ export function SearchConsoleOnboardingStep() { - const projectQuery = useQuery({ - queryKey: ["defaultProject"], - queryFn: () => getOrCreateDefaultProject(), + const projectsQuery = useQuery({ + queryKey: ["projects"], + queryFn: () => getProjects(), }); + const projectId = projectsQuery.data?.[0]?.id; return (
@@ -33,11 +34,7 @@ export function SearchConsoleOnboardingStep() { Connect with Google Search Console now? - {projectQuery.data ? ( - - ) : ( - - )} + {projectId ? : }

For now, Search Console data flows through the OpenSEO MCP. We're diff --git a/src/client/features/projects/CreateProjectModal.tsx b/src/client/features/projects/CreateProjectModal.tsx new file mode 100644 index 0000000..23f248e --- /dev/null +++ b/src/client/features/projects/CreateProjectModal.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Modal } from "@/client/components/Modal"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { setLastProjectId } from "@/client/lib/active-project"; +import { createProject } from "@/serverFunctions/projects"; + +export function CreateProjectModal({ onClose }: { onClose: () => void }) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [name, setName] = React.useState(""); + const [domain, setDomain] = React.useState(""); + + const createMutation = useMutation({ + mutationFn: () => + createProject({ + data: { name: name.trim(), domain: domain.trim() || undefined }, + }), + onSuccess: async (created) => { + setLastProjectId(created.id); + await queryClient.invalidateQueries({ queryKey: ["projects"] }); + onClose(); + toast.success("Project created"); + // Land on the new project's settings so they can connect Search Console + // and finish setting up the workspace. + void navigate({ + to: "/p/$projectId/settings", + params: { projectId: created.id }, + }); + }, + onError: (error) => + toast.error(getStandardErrorMessage(error, "Failed to create project")), + }); + + const isPending = createMutation.isPending; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (isPending) return; + if (!name.trim()) { + toast.error("Project name is required"); + return; + } + createMutation.mutate(); + }; + + return ( + +

+

+ New project +

+ + + + + +
+ + +
+
+ + ); +} diff --git a/src/client/features/projects/ProjectSettings.tsx b/src/client/features/projects/ProjectSettings.tsx new file mode 100644 index 0000000..4383fd8 --- /dev/null +++ b/src/client/features/projects/ProjectSettings.tsx @@ -0,0 +1,225 @@ +import * as React from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronLeft } from "lucide-react"; +import { toast } from "sonner"; +import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { + clearLastProjectId, + getLastProjectId, +} from "@/client/lib/active-project"; +import { + deleteProject, + getProjects, + updateProject, +} from "@/serverFunctions/projects"; +import type { ProjectSummary } from "./types"; + +export function ProjectSettings({ projectId }: { projectId: string }) { + const projectsQuery = useQuery({ + queryKey: ["projects"], + queryFn: () => getProjects(), + }); + const projects = projectsQuery.data ?? []; + const project = projects.find((entry) => entry.id === projectId) ?? null; + + if (!project) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + Projects + +
+

+ Project settings +

+

{project.name}

+
+
+ + {/* key resets the form's local state when switching between projects */} + + +
+

+ Search Console +

+ +
+ + 1} /> +
+ ); +} + +function GeneralSection({ project }: { project: ProjectSummary }) { + const queryClient = useQueryClient(); + const [name, setName] = React.useState(project.name); + const [domain, setDomain] = React.useState(project.domain ?? ""); + + const updateMutation = useMutation({ + mutationFn: () => + updateProject({ + data: { + projectId: project.id, + name: name.trim(), + domain: domain.trim() || undefined, + }, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.success("Project updated"); + }, + onError: (error) => + toast.error(getStandardErrorMessage(error, "Failed to update project")), + }); + + const isDirty = + name.trim() !== project.name || + (domain.trim() || "") !== (project.domain ?? ""); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (updateMutation.isPending) return; + if (!name.trim()) { + toast.error("Project name is required"); + return; + } + updateMutation.mutate(); + }; + + return ( +
+

General

+
+ + + + +
+ +
+
+
+ ); +} + +function DangerSection({ + project, + canDelete, +}: { + project: ProjectSummary; + canDelete: boolean; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [confirming, setConfirming] = React.useState(false); + + const deleteMutation = useMutation({ + mutationFn: () => deleteProject({ data: { projectId: project.id } }), + onSuccess: async () => { + if (getLastProjectId() === project.id) clearLastProjectId(); + await queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.success("Project deleted"); + // Re-resolve to a remaining project via the landing redirect. + void navigate({ to: "/" }); + }, + onError: (error) => + toast.error(getStandardErrorMessage(error, "Failed to delete project")), + }); + + return ( +
+

+ Delete project +

+ + {confirming ? ( +
+

+ Deleting{" "} + + {project.name} + {" "} + permanently removes its Search Console connection, rank tracking, + audits, and saved keywords. This can't be undone. +

+
+ + +
+
+ ) : ( +
+

+ {canDelete + ? "Permanently delete this project and all of its data." + : "You can't delete your only project."} +

+ +
+ )} +
+ ); +} diff --git a/src/client/features/projects/ProjectSwitcher.tsx b/src/client/features/projects/ProjectSwitcher.tsx new file mode 100644 index 0000000..29af615 --- /dev/null +++ b/src/client/features/projects/ProjectSwitcher.tsx @@ -0,0 +1,124 @@ +import { Link, useNavigate } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { Check, ChevronsUpDown, FolderCog } from "lucide-react"; +import { getProjects } from "@/serverFunctions/projects"; +import { setLastProjectId } from "@/client/lib/active-project"; +import type { ProjectSummary } from "./types"; + +function closeDropdown() { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } +} + +export function ProjectSwitcher({ + activeProjectId, + variant = "topbar", + onCloseDrawer, +}: { + activeProjectId: string | null; + variant?: "topbar" | "sidebar"; + // Mobile sidebar passes this so switching / navigating away also closes the + // drawer overlay. + onCloseDrawer?: () => void; +}) { + const navigate = useNavigate(); + const projectsQuery = useQuery({ + queryKey: ["projects"], + queryFn: () => getProjects(), + }); + const projects = projectsQuery.data ?? []; + const activeProject = + projects.find((project) => project.id === activeProjectId) ?? null; + + const isSidebar = variant === "sidebar"; + + const handleSelect = (project: ProjectSummary) => { + closeDropdown(); + onCloseDrawer?.(); + if (project.id === activeProjectId) return; + setLastProjectId(project.id); + void navigate({ + to: "/p/$projectId/keywords", + params: { projectId: project.id }, + }); + }; + + return ( +
+ + +
    + {projects.map((project) => { + const isActive = project.id === activeProjectId; + return ( +
  • + +
  • + ); + })} + + {projects.length > 0 ? ( +
  • +
    +
  • + ) : null} + +
  • + { + closeDropdown(); + onCloseDrawer?.(); + }} + > + + Manage projects + +
  • +
+
+ ); +} diff --git a/src/client/features/projects/types.ts b/src/client/features/projects/types.ts new file mode 100644 index 0000000..22d3a49 --- /dev/null +++ b/src/client/features/projects/types.ts @@ -0,0 +1,7 @@ +// Shape returned by the getProjects server function (a mapped project row). +export type ProjectSummary = { + id: string; + name: string; + domain: string | null; + createdAt: string; +}; diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx index bd041f9..1e921f3 100644 --- a/src/client/layout/AppShell.tsx +++ b/src/client/layout/AppShell.tsx @@ -3,11 +3,10 @@ import { Link, useLocation } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { ChevronDown, - ChevronsUpDown, CircleHelp, CreditCard, + FolderCog, Menu, - Plug, Settings, User, } from "lucide-react"; @@ -19,13 +18,15 @@ import { import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal"; import { getProjectNavGroups, - integrationsLinkOptions, + projectSettingsLinkOptions, } from "@/client/navigation/items"; import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { BILLING_ROUTE } from "@/shared/billing"; import { getSeoApiKeyStatus } from "@/serverFunctions/config"; -import { getOrCreateDefaultProject } from "@/serverFunctions/projects"; +import { getProjects } from "@/serverFunctions/projects"; +import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher"; +import { getLastProjectId } from "@/client/lib/active-project"; const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key"; const SUPPORT_PATH = "/support"; @@ -44,12 +45,27 @@ export function AuthenticatedAppLayout({ const setupModalRef = React.useRef(null); const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] = React.useState(false); - const defaultProjectQuery = useQuery({ - queryKey: ["defaultProject"], - queryFn: () => getOrCreateDefaultProject(), + // On non-project pages (e.g. /settings) there's no projectId in the URL, so + // derive one for the nav/switcher: prefer the last-visited project, else the + // most recent. Reading localStorage in an effect keeps SSR/first render stable. + const projectsQuery = useQuery({ + queryKey: ["projects"], + queryFn: () => getProjects(), enabled: !projectId, }); - const headerProjectId = projectId ?? defaultProjectQuery.data?.id ?? null; + const [rememberedProjectId, setRememberedProjectId] = React.useState< + string | null + >(null); + React.useEffect(() => { + setRememberedProjectId(getLastProjectId()); + }, []); + const fallbackProjects = projectsQuery.data ?? []; + const fallbackProjectId = + fallbackProjects.find((project) => project.id === rememberedProjectId) + ?.id ?? + fallbackProjects[0]?.id ?? + null; + const headerProjectId = projectId ?? fallbackProjectId; const shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE; const seoApiKeyStatusQuery = useQuery({ queryKey: ["seoApiKeyStatus"], @@ -284,21 +300,7 @@ function TopNav({
-
- -
+
@@ -363,11 +365,11 @@ function AccountMenu({ {projectId ? (
  • - - Integrations + + Project settings
  • ) : null} diff --git a/src/client/lib/active-project.ts b/src/client/lib/active-project.ts new file mode 100644 index 0000000..5905fe8 --- /dev/null +++ b/src/client/lib/active-project.ts @@ -0,0 +1,32 @@ +// Remembers the last project the user was looking at so the app can return them +// there on the next visit. Browser-local only (per-device); the server never +// trusts it — landing and the route guard always re-validate the id against the +// org's project list. +const LAST_PROJECT_KEY = "openseo:lastProjectId"; + +export function getLastProjectId(): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(LAST_PROJECT_KEY); + } catch { + return null; + } +} + +export function setLastProjectId(projectId: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(LAST_PROJECT_KEY, projectId); + } catch { + // Ignore private-mode / disabled-storage failures. + } +} + +export function clearLastProjectId(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(LAST_PROJECT_KEY); + } catch { + // Ignore private-mode / disabled-storage failures. + } +} diff --git a/src/client/navigation/items.ts b/src/client/navigation/items.ts index 74158fc..6ee75ba 100644 --- a/src/client/navigation/items.ts +++ b/src/client/navigation/items.ts @@ -120,12 +120,11 @@ export function getProjectNavGroups(projectId: string) { ]; } -// Integrations is a setup surface, so it lives in the account dropdown rather -// than the workflow nav — but it's still project-scoped (a property is bound -// per project), hence the projectId param. -export function integrationsLinkOptions(projectId: string) { +// Per-project configuration (name, domain, Search Console, delete) lives on the +// project settings page rather than the workflow nav, hence the projectId param. +export function projectSettingsLinkOptions(projectId: string) { return linkOptions({ - to: "/p/$projectId/integrations" as const, + to: "/p/$projectId/settings" as const, params: { projectId }, }); } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0a1117b..bc5f533 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AppSupportRouteImport } from './routes/_app/support' import { Route as AppSettingsRouteImport } from './routes/_app/settings' +import { Route as AppProjectsRouteImport } from './routes/_app/projects' import { Route as AppBillingRouteImport } from './routes/_app/billing' import { Route as AppAiRouteImport } from './routes/_app/ai' import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge' @@ -33,11 +34,11 @@ import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/ import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback' +import { Route as ProjectPProjectIdSettingsRouteImport } from './routes/_project/p/$projectId/settings' import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved' import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking' import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer' import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords' -import { Route as ProjectPProjectIdIntegrationsRouteImport } from './routes/_project/p/$projectId/integrations' import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain' import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup' import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks' @@ -119,6 +120,11 @@ const AppSettingsRoute = AppSettingsRouteImport.update({ path: '/settings', getParentRoute: () => AppRouteRoute, } as any) +const AppProjectsRoute = AppProjectsRouteImport.update({ + id: '/projects', + path: '/projects', + getParentRoute: () => AppRouteRoute, +} as any) const AppBillingRoute = AppBillingRouteImport.update({ id: '/billing', path: '/billing', @@ -165,6 +171,12 @@ const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({ path: '/api/gsc/oauth/callback', getParentRoute: () => rootRouteImport, } as any) +const ProjectPProjectIdSettingsRoute = + ProjectPProjectIdSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => ProjectPProjectIdRouteRoute, + } as any) const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({ id: '/saved', path: '/saved', @@ -188,12 +200,6 @@ const ProjectPProjectIdKeywordsRoute = path: '/keywords', getParentRoute: () => ProjectPProjectIdRouteRoute, } as any) -const ProjectPProjectIdIntegrationsRoute = - ProjectPProjectIdIntegrationsRouteImport.update({ - id: '/integrations', - path: '/integrations', - getParentRoute: () => ProjectPProjectIdRouteRoute, - } as any) const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({ id: '/domain', path: '/domain', @@ -249,6 +255,7 @@ export interface FileRoutesByFullPath { '/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute '/ai': typeof AppAiRoute '/billing': typeof AppBillingRoute + '/projects': typeof AppProjectsRoute '/settings': typeof AppSettingsRoute '/support': typeof AppSupportRoute '/sign-in': typeof AuthSignInRoute @@ -264,11 +271,11 @@ export interface FileRoutesByFullPath { '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute - '/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute + '/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute @@ -284,6 +291,7 @@ export interface FileRoutesByTo { '/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute '/ai': typeof AppAiRoute '/billing': typeof AppBillingRoute + '/projects': typeof AppProjectsRoute '/settings': typeof AppSettingsRoute '/support': typeof AppSupportRoute '/sign-in': typeof AuthSignInRoute @@ -297,10 +305,10 @@ export interface FileRoutesByTo { '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute - '/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute + '/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/p/$projectId': typeof ProjectPProjectIdIndexRoute '/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute @@ -320,6 +328,7 @@ export interface FileRoutesById { '/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute '/_app/ai': typeof AppAiRoute '/_app/billing': typeof AppBillingRoute + '/_app/projects': typeof AppProjectsRoute '/_app/settings': typeof AppSettingsRoute '/_app/support': typeof AppSupportRoute '/_auth/sign-in': typeof AuthSignInRoute @@ -336,11 +345,11 @@ export interface FileRoutesById { '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute - '/_project/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute '/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute + '/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute @@ -358,6 +367,7 @@ export interface FileRouteTypes { | '/.well-known/openai-apps-challenge' | '/ai' | '/billing' + | '/projects' | '/settings' | '/support' | '/sign-in' @@ -373,11 +383,11 @@ export interface FileRouteTypes { | '/p/$projectId/backlinks' | '/p/$projectId/brand-lookup' | '/p/$projectId/domain' - | '/p/$projectId/integrations' | '/p/$projectId/keywords' | '/p/$projectId/prompt-explorer' | '/p/$projectId/rank-tracking' | '/p/$projectId/saved' + | '/p/$projectId/settings' | '/api/gsc/oauth/callback' | '/p/$projectId/' | '/p/$projectId/rank-tracking/$configId' @@ -393,6 +403,7 @@ export interface FileRouteTypes { | '/.well-known/openai-apps-challenge' | '/ai' | '/billing' + | '/projects' | '/settings' | '/support' | '/sign-in' @@ -406,10 +417,10 @@ export interface FileRouteTypes { | '/p/$projectId/backlinks' | '/p/$projectId/brand-lookup' | '/p/$projectId/domain' - | '/p/$projectId/integrations' | '/p/$projectId/keywords' | '/p/$projectId/prompt-explorer' | '/p/$projectId/saved' + | '/p/$projectId/settings' | '/api/gsc/oauth/callback' | '/p/$projectId' | '/p/$projectId/rank-tracking/$configId' @@ -428,6 +439,7 @@ export interface FileRouteTypes { | '/.well-known/openai-apps-challenge' | '/_app/ai' | '/_app/billing' + | '/_app/projects' | '/_app/settings' | '/_app/support' | '/_auth/sign-in' @@ -444,11 +456,11 @@ export interface FileRouteTypes { | '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/brand-lookup' | '/_project/p/$projectId/domain' - | '/_project/p/$projectId/integrations' | '/_project/p/$projectId/keywords' | '/_project/p/$projectId/prompt-explorer' | '/_project/p/$projectId/rank-tracking' | '/_project/p/$projectId/saved' + | '/_project/p/$projectId/settings' | '/api/gsc/oauth/callback' | '/_project/p/$projectId/' | '/_project/p/$projectId/rank-tracking/$configId' @@ -578,6 +590,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppSettingsRouteImport parentRoute: typeof AppRouteRoute } + '/_app/projects': { + id: '/_app/projects' + path: '/projects' + fullPath: '/projects' + preLoaderRoute: typeof AppProjectsRouteImport + parentRoute: typeof AppRouteRoute + } '/_app/billing': { id: '/_app/billing' path: '/billing' @@ -641,6 +660,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGscOauthCallbackRouteImport parentRoute: typeof rootRouteImport } + '/_project/p/$projectId/settings': { + id: '/_project/p/$projectId/settings' + path: '/settings' + fullPath: '/p/$projectId/settings' + preLoaderRoute: typeof ProjectPProjectIdSettingsRouteImport + parentRoute: typeof ProjectPProjectIdRouteRoute + } '/_project/p/$projectId/saved': { id: '/_project/p/$projectId/saved' path: '/saved' @@ -669,13 +695,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectPProjectIdKeywordsRouteImport parentRoute: typeof ProjectPProjectIdRouteRoute } - '/_project/p/$projectId/integrations': { - id: '/_project/p/$projectId/integrations' - path: '/integrations' - fullPath: '/p/$projectId/integrations' - preLoaderRoute: typeof ProjectPProjectIdIntegrationsRouteImport - parentRoute: typeof ProjectPProjectIdRouteRoute - } '/_project/p/$projectId/domain': { id: '/_project/p/$projectId/domain' path: '/domain' @@ -738,6 +757,7 @@ declare module '@tanstack/react-router' { interface AppRouteRouteChildren { AppAiRoute: typeof AppAiRoute AppBillingRoute: typeof AppBillingRoute + AppProjectsRoute: typeof AppProjectsRoute AppSettingsRoute: typeof AppSettingsRoute AppSupportRoute: typeof AppSupportRoute AppIndexRoute: typeof AppIndexRoute @@ -747,6 +767,7 @@ interface AppRouteRouteChildren { const AppRouteRouteChildren: AppRouteRouteChildren = { AppAiRoute: AppAiRoute, AppBillingRoute: AppBillingRoute, + AppProjectsRoute: AppProjectsRoute, AppSettingsRoute: AppSettingsRoute, AppSupportRoute: AppSupportRoute, AppIndexRoute: AppIndexRoute, @@ -797,11 +818,11 @@ interface ProjectPProjectIdRouteRouteChildren { ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute - ProjectPProjectIdIntegrationsRoute: typeof ProjectPProjectIdIntegrationsRoute ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute + ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRoute ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute } @@ -811,12 +832,12 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren = ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute, ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute, ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute, - ProjectPProjectIdIntegrationsRoute: ProjectPProjectIdIntegrationsRoute, ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute, ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute, ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRouteWithChildren, ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute, + ProjectPProjectIdSettingsRoute: ProjectPProjectIdSettingsRoute, ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute, } diff --git a/src/routes/_app/index.tsx b/src/routes/_app/index.tsx index 81b2424..d4b0c74 100644 --- a/src/routes/_app/index.tsx +++ b/src/routes/_app/index.tsx @@ -1,7 +1,11 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; -import { useMutation } from "@tanstack/react-query"; -import { getOrCreateDefaultProject } from "@/serverFunctions/projects"; +import { useQuery } from "@tanstack/react-query"; +import { getProjects } from "@/serverFunctions/projects"; +import { + clearLastProjectId, + getLastProjectId, +} from "@/client/lib/active-project"; import { getErrorCode, getStandardErrorMessage, @@ -17,19 +21,29 @@ export const Route = createFileRoute("/_app/")({ function IndexRedirect() { const navigate = useNavigate(); - const { mutate, error, isError } = useMutation({ - mutationFn: () => getOrCreateDefaultProject(), - onSuccess: (project) => { - void navigate({ - to: "/p/$projectId/keywords", - params: { projectId: project.id }, - }); - }, + const { data, error, isError, refetch } = useQuery({ + queryKey: ["projects"], + queryFn: () => getProjects(), + retry: false, }); useEffect(() => { - mutate(); - }, [mutate]); + if (!data || data.length === 0) return; + + // localStorage is untrusted — only honor the remembered project if it's + // actually in the org's list; otherwise fall back to the most recent and + // clear the stale id. + const lastProjectId = getLastProjectId(); + const target = data.find((project) => project.id === lastProjectId); + if (lastProjectId && !target) { + clearLastProjectId(); + } + + void navigate({ + to: "/p/$projectId/keywords", + params: { projectId: (target ?? data[0]).id }, + }); + }, [data, navigate]); useEffect(() => { if (getErrorCode(error) !== "PAYMENT_REQUIRED") { @@ -51,7 +65,7 @@ function IndexRedirect() { "An unexpected error occurred. Please check server logs.", )} onRetry={() => { - mutate(); + void refetch(); }} /> @@ -64,7 +78,7 @@ function IndexRedirect() { { - mutate(); + void refetch(); }} /> diff --git a/src/routes/_app/projects.tsx b/src/routes/_app/projects.tsx new file mode 100644 index 0000000..fa1fa53 --- /dev/null +++ b/src/routes/_app/projects.tsx @@ -0,0 +1,90 @@ +import * as React from "react"; +import { Link, createFileRoute } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronRight, Plus } from "lucide-react"; +import { getProjects } from "@/serverFunctions/projects"; +import { getLastProjectId } from "@/client/lib/active-project"; +import { CreateProjectModal } from "@/client/features/projects/CreateProjectModal"; + +export const Route = createFileRoute("/_app/projects")({ + component: ProjectsPage, +}); + +function ProjectsPage() { + const [creating, setCreating] = React.useState(false); + // Read after mount to keep SSR/first render stable. + const [currentProjectId, setCurrentProjectId] = React.useState( + null, + ); + React.useEffect(() => { + setCurrentProjectId(getLastProjectId()); + }, []); + const projectsQuery = useQuery({ + queryKey: ["projects"], + queryFn: () => getProjects(), + }); + const projects = projectsQuery.data ?? []; + + return ( +
    +
    +
    +
    +

    Projects

    +

    + Each project is a separate workspace with its own Search Console, + rank tracking, and audits. +

    +
    + +
    + + {projectsQuery.isLoading ? ( +
    + +
    + ) : ( +
      + {projects.map((project) => ( +
    • + + + + + {project.name} + + {project.id === currentProjectId ? ( + + Current + + ) : null} + + + {project.domain ?? "No domain set"} + + + + +
    • + ))} +
    + )} +
    + + {creating ? ( + setCreating(false)} /> + ) : null} +
    + ); +} diff --git a/src/routes/_project/p/$projectId/integrations.tsx b/src/routes/_project/p/$projectId/integrations.tsx deleted file mode 100644 index 4ef1ff2..0000000 --- a/src/routes/_project/p/$projectId/integrations.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard"; - -export const Route = createFileRoute("/_project/p/$projectId/integrations")({ - component: IntegrationsRoute, -}); - -function IntegrationsRoute() { - const { projectId } = Route.useParams(); - - return ( -
    -
    -

    Integrations

    -

    - Connect your data sources. -

    -
    - -
    - ); -} diff --git a/src/routes/_project/p/$projectId/route.tsx b/src/routes/_project/p/$projectId/route.tsx index bd04235..79b198b 100644 --- a/src/routes/_project/p/$projectId/route.tsx +++ b/src/routes/_project/p/$projectId/route.tsx @@ -1,4 +1,11 @@ -import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { + Outlet, + createFileRoute, + redirect, + useLocation, +} from "@tanstack/react-router"; +import { useEffect } from "react"; +import { setLastProjectId } from "@/client/lib/active-project"; import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard"; import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; @@ -37,6 +44,18 @@ function ProjectLayout() { const authGate = useHostedAuthRouteGuard(); useOnboardingRedirect(); + // Remember this as the last-visited project for the landing redirect. + // Settings is excluded: editing another project's settings is + // administration, not a context switch, so it shouldn't change which + // project the app opens next time. + const isSettingsPage = useLocation({ + select: (l) => l.pathname.endsWith("/settings"), + }); + useEffect(() => { + if (isSettingsPage) return; + setLastProjectId(projectId); + }, [projectId, isSettingsPage]); + if (!authGate.canRenderAuthenticatedContent) { return null; } diff --git a/src/routes/_project/p/$projectId/settings.tsx b/src/routes/_project/p/$projectId/settings.tsx new file mode 100644 index 0000000..7870ccc --- /dev/null +++ b/src/routes/_project/p/$projectId/settings.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ProjectSettings } from "@/client/features/projects/ProjectSettings"; + +export const Route = createFileRoute("/_project/p/$projectId/settings")({ + component: ProjectSettingsRoute, +}); + +function ProjectSettingsRoute() { + const { projectId } = Route.useParams(); + return ( +
    + +
    + ); +} diff --git a/src/server/features/projects/repositories/ProjectRepository.ts b/src/server/features/projects/repositories/ProjectRepository.ts index faed5c0..ebdeeac 100644 --- a/src/server/features/projects/repositories/ProjectRepository.ts +++ b/src/server/features/projects/repositories/ProjectRepository.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, count, desc, eq } from "drizzle-orm"; import { db } from "@/db"; import { projects } from "@/db/schema"; import { AppError } from "@/server/lib/errors"; @@ -10,15 +10,12 @@ async function listProjects(organizationId: string) { }); } -async function getDefaultProjectForOrganization(organizationId: string) { - return db.query.projects.findFirst({ - where: and( - eq(projects.organizationId, organizationId), - eq(projects.name, "Default"), - isNull(projects.domain), - ), - orderBy: [desc(projects.createdAt), desc(projects.id)], - }); +async function countProjects(organizationId: string) { + const [row] = await db + .select({ value: count() }) + .from(projects) + .where(eq(projects.organizationId, organizationId)); + return row?.value ?? 0; } async function getProjectForOrganization( @@ -33,25 +30,40 @@ async function getProjectForOrganization( }); } -async function getProjectById(projectId: string) { - return db.query.projects.findFirst({ - where: eq(projects.id, projectId), - }); -} - async function createProject( organizationId: string, name: string, domain?: string, ) { const id = crypto.randomUUID(); - await db.insert(projects).values({ - id, - organizationId, - name, - domain, - }); - return id; + const [row] = await db + .insert(projects) + .values({ id, organizationId, name, domain }) + .returning(); + return row; +} + +async function updateProject( + projectId: string, + organizationId: string, + input: { name: string; domain?: string }, +) { + const [row] = await db + .update(projects) + .set({ name: input.name, domain: input.domain ?? null }) + .where( + and( + eq(projects.id, projectId), + eq(projects.organizationId, organizationId), + ), + ) + .returning(); + + if (!row) { + throw new AppError("NOT_FOUND"); + } + + return row; } async function tryCreateDefaultProject(organizationId: string) { @@ -87,10 +99,10 @@ async function deleteProject(projectId: string, organizationId: string) { export const ProjectRepository = { listProjects, - getDefaultProjectForOrganization, + countProjects, getProjectForOrganization, - getProjectById, createProject, + updateProject, tryCreateDefaultProject, deleteProject, } as const; diff --git a/src/server/features/projects/services/ProjectService.ts b/src/server/features/projects/services/ProjectService.ts index 6adbea6..3e49494 100644 --- a/src/server/features/projects/services/ProjectService.ts +++ b/src/server/features/projects/services/ProjectService.ts @@ -1,17 +1,17 @@ import { createProject, deleteProject, - getOrCreateDefaultProject, - getProject, getProjectForOrganization, listProjects, + listProjectsEnsuringOne, + updateProject, } from "@/server/features/projects/services/projects"; export const ProjectService = { listProjects, + listProjectsEnsuringOne, createProject, + updateProject, deleteProject, - getOrCreateDefaultProject, - getProject, getProjectForOrganization, } as const; diff --git a/src/server/features/projects/services/projects.test.ts b/src/server/features/projects/services/projects.test.ts index 75301b4..9a4af00 100644 --- a/src/server/features/projects/services/projects.test.ts +++ b/src/server/features/projects/services/projects.test.ts @@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ createProject: vi.fn(), + updateProject: vi.fn(), deleteProject: vi.fn(), - getDefaultProjectForOrganization: vi.fn(), - getProjectById: vi.fn(), + countProjects: vi.fn(), getProjectForOrganization: vi.fn(), listProjects: vi.fn(), tryCreateDefaultProject: vi.fn(), @@ -21,33 +21,148 @@ const defaultProject = { createdAt: "2026-05-19 12:00:00", }; +const namedProject = { + id: "project_acme", + name: "Acme", + domain: "acme.com", + createdAt: "2026-05-20 12:00:00", +}; + describe("project service", () => { beforeEach(() => { vi.resetModules(); for (const mock of Object.values(mocks)) mock.mockReset(); }); - it("recovers from the default project creation race", async () => { - mocks.getDefaultProjectForOrganization - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(defaultProject); - mocks.tryCreateDefaultProject.mockResolvedValue(null); - const { getOrCreateDefaultProject } = await import("./projects"); + describe("listProjectsEnsuringOne", () => { + it("returns existing projects without creating a Default", async () => { + mocks.listProjects.mockResolvedValue([namedProject]); + const { listProjectsEnsuringOne } = await import("./projects"); - await expect(getOrCreateDefaultProject("org_1")).resolves.toEqual( - defaultProject, - ); - expect(mocks.tryCreateDefaultProject).toHaveBeenCalledWith("org_1"); - expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(2); + await expect(listProjectsEnsuringOne("org_1")).resolves.toEqual([ + namedProject, + ]); + expect(mocks.tryCreateDefaultProject).not.toHaveBeenCalled(); + expect(mocks.listProjects).toHaveBeenCalledTimes(1); + }); + + it("creates a Default when the org has no projects", async () => { + mocks.listProjects + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([defaultProject]); + mocks.tryCreateDefaultProject.mockResolvedValue("project_default"); + const { listProjectsEnsuringOne } = await import("./projects"); + + await expect(listProjectsEnsuringOne("org_1")).resolves.toEqual([ + defaultProject, + ]); + expect(mocks.tryCreateDefaultProject).toHaveBeenCalledWith("org_1"); + expect(mocks.listProjects).toHaveBeenCalledTimes(2); + }); + + it("recovers from the Default creation race", async () => { + mocks.listProjects + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([defaultProject]); + // A racing request won the insert, so this call's onConflictDoNothing + // returns null — but the re-list still finds the Default. + mocks.tryCreateDefaultProject.mockResolvedValue(null); + const { listProjectsEnsuringOne } = await import("./projects"); + + await expect(listProjectsEnsuringOne("org_1")).resolves.toEqual([ + defaultProject, + ]); + }); }); - it("does not swallow unrelated default project create failures", async () => { - const error = new Error("D1 unavailable"); - mocks.getDefaultProjectForOrganization.mockResolvedValue(null); - mocks.tryCreateDefaultProject.mockRejectedValue(error); - const { getOrCreateDefaultProject } = await import("./projects"); + describe("createProject", () => { + it("returns the full created project", async () => { + mocks.createProject.mockResolvedValue(namedProject); + const { createProject } = await import("./projects"); - await expect(getOrCreateDefaultProject("org_1")).rejects.toBe(error); - expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(1); + await expect( + createProject("org_1", { name: "Acme", domain: "acme.com" }), + ).resolves.toEqual(namedProject); + expect(mocks.createProject).toHaveBeenCalledWith( + "org_1", + "Acme", + "acme.com", + ); + }); + + it("maps the reserved Default conflict to a friendly CONFLICT", async () => { + mocks.createProject.mockRejectedValue( + new Error( + "UNIQUE constraint failed: projects.projects_one_default_per_organization_idx", + ), + ); + const { createProject } = await import("./projects"); + + await expect( + createProject("org_1", { name: "Default", domain: undefined }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + }); + + describe("updateProject", () => { + it("returns the updated project", async () => { + mocks.updateProject.mockResolvedValue(namedProject); + const { updateProject } = await import("./projects"); + + await expect( + updateProject("org_1", { + projectId: "project_acme", + name: "Acme", + domain: "acme.com", + }), + ).resolves.toEqual(namedProject); + expect(mocks.updateProject).toHaveBeenCalledWith( + "project_acme", + "org_1", + { name: "Acme", domain: "acme.com" }, + ); + }); + + it("clears the domain when none is provided", async () => { + const cleared = { ...namedProject, domain: null }; + mocks.updateProject.mockResolvedValue(cleared); + const { updateProject } = await import("./projects"); + + await expect( + updateProject("org_1", { + projectId: "project_acme", + name: "Acme", + domain: undefined, + }), + ).resolves.toEqual(cleared); + expect(mocks.updateProject).toHaveBeenCalledWith( + "project_acme", + "org_1", + { name: "Acme", domain: undefined }, + ); + }); + }); + + describe("deleteProject", () => { + it("refuses to delete the org's only project", async () => { + mocks.countProjects.mockResolvedValue(1); + const { deleteProject } = await import("./projects"); + + await expect( + deleteProject("org_1", { projectId: "project_default" }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mocks.deleteProject).not.toHaveBeenCalled(); + }); + + it("deletes when more than one project remains", async () => { + mocks.countProjects.mockResolvedValue(2); + mocks.deleteProject.mockResolvedValue(undefined); + const { deleteProject } = await import("./projects"); + + await expect( + deleteProject("org_1", { projectId: "project_acme" }), + ).resolves.toEqual({ success: true }); + expect(mocks.deleteProject).toHaveBeenCalledWith("project_acme", "org_1"); + }); }); }); diff --git a/src/server/features/projects/services/projects.ts b/src/server/features/projects/services/projects.ts index 8a293f0..8203918 100644 --- a/src/server/features/projects/services/projects.ts +++ b/src/server/features/projects/services/projects.ts @@ -1,6 +1,7 @@ import type { CreateProjectInput, DeleteProjectInput, + UpdateProjectInput, } from "@/types/schemas/projects"; import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; import { AppError } from "@/server/lib/errors"; @@ -19,66 +20,94 @@ function mapProject(project: { }; } +// The projects table's only unique index guards the auto-created ("Default", +// null) singleton. A UNIQUE violation while writing exactly that name/domain +// therefore means one already exists — gating on the input (not just the error +// string) keeps this from misclassifying any unrelated failure. +function isReservedDefaultConflict( + error: unknown, + input: { name: string; domain?: string }, +) { + return ( + input.name === "Default" && + !input.domain && + error instanceof Error && + error.message.includes("UNIQUE constraint failed") + ); +} + +const RESERVED_DEFAULT_MESSAGE = + 'A project named "Default" with no domain already exists. Pick a different name or add a domain.'; + export async function listProjects(organizationId: string) { const rows = await ProjectRepository.listProjects(organizationId); return rows.map(mapProject); } +// Source of truth for "which projects does this org have", guaranteeing at least +// one. Count-based — never matches on the "Default" name — so renaming the last +// project does not cause a spurious second Default to be created on next visit. +export async function listProjectsEnsuringOne(organizationId: string) { + const existing = await listProjects(organizationId); + if (existing.length > 0) { + return existing; + } + + await ProjectRepository.tryCreateDefaultProject(organizationId); + return listProjects(organizationId); +} + export async function createProject( organizationId: string, input: CreateProjectInput, ) { - const id = await ProjectRepository.createProject( - organizationId, - input.name, - input.domain, - ); - return { id }; + try { + const row = await ProjectRepository.createProject( + organizationId, + input.name, + input.domain, + ); + return mapProject(row); + } catch (error) { + if (isReservedDefaultConflict(error, input)) { + throw new AppError("CONFLICT", RESERVED_DEFAULT_MESSAGE); + } + throw error; + } +} + +export async function updateProject( + organizationId: string, + input: UpdateProjectInput, +) { + try { + const row = await ProjectRepository.updateProject( + input.projectId, + organizationId, + { name: input.name, domain: input.domain }, + ); + return mapProject(row); + } catch (error) { + if (isReservedDefaultConflict(error, input)) { + throw new AppError("CONFLICT", RESERVED_DEFAULT_MESSAGE); + } + throw error; + } } export async function deleteProject( organizationId: string, input: DeleteProjectInput, ) { + const remaining = await ProjectRepository.countProjects(organizationId); + if (remaining <= 1) { + throw new AppError("CONFLICT", "You can't delete your only project."); + } + await ProjectRepository.deleteProject(input.projectId, organizationId); return { success: true }; } -export async function getOrCreateDefaultProject(organizationId: string) { - const existing = - await ProjectRepository.getDefaultProjectForOrganization(organizationId); - if (existing) { - return mapProject(existing); - } - - const id = await ProjectRepository.tryCreateDefaultProject(organizationId); - if (id) { - return { - id, - name: "Default", - domain: null, - createdAt: new Date().toISOString(), - }; - } - - const createdProject = - await ProjectRepository.getDefaultProjectForOrganization(organizationId); - if (createdProject) { - return mapProject(createdProject); - } - - throw new AppError("INTERNAL_ERROR"); -} - -export async function getProject(projectId: string) { - const project = await ProjectRepository.getProjectById(projectId); - if (!project) { - throw new AppError("NOT_FOUND"); - } - - return mapProject(project); -} - export async function getProjectForOrganization( organizationId: string, projectId: string, diff --git a/src/server/mcp/tools/search-console-tools.test.ts b/src/server/mcp/tools/search-console-tools.test.ts index 9c8f9df..4f0da1a 100644 --- a/src/server/mcp/tools/search-console-tools.test.ts +++ b/src/server/mcp/tools/search-console-tools.test.ts @@ -161,7 +161,7 @@ describe("search console MCP tools", () => { const first = result.content[0]; expect(first.type).toBe("text"); expect(first.type === "text" && first.text).toContain( - "/p/project_1/integrations", + "/p/project_1/settings", ); }); @@ -183,7 +183,7 @@ describe("search console MCP tools", () => { }); const first = result.content[0]; expect(first.type === "text" && first.text).toContain( - "/p/project_1/integrations", + "/p/project_1/settings", ); }); diff --git a/src/server/mcp/tools/search-console-tools.ts b/src/server/mcp/tools/search-console-tools.ts index 28521ed..4b9c4fa 100644 --- a/src/server/mcp/tools/search-console-tools.ts +++ b/src/server/mcp/tools/search-console-tools.ts @@ -31,8 +31,8 @@ type ProjectAuthContext = { baseUrl: string; }; -function integrationsUrl(baseUrl: string, projectId: string): string { - return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`); +function connectGscUrl(baseUrl: string, projectId: string): string { + return buildDashboardUrl(baseUrl, `/p/${projectId}/settings#search-console`); } /** Self-hosted GSC requires the operator to provide a Google OAuth client and @@ -49,7 +49,7 @@ async function missingSelfHostedGoogleClientResponse( if (hosted || configured) return null; return mcpResponse({ - text: `This self-hosted OpenSEO deployment is not configured for Search Console yet. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET, then reconnect Search Console from Integrations. Setup docs: ${GSC_SELF_HOSTED_SETUP_DOCS_URL}`, + text: `This self-hosted OpenSEO deployment is not configured for Search Console yet. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET, then reconnect Search Console from the project's settings page. Setup docs: ${GSC_SELF_HOSTED_SETUP_DOCS_URL}`, meta: buildProjectMeta(context, projectId), structuredContent: { ok: false, @@ -196,11 +196,11 @@ export const getSearchConsolePerformanceTool = { ); if (blocked) return blocked; - const connectUrl = integrationsUrl(context.baseUrl, args.projectId); + const connectUrl = connectGscUrl(context.baseUrl, args.projectId); const meta = buildProjectMeta( context, args.projectId, - `/p/${args.projectId}/integrations`, + `/p/${args.projectId}/settings`, ); // GSC rejects searchAppearance combined with any other dimension. @@ -337,11 +337,11 @@ export const inspectUrlsTool = { ); if (blocked) return blocked; - const connectUrl = integrationsUrl(context.baseUrl, args.projectId); + const connectUrl = connectGscUrl(context.baseUrl, args.projectId); const meta = buildProjectMeta( context, args.projectId, - `/p/${args.projectId}/integrations`, + `/p/${args.projectId}/settings`, ); try { diff --git a/src/serverFunctions/projects.ts b/src/serverFunctions/projects.ts index ecd3045..2e72439 100644 --- a/src/serverFunctions/projects.ts +++ b/src/serverFunctions/projects.ts @@ -1,12 +1,41 @@ import { createServerFn } from "@tanstack/react-start"; import { ProjectService } from "@/server/features/projects/services/ProjectService"; -import { requireAuthenticatedContext } from "@/serverFunctions/middleware"; +import { + requireAuthenticatedContext, + requireProjectContext, +} from "@/serverFunctions/middleware"; +import { + createProjectSchema, + deleteProjectSchema, + updateProjectSchema, +} from "@/types/schemas/projects"; import { z } from "zod"; -export const getOrCreateDefaultProject = createServerFn({ method: "POST" }) +export const getProjects = createServerFn({ method: "POST" }) .middleware(requireAuthenticatedContext) .handler(async ({ context }) => - ProjectService.getOrCreateDefaultProject(context.organizationId), + ProjectService.listProjectsEnsuringOne(context.organizationId), + ); + +export const createProject = createServerFn({ method: "POST" }) + .middleware(requireAuthenticatedContext) + .inputValidator((data: unknown) => createProjectSchema.parse(data)) + .handler(async ({ data, context }) => + ProjectService.createProject(context.organizationId, data), + ); + +export const updateProject = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => updateProjectSchema.parse(data)) + .handler(async ({ data, context }) => + ProjectService.updateProject(context.organizationId, data), + ); + +export const deleteProject = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => deleteProjectSchema.parse(data)) + .handler(async ({ data, context }) => + ProjectService.deleteProject(context.organizationId, data), ); export const getProjectAccess = createServerFn({ method: "POST" }) diff --git a/src/types/schemas/projects.ts b/src/types/schemas/projects.ts index 666a94a..7c9653c 100644 --- a/src/types/schemas/projects.ts +++ b/src/types/schemas/projects.ts @@ -1,13 +1,27 @@ import { z } from "zod"; +const projectNameField = z + .string() + .trim() + .min(1, "Project name is required") + .max(120); + +const projectDomainField = z + .string() + .trim() + .max(255) + .transform((value) => value || undefined) + .optional(); + export const createProjectSchema = z.object({ - name: z.string().min(1, "Project name is required").max(120), - domain: z - .string() - .trim() - .max(255) - .transform((value) => value || undefined) - .optional(), + name: projectNameField, + domain: projectDomainField, +}); + +export const updateProjectSchema = z.object({ + projectId: z.string().min(1), + name: projectNameField, + domain: projectDomainField, }); export const deleteProjectSchema = z.object({ @@ -15,4 +29,5 @@ export const deleteProjectSchema = z.object({ }); export type CreateProjectInput = z.infer; +export type UpdateProjectInput = z.infer; export type DeleteProjectInput = z.infer;