Multi-project support per organization (#246)

This commit is contained in:
Ben Senescu 2026-06-09 20:12:09 -04:00 committed by GitHub
parent e16f8d5647
commit 076a7fb6d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1052 additions and 224 deletions

View File

@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { ChevronsUpDown, X } from "lucide-react"; import { X } from "lucide-react";
import { getProjectNavGroups } from "@/client/navigation/items"; import { getProjectNavGroups } from "@/client/navigation/items";
import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher";
interface SidebarProps { interface SidebarProps {
projectId: string; projectId: string;
@ -29,15 +30,11 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
{/* Project picker */} {/* Project picker */}
<div className="px-3 py-3 border-b border-base-300"> <div className="px-3 py-3 border-b border-base-300">
<div <ProjectSwitcher
className="tooltip tooltip-bottom w-full" activeProjectId={projectId}
data-tip="Multiple projects coming soon" variant="sidebar"
> onCloseDrawer={onNavigate}
<button className="btn btn-ghost btn-sm w-full justify-between font-medium text-sm cursor-default"> />
<span className="truncate">Default</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
</button>
</div>
</div> </div>
{/* Navigation */} {/* Navigation */}

View File

@ -87,10 +87,10 @@ export function GscReEngagementModal({
// screen, and on return they'll either have a grant (which suppresses this // screen, and on return they'll either have a grant (which suppresses this
// anyway) or have abandoned it — neither case should re-nag. // anyway) or have abandoned it — neither case should re-nag.
persistDismiss(); 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). // right after granting access (the grant alone has no property bound yet).
const callbackURL = projectId const callbackURL = projectId
? `${window.location.origin}/p/${projectId}/integrations` ? `${window.location.origin}/p/${projectId}/settings#search-console`
: window.location.href; : window.location.href;
void startGscLink(callbackURL); void startGscLink(callbackURL);
} }

View File

@ -13,19 +13,20 @@ import {
listGscSites, listGscSites,
setGscSite, setGscSite,
} from "@/serverFunctions/gsc"; } from "@/serverFunctions/gsc";
import { getOrCreateDefaultProject } from "@/serverFunctions/projects"; import { getProjects } from "@/serverFunctions/projects";
/** /**
* Onboarding step for connecting Google Search Console: link the account-level * 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 * the same binding the project's Integrations page does — so it's done in one
* place. * place.
*/ */
export function SearchConsoleOnboardingStep() { export function SearchConsoleOnboardingStep() {
const projectQuery = useQuery({ const projectsQuery = useQuery({
queryKey: ["defaultProject"], queryKey: ["projects"],
queryFn: () => getOrCreateDefaultProject(), queryFn: () => getProjects(),
}); });
const projectId = projectsQuery.data?.[0]?.id;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -33,11 +34,7 @@ export function SearchConsoleOnboardingStep() {
Connect with Google Search Console now? Connect with Google Search Console now?
</h2> </h2>
{projectQuery.data ? ( {projectId ? <GscConnect projectId={projectId} /> : <Checking />}
<GscConnect projectId={projectQuery.data.id} />
) : (
<Checking />
)}
<p className="text-xs leading-relaxed text-base-content/55"> <p className="text-xs leading-relaxed text-base-content/55">
For now, Search Console data flows through the OpenSEO MCP. We're For now, Search Console data flows through the OpenSEO MCP. We're

View File

@ -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 (
<Modal
maxWidth="max-w-md"
onClose={isPending ? undefined : onClose}
labelledBy="create-project-title"
>
<form onSubmit={handleSubmit} className="space-y-4">
<h2 id="create-project-title" className="text-lg font-semibold">
New project
</h2>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Name</span>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Acme Inc."
maxLength={120}
autoFocus
className="input input-bordered w-full"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">
Domain <span className="text-base-content/50">(optional)</span>
</span>
<input
type="text"
value={domain}
onChange={(event) => setDomain(event.target.value)}
placeholder="example.com"
maxLength={255}
className="input input-bordered w-full"
/>
<span className="text-xs text-base-content/50">
You can connect Search Console and set up rank tracking after
creating the project.
</span>
</label>
<div className="flex justify-end gap-2">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onClose}
disabled={isPending}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={isPending}
>
Create project
</button>
</div>
</form>
</Modal>
);
}

View File

@ -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 (
<div className="flex h-full items-center justify-center">
<span className="loading loading-spinner loading-md" />
</div>
);
}
return (
<div className="mx-auto w-full max-w-2xl space-y-8 p-4 py-8 sm:p-6 md:py-12">
<div className="space-y-4">
<Link
to="/projects"
className="inline-flex items-center gap-1 text-sm text-base-content/60 transition-colors hover:text-base-content"
>
<ChevronLeft className="size-4" />
Projects
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight">
Project settings
</h1>
<p className="text-sm text-base-content/60">{project.name}</p>
</div>
</div>
{/* key resets the form's local state when switching between projects */}
<GeneralSection key={project.id} project={project} />
<section id="search-console" className="space-y-3 scroll-mt-6">
<h2 className="text-sm font-medium text-base-content/50">
Search Console
</h2>
<SearchConsoleConnectionCard projectId={projectId} />
</section>
<DangerSection project={project} canDelete={projects.length > 1} />
</div>
);
}
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 (
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">General</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Name</span>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={120}
className="input input-bordered w-full"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">
Domain <span className="text-base-content/50">(optional)</span>
</span>
<input
type="text"
value={domain}
onChange={(event) => setDomain(event.target.value)}
placeholder="example.com"
maxLength={255}
className="input input-bordered w-full"
/>
</label>
<div className="flex justify-end">
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={updateMutation.isPending || !isDirty}
>
Save changes
</button>
</div>
</form>
</section>
);
}
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 (
<section className="space-y-3 border-t border-base-300 pt-8">
<h2 className="text-sm font-medium text-base-content/50">
Delete project
</h2>
{confirming ? (
<div className="space-y-3">
<p className="text-sm text-base-content/70">
Deleting{" "}
<span className="font-medium text-base-content">
{project.name}
</span>{" "}
permanently removes its Search Console connection, rank tracking,
audits, and saved keywords. This can't be undone.
</p>
<div className="flex gap-2">
<button
type="button"
className="btn btn-error btn-sm"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
Yes, delete project
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)}
disabled={deleteMutation.isPending}
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-base-content/60">
{canDelete
? "Permanently delete this project and all of its data."
: "You can't delete your only project."}
</p>
<button
type="button"
className="btn btn-outline btn-error btn-sm shrink-0"
onClick={() => setConfirming(true)}
disabled={!canDelete}
>
Delete project
</button>
</div>
)}
</section>
);
}

View File

@ -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 (
<div className={`dropdown ${isSidebar ? "w-full" : "dropdown-end"}`}>
<button
type="button"
tabIndex={0}
aria-label="Switch project"
className={
isSidebar
? "btn btn-ghost btn-sm w-full justify-between font-medium"
: "flex h-10 max-w-[12rem] items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80"
}
>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-base-content">
{activeProject?.name ?? "Select project"}
</span>
{activeProject?.domain ? (
<span className="truncate text-xs font-normal text-base-content/50">
{activeProject.domain}
</span>
) : null}
</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
</button>
<ul
tabIndex={0}
className={`dropdown-content z-30 menu rounded-box border border-base-300 bg-base-100 p-2 shadow-lg ${
isSidebar ? "w-full" : "mt-2 w-64"
}`}
>
{projects.map((project) => {
const isActive = project.id === activeProjectId;
return (
<li key={project.id}>
<button
type="button"
onClick={() => handleSelect(project)}
className={isActive ? "active" : ""}
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{project.name}</span>
{project.domain ? (
<span className="truncate text-xs text-base-content/50">
{project.domain}
</span>
) : null}
</span>
{isActive ? (
<Check className="size-4 shrink-0 text-primary" />
) : null}
</button>
</li>
);
})}
{projects.length > 0 ? (
<li>
<hr className="my-1 border-base-300" />
</li>
) : null}
<li>
<Link
to="/projects"
onClick={() => {
closeDropdown();
onCloseDrawer?.();
}}
>
<FolderCog className="size-4" />
Manage projects
</Link>
</li>
</ul>
</div>
);
}

View File

@ -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;
};

View File

@ -3,11 +3,10 @@ import { Link, useLocation } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
ChevronDown, ChevronDown,
ChevronsUpDown,
CircleHelp, CircleHelp,
CreditCard, CreditCard,
FolderCog,
Menu, Menu,
Plug,
Settings, Settings,
User, User,
} from "lucide-react"; } from "lucide-react";
@ -19,13 +18,15 @@ import {
import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal"; import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal";
import { import {
getProjectNavGroups, getProjectNavGroups,
integrationsLinkOptions, projectSettingsLinkOptions,
} from "@/client/navigation/items"; } from "@/client/navigation/items";
import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { BILLING_ROUTE } from "@/shared/billing"; import { BILLING_ROUTE } from "@/shared/billing";
import { getSeoApiKeyStatus } from "@/serverFunctions/config"; 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 DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key";
const SUPPORT_PATH = "/support"; const SUPPORT_PATH = "/support";
@ -44,12 +45,27 @@ export function AuthenticatedAppLayout({
const setupModalRef = React.useRef<HTMLDivElement | null>(null); const setupModalRef = React.useRef<HTMLDivElement | null>(null);
const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] = const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] =
React.useState(false); React.useState(false);
const defaultProjectQuery = useQuery({ // On non-project pages (e.g. /settings) there's no projectId in the URL, so
queryKey: ["defaultProject"], // derive one for the nav/switcher: prefer the last-visited project, else the
queryFn: () => getOrCreateDefaultProject(), // most recent. Reading localStorage in an effect keeps SSR/first render stable.
const projectsQuery = useQuery({
queryKey: ["projects"],
queryFn: () => getProjects(),
enabled: !projectId, 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 shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE;
const seoApiKeyStatusQuery = useQuery({ const seoApiKeyStatusQuery = useQuery({
queryKey: ["seoApiKeyStatus"], queryKey: ["seoApiKeyStatus"],
@ -284,21 +300,7 @@ function TopNav({
</div> </div>
<div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm"> <div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm">
<div <ProjectSwitcher activeProjectId={projectId} variant="topbar" />
className="tooltip tooltip-left before:whitespace-nowrap"
data-tip="Multiple projects coming soon"
>
<button
type="button"
className="flex h-10 cursor-default items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80"
aria-label="Current project"
>
<span className="max-w-28 truncate text-sm font-medium text-base-content">
Default
</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/35" />
</button>
</div>
<AccountMenu projectId={projectId} /> <AccountMenu projectId={projectId} />
</div> </div>
@ -363,11 +365,11 @@ function AccountMenu({
{projectId ? ( {projectId ? (
<li> <li>
<Link <Link
{...integrationsLinkOptions(projectId)} {...projectSettingsLinkOptions(projectId)}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Plug className="h-4 w-4" /> <FolderCog className="h-4 w-4" />
Integrations Project settings
</Link> </Link>
</li> </li>
) : null} ) : null}

View File

@ -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.
}
}

View File

@ -120,12 +120,11 @@ export function getProjectNavGroups(projectId: string) {
]; ];
} }
// Integrations is a setup surface, so it lives in the account dropdown rather // Per-project configuration (name, domain, Search Console, delete) lives on the
// than the workflow nav — but it's still project-scoped (a property is bound // project settings page rather than the workflow nav, hence the projectId param.
// per project), hence the projectId param. export function projectSettingsLinkOptions(projectId: string) {
export function integrationsLinkOptions(projectId: string) {
return linkOptions({ return linkOptions({
to: "/p/$projectId/integrations" as const, to: "/p/$projectId/settings" as const,
params: { projectId }, params: { projectId },
}); });
} }

View File

@ -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 AuthSignInRouteImport } from './routes/_auth.sign-in'
import { Route as AppSupportRouteImport } from './routes/_app/support' import { Route as AppSupportRouteImport } from './routes/_app/support'
import { Route as AppSettingsRouteImport } from './routes/_app/settings' 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 AppBillingRouteImport } from './routes/_app/billing'
import { Route as AppAiRouteImport } from './routes/_app/ai' import { Route as AppAiRouteImport } from './routes/_app/ai'
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge' 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 ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback' 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 ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking' 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 ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords' 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 ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup' import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks' import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
@ -119,6 +120,11 @@ const AppSettingsRoute = AppSettingsRouteImport.update({
path: '/settings', path: '/settings',
getParentRoute: () => AppRouteRoute, getParentRoute: () => AppRouteRoute,
} as any) } as any)
const AppProjectsRoute = AppProjectsRouteImport.update({
id: '/projects',
path: '/projects',
getParentRoute: () => AppRouteRoute,
} as any)
const AppBillingRoute = AppBillingRouteImport.update({ const AppBillingRoute = AppBillingRouteImport.update({
id: '/billing', id: '/billing',
path: '/billing', path: '/billing',
@ -165,6 +171,12 @@ const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
path: '/api/gsc/oauth/callback', path: '/api/gsc/oauth/callback',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ProjectPProjectIdSettingsRoute =
ProjectPProjectIdSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({ const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
id: '/saved', id: '/saved',
path: '/saved', path: '/saved',
@ -188,12 +200,6 @@ const ProjectPProjectIdKeywordsRoute =
path: '/keywords', path: '/keywords',
getParentRoute: () => ProjectPProjectIdRouteRoute, getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any) } as any)
const ProjectPProjectIdIntegrationsRoute =
ProjectPProjectIdIntegrationsRouteImport.update({
id: '/integrations',
path: '/integrations',
getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any)
const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({ const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({
id: '/domain', id: '/domain',
path: '/domain', path: '/domain',
@ -249,6 +255,7 @@ export interface FileRoutesByFullPath {
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute '/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
'/ai': typeof AppAiRoute '/ai': typeof AppAiRoute
'/billing': typeof AppBillingRoute '/billing': typeof AppBillingRoute
'/projects': typeof AppProjectsRoute
'/settings': typeof AppSettingsRoute '/settings': typeof AppSettingsRoute
'/support': typeof AppSupportRoute '/support': typeof AppSupportRoute
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
@ -264,11 +271,11 @@ export interface FileRoutesByFullPath {
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute '/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
@ -284,6 +291,7 @@ export interface FileRoutesByTo {
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute '/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
'/ai': typeof AppAiRoute '/ai': typeof AppAiRoute
'/billing': typeof AppBillingRoute '/billing': typeof AppBillingRoute
'/projects': typeof AppProjectsRoute
'/settings': typeof AppSettingsRoute '/settings': typeof AppSettingsRoute
'/support': typeof AppSupportRoute '/support': typeof AppSupportRoute
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
@ -297,10 +305,10 @@ export interface FileRoutesByTo {
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/p/$projectId': typeof ProjectPProjectIdIndexRoute '/p/$projectId': typeof ProjectPProjectIdIndexRoute
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute '/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
@ -320,6 +328,7 @@ export interface FileRoutesById {
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute '/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
'/_app/ai': typeof AppAiRoute '/_app/ai': typeof AppAiRoute
'/_app/billing': typeof AppBillingRoute '/_app/billing': typeof AppBillingRoute
'/_app/projects': typeof AppProjectsRoute
'/_app/settings': typeof AppSettingsRoute '/_app/settings': typeof AppSettingsRoute
'/_app/support': typeof AppSupportRoute '/_app/support': typeof AppSupportRoute
'/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-in': typeof AuthSignInRoute
@ -336,11 +345,11 @@ export interface FileRoutesById {
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
'/_project/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute '/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
'/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
'/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute '/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
@ -358,6 +367,7 @@ export interface FileRouteTypes {
| '/.well-known/openai-apps-challenge' | '/.well-known/openai-apps-challenge'
| '/ai' | '/ai'
| '/billing' | '/billing'
| '/projects'
| '/settings' | '/settings'
| '/support' | '/support'
| '/sign-in' | '/sign-in'
@ -373,11 +383,11 @@ export interface FileRouteTypes {
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
| '/p/$projectId/domain' | '/p/$projectId/domain'
| '/p/$projectId/integrations'
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer' | '/p/$projectId/prompt-explorer'
| '/p/$projectId/rank-tracking' | '/p/$projectId/rank-tracking'
| '/p/$projectId/saved' | '/p/$projectId/saved'
| '/p/$projectId/settings'
| '/api/gsc/oauth/callback' | '/api/gsc/oauth/callback'
| '/p/$projectId/' | '/p/$projectId/'
| '/p/$projectId/rank-tracking/$configId' | '/p/$projectId/rank-tracking/$configId'
@ -393,6 +403,7 @@ export interface FileRouteTypes {
| '/.well-known/openai-apps-challenge' | '/.well-known/openai-apps-challenge'
| '/ai' | '/ai'
| '/billing' | '/billing'
| '/projects'
| '/settings' | '/settings'
| '/support' | '/support'
| '/sign-in' | '/sign-in'
@ -406,10 +417,10 @@ export interface FileRouteTypes {
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
| '/p/$projectId/domain' | '/p/$projectId/domain'
| '/p/$projectId/integrations'
| '/p/$projectId/keywords' | '/p/$projectId/keywords'
| '/p/$projectId/prompt-explorer' | '/p/$projectId/prompt-explorer'
| '/p/$projectId/saved' | '/p/$projectId/saved'
| '/p/$projectId/settings'
| '/api/gsc/oauth/callback' | '/api/gsc/oauth/callback'
| '/p/$projectId' | '/p/$projectId'
| '/p/$projectId/rank-tracking/$configId' | '/p/$projectId/rank-tracking/$configId'
@ -428,6 +439,7 @@ export interface FileRouteTypes {
| '/.well-known/openai-apps-challenge' | '/.well-known/openai-apps-challenge'
| '/_app/ai' | '/_app/ai'
| '/_app/billing' | '/_app/billing'
| '/_app/projects'
| '/_app/settings' | '/_app/settings'
| '/_app/support' | '/_app/support'
| '/_auth/sign-in' | '/_auth/sign-in'
@ -444,11 +456,11 @@ export interface FileRouteTypes {
| '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/backlinks'
| '/_project/p/$projectId/brand-lookup' | '/_project/p/$projectId/brand-lookup'
| '/_project/p/$projectId/domain' | '/_project/p/$projectId/domain'
| '/_project/p/$projectId/integrations'
| '/_project/p/$projectId/keywords' | '/_project/p/$projectId/keywords'
| '/_project/p/$projectId/prompt-explorer' | '/_project/p/$projectId/prompt-explorer'
| '/_project/p/$projectId/rank-tracking' | '/_project/p/$projectId/rank-tracking'
| '/_project/p/$projectId/saved' | '/_project/p/$projectId/saved'
| '/_project/p/$projectId/settings'
| '/api/gsc/oauth/callback' | '/api/gsc/oauth/callback'
| '/_project/p/$projectId/' | '/_project/p/$projectId/'
| '/_project/p/$projectId/rank-tracking/$configId' | '/_project/p/$projectId/rank-tracking/$configId'
@ -578,6 +590,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppSettingsRouteImport preLoaderRoute: typeof AppSettingsRouteImport
parentRoute: typeof AppRouteRoute parentRoute: typeof AppRouteRoute
} }
'/_app/projects': {
id: '/_app/projects'
path: '/projects'
fullPath: '/projects'
preLoaderRoute: typeof AppProjectsRouteImport
parentRoute: typeof AppRouteRoute
}
'/_app/billing': { '/_app/billing': {
id: '/_app/billing' id: '/_app/billing'
path: '/billing' path: '/billing'
@ -641,6 +660,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiGscOauthCallbackRouteImport preLoaderRoute: typeof ApiGscOauthCallbackRouteImport
parentRoute: typeof rootRouteImport 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': { '/_project/p/$projectId/saved': {
id: '/_project/p/$projectId/saved' id: '/_project/p/$projectId/saved'
path: '/saved' path: '/saved'
@ -669,13 +695,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdKeywordsRouteImport preLoaderRoute: typeof ProjectPProjectIdKeywordsRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute 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': { '/_project/p/$projectId/domain': {
id: '/_project/p/$projectId/domain' id: '/_project/p/$projectId/domain'
path: '/domain' path: '/domain'
@ -738,6 +757,7 @@ declare module '@tanstack/react-router' {
interface AppRouteRouteChildren { interface AppRouteRouteChildren {
AppAiRoute: typeof AppAiRoute AppAiRoute: typeof AppAiRoute
AppBillingRoute: typeof AppBillingRoute AppBillingRoute: typeof AppBillingRoute
AppProjectsRoute: typeof AppProjectsRoute
AppSettingsRoute: typeof AppSettingsRoute AppSettingsRoute: typeof AppSettingsRoute
AppSupportRoute: typeof AppSupportRoute AppSupportRoute: typeof AppSupportRoute
AppIndexRoute: typeof AppIndexRoute AppIndexRoute: typeof AppIndexRoute
@ -747,6 +767,7 @@ interface AppRouteRouteChildren {
const AppRouteRouteChildren: AppRouteRouteChildren = { const AppRouteRouteChildren: AppRouteRouteChildren = {
AppAiRoute: AppAiRoute, AppAiRoute: AppAiRoute,
AppBillingRoute: AppBillingRoute, AppBillingRoute: AppBillingRoute,
AppProjectsRoute: AppProjectsRoute,
AppSettingsRoute: AppSettingsRoute, AppSettingsRoute: AppSettingsRoute,
AppSupportRoute: AppSupportRoute, AppSupportRoute: AppSupportRoute,
AppIndexRoute: AppIndexRoute, AppIndexRoute: AppIndexRoute,
@ -797,11 +818,11 @@ interface ProjectPProjectIdRouteRouteChildren {
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
ProjectPProjectIdIntegrationsRoute: typeof ProjectPProjectIdIntegrationsRoute
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRoute
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
} }
@ -811,12 +832,12 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute, ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute, ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute,
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute, ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
ProjectPProjectIdIntegrationsRoute: ProjectPProjectIdIntegrationsRoute,
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute, ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute, ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
ProjectPProjectIdRankTrackingRoute: ProjectPProjectIdRankTrackingRoute:
ProjectPProjectIdRankTrackingRouteWithChildren, ProjectPProjectIdRankTrackingRouteWithChildren,
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute, ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
ProjectPProjectIdSettingsRoute: ProjectPProjectIdSettingsRoute,
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute, ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
} }

View File

@ -1,7 +1,11 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getOrCreateDefaultProject } from "@/serverFunctions/projects"; import { getProjects } from "@/serverFunctions/projects";
import {
clearLastProjectId,
getLastProjectId,
} from "@/client/lib/active-project";
import { import {
getErrorCode, getErrorCode,
getStandardErrorMessage, getStandardErrorMessage,
@ -17,19 +21,29 @@ export const Route = createFileRoute("/_app/")({
function IndexRedirect() { function IndexRedirect() {
const navigate = useNavigate(); const navigate = useNavigate();
const { mutate, error, isError } = useMutation({ const { data, error, isError, refetch } = useQuery({
mutationFn: () => getOrCreateDefaultProject(), queryKey: ["projects"],
onSuccess: (project) => { queryFn: () => getProjects(),
void navigate({ retry: false,
to: "/p/$projectId/keywords",
params: { projectId: project.id },
});
},
}); });
useEffect(() => { useEffect(() => {
mutate(); if (!data || data.length === 0) return;
}, [mutate]);
// 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(() => { useEffect(() => {
if (getErrorCode(error) !== "PAYMENT_REQUIRED") { if (getErrorCode(error) !== "PAYMENT_REQUIRED") {
@ -51,7 +65,7 @@ function IndexRedirect() {
"An unexpected error occurred. Please check server logs.", "An unexpected error occurred. Please check server logs.",
)} )}
onRetry={() => { onRetry={() => {
mutate(); void refetch();
}} }}
/> />
</div> </div>
@ -64,7 +78,7 @@ function IndexRedirect() {
<UnauthenticatedErrorCard <UnauthenticatedErrorCard
message="Please sign in to access your OpenSEO workspace." message="Please sign in to access your OpenSEO workspace."
onRetry={() => { onRetry={() => {
mutate(); void refetch();
}} }}
/> />
</div> </div>

View File

@ -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<string | null>(
null,
);
React.useEffect(() => {
setCurrentProjectId(getLastProjectId());
}, []);
const projectsQuery = useQuery({
queryKey: ["projects"],
queryFn: () => getProjects(),
});
const projects = projectsQuery.data ?? [];
return (
<div className="h-full overflow-auto bg-base-100 px-4 py-8 pb-24 md:px-6 md:py-12 md:pb-8">
<div className="mx-auto w-full max-w-2xl space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
<p className="mt-1 text-sm text-base-content/60">
Each project is a separate workspace with its own Search Console,
rank tracking, and audits.
</p>
</div>
<button
type="button"
className="btn btn-primary btn-sm shrink-0"
onClick={() => setCreating(true)}
>
<Plus className="size-4" />
New project
</button>
</div>
{projectsQuery.isLoading ? (
<div className="flex justify-center py-10">
<span className="loading loading-spinner loading-md" />
</div>
) : (
<ul className="divide-y divide-base-300 overflow-hidden rounded-lg border border-base-300">
{projects.map((project) => (
<li key={project.id}>
<Link
to="/p/$projectId/settings"
params={{ projectId: project.id }}
className="flex items-center justify-between gap-3 p-3 transition-colors hover:bg-base-200/40"
>
<span className="flex min-w-0 flex-col">
<span className="flex items-center gap-2">
<span className="truncate font-medium">
{project.name}
</span>
{project.id === currentProjectId ? (
<span className="shrink-0 rounded-full bg-base-300/70 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-base-content/60">
Current
</span>
) : null}
</span>
<span className="truncate text-xs text-base-content/50">
{project.domain ?? "No domain set"}
</span>
</span>
<ChevronRight className="size-4 shrink-0 text-base-content/40" />
</Link>
</li>
))}
</ul>
)}
</div>
{creating ? (
<CreateProjectModal onClose={() => setCreating(false)} />
) : null}
</div>
);
}

View File

@ -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 (
<div
id="search-console"
className="mx-auto w-full max-w-4xl space-y-6 p-4 sm:p-6"
>
<div>
<h1 className="text-2xl font-semibold">Integrations</h1>
<p className="text-sm text-base-content/60">
Connect your data sources.
</p>
</div>
<SearchConsoleConnectionCard projectId={projectId} />
</div>
);
}

View File

@ -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 { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner"; import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
@ -37,6 +44,18 @@ function ProjectLayout() {
const authGate = useHostedAuthRouteGuard(); const authGate = useHostedAuthRouteGuard();
useOnboardingRedirect(); 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) { if (!authGate.canRenderAuthenticatedContent) {
return null; return null;
} }

View File

@ -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 (
<div className="h-full overflow-auto bg-base-100">
<ProjectSettings projectId={projectId} />
</div>
);
}

View File

@ -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 { db } from "@/db";
import { projects } from "@/db/schema"; import { projects } from "@/db/schema";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
@ -10,15 +10,12 @@ async function listProjects(organizationId: string) {
}); });
} }
async function getDefaultProjectForOrganization(organizationId: string) { async function countProjects(organizationId: string) {
return db.query.projects.findFirst({ const [row] = await db
where: and( .select({ value: count() })
eq(projects.organizationId, organizationId), .from(projects)
eq(projects.name, "Default"), .where(eq(projects.organizationId, organizationId));
isNull(projects.domain), return row?.value ?? 0;
),
orderBy: [desc(projects.createdAt), desc(projects.id)],
});
} }
async function getProjectForOrganization( 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( async function createProject(
organizationId: string, organizationId: string,
name: string, name: string,
domain?: string, domain?: string,
) { ) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
await db.insert(projects).values({ const [row] = await db
id, .insert(projects)
organizationId, .values({ id, organizationId, name, domain })
name, .returning();
domain, return row;
}); }
return id;
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) { async function tryCreateDefaultProject(organizationId: string) {
@ -87,10 +99,10 @@ async function deleteProject(projectId: string, organizationId: string) {
export const ProjectRepository = { export const ProjectRepository = {
listProjects, listProjects,
getDefaultProjectForOrganization, countProjects,
getProjectForOrganization, getProjectForOrganization,
getProjectById,
createProject, createProject,
updateProject,
tryCreateDefaultProject, tryCreateDefaultProject,
deleteProject, deleteProject,
} as const; } as const;

View File

@ -1,17 +1,17 @@
import { import {
createProject, createProject,
deleteProject, deleteProject,
getOrCreateDefaultProject,
getProject,
getProjectForOrganization, getProjectForOrganization,
listProjects, listProjects,
listProjectsEnsuringOne,
updateProject,
} from "@/server/features/projects/services/projects"; } from "@/server/features/projects/services/projects";
export const ProjectService = { export const ProjectService = {
listProjects, listProjects,
listProjectsEnsuringOne,
createProject, createProject,
updateProject,
deleteProject, deleteProject,
getOrCreateDefaultProject,
getProject,
getProjectForOrganization, getProjectForOrganization,
} as const; } as const;

View File

@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
createProject: vi.fn(), createProject: vi.fn(),
updateProject: vi.fn(),
deleteProject: vi.fn(), deleteProject: vi.fn(),
getDefaultProjectForOrganization: vi.fn(), countProjects: vi.fn(),
getProjectById: vi.fn(),
getProjectForOrganization: vi.fn(), getProjectForOrganization: vi.fn(),
listProjects: vi.fn(), listProjects: vi.fn(),
tryCreateDefaultProject: vi.fn(), tryCreateDefaultProject: vi.fn(),
@ -21,33 +21,148 @@ const defaultProject = {
createdAt: "2026-05-19 12:00:00", 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", () => { describe("project service", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
for (const mock of Object.values(mocks)) mock.mockReset(); for (const mock of Object.values(mocks)) mock.mockReset();
}); });
it("recovers from the default project creation race", async () => { describe("listProjectsEnsuringOne", () => {
mocks.getDefaultProjectForOrganization it("returns existing projects without creating a Default", async () => {
.mockResolvedValueOnce(null) mocks.listProjects.mockResolvedValue([namedProject]);
.mockResolvedValueOnce(defaultProject); const { listProjectsEnsuringOne } = await import("./projects");
mocks.tryCreateDefaultProject.mockResolvedValue(null);
const { getOrCreateDefaultProject } = await import("./projects");
await expect(getOrCreateDefaultProject("org_1")).resolves.toEqual( await expect(listProjectsEnsuringOne("org_1")).resolves.toEqual([
defaultProject, namedProject,
); ]);
expect(mocks.tryCreateDefaultProject).toHaveBeenCalledWith("org_1"); expect(mocks.tryCreateDefaultProject).not.toHaveBeenCalled();
expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(2); 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 () => { describe("createProject", () => {
const error = new Error("D1 unavailable"); it("returns the full created project", async () => {
mocks.getDefaultProjectForOrganization.mockResolvedValue(null); mocks.createProject.mockResolvedValue(namedProject);
mocks.tryCreateDefaultProject.mockRejectedValue(error); const { createProject } = await import("./projects");
const { getOrCreateDefaultProject } = await import("./projects");
await expect(getOrCreateDefaultProject("org_1")).rejects.toBe(error); await expect(
expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(1); 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");
});
}); });
}); });

View File

@ -1,6 +1,7 @@
import type { import type {
CreateProjectInput, CreateProjectInput,
DeleteProjectInput, DeleteProjectInput,
UpdateProjectInput,
} from "@/types/schemas/projects"; } from "@/types/schemas/projects";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { AppError } from "@/server/lib/errors"; 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) { export async function listProjects(organizationId: string) {
const rows = await ProjectRepository.listProjects(organizationId); const rows = await ProjectRepository.listProjects(organizationId);
return rows.map(mapProject); 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( export async function createProject(
organizationId: string, organizationId: string,
input: CreateProjectInput, input: CreateProjectInput,
) { ) {
const id = await ProjectRepository.createProject( try {
organizationId, const row = await ProjectRepository.createProject(
input.name, organizationId,
input.domain, input.name,
); input.domain,
return { id }; );
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( export async function deleteProject(
organizationId: string, organizationId: string,
input: DeleteProjectInput, 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); await ProjectRepository.deleteProject(input.projectId, organizationId);
return { success: true }; 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( export async function getProjectForOrganization(
organizationId: string, organizationId: string,
projectId: string, projectId: string,

View File

@ -161,7 +161,7 @@ describe("search console MCP tools", () => {
const first = result.content[0]; const first = result.content[0];
expect(first.type).toBe("text"); expect(first.type).toBe("text");
expect(first.type === "text" && first.text).toContain( 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]; const first = result.content[0];
expect(first.type === "text" && first.text).toContain( expect(first.type === "text" && first.text).toContain(
"/p/project_1/integrations", "/p/project_1/settings",
); );
}); });

View File

@ -31,8 +31,8 @@ type ProjectAuthContext = {
baseUrl: string; baseUrl: string;
}; };
function integrationsUrl(baseUrl: string, projectId: string): string { function connectGscUrl(baseUrl: string, projectId: string): string {
return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`); return buildDashboardUrl(baseUrl, `/p/${projectId}/settings#search-console`);
} }
/** Self-hosted GSC requires the operator to provide a Google OAuth client and /** 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; if (hosted || configured) return null;
return mcpResponse({ 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), meta: buildProjectMeta(context, projectId),
structuredContent: { structuredContent: {
ok: false, ok: false,
@ -196,11 +196,11 @@ export const getSearchConsolePerformanceTool = {
); );
if (blocked) return blocked; if (blocked) return blocked;
const connectUrl = integrationsUrl(context.baseUrl, args.projectId); const connectUrl = connectGscUrl(context.baseUrl, args.projectId);
const meta = buildProjectMeta( const meta = buildProjectMeta(
context, context,
args.projectId, args.projectId,
`/p/${args.projectId}/integrations`, `/p/${args.projectId}/settings`,
); );
// GSC rejects searchAppearance combined with any other dimension. // GSC rejects searchAppearance combined with any other dimension.
@ -337,11 +337,11 @@ export const inspectUrlsTool = {
); );
if (blocked) return blocked; if (blocked) return blocked;
const connectUrl = integrationsUrl(context.baseUrl, args.projectId); const connectUrl = connectGscUrl(context.baseUrl, args.projectId);
const meta = buildProjectMeta( const meta = buildProjectMeta(
context, context,
args.projectId, args.projectId,
`/p/${args.projectId}/integrations`, `/p/${args.projectId}/settings`,
); );
try { try {

View File

@ -1,12 +1,41 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { ProjectService } from "@/server/features/projects/services/ProjectService"; 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"; import { z } from "zod";
export const getOrCreateDefaultProject = createServerFn({ method: "POST" }) export const getProjects = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext) .middleware(requireAuthenticatedContext)
.handler(async ({ context }) => .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" }) export const getProjectAccess = createServerFn({ method: "POST" })

View File

@ -1,13 +1,27 @@
import { z } from "zod"; 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({ export const createProjectSchema = z.object({
name: z.string().min(1, "Project name is required").max(120), name: projectNameField,
domain: z domain: projectDomainField,
.string() });
.trim()
.max(255) export const updateProjectSchema = z.object({
.transform((value) => value || undefined) projectId: z.string().min(1),
.optional(), name: projectNameField,
domain: projectDomainField,
}); });
export const deleteProjectSchema = z.object({ export const deleteProjectSchema = z.object({
@ -15,4 +29,5 @@ export const deleteProjectSchema = z.object({
}); });
export type CreateProjectInput = z.infer<typeof createProjectSchema>; export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>; export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>;