diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 9f0ec39a49b..f3e93efef68 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -1791,7 +1791,8 @@ export async function createWorktreeFromPr({ { cwd: worktreePath, timeout: 120_000 }, ); } catch (ghError) { - const ghMsg = ghError instanceof Error ? ghError.message : String(ghError); + const ghMsg = + ghError instanceof Error ? ghError.message : String(ghError); // `gh pr checkout` can fail with "is not a branch" when the branch name // contains '/' (e.g. "user/feature-branch"). Git has trouble resolving // "origin/user/feature-branch" as a tracking ref inside a worktree. diff --git a/apps/desktop/src/main/lib/notifications/notification-manager.test.ts b/apps/desktop/src/main/lib/notifications/notification-manager.test.ts index 7671d38fa1e..412345fd2c8 100644 --- a/apps/desktop/src/main/lib/notifications/notification-manager.test.ts +++ b/apps/desktop/src/main/lib/notifications/notification-manager.test.ts @@ -297,8 +297,8 @@ describe("NotificationManager", () => { expect(createNotification).toHaveBeenCalledWith( expect.objectContaining({ - title: "Input Needed — Test Workspace", - body: '"Test Title" needs your attention', + title: "Awaiting Response — Test Workspace", + body: '"Test Title" is waiting for your reply', }), ); }); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index c89d778fc72..14ec71c7380 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -14,7 +14,6 @@ import { useFocusPromptOnPane } from "renderer/components/Chat/ChatInterface/hoo import { useHotkeyDisplay } from "renderer/hotkeys"; import type { SlashCommand } from "../../hooks/useSlashCommands"; import type { ModelOption, PermissionMode } from "../../types"; -import { IssueLinkCommand } from "../IssueLinkCommand"; import { TiptapPromptEditor } from "../TiptapPromptEditor"; import { ChatComposerControls } from "./components/ChatComposerControls"; import { ChatInputDropZone } from "./components/ChatInputDropZone"; @@ -100,23 +99,12 @@ export function ChatInputFooter({ } }, [pendingQuestion, textInput]); - const [issueLinkOpen, setIssueLinkOpen] = useState(false); const [linkedIssues, setLinkedIssues] = useState([]); const inputRootRef = useRef(null); const errorMessage = getErrorMessage(error); const focusShortcutText = useHotkeyDisplay("FOCUS_CHAT_INPUT").text; const showFocusHint = focusShortcutText !== "Unassigned"; - const addLinkedIssue = useCallback( - (slug: string, title: string, taskId: string | undefined, url?: string) => { - setLinkedIssues((prev) => { - if (prev.some((issue) => issue.slug === slug)) return prev; - return [...prev, { slug, title, taskId, url }]; - }); - }, - [], - ); - const removeLinkedIssue = useCallback((slug: string) => { setLinkedIssues((prev) => prev.filter((issue) => issue.slug !== slug)); }, []); @@ -176,15 +164,7 @@ export function ChatInputFooter({ maxFileSize={10 * 1024 * 1024} globalDrop > - - + {renderAttachment ?? @@ -217,7 +197,6 @@ export function ChatInputFooter({ submitStatus={submitStatus} submitDisabled={submitDisabled} onStop={onStop} - onLinkIssue={() => setIssueLinkOpen(true)} /> diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx index d477e92e891..7039c5cb8a9 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx @@ -30,7 +30,6 @@ interface ChatComposerControlsProps { submitStatus?: ChatStatus; submitDisabled?: boolean; onStop: (event: React.MouseEvent) => void; - onLinkIssue: () => void; } export function ChatComposerControls({ @@ -47,7 +46,6 @@ export function ChatComposerControls({ submitStatus, submitDisabled, onStop, - onLinkIssue, }: ChatComposerControlsProps) { return ( @@ -70,7 +68,7 @@ export function ChatComposerControls({ />
- + >; } -export function ChatShortcuts({ - isFocused, - setIssueLinkOpen, -}: ChatShortcutsProps) { +export function ChatShortcuts({ isFocused }: ChatShortcutsProps) { const attachments = usePromptInputAttachments(); const { textInput } = usePromptInputController(); @@ -25,14 +20,6 @@ export function ChatShortcuts({ { enabled: isFocused, preventDefault: true }, ); - useHotkey( - "CHAT_LINK_ISSUE", - () => { - setIssueLinkOpen((prev) => !prev); - }, - { enabled: isFocused, preventDefault: true }, - ); - useHotkey( "FOCUS_CHAT_INPUT", () => { diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/IssueLinkCommand/IssueLinkCommand.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/IssueLinkCommand/IssueLinkCommand.tsx index 280f64c4079..9e525202f2c 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/IssueLinkCommand/IssueLinkCommand.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/IssueLinkCommand/IssueLinkCommand.tsx @@ -1,17 +1,16 @@ import { Command, - CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@superset/ui/command"; -import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useLiveQuery } from "@tanstack/react-db"; import Fuse from "fuse.js"; -import type React from "react"; -import type { RefObject } from "react"; +import type { ReactNode } from "react"; import { useMemo, useState } from "react"; import { StatusIcon, @@ -21,22 +20,23 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect const MAX_RESULTS = 20; -type IssueLinkCommandProps = { - open: boolean; - onOpenChange: (open: boolean) => void; +interface IssueLinkCommandProps { + children: ReactNode; + tooltipLabel: string; onSelect: ( slug: string, title: string, taskId: string | undefined, url?: string, ) => void; -} & ( - | { variant?: "dialog" } - | { variant: "popover"; anchorRef: RefObject } -); +} -export function IssueLinkCommand(props: IssueLinkCommandProps) { - const { open, onOpenChange, onSelect } = props; +export function IssueLinkCommand({ + children, + tooltipLabel, + onSelect, +}: IssueLinkCommandProps) { + const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const collections = useCollections(); @@ -108,11 +108,6 @@ export function IssueLinkCommand(props: IssueLinkCommandProps) { .map((r) => r.item); }, [allTasks, searchQuery, taskFuse]); - const handleClose = () => { - setSearchQuery(""); - onOpenChange(false); - }; - const handleSelect = ( slug: string, title: string, @@ -120,103 +115,86 @@ export function IssueLinkCommand(props: IssueLinkCommandProps) { url?: string, ) => { onSelect(slug, title, taskId, url); - handleClose(); + setSearchQuery(""); + setOpen(false); }; - const issueListContent = ( - <> - - - {filteredTasks.length === 0 && ( - No issues found. - )} - {filteredTasks.length > 0 && ( - - {filteredTasks.map((task) => { - const status = task.statusId - ? statusMap.get(task.statusId) - : undefined; - return ( - - handleSelect( - task.slug, - task.title, - task.id, - task.externalUrl ?? undefined, - ) - } - className="group" - > - {status ? ( - - ) : ( - - )} - - {task.slug} - - - {task.title} - - - Link ↵ - - - ); - })} - - )} - - - ); - - if (props.variant === "popover") { - return ( - - } - /> - event.stopPropagation()} - onPointerDownOutside={handleClose} - onEscapeKeyDown={handleClose} - onFocusOutside={(e) => e.preventDefault()} - > - {issueListContent} - - - ); - } - return ( - { - if (!nextOpen) setSearchQuery(""); - onOpenChange(nextOpen); + onOpenChange={(next) => { + if (!next) setSearchQuery(""); + setOpen(next); }} - modal - title="Link issue" - description="Search for an issue to link" - showCloseButton={false} > - {issueListContent} - + + + {children} + + {tooltipLabel} + + event.stopPropagation()} + > + + + + {filteredTasks.length === 0 && ( + No issues found. + )} + {filteredTasks.length > 0 && ( + + {filteredTasks.map((task) => { + const status = task.statusId + ? statusMap.get(task.statusId) + : undefined; + return ( + + handleSelect( + task.slug, + task.title, + task.id, + task.externalUrl ?? undefined, + ) + } + className="group" + > + {status ? ( + + ) : ( + + )} + + {task.slug} + + + {task.title} + + + Link ↵ + + + ); + })} + + )} + + + + ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/PlusMenu/PlusMenu.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/PlusMenu/PlusMenu.tsx index 0d527d44dbd..a20f6c23dac 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/PlusMenu/PlusMenu.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/PlusMenu/PlusMenu.tsx @@ -2,49 +2,25 @@ import { PromptInputButton, usePromptInputAttachments, } from "@superset/ui/ai-elements/prompt-input"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { HiMiniPaperClip } from "react-icons/hi2"; -import { LuPlus } from "react-icons/lu"; -import { SiLinear } from "react-icons/si"; import { PILL_BUTTON_CLASS } from "../../styles"; -interface PlusMenuProps { - onLinkIssue: () => void; -} - -export function PlusMenu({ onLinkIssue }: PlusMenuProps) { +export function PlusMenu() { const attachments = usePromptInputAttachments(); return ( - - - - + + + attachments.openFileDialog()} + > + - - e.preventDefault()} - > - attachments.openFileDialog()}> - - Add attachment - ⌘U - - - - Link issue - ⌘I - - - + + Add attachment + ); } diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx index 51d26ad79b3..2967895f530 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx @@ -44,10 +44,8 @@ import { } from "react-icons/go"; import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; import { LuFolderGit, LuFolderOpen, LuGitPullRequest } from "react-icons/lu"; -import { SiLinear } from "react-icons/si"; import { AgentSelect } from "renderer/components/AgentSelect"; import { LinkedIssuePill } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/LinkedIssuePill"; -import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; import { useAgentLaunchPreferences } from "renderer/hooks/useAgentLaunchPreferences"; import { PLATFORM } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -114,12 +112,10 @@ export function PromptGroup(props: PromptGroupProps) { function AttachmentButtons({ anchorRef, - onOpenIssueLink, onOpenGitHubIssue, onOpenPRLink, }: { anchorRef: React.RefObject; - onOpenIssueLink: () => void; onOpenGitHubIssue: () => void; onOpenPRLink: () => void; }) { @@ -138,17 +134,6 @@ function AttachmentButtons({ Add attachment - - - - - - - Link issue - (null); @@ -1133,21 +1117,6 @@ ${sanitizeText(truncatedBody)}`; [closeModal, navigate], ); - const addLinkedIssue = ( - slug: string, - title: string, - taskId: string | undefined, - url?: string, - ) => { - if (linkedIssues.some((issue) => issue.slug === slug)) return; - updateDraft({ - linkedIssues: [ - ...linkedIssues, - { slug, title, source: "internal", taskId, url }, - ], - }); - }; - const addLinkedGitHubIssue = ( issueNumber: number, title: string, @@ -1318,9 +1287,6 @@ ${sanitizeText(truncatedBody)}`;
- requestAnimationFrame(() => setIssueLinkOpen(true)) - } onOpenGitHubIssue={() => requestAnimationFrame(() => setGitHubIssueLinkOpen(true)) } @@ -1328,13 +1294,6 @@ ${sanitizeText(truncatedBody)}`; requestAnimationFrame(() => setPRLinkOpen(true)) } /> - { + toast.success("Project ready — open it from the sidebar."); + }, + onError: (message) => { + toast.error(`Import failed: ${message}`); + }, + }); + + // A counter (not boolean) so successive clicks re-invoke. Depend only on + // the counter — folderImport.start's identity changes every render, so + // including it would refire the effect mid-flow and re-open the picker. + const startRef = useRef(folderImport.start); + startRef.current = folderImport.start; + useEffect(() => { + if (folderImportTrigger === 0) return; + startRef.current().catch((err) => { + toast.error( + `Import failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + }, [folderImportTrigger]); + + return ( + <> + { + if (!open) close(); + }} + onSuccess={() => toast.success("Project created.")} + onError={(message) => toast.error(`Create failed: ${message}`)} + /> + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/FolderFirstImportModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/FolderFirstImportModal.tsx new file mode 100644 index 00000000000..0d727e8dfc5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/FolderFirstImportModal.tsx @@ -0,0 +1,117 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { type FormEvent, useState } from "react"; +import type { + FolderFirstImportState, + UseFolderFirstImportResult, +} from "../../hooks/useFolderFirstImport"; + +interface FolderFirstImportModalProps { + state: FolderFirstImportState; + onCancel: UseFolderFirstImportResult["cancel"]; + onConfirmCreateAsNew: UseFolderFirstImportResult["confirmCreateAsNew"]; +} + +export function FolderFirstImportModal({ + state, + onCancel, + onConfirmCreateAsNew, +}: FolderFirstImportModalProps) { + const open = state.kind !== "idle"; + return ( + { + if (!next) onCancel(); + }} + > + + {state.kind === "no-match" && ( + + )} + + + ); +} + +interface NoMatchContentProps { + repoPath: string; + working: boolean; + onCancel: () => void; + onConfirm: (input: { name: string }) => Promise; +} + +function NoMatchContent({ + repoPath, + working, + onCancel, + onConfirm, +}: NoMatchContentProps) { + const [name, setName] = useState(""); + const trimmed = name.trim(); + const canSubmit = trimmed.length > 0 && !working; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + void onConfirm({ name: trimmed }); + }; + + return ( +
+ + Create a new project? + + No existing project matches this folder. Name it to create a new + project bound to the folder's git remote. + + +
+
+ + + {repoPath} + +
+
+ + setName(event.target.value)} + disabled={working} + placeholder="e.g. my-project" + /> +
+
+ + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/index.ts new file mode 100644 index 00000000000..e337b5a82cf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/FolderFirstImportModal/index.ts @@ -0,0 +1 @@ +export { FolderFirstImportModal } from "./FolderFirstImportModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/NewProjectModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/NewProjectModal.tsx new file mode 100644 index 00000000000..4cf0ced1348 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/NewProjectModal.tsx @@ -0,0 +1,297 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { cn } from "@superset/ui/utils"; +import { useEffect, useState } from "react"; +import { FaGithub } from "react-icons/fa"; +import { + LuFolderOpen, + LuFolderPlus, + LuLayoutTemplate, + LuX, +} from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +type NewProjectMode = "clone" | "empty" | "template"; + +interface NewProjectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (result: { projectId: string; repoPath: string }) => void; + onError?: (message: string) => void; +} + +const OPTIONS: { + mode: NewProjectMode; + label: string; + suffix?: string; + icon: typeof FaGithub; + disabled?: boolean; +}[] = [ + { + mode: "clone", + label: "Clone from GitHub", + icon: FaGithub, + }, + { + mode: "empty", + label: "Empty", + suffix: "(coming soon)", + icon: LuFolderPlus, + disabled: true, + }, + { + mode: "template", + label: "Template", + suffix: "(coming soon)", + icon: LuLayoutTemplate, + disabled: true, + }, +]; + +function deriveProjectNameFromUrl(url: string): string { + const trimmed = url.trim().replace(/\.git$/i, ""); + const segments = trimmed.split(/[/:]/).filter(Boolean); + return segments[segments.length - 1] ?? ""; +} + +export function NewProjectModal({ + open, + onOpenChange, + onSuccess, + onError, +}: NewProjectModalProps) { + const { activeHostUrl } = useLocalHostService(); + const { ensureProjectInSidebar } = useDashboardSidebarState(); + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + const { data: homeDir } = electronTrpc.window.getHomeDir.useQuery(); + + const [mode, setMode] = useState("clone"); + const [parentDir, setParentDir] = useState(""); + const [url, setUrl] = useState(""); + const [error, setError] = useState(null); + const [working, setWorking] = useState(false); + + useEffect(() => { + if (parentDir || !homeDir) return; + setParentDir(`${homeDir}/.superset/projects`); + }, [homeDir, parentDir]); + + const reset = () => { + setUrl(""); + setError(null); + setWorking(false); + }; + + const handleOpenChange = (next: boolean) => { + if (!next && working) return; + if (!next) reset(); + onOpenChange(next); + }; + + const handleBrowse = async () => { + try { + const result = await selectDirectory.mutateAsync({ + title: "Select project location", + defaultPath: parentDir || undefined, + }); + if (!result.canceled && result.path) { + setParentDir(result.path); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const createFromClone = async () => { + if (!activeHostUrl) { + setError("Host service not available"); + return; + } + const trimmedUrl = url.trim(); + const trimmedParent = parentDir.trim(); + if (!trimmedUrl) { + setError("Please enter a repository URL"); + return; + } + if (!trimmedParent) { + setError("Please select a project location"); + return; + } + const name = deriveProjectNameFromUrl(trimmedUrl); + if (!name) { + setError("Could not derive a project name from the URL"); + return; + } + + setWorking(true); + setError(null); + try { + const client = getHostServiceClientByUrl(activeHostUrl); + const result = await client.project.create.mutate({ + name, + mode: { kind: "clone", parentDir: trimmedParent, url: trimmedUrl }, + }); + ensureProjectInSidebar(result.projectId); + onSuccess?.(result); + reset(); + onOpenChange(false); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + onError?.(message); + } finally { + setWorking(false); + } + }; + + return ( + + + + New project + + Create a new project by cloning a repository. + + + +
+
+ +
+ setParentDir(e.target.value)} + disabled={working} + className="flex-1 font-mono text-xs" + /> + +
+
+ +
+ {OPTIONS.map((option) => { + const selected = mode === option.mode; + const isDisabled = option.disabled || working; + return ( + + ); + })} +
+ + {mode === "clone" && ( +
+ + setUrl(e.target.value)} + placeholder="https://github.com/owner/repo.git" + disabled={working} + onKeyDown={(e) => { + if (e.key === "Enter" && !working) { + void createFromClone(); + } + }} + autoFocus + /> +
+ )} + + {error && ( +
+ {error} + +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/index.ts new file mode 100644 index 00000000000..1fb78225d72 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/index.ts @@ -0,0 +1 @@ +export { NewProjectModal } from "./NewProjectModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/index.ts new file mode 100644 index 00000000000..98b18691822 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/index.ts @@ -0,0 +1,5 @@ +export { + type FolderFirstImportState, + type UseFolderFirstImportResult, + useFolderFirstImport, +} from "./useFolderFirstImport"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts new file mode 100644 index 00000000000..a8939de39bc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts @@ -0,0 +1,172 @@ +import { useCallback, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +interface FolderImportCandidate { + id: string; + name: string; + slug: string; + organizationId: string; + organizationName: string; +} + +// idle — no modal. +// no-match — picked folder has no cloud project; user names it. +// (1-match has no state — setup runs immediately.) +export type FolderFirstImportState = + | { kind: "idle" } + | { kind: "no-match"; repoPath: string; working: boolean }; + +export interface UseFolderFirstImportResult { + state: FolderFirstImportState; + start: () => Promise; + /** No-op while a mutation is working. */ + cancel: () => void; + confirmCreateAsNew: (input: { name: string }) => Promise; +} + +type SetupInvokeResult = + | { status: "ok"; projectId: string; repoPath: string } + | { status: "error"; message: string }; + +export function useFolderFirstImport(options?: { + onSuccess?: (result: { projectId: string; repoPath: string }) => void; + onError?: (message: string) => void; +}): UseFolderFirstImportResult { + const { activeHostUrl } = useLocalHostService(); + const { ensureProjectInSidebar } = useDashboardSidebarState(); + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + + const [state, setState] = useState({ kind: "idle" }); + + const reset = useCallback(() => setState({ kind: "idle" }), []); + + const reportSuccess = useCallback( + (result: { projectId: string; repoPath: string }) => { + ensureProjectInSidebar(result.projectId); + options?.onSuccess?.(result); + reset(); + }, + [ensureProjectInSidebar, options, reset], + ); + + const reportError = useCallback( + (message: string) => { + options?.onError?.(message); + }, + [options], + ); + + const runSetup = useCallback( + async (projectId: string, repoPath: string): Promise => { + if (!activeHostUrl) { + return { status: "error", message: "Host service not available" }; + } + const client = getHostServiceClientByUrl(activeHostUrl); + try { + const result = await client.project.setup.mutate({ + projectId, + mode: { kind: "import", repoPath }, + }); + return { status: "ok", projectId, repoPath: result.repoPath }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { status: "error", message }; + } + }, + [activeHostUrl], + ); + + const start = useCallback(async () => { + if (!activeHostUrl) { + reportError("Host service not available"); + return; + } + + let repoPath: string; + try { + const picked = await selectDirectory.mutateAsync({ + title: "Import existing folder", + }); + if (picked.canceled || !picked.path) return; + repoPath = picked.path; + } catch (err) { + reportError(err instanceof Error ? err.message : String(err)); + return; + } + + const client = getHostServiceClientByUrl(activeHostUrl); + let candidates: FolderImportCandidate[]; + try { + const response = await client.project.findByPath.query({ repoPath }); + candidates = response.candidates; + } catch (err) { + reportError(err instanceof Error ? err.message : String(err)); + return; + } + + const [only, ...rest] = candidates; + if (!only) { + setState({ kind: "no-match", repoPath, working: false }); + return; + } + if (rest.length > 0) { + // Unreachable given single-org findByGitHubRemote + the unique + // index on (organizationId, lower(repoCloneUrl)). Surface loudly + // if we ever hit it — means the invariants broke. + reportError( + `Multiple matching projects returned (${candidates.length}) — please report this`, + ); + return; + } + const result = await runSetup(only.id, repoPath); + if (result.status === "ok") { + reportSuccess(result); + } else { + reportError(result.message); + } + }, [activeHostUrl, reportError, reportSuccess, runSetup, selectDirectory]); + + const cancel = useCallback(() => { + setState((prev) => { + // Don't drop the modal while a mutation is mid-flight; the user will + // see the disabled state and wait, or the mutation will resolve and + // reset us. + if (prev.kind !== "idle" && prev.working) return prev; + return { kind: "idle" }; + }); + }, []); + + const confirmCreateAsNew = useCallback( + async ({ name }: { name: string }) => { + if (state.kind !== "no-match") return; + if (!activeHostUrl) { + reportError("Host service not available"); + return; + } + const repoPath = state.repoPath; + setState({ kind: "no-match", repoPath, working: true }); + const client = getHostServiceClientByUrl(activeHostUrl); + try { + const result = await client.project.create.mutate({ + name, + mode: { kind: "importLocal", repoPath }, + }); + reportSuccess(result); + } catch (err) { + reportError(err instanceof Error ? err.message : String(err)); + setState({ kind: "no-match", repoPath, working: false }); + } + }, + [activeHostUrl, reportError, reportSuccess, state], + ); + + return { + state, + start, + cancel, + confirmCreateAsNew, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/index.ts new file mode 100644 index 00000000000..fcc618e3efb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/index.ts @@ -0,0 +1 @@ +export { AddRepositoryModals } from "./AddRepositoryModals"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx index b781725cd42..a5021c3863f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx @@ -1,13 +1,23 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { HiOutlineClipboardDocumentList } from "react-icons/hi2"; -import { LuFolderPlus, LuLayers, LuPlus } from "react-icons/lu"; +import { HiMiniPlus, HiOutlineClipboardDocumentList } from "react-icons/hi2"; +import { LuFolderInput, LuFolderPlus, LuLayers, LuPlus } from "react-icons/lu"; import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; import { useHotkeyDisplay } from "renderer/hotkeys"; import { OrganizationDropdown } from "renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown"; import { useTasksFilterStore } from "renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state"; import { STROKE_WIDTH_THICK } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { + useOpenNewProjectModal, + useTriggerFolderImport, +} from "renderer/stores/add-repository-modal"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; interface DashboardSidebarHeaderProps { @@ -18,6 +28,8 @@ export function DashboardSidebarHeader({ isCollapsed = false, }: DashboardSidebarHeaderProps) { const openModal = useOpenNewWorkspaceModal(); + const openNewProject = useOpenNewProjectModal(); + const triggerFolderImport = useTriggerFolderImport(); const shortcutText = useHotkeyDisplay("NEW_WORKSPACE").text; const navigate = useNavigate(); const matchRoute = useMatchRoute(); @@ -86,17 +98,32 @@ export function DashboardSidebarHeader({ Tasks - - - - - Add Repository - + + + + + + + + Add repository + + + + + New project + + + + Import existing folder + + + @@ -122,17 +149,32 @@ export function DashboardSidebarHeader({
- - - - - Add Repository - + + + + + + + + Add repository + + + + + New project + + + + Import existing folder + + +
+ {deleteTarget && ( { + const fire = useCallback(async () => { if (!pending) return; collections.pendingWorkspaces.update(pendingId, (draft) => { @@ -202,6 +202,8 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { trpcUtils, activeHostUrl, ]); + + return fire; } function PendingWorkspacePage() { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index 8ba61590dbb..d0a37e633aa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -11,7 +11,6 @@ import type React from "react"; import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { QuestionInputOverlay } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay"; -import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; import { TiptapPromptEditor } from "renderer/components/Chat/ChatInterface/components/TiptapPromptEditor"; import { useFocusPromptOnPane } from "renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane"; import type { SlashCommand } from "renderer/components/Chat/ChatInterface/hooks/useSlashCommands"; @@ -106,23 +105,12 @@ export function ChatInputFooter({ } }, [pendingQuestion, textInput]); - const [issueLinkOpen, setIssueLinkOpen] = useState(false); const [linkedIssues, setLinkedIssues] = useState([]); const inputRootRef = useRef(null); const errorMessage = getErrorMessage(error); const focusShortcutText = useHotkeyDisplay("FOCUS_CHAT_INPUT").text; const showFocusHint = focusShortcutText !== "Unassigned"; - const addLinkedIssue = useCallback( - (slug: string, title: string, taskId: string | undefined, url?: string) => { - setLinkedIssues((prev) => { - if (prev.some((issue) => issue.slug === slug)) return prev; - return [...prev, { slug, title, taskId, url }]; - }); - }, - [], - ); - const removeLinkedIssue = useCallback((slug: string) => { setLinkedIssues((prev) => prev.filter((issue) => issue.slug !== slug)); }, []); @@ -188,15 +176,7 @@ export function ChatInputFooter({ maxFileSize={10 * 1024 * 1024} globalDrop > - - + {renderAttachment ?? @@ -231,7 +211,6 @@ export function ChatInputFooter({ submitStatus={submitStatus} submitDisabled={submitDisabled} onStop={onStop} - onLinkIssue={() => setIssueLinkOpen(true)} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx index b6274ef8de3..80412316c4b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx @@ -33,7 +33,6 @@ interface ChatComposerControlsProps { submitStatus?: ChatStatus; submitDisabled?: boolean; onStop: (event: React.MouseEvent) => void; - onLinkIssue: () => void; } export function ChatComposerControls({ @@ -50,7 +49,6 @@ export function ChatComposerControls({ submitStatus, submitDisabled, onStop, - onLinkIssue, }: ChatComposerControlsProps) { return ( @@ -73,7 +71,7 @@ export function ChatComposerControls({ />
- + >; } -export function ChatShortcuts({ - isFocused, - setIssueLinkOpen, -}: ChatShortcutsProps) { +export function ChatShortcuts({ isFocused }: ChatShortcutsProps) { const attachments = usePromptInputAttachments(); const { textInput } = usePromptInputController(); @@ -25,14 +20,6 @@ export function ChatShortcuts({ { enabled: isFocused, preventDefault: true }, ); - useHotkey( - "CHAT_LINK_ISSUE", - () => { - setIssueLinkOpen((prev) => !prev); - }, - { enabled: isFocused, preventDefault: true }, - ); - useHotkey( "FOCUS_CHAT_INPUT", () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 331ff997007..6a432078b0a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -34,7 +34,6 @@ import { } from "../../state/fileDocumentStore"; import type { BrowserPaneData, - ChatPaneData, CommentPaneData, DevtoolsPaneData, FilePaneData, @@ -46,7 +45,6 @@ import { BrowserPaneToolbar, browserRuntimeRegistry, } from "./components/BrowserPane"; -import { ChatPane } from "./components/ChatPane"; import { CommentPane } from "./components/CommentPane"; import { DiffPane } from "./components/DiffPane"; import { FilePane } from "./components/FilePane"; @@ -345,28 +343,13 @@ export function usePaneRegistry( chat: { getIcon: () => , getTitle: () => "Chat", - renderPane: (ctx: RendererContext) => { - const data = ctx.pane.data as ChatPaneData; - return ( - - ctx.actions.updateData({ - sessionId, - launchConfig: data.launchConfig ?? null, - } as PaneViewerData) - } - sessionId={data.sessionId} - workspaceId={workspaceId} - initialLaunchConfig={data.launchConfig ?? null} - onConsumeLaunchConfig={() => - ctx.actions.updateData({ - sessionId: data.sessionId, - launchConfig: null, - } as PaneViewerData) - } - /> - ); - }, + // Disabled until ChatServiceProvider is wired above v2 panes — + // TiptapPromptEditor needs its tRPC context. + renderPane: (_ctx: RendererContext) => ( +
+ Chat pane is temporarily disabled. +
+ ), contextMenuActions: (_ctx, defaults) => defaults.map((d) => d.key === "close-pane" ? { ...d, label: "Close Chat" } : d, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx index 10a29d40088..31937ab17f4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx @@ -25,7 +25,6 @@ import { V2WorkspaceRow } from "./components/V2WorkspaceRow"; interface V2WorkspacesListProps { pinned: AccessibleV2Workspace[]; others: AccessibleV2Workspace[]; - hasAnyAccessible: boolean; } interface ProjectGroup { @@ -75,11 +74,7 @@ function groupByProject(workspaces: AccessibleV2Workspace[]): ProjectGroup[] { }); } -export function V2WorkspacesList({ - pinned, - others, - hasAnyAccessible, -}: V2WorkspacesListProps) { +export function V2WorkspacesList({ pinned, others }: V2WorkspacesListProps) { const matchRoute = useMatchRoute(); const currentWorkspaceMatch = matchRoute({ to: "/v2-workspace/$workspaceId", @@ -93,8 +88,6 @@ export function V2WorkspacesList({ ); const resetFilters = useV2WorkspacesFilterStore((state) => state.reset); - // `pinned` / `others` already have the search filter applied upstream in - // useAccessibleV2Workspaces, so here we only narrow by device filter. const filteredPinnedGroups = useMemo(() => { const filtered = pinned.filter((workspace) => matchesDeviceFilter(workspace.hostType, deviceFilter), @@ -120,26 +113,6 @@ export function V2WorkspacesList({ const hasAnyMatches = pinnedCount > 0 || othersCount > 0; const hasActiveFilters = searchQuery.trim() !== "" || deviceFilter !== "all"; - if (!hasAnyAccessible) { - return ( - - - - - - No workspaces yet - - Create a workspace from the sidebar to get started. Workspaces you - have access to across all your devices will show up here. - - - - ); - } - if (!hasAnyMatches) { return ( @@ -148,11 +121,17 @@ export function V2WorkspacesList({ variant="icon" className="size-14 [&_svg:not([class*='size-'])]:size-7" > - + {hasActiveFilters ? : } - No workspaces match your filters + + {hasActiveFilters + ? "No workspaces match your filters" + : "No workspaces yet"} + - Try a different search term or clear the device filter. + {hasActiveFilters + ? "Try a different search term or clear the device filter." + : "Workspaces you have access to across all your devices will show up here."} {hasActiveFilters ? ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx index d8286acf22d..fee586a4f3a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx @@ -23,16 +23,11 @@ function V2WorkspacesPage() { }, [resetFilters]); const { pinned, others, counts } = useAccessibleV2Workspaces({ searchQuery }); - const hasAnyAccessible = pinned.length > 0 || others.length > 0; return (
- +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx index cbaa45df94c..0c4652fa3d0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx @@ -2,6 +2,7 @@ import { PromptInput, PromptInputAttachment, PromptInputAttachments, + PromptInputButton, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, @@ -14,8 +15,10 @@ import { cn } from "@superset/ui/utils"; import type { FileUIPart } from "ai"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowUpIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { GoIssueOpened } from "react-icons/go"; import { LuGitPullRequest } from "react-icons/lu"; +import { SiLinear } from "react-icons/si"; import { AgentSelect } from "renderer/components/AgentSelect"; import { LinkedIssuePill } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/LinkedIssuePill"; import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; @@ -91,11 +94,6 @@ export function PromptGroup({ agentsReady: agentPresetsQuery.isFetched, }); - // ── Link commands ──────────────────────────────────────────────── - const [issueLinkOpen, setIssueLinkOpen] = useState(false); - const [gitHubIssueLinkOpen, setGitHubIssueLinkOpen] = useState(false); - const [prLinkOpen, setPRLinkOpen] = useState(false); - const plusMenuRef = useRef(null); const trimmedPrompt = prompt.trim(); const branchPreview = branchNameEdited ? sanitizeUserBranchName(branchName) @@ -299,46 +297,56 @@ export function PromptGroup({
- requestAnimationFrame(() => setIssueLinkOpen(true)) - } - onOpenGitHubIssue={() => - requestAnimationFrame(() => setGitHubIssueLinkOpen(true)) + linearIssueTrigger={ + + + + + } - onOpenPRLink={() => - requestAnimationFrame(() => setPRLinkOpen(true)) + githubIssueTrigger={ + + addLinkedGitHubIssue( + issue.issueNumber, + issue.title, + issue.url, + issue.state, + ) + } + projectId={projectId} + hostTarget={hostTarget} + tooltipLabel="Link GitHub issue" + > + + + + } - /> - - - addLinkedGitHubIssue( - issue.issueNumber, - issue.title, - issue.url, - issue.state, - ) + prTrigger={ + + + + + } - projectId={projectId} - hostTarget={hostTarget} - anchorRef={plusMenuRef} - /> -
+ updateDraft({ hostTarget: t })} + /> @@ -389,10 +401,11 @@ export function PromptGroup({
- updateDraft({ hostTarget: t })} - /> + {selectedProject?.needsSetup === true && ( + + Project needs to be set up + + )} {modKey}↵ diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/AttachmentButtons.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/AttachmentButtons.tsx index d753778a9a7..5b0fb24b63c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/AttachmentButtons.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/AttachmentButtons.tsx @@ -4,30 +4,27 @@ import { } from "@superset/ui/ai-elements/prompt-input"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { PaperclipIcon } from "lucide-react"; -import { GoIssueOpened } from "react-icons/go"; -import { LuGitPullRequest } from "react-icons/lu"; -import { SiLinear } from "react-icons/si"; +import type { ReactNode } from "react"; import { PILL_BUTTON_CLASS } from "../../types"; interface AttachmentButtonsProps { - anchorRef: React.RefObject; - onOpenIssueLink: () => void; - onOpenGitHubIssue: () => void; - onOpenPRLink: () => void; + linearIssueTrigger: ReactNode; + githubIssueTrigger: ReactNode; + prTrigger: ReactNode; } export function AttachmentButtons({ - anchorRef, - onOpenIssueLink, - onOpenGitHubIssue, - onOpenPRLink, + linearIssueTrigger, + githubIssueTrigger, + prTrigger, }: AttachmentButtonsProps) { const attachments = usePromptInputAttachments(); return ( -
+
attachments.openFileDialog()} > @@ -36,39 +33,9 @@ export function AttachmentButtons({ Add attachment - - - - - - - Link issue - - - - - - - - Link GitHub issue - - - - - - - - Link pull request - + {linearIssueTrigger} + {githubIssueTrigger} + {prTrigger}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/CompareBaseBranchPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/CompareBaseBranchPicker.tsx index 3e64b3e2108..c01f0fe6ffb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/CompareBaseBranchPicker.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/CompareBaseBranchPicker.tsx @@ -18,6 +18,7 @@ import { GoGitBranch } from "react-icons/go"; import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import type { BranchFilter, BranchRow } from "../../../hooks/useBranchContext"; +import { FormPickerTrigger } from "../FormPickerTrigger"; interface CompareBaseBranchPickerProps { effectiveCompareBaseBranch: string | null; @@ -107,10 +108,9 @@ export function CompareBaseBranchPicker({ }} > - + ) { + return ( + + - + onSelectHostTarget({ kind: "local" })} > diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx index be033fa47d3..7faf257ae6e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx @@ -1,8 +1,13 @@ +import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useMemo, useRef } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { MOCK_ORG_ID } from "shared/constants"; import { useDashboardNewWorkspaceDraft } from "../../DashboardNewWorkspaceDraftContext"; import { PromptGroup } from "../DashboardNewWorkspaceForm/PromptGroup"; +import { useSelectedHostProjectIds } from "./hooks/useSelectedHostProjectIds"; interface DashboardNewWorkspaceModalContentProps { isOpen: boolean; @@ -22,13 +27,20 @@ export function DashboardNewWorkspaceModalContent({ }: DashboardNewWorkspaceModalContentProps) { const { draft, updateDraft } = useDashboardNewWorkspaceDraft(); const collections = useCollections(); + const { data: session } = authClient.useSession(); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { data: v2Projects } = useLiveQuery( (q) => q .from({ projects: collections.v2Projects }) + .where(({ projects }) => + eq(projects.organizationId, activeOrganizationId ?? ""), + ) .select(({ projects }) => ({ ...projects })), - [collections], + [collections, activeOrganizationId], ); const { data: githubRepositories } = useLiveQuery( @@ -41,6 +53,8 @@ export function DashboardNewWorkspaceModalContent({ [collections], ); + const setUpProjectIds = useSelectedHostProjectIds(draft.hostTarget); + const recentProjects = useMemo(() => { const repoById = new Map( (githubRepositories ?? []).map((repo) => [repo.id, repo]), @@ -54,9 +68,11 @@ export function DashboardNewWorkspaceModalContent({ name: project.name, githubOwner: repo?.owner ?? null, githubRepoName: repo?.name ?? null, + needsSetup: + setUpProjectIds === null ? null : !setUpProjectIds.has(project.id), }; }); - }, [githubRepositories, v2Projects]); + }, [githubRepositories, setUpProjectIds, v2Projects]); const areProjectsReady = v2Projects !== undefined; const appliedPreSelectionRef = useRef(null); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/index.ts new file mode 100644 index 00000000000..d785220c21b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/index.ts @@ -0,0 +1 @@ +export { useSelectedHostProjectIds } from "./useSelectedHostProjectIds"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/useSelectedHostProjectIds.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/useSelectedHostProjectIds.ts new file mode 100644 index 00000000000..0651c64dff1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/useSelectedHostProjectIds.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import { env } from "renderer/env.renderer"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import type { WorkspaceHostTarget } from "../../../DashboardNewWorkspaceForm/components/DevicePicker/types"; + +/** + * IDs of projects already set up on the selected host. Returns `null` when + * we couldn't reach that host (treat as "unknown" — no setup indicator). + */ +export function useSelectedHostProjectIds( + hostTarget: WorkspaceHostTarget, +): Set | null { + const { activeHostUrl } = useLocalHostService(); + const hostUrl = + hostTarget.kind === "local" + ? activeHostUrl + : `${env.RELAY_URL}/hosts/${hostTarget.hostId}`; + + const { data } = useQuery({ + queryKey: ["project", "list", hostUrl], + enabled: !!hostUrl, + queryFn: async () => { + if (!hostUrl) return null; + try { + const client = getHostServiceClientByUrl(hostUrl); + const rows = await client.project.list.query(); + return new Set(rows.map((row) => row.id)); + } catch { + return null; + } + }, + }); + + return data ?? null; +} diff --git a/apps/desktop/src/renderer/stores/add-repository-modal.ts b/apps/desktop/src/renderer/stores/add-repository-modal.ts new file mode 100644 index 00000000000..e8741ba36cf --- /dev/null +++ b/apps/desktop/src/renderer/stores/add-repository-modal.ts @@ -0,0 +1,43 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +type ActiveModal = { kind: "none" } | { kind: "new-project" }; + +interface AddRepositoryModalState { + active: ActiveModal; + // Monotonically increasing counter; bumping it signals the host + // component to run the folder-first import flow (which is owned by a + // useFolderFirstImport hook — hooks can't live in a zustand store, so we + // use a trigger pulse instead). + folderImportTrigger: number; + openNewProject: () => void; + triggerFolderImport: () => void; + close: () => void; +} + +export const useAddRepositoryModalStore = create()( + devtools( + (set) => ({ + active: { kind: "none" }, + folderImportTrigger: 0, + openNewProject: () => set({ active: { kind: "new-project" } }), + triggerFolderImport: () => + set((state) => ({ + folderImportTrigger: state.folderImportTrigger + 1, + })), + close: () => set({ active: { kind: "none" } }), + }), + { name: "add-repository-modal" }, + ), +); + +export const useAddRepositoryModalActive = () => + useAddRepositoryModalStore((state) => state.active); +export const useFolderImportTrigger = () => + useAddRepositoryModalStore((state) => state.folderImportTrigger); +export const useOpenNewProjectModal = () => + useAddRepositoryModalStore((state) => state.openNewProject); +export const useTriggerFolderImport = () => + useAddRepositoryModalStore((state) => state.triggerFolderImport); +export const useCloseAddRepositoryModal = () => + useAddRepositoryModalStore((state) => state.close); diff --git a/docs/design/v2-project-create-import.md b/docs/design/v2-project-create-import.md new file mode 100644 index 00000000000..ce9d2c3ffea --- /dev/null +++ b/docs/design/v2-project-create-import.md @@ -0,0 +1,226 @@ +# V2 Project Create & Import + +Design for the v2 "create project" and "import project" flows. V2 projects are cloud-driven; materialization is per-host but resolved lazily, not pre-computed. + +--- + +## Two rules for v1 + +- **Sidebar** — pinned projects and their workspaces. Pin happens as a side-effect of `project.create` / `project.setup`; there's no standalone pin UI. +- **Workspaces tab** — workspaces in the user's active org scoped to hosts the user is linked to via `v2_users_hosts`. No filtering by pin or online status. + +Everything below serves one of those two rules. + +--- + +## Backing: local-only, action-time + +A project is **backed on a host** iff that host's `host-service.projects` table has a row for it (`packages/host-service/src/db/schema.ts:32`): + +```ts +projects { + id text PK // matches cloud v2_projects.id + repoPath text NOT NULL // local main repo path + repoProvider, repoOwner, repoName, repoUrl, remoteName + createdAt +} +``` + +`workspaces.projectId` FKs to this — no project row means no workspaces on that host. + +Backing is checked at the point of action (workspace creation, git ops). Remote hosts' backing state is their own business — we never render it. + +--- + +## State matrix + +Two axes, one per data source: + +| # | Cloud `v2_projects` | Host-service `projects` | Meaning | Action | +| --- | --- | --- | --- | --- | +| 1 | ✓ | ✗ | Cloud-only on this host | `project.setup` | +| 2 | ✓ | ✓ | Backed here | — | +| 3 | ✗ | — | Brand new | `project.create` | + +Stale `repoPath` fails at action time and surfaces as a toast. v1 has no automated recovery — user removes + re-imports if they want to fix it. + +--- + +## Host-service as orchestrator + +Every client calls host-service. Desktop today; web/mobile route through host-service later. The host-service RPC **is the create flow** — cloud-row creation, optional GitHub repo provisioning, local git, local DB insert. + +Neither `project.create` nor `project.setup` auto-creates a workspace. Workspaces are always explicit user action. + +### `project.create` + +User-facing intent: **"clone a new project."** Cloud row + local clone. + +```ts +project.create({ + name: string, + mode: + | { kind: "empty"; parentDir: string; visibility: "private" | "public" } + | { kind: "clone"; parentDir: string; url: string } + | { kind: "importLocal"; repoPath: string } + | { kind: "template"; parentDir: string; templateId: string; visibility: "private" | "public" } +}) → { projectId: string; repoPath: string } +``` + +`visibility` lives on the GitHub-provisioning modes (`empty`, `template`) only. `clone` and `importLocal` reuse an existing remote. + +Path semantics are baked into each variant: `parentDir` for modes that create a new directory; `repoPath` (git root) for `importLocal`. + +Ordering: + +1. `clone` — clone first into `parentDir`. On clone failure we leave no cloud state behind. +2. Cloud: create `v2_projects` row. On failure, `rmSync` the clone to roll back. +3. Upsert local `host-service.projects` row. + +`importLocal` does cloud-then-local (no remote work to roll back). + +**Always materializes on the calling host.** No cloud-only mode. + +Phase 1 ships `clone` and `importLocal` only; `empty` and `template` throw `not_implemented`. + +### `project.setup` + +User-facing intent: **"import."** Cell-1 → cell-2 (first-time setup). + +```ts +project.setup({ + projectId: string, + mode: + | { kind: "clone"; parentDir: string } + | { kind: "import"; repoPath: string } +}) → { repoPath: string } +``` + +**No re-pointing in v1.** If a `host-service.projects` row already exists for `projectId`: +- Same resolved path → no-op success (idempotent). +- Different path → `CONFLICT`. Caller must `project.remove` first to re-import elsewhere. + +### `project.findByPath` + +```ts +project.findByPath({ repoPath }) → { + candidates: Array<{ id, name, slug, organizationId, organizationName }> +} +``` + +Validates git root, reads the remote, forwards to cloud `v2Projects.findByGitHubRemote`. Drives the folder-first import picker. + +### `project.remove` + +Deletes the local row, worktrees, and the repo directory. + +### Client responsibilities + +Native pickers stay in the client — host-service has no UI. + +--- + +## Existing types — reuse, don't redeclare + +| Need | Source | +| --- | --- | +| Cloud project row | `typeof v2Projects.$inferSelect` | +| Cloud project creation | `v2Projects.create` — `{ organizationId, name, slug, repoCloneUrl }` (jwt-scoped) | +| Workspace (cloud) | `typeof v2Workspaces.$inferSelect` | +| Host (cloud) | `typeof v2Hosts.$inferSelect` | +| Host-service project row | `typeof projects.$inferSelect` | +| Host-service workspace row | `typeof workspaces.$inferSelect` | +| Current host identity | `useLocalHostService().machineId` + `activeHostUrl` | +| Pinned-in-sidebar rows | `v2SidebarProjects` / `v2WorkspaceLocalState` (localStorage) | + +--- + +## Sidebar + +**Pin alone.** A pinned project (`v2SidebarProjects` row) renders. No backing-derived filtering, no row decoration. `useDashboardSidebarData` does not call host-service. + +Entry points (in the sidebar `+` dropdown): + +- **"+ New project"** → `project.create` +- **"Import existing folder"** → folder-first picker + +That's it. No "Pin existing project" action, no Available section, no inline setup step. Add them back in a later PR if users report missing them. + +### Remote-device workspace clicks + +No gating. A remote workspace opens the normal workspace page — you can see it the same way as a local one. Operations that assume local filesystem (terminal spawn, local git) will fail at the point they're triggered; we'll address those as they surface. + +--- + +## Workspaces tab + +Lists workspaces in the user's active org scoped to hosts the user is linked to via `v2_users_hosts` (`workspaces.organizationId === activeOrganizationId AND userHosts.userId === currentUserId`). Teammates' workspaces on hosts you aren't linked to are not surfaced here. + +Rows indicate their host via a `hostType` chip (`local-device` / `remote-device` / `cloud`). Remote-device clicks route to the same stub as the sidebar. + +No Available section. No "+ New project" or "Import folder" CTAs — those live in the sidebar dropdown. + +--- + +## Folder-first import — picker flow + +1. User clicks "Import existing folder" → native picker. +2. Client calls `project.findByPath({ repoPath })`. +3. Host-service validates git root, reads remote, forwards to `v2Projects.findByGitHubRemote({ repoCloneUrl })`. +4. Cloud filters to projects in orgs the user belongs to. +5. Client branches on `candidates.length`: + - **0** → "No match — create as new project" (pivots to `project.create importLocal`). + - **1, not yet set up here** → auto-advance to `project.setup({ projectId, mode: { kind: "import", repoPath } })`. + - **1, already set up here at a different path** → surface the `CONFLICT` error; user must `project.remove` first to re-import. + - **>1** → picker; user picks; then `project.setup`. + +Candidate list is scoped to the user's accessible orgs — not global. + +--- + +## User journeys + +### 1. New user, new org — first project + +Sidebar `+` → "New project" → `project.create` → sidebar shows the project, no workspaces yet. + +### 2. Join an org with existing projects + +Workspaces tab shows workspaces in the org on hosts the user is linked to (via `v2_users_hosts`), including teammates' workspaces on hosts you share. Click any of them to open — local-fs operations degrade as they hit their limits; the workspace itself renders. + +### 3. Adding a second host + +New device, sidebar starts empty (pins are per-device). User re-pins via "New project" or "Import folder", or by clicking a remote workspace row and choosing "Set up here". + +### 4. `repoPath` deleted out of band + +Next git op or `workspace.create` fails with ENOENT. Handler surfaces the failure; recovery UX is deferred (see "Out of scope for v1"). + +--- + +## Flow summary + +| Transition | RPC | Entry point | +| --- | --- | --- | +| cell 3 → cell 2 | `project.create` | Sidebar `+` → New project | +| cell 1 → cell 2 | `project.setup` | Folder-first import (non-conflict) | +| cell 2 → cell 2 (re-point) | _deferred_ | — (user removes + re-imports) | + +--- + +## Out of scope for v1 + +- **Available section / rediscovery UX.** Workspaces tab just shows what exists; it doesn't surface cloud projects the user could pin. +- **Standalone pin UI.** Pins happen as a side-effect of create/setup. +- **Inline `project.setup` step in New Workspace modal.** If a pinned project ever gets into an unbacked state (e.g. cross-device pin sync), `workspace.create` fails with `PROJECT_NOT_SETUP` and we surface a toast. No modal recovery loop in v1. +- **Cross-device pin sync, auto-pin, unpin UX.** +- **GitHub repo creation (`project.create` `empty` / `template`).** Returns `not_implemented`. +- **Template source.** +- **Preemptive "host offline" / "not set up here" hints.** +- **Orphaned cloud-row cleanup.** + +--- + +## Phasing + +See [`plans/20260417-v2-project-create-import-impl.md`](../../plans/20260417-v2-project-create-import-impl.md). diff --git a/packages/db/drizzle/0034_v2_projects_decouple_github_install_add_clone_url.sql b/packages/db/drizzle/0034_v2_projects_decouple_github_install_add_clone_url.sql new file mode 100644 index 00000000000..be40a7cd13e --- /dev/null +++ b/packages/db/drizzle/0034_v2_projects_decouple_github_install_add_clone_url.sql @@ -0,0 +1,6 @@ +ALTER TABLE "v2_projects" DROP CONSTRAINT "v2_projects_github_repository_id_github_repositories_id_fk"; +--> statement-breakpoint +ALTER TABLE "v2_projects" ALTER COLUMN "github_repository_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "v2_projects" ADD COLUMN "repo_clone_url" text;--> statement-breakpoint +ALTER TABLE "v2_projects" ADD CONSTRAINT "v2_projects_github_repository_id_github_repositories_id_fk" FOREIGN KEY ("github_repository_id") REFERENCES "public"."github_repositories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "v2_projects_org_repo_clone_url_unique" ON "v2_projects" USING btree ("organization_id",lower("repo_clone_url")); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0034_snapshot.json b/packages/db/drizzle/meta/0034_snapshot.json new file mode 100644 index 00000000000..9b570800541 --- /dev/null +++ b/packages/db/drizzle/meta/0034_snapshot.json @@ -0,0 +1,5291 @@ +{ + "id": "d4ab0483-111f-4b61-8b02-d178afe3c19b", + "prevId": "23f7159b-4d98-4245-9fd6-87789de09467", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.device_codes": { + "name": "device_codes", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.jwkss": { + "name": "jwkss", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "schemaTo": "auth", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_clients": { + "name": "oauth_clients", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_domains": { + "name": "allowed_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_allowed_domains_idx": { + "name": "organizations_allowed_domains_idx", + "columns": [ + { + "expression": "allowed_domains", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_org_id_idx": { + "name": "github_pull_requests_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pull_requests_organization_id_organizations_id_fk": { + "name": "github_pull_requests_organization_id_organizations_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_org_id_idx": { + "name": "github_repositories_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_repositories_organization_id_organizations_id_fk": { + "name": "github_repositories_organization_id_organizations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_org_idx": { + "name": "chat_sessions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_created_by_idx": { + "name": "chat_sessions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_last_active_idx": { + "name": "chat_sessions_last_active_idx", + "columns": [ + { + "expression": "last_active_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_created_by_users_id_fk": { + "name": "chat_sessions_created_by_users_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_sessions_v2_workspace_id_v2_workspaces_id_fk": { + "name": "chat_sessions_v2_workspace_id_v2_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "v2_workspaces", + "columnsFrom": [ + "v2_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_presence": { + "name": "device_presence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_presence_user_org_idx": { + "name": "device_presence_user_org_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_user_device_idx": { + "name": "device_presence_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_last_seen_idx": { + "name": "device_presence_last_seen_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_presence_user_id_users_id_fk": { + "name": "device_presence_user_id_users_id_fk", + "tableFrom": "device_presence", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_presence_organization_id_organizations_id_fk": { + "name": "device_presence_organization_id_organizations_id_fk", + "tableFrom": "device_presence", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_unique": { + "name": "integration_connections_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_github_repository_id_github_repositories_id_fk": { + "name": "projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_org_slug_unique": { + "name": "projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_images": { + "name": "sandbox_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "setup_commands": { + "name": "setup_commands", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "base_image": { + "name": "base_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_images_organization_id_idx": { + "name": "sandbox_images_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sandbox_images_organization_id_organizations_id_fk": { + "name": "sandbox_images_organization_id_organizations_id_fk", + "tableFrom": "sandbox_images", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sandbox_images_project_id_projects_id_fk": { + "name": "sandbox_images_project_id_projects_id_fk", + "tableFrom": "sandbox_images", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sandbox_images_project_unique": { + "name": "sandbox_images_project_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secrets_project_id_idx": { + "name": "secrets_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secrets_organization_id_idx": { + "name": "secrets_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secrets_organization_id_organizations_id_fk": { + "name": "secrets_organization_id_organizations_id_fk", + "tableFrom": "secrets", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "secrets_project_id_projects_id_fk": { + "name": "secrets_project_id_projects_id_fk", + "tableFrom": "secrets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "secrets_created_by_user_id_users_id_fk": { + "name": "secrets_created_by_user_id_users_id_fk", + "tableFrom": "secrets", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "secrets_project_key_unique": { + "name": "secrets_project_key_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_hosts": { + "name": "session_hosts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_hosts_session_id_idx": { + "name": "session_hosts_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_hosts_org_idx": { + "name": "session_hosts_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_hosts_device_id_idx": { + "name": "session_hosts_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_hosts_session_id_chat_sessions_id_fk": { + "name": "session_hosts_session_id_chat_sessions_id_fk", + "tableFrom": "session_hosts", + "tableTo": "chat_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_hosts_organization_id_organizations_id_fk": { + "name": "session_hosts_organization_id_organizations_id_fk", + "tableFrom": "session_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_external_id": { + "name": "assignee_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_display_name": { + "name": "assignee_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_avatar_url": { + "name": "assignee_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_external_id_idx": { + "name": "tasks_assignee_external_id_idx", + "columns": [ + { + "expression": "assignee_external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users__slack_users": { + "name": "users__slack_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "model_preference": { + "name": "model_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users__slack_users_user_idx": { + "name": "users__slack_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users__slack_users_org_idx": { + "name": "users__slack_users_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users__slack_users_user_id_users_id_fk": { + "name": "users__slack_users_user_id_users_id_fk", + "tableFrom": "users__slack_users", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users__slack_users_organization_id_organizations_id_fk": { + "name": "users__slack_users_organization_id_organizations_id_fk", + "tableFrom": "users__slack_users", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users__slack_users_unique": { + "name": "users__slack_users_unique", + "nullsNotDistinct": false, + "columns": [ + "slack_user_id", + "team_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_clients": { + "name": "v2_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_clients_organization_id_idx": { + "name": "v2_clients_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_clients_user_id_idx": { + "name": "v2_clients_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_clients_organization_id_organizations_id_fk": { + "name": "v2_clients_organization_id_organizations_id_fk", + "tableFrom": "v2_clients", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_clients_user_id_users_id_fk": { + "name": "v2_clients_user_id_users_id_fk", + "tableFrom": "v2_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_clients_org_user_machine_unique": { + "name": "v2_clients_org_user_machine_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "user_id", + "machine_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_hosts": { + "name": "v2_hosts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_online": { + "name": "is_online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_hosts_organization_id_idx": { + "name": "v2_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_hosts_organization_id_organizations_id_fk": { + "name": "v2_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_hosts_created_by_user_id_users_id_fk": { + "name": "v2_hosts_created_by_user_id_users_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_hosts_org_machine_id_unique": { + "name": "v2_hosts_org_machine_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "machine_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_projects": { + "name": "v2_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_clone_url": { + "name": "repo_clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_projects_organization_id_idx": { + "name": "v2_projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_projects_org_repo_clone_url_unique": { + "name": "v2_projects_org_repo_clone_url_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"repo_clone_url\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_projects_organization_id_organizations_id_fk": { + "name": "v2_projects_organization_id_organizations_id_fk", + "tableFrom": "v2_projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_projects_github_repository_id_github_repositories_id_fk": { + "name": "v2_projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "v2_projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_projects_org_slug_unique": { + "name": "v2_projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_users_hosts": { + "name": "v2_users_hosts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "v2_users_host_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_users_hosts_organization_id_idx": { + "name": "v2_users_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_user_id_idx": { + "name": "v2_users_hosts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_host_id_idx": { + "name": "v2_users_hosts_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_users_hosts_organization_id_organizations_id_fk": { + "name": "v2_users_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_user_id_users_id_fk": { + "name": "v2_users_hosts_user_id_users_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_host_id_v2_hosts_id_fk": { + "name": "v2_users_hosts_host_id_v2_hosts_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "v2_hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_users_hosts_org_user_host_unique": { + "name": "v2_users_hosts_org_user_host_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "user_id", + "host_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_workspaces": { + "name": "v2_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_workspaces_project_id_idx": { + "name": "v2_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_organization_id_idx": { + "name": "v2_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_host_id_idx": { + "name": "v2_workspaces_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_workspaces_organization_id_organizations_id_fk": { + "name": "v2_workspaces_organization_id_organizations_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_project_id_v2_projects_id_fk": { + "name": "v2_workspaces_project_id_v2_projects_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_host_id_v2_hosts_id_fk": { + "name": "v2_workspaces_host_id_v2_hosts_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "v2_workspaces_created_by_user_id_users_id_fk": { + "name": "v2_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_organization_id_idx": { + "name": "workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_type_idx": { + "name": "workspaces_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_user_id_users_id_fk": { + "name": "workspaces_created_by_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed", + "timeout" + ] + }, + "public.device_type": { + "name": "device_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + }, + "public.v2_client_type": { + "name": "v2_client_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.v2_users_host_role": { + "name": "v2_users_host_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + }, + "public.workspace_type": { + "name": "workspace_type", + "schema": "public", + "values": [ + "local", + "cloud" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 1123db1ab67..8be581b485a 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1775788950626, "tag": "0033_add_oauth_refresh_token_auth_time", "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1776647783826, + "tag": "0034_v2_projects_decouple_github_install_add_clone_url", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index c50c2568ebb..9171556ab85 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { boolean, index, @@ -386,9 +387,11 @@ export const v2Projects = pgTable( .references(() => organizations.id, { onDelete: "cascade" }), name: text().notNull(), slug: text().notNull(), - githubRepositoryId: uuid("github_repository_id") - .notNull() - .references(() => githubRepositories.id, { onDelete: "restrict" }), + repoCloneUrl: text("repo_clone_url"), + githubRepositoryId: uuid("github_repository_id").references( + () => githubRepositories.id, + { onDelete: "set null" }, + ), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -400,6 +403,12 @@ export const v2Projects = pgTable( (table) => [ index("v2_projects_organization_id_idx").on(table.organizationId), unique("v2_projects_org_slug_unique").on(table.organizationId, table.slug), + // One project per repo URL per org. NULLs don't collide (PG default) + // so empty-mode projects without a remote can still be created. + uniqueIndex("v2_projects_org_repo_clone_url_unique").on( + table.organizationId, + sql`lower(${table.repoCloneUrl})`, + ), ], ); diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.ts index a64d9f55871..af89211a87f 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -1,11 +1,11 @@ import { randomUUID } from "node:crypto"; import type { Octokit } from "@octokit/rest"; +import { parseGitHubRemote } from "@superset/shared/github-remote"; import { and, eq, inArray } from "drizzle-orm"; import type { HostDb } from "../../db"; import { projects, pullRequests, workspaces } from "../../db/schema"; import type { GitFactory } from "../git"; import { fetchRepositoryPullRequests } from "./utils/github-query"; -import { parseGitHubRemote } from "./utils/parse-github-remote"; import { type ChecksStatus, coerceChecksStatus, diff --git a/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/index.ts b/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/index.ts deleted file mode 100644 index 25ae243201e..00000000000 --- a/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - type ParsedGitHubRemote, - parseGitHubRemote, -} from "./parse-github-remote"; diff --git a/packages/host-service/src/trpc/error-types.ts b/packages/host-service/src/trpc/error-types.ts index 094a3f50d5f..8fa6e3b0564 100644 --- a/packages/host-service/src/trpc/error-types.ts +++ b/packages/host-service/src/trpc/error-types.ts @@ -22,3 +22,23 @@ export function isTeardownFailureCause( (value as { kind: unknown }).kind === "TEARDOWN_FAILED" ); } + +/** + * Thrown by host-service procedures that require the project to already + * be set up on this host. + */ +export interface ProjectNotSetupCause { + kind: "PROJECT_NOT_SETUP"; + projectId: string; +} + +export function isProjectNotSetupCause( + value: unknown, +): value is ProjectNotSetupCause { + return ( + !!value && + typeof value === "object" && + "kind" in value && + (value as { kind: unknown }).kind === "PROJECT_NOT_SETUP" + ); +} diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index 7d7aee6ddf9..a74f14b22d7 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -2,7 +2,9 @@ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import type { HostServiceContext } from "../types"; import { + isProjectNotSetupCause, isTeardownFailureCause, + type ProjectNotSetupCause, type TeardownFailureCause, } from "./error-types"; @@ -24,11 +26,19 @@ const t = initTRPC.context().create({ outputTail: error.cause.outputTail, } : undefined; + const projectNotSetup: ProjectNotSetupCause | undefined = + isProjectNotSetupCause(error.cause) + ? { + kind: "PROJECT_NOT_SETUP", + projectId: error.cause.projectId, + } + : undefined; return { ...shape, data: { ...shape.data, teardownFailure, + projectNotSetup, }, }; }, @@ -47,5 +57,8 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { return next({ ctx }); }); -export type { TeardownFailureCause } from "./error-types"; +export type { + ProjectNotSetupCause, + TeardownFailureCause, +} from "./error-types"; export type { AppRouter } from "./router"; diff --git a/packages/host-service/src/trpc/router/project/handlers.ts b/packages/host-service/src/trpc/router/project/handlers.ts new file mode 100644 index 00000000000..5e967a622ba --- /dev/null +++ b/packages/host-service/src/trpc/router/project/handlers.ts @@ -0,0 +1,72 @@ +import { rmSync } from "node:fs"; +import { TRPCError } from "@trpc/server"; +import type { HostServiceContext } from "../../../types"; +import { persistLocalProject } from "./utils/persist-project"; +import { cloneRepoInto, resolveWithPrimaryRemote } from "./utils/resolve-repo"; + +function slugifyProjectName(name: string): string { + const slug = name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!slug) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Project name must contain at least one alphanumeric character", + }); + } + return slug; +} + +interface CreateResult { + projectId: string; + repoPath: string; +} + +/** + * Clone first so clone-time failures (bad URL, auth, network, dir + * collision) leave no cloud state behind; rollback the local clone on + * cloud failure. Mirrors workspace.create's local-first-then-cloud order. + */ +export async function createFromClone( + ctx: HostServiceContext, + args: { name: string; parentDir: string; url: string }, +): Promise { + const resolved = await cloneRepoInto(args.url, args.parentDir); + try { + const cloudProject = await ctx.api.v2Project.create.mutate({ + organizationId: ctx.organizationId, + name: args.name, + slug: slugifyProjectName(args.name), + repoCloneUrl: args.url, + }); + persistLocalProject(ctx, cloudProject.id, resolved); + return { projectId: cloudProject.id, repoPath: resolved.repoPath }; + } catch (err) { + try { + rmSync(resolved.repoPath, { recursive: true, force: true }); + } catch (cleanupErr) { + console.warn( + "[project.createFromClone] failed to rollback clone after cloud error", + { repoPath: resolved.repoPath, cleanupErr }, + ); + } + throw err; + } +} + +export async function createFromImportLocal( + ctx: HostServiceContext, + args: { name: string; repoPath: string }, +): Promise { + const resolved = await resolveWithPrimaryRemote(args.repoPath); + const cloudProject = await ctx.api.v2Project.create.mutate({ + organizationId: ctx.organizationId, + name: args.name, + slug: slugifyProjectName(args.name), + repoCloneUrl: resolved.parsed.url, + }); + persistLocalProject(ctx, cloudProject.id, resolved); + return { projectId: cloudProject.id, repoPath: resolved.repoPath }; +} diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 1a9b23b7d41..6121303cbef 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -1,53 +1,121 @@ -import { existsSync, rmSync, statSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { rmSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import { parseGitHubRemote } from "@superset/shared/github-remote"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; -import simpleGit from "simple-git"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; -import { parseGitHubRemote } from "../../../runtime/pull-requests/utils/parse-github-remote"; import { protectedProcedure, router } from "../../index"; +import { createFromClone, createFromImportLocal } from "./handlers"; +import { persistLocalProject } from "./utils/persist-project"; import { - findMatchingRemote, - getGitHubRemotes, - type ParsedGitHubRemote, -} from "./utils/git-remote"; - -interface ResolvedRepo { - repoPath: string; - matchingRemote: string; - parsed: ParsedGitHubRemote; -} + cloneRepoInto, + resolveMatchingSlug, + resolveWithPrimaryRemote, +} from "./utils/resolve-repo"; export const projectRouter = router({ - setup: protectedProcedure + list: protectedProcedure.query(({ ctx }) => { + return ctx.db.select({ id: projects.id }).from(projects).all(); + }), + + findByPath: protectedProcedure + .input(z.object({ repoPath: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const { parsed } = await resolveWithPrimaryRemote(input.repoPath); + const { candidates } = await ctx.api.v2Project.findByGitHubRemote.query({ + organizationId: ctx.organizationId, + repoCloneUrl: parsed.url, + }); + return { candidates }; + }), + + create: protectedProcedure .input( z.object({ - projectId: z.string(), - mode: z.enum(["import", "clone"]), - localPath: z.string().min(1), + name: z.string().min(1), + // `visibility` lives on the GitHub-provisioning modes only. + // Clone + importLocal reuse an existing remote where visibility + // is already set on the remote itself. + mode: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("empty"), + parentDir: z.string().min(1), + visibility: z.enum(["private", "public"]), + }), + z.object({ + kind: z.literal("clone"), + parentDir: z.string().min(1), + url: z.string().min(1), + }), + z.object({ + kind: z.literal("importLocal"), + repoPath: z.string().min(1), + }), + z.object({ + kind: z.literal("template"), + parentDir: z.string().min(1), + templateId: z.string().min(1), + visibility: z.enum(["private", "public"]), + }), + ]), }), ) .mutation(async ({ ctx, input }) => { - if (!ctx.api) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Cloud API not configured", - }); + switch (input.mode.kind) { + case "empty": + case "template": + throw new TRPCError({ + code: "NOT_IMPLEMENTED", + message: `project.create mode="${input.mode.kind}" is not implemented yet`, + }); + case "clone": + return createFromClone(ctx, { + name: input.name, + parentDir: input.mode.parentDir, + url: input.mode.url, + }); + case "importLocal": + return createFromImportLocal(ctx, { + name: input.name, + repoPath: input.mode.repoPath, + }); } + }), + + setup: protectedProcedure + .input( + z.object({ + projectId: z.string().uuid(), + mode: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("clone"), + parentDir: z.string().min(1), + }), + z.object({ + kind: z.literal("import"), + repoPath: z.string().min(1), + }), + ]), + }), + ) + .mutation(async ({ ctx, input }) => { + const existing = ctx.db + .select({ id: projects.id, repoPath: projects.repoPath }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); const cloudProject = await ctx.api.v2Project.get.query({ organizationId: ctx.organizationId, id: input.projectId, }); - if (!cloudProject.repoCloneUrl) { throw new TRPCError({ code: "BAD_REQUEST", message: "Project has no linked GitHub repository — cannot set up", }); } - const expectedParsed = parseGitHubRemote(cloudProject.repoCloneUrl); if (!expectedParsed) { throw new TRPCError({ @@ -55,59 +123,55 @@ export const projectRouter = router({ message: `Could not parse GitHub remote from ${cloudProject.repoCloneUrl}`, }); } - const expectedSlug = `${expectedParsed.owner}/${expectedParsed.name}`; - let resolved: ResolvedRepo; - - if (input.mode === "import") { - resolved = await importExistingRepo(input.localPath, expectedSlug); - } else { - resolved = await cloneRepo( - cloudProject.repoCloneUrl, - input.localPath, - expectedSlug, - ); + // v1 never re-points an existing project. Same-path setup is a + // no-op; different-path throws and the user must `project.remove` + // first if they genuinely want to move. + const rejectIfRepoint = (targetPath: string) => { + if (!existing) return; + if (existing.repoPath === targetPath) return; + throw new TRPCError({ + code: "CONFLICT", + message: `Project is already set up on this device at ${existing.repoPath}. Remove it first to re-import at a different location.`, + }); + }; + + switch (input.mode.kind) { + case "clone": { + const predictedPath = resolvePath( + input.mode.parentDir, + expectedParsed.name, + ); + rejectIfRepoint(predictedPath); + if (existing) return { repoPath: existing.repoPath }; + const resolved = await cloneRepoInto( + cloudProject.repoCloneUrl, + input.mode.parentDir, + ); + persistLocalProject(ctx, input.projectId, resolved); + return { repoPath: resolved.repoPath }; + } + case "import": { + const resolved = await resolveMatchingSlug( + input.mode.repoPath, + expectedSlug, + ); + rejectIfRepoint(resolved.repoPath); + if (existing) return { repoPath: existing.repoPath }; + persistLocalProject(ctx, input.projectId, resolved); + return { repoPath: resolved.repoPath }; + } } - - ctx.db - .insert(projects) - .values({ - id: input.projectId, - repoPath: resolved.repoPath, - repoProvider: "github", - repoOwner: resolved.parsed.owner, - repoName: resolved.parsed.name, - repoUrl: resolved.parsed.url, - remoteName: resolved.matchingRemote, - }) - .onConflictDoUpdate({ - target: projects.id, - set: { - repoPath: resolved.repoPath, - repoProvider: "github", - repoOwner: resolved.parsed.owner, - repoName: resolved.parsed.name, - repoUrl: resolved.parsed.url, - remoteName: resolved.matchingRemote, - }, - }) - .run(); - - return { repoPath: resolved.repoPath }; }), - // TODO: remove remove: protectedProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { const localProject = ctx.db.query.projects .findFirst({ where: eq(projects.id, input.projectId) }) .sync(); - - if (!localProject) { - return { success: true }; - } + if (!localProject) return { success: true }; const localWorkspaces = ctx.db .select() @@ -143,129 +207,3 @@ export const projectRouter = router({ return { success: true }; }), }); - -async function importExistingRepo( - localPath: string, - expectedSlug: string, -): Promise { - if (!existsSync(localPath)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Path does not exist: ${localPath}`, - }); - } - - if (!statSync(localPath).isDirectory()) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Path is not a directory: ${localPath}`, - }); - } - - const git = simpleGit(localPath); - - let gitRoot: string; - try { - gitRoot = (await git.revparse(["--show-toplevel"])).trim(); - } catch { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Not a git repository: ${localPath}`, - }); - } - - const remotes = await getGitHubRemotes(simpleGit(gitRoot)); - const matchingRemote = findMatchingRemote(remotes, expectedSlug); - - if (!matchingRemote) { - const found = [...remotes.entries()] - .map(([name, parsed]) => `${name}: ${parsed.owner}/${parsed.name}`) - .join(", "); - throw new TRPCError({ - code: "BAD_REQUEST", - message: `No remote matches ${expectedSlug}. Found: ${found || "no remotes"}`, - }); - } - - const parsed = remotes.get(matchingRemote); - if (!parsed) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Remote "${matchingRemote}" matched but has no parsed data`, - }); - } - - return { repoPath: gitRoot, matchingRemote, parsed }; -} - -async function cloneRepo( - repoCloneUrl: string, - parentDir: string, - expectedSlug: string, -): Promise { - const resolvedParentDir = resolve(parentDir); - - if (!existsSync(resolvedParentDir)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Parent directory does not exist: ${resolvedParentDir}`, - }); - } - - if (!statSync(resolvedParentDir).isDirectory()) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Parent path is not a directory: ${resolvedParentDir}`, - }); - } - - const repoName = extractRepoNameFromUrl(repoCloneUrl); - const targetPath = join(resolvedParentDir, repoName); - - if (existsSync(targetPath)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Directory already exists: ${targetPath}`, - }); - } - - try { - await simpleGit().clone(repoCloneUrl, targetPath); - } catch (err) { - if (existsSync(targetPath)) { - rmSync(targetPath, { recursive: true, force: true }); - } - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Failed to clone repository: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - const remotes = await getGitHubRemotes(simpleGit(targetPath)); - const matchingRemote = findMatchingRemote(remotes, expectedSlug); - - if (!matchingRemote) { - rmSync(targetPath, { recursive: true, force: true }); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Cloned repo does not match expected GitHub remote", - }); - } - - const parsed = remotes.get(matchingRemote); - if (!parsed) { - rmSync(targetPath, { recursive: true, force: true }); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Remote "${matchingRemote}" matched but has no parsed data`, - }); - } - - return { repoPath: targetPath, matchingRemote, parsed }; -} - -function extractRepoNameFromUrl(url: string): string { - const parsed = parseGitHubRemote(url); - if (parsed) return parsed.name; - return basename(url, ".git"); -} diff --git a/packages/host-service/src/trpc/router/project/utils/git-remote.ts b/packages/host-service/src/trpc/router/project/utils/git-remote.ts index 35d8624f012..cc27dafdcf1 100644 --- a/packages/host-service/src/trpc/router/project/utils/git-remote.ts +++ b/packages/host-service/src/trpc/router/project/utils/git-remote.ts @@ -1,8 +1,8 @@ -import type { SimpleGit } from "simple-git"; import { type ParsedGitHubRemote, parseGitHubRemote, -} from "../../../../runtime/pull-requests/utils/parse-github-remote"; +} from "@superset/shared/github-remote"; +import type { SimpleGit } from "simple-git"; export type { ParsedGitHubRemote }; diff --git a/packages/host-service/src/trpc/router/project/utils/persist-project.ts b/packages/host-service/src/trpc/router/project/utils/persist-project.ts new file mode 100644 index 00000000000..19ab45179c0 --- /dev/null +++ b/packages/host-service/src/trpc/router/project/utils/persist-project.ts @@ -0,0 +1,23 @@ +import { projects } from "../../../../db/schema"; +import type { HostServiceContext } from "../../../../types"; +import type { ResolvedRepo } from "./resolve-repo"; + +export function persistLocalProject( + ctx: HostServiceContext, + projectId: string, + resolved: ResolvedRepo, +): void { + const repoFields = { + repoPath: resolved.repoPath, + repoProvider: "github" as const, + repoOwner: resolved.parsed.owner, + repoName: resolved.parsed.name, + repoUrl: resolved.parsed.url, + remoteName: resolved.remoteName, + }; + ctx.db + .insert(projects) + .values({ id: projectId, ...repoFields }) + .onConflictDoUpdate({ target: projects.id, set: repoFields }) + .run(); +} diff --git a/packages/host-service/src/trpc/router/project/utils/resolve-repo.ts b/packages/host-service/src/trpc/router/project/utils/resolve-repo.ts new file mode 100644 index 00000000000..28dddfca654 --- /dev/null +++ b/packages/host-service/src/trpc/router/project/utils/resolve-repo.ts @@ -0,0 +1,179 @@ +import { existsSync, mkdirSync, rmSync, statSync } from "node:fs"; +import { join, resolve as resolvePath } from "node:path"; +import { parseGitHubRemote } from "@superset/shared/github-remote"; +import { TRPCError } from "@trpc/server"; +import simpleGit from "simple-git"; +import { + findMatchingRemote, + getGitHubRemotes, + type ParsedGitHubRemote, +} from "./git-remote"; + +export interface ResolvedRepo { + repoPath: string; + remoteName: string; + parsed: ParsedGitHubRemote; +} + +function validateDirectoryPath(path: string, label: string): void { + if (!existsSync(path)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${label} does not exist: ${path}`, + }); + } + if (!statSync(path).isDirectory()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${label} is not a directory: ${path}`, + }); + } +} + +async function revParseGitRoot(path: string): Promise { + try { + return (await simpleGit(path).revparse(["--show-toplevel"])).trim(); + } catch { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Not a git repository: ${path}`, + }); + } +} + +/** + * Validates that a path is a git working tree and returns the canonical git + * root plus its "primary" GitHub remote — `origin` if present, otherwise + * the first GitHub remote found. Throws if the path isn't a git repo or has + * no GitHub remotes. + * + * Used when the caller doesn't have an authoritative clone URL to match + * against (e.g. `findByPath`, `create mode=importLocal`). + */ +export async function resolveWithPrimaryRemote( + repoPath: string, +): Promise { + validateDirectoryPath(repoPath, "Path"); + const gitRoot = await revParseGitRoot(repoPath); + const remotes = await getGitHubRemotes(simpleGit(gitRoot)); + if (remotes.size === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Repository has no GitHub remotes", + }); + } + const originParsed = remotes.get("origin"); + if (originParsed) { + return { repoPath: gitRoot, remoteName: "origin", parsed: originParsed }; + } + const first = remotes.entries().next().value; + if (!first) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Remote iteration produced no entries", + }); + } + const [firstName, firstParsed] = first; + return { repoPath: gitRoot, remoteName: firstName, parsed: firstParsed }; +} + +/** + * Validates that a path is a git working tree and returns the canonical git + * root plus the GitHub remote whose `owner/name` matches `expectedSlug`. + * Throws if no matching remote exists. + * + * Used when the caller has an authoritative clone URL from the cloud and + * wants to confirm this local repo is actually that project (`setup + * mode=import`, post-clone validation). + */ +export async function resolveMatchingSlug( + repoPath: string, + expectedSlug: string, +): Promise { + validateDirectoryPath(repoPath, "Path"); + const gitRoot = await revParseGitRoot(repoPath); + const remotes = await getGitHubRemotes(simpleGit(gitRoot)); + const remoteName = findMatchingRemote(remotes, expectedSlug); + if (!remoteName) { + const found = [...remotes.entries()] + .map(([name, parsed]) => `${name}: ${parsed.owner}/${parsed.name}`) + .join(", "); + throw new TRPCError({ + code: "BAD_REQUEST", + message: `No remote matches ${expectedSlug}. Found: ${found || "no remotes"}`, + }); + } + const parsed = remotes.get(remoteName); + if (!parsed) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Remote "${remoteName}" matched but has no parsed data`, + }); + } + return { repoPath: gitRoot, remoteName, parsed }; +} + +/** + * Clones a GitHub repo into `/` and returns the resolved + * repo. Fails and cleans up the target directory if the clone succeeds but + * the resulting remote doesn't match the URL we cloned from (defensive). + */ +export async function cloneRepoInto( + repoCloneUrl: string, + parentDir: string, +): Promise { + const parsedUrl = parseGitHubRemote(repoCloneUrl); + if (!parsedUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Could not parse GitHub remote from ${repoCloneUrl}`, + }); + } + const expectedSlug = `${parsedUrl.owner}/${parsedUrl.name}`; + + const resolvedParentDir = resolvePath(parentDir); + validateDirectoryPath(resolvedParentDir, "Parent directory"); + + const targetPath = join(resolvedParentDir, parsedUrl.name); + + // Atomic claim: mkdirSync without `recursive` throws EEXIST when the + // path is already present, which avoids the TOCTOU window between an + // existsSync check and the clone call. If clone fails afterwards we + // know we created the dir and can rmSync it without risk of deleting + // someone else's directory. + try { + mkdirSync(targetPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Directory already exists: ${targetPath}`, + }); + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Could not create target directory: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + } + + try { + await simpleGit().clone(repoCloneUrl, targetPath); + } catch (err) { + rmSync(targetPath, { recursive: true, force: true }); + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Failed to clone repository: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + } + + try { + return await resolveMatchingSlug(targetPath, expectedSlug); + } catch (err) { + rmSync(targetPath, { recursive: true, force: true }); + throw err; + } +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index 5d77aa6d672..c75b8cef913 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -1,9 +1,8 @@ -import { existsSync, mkdirSync } from "node:fs"; -import { dirname, join, resolve, sep } from "node:path"; +import { existsSync } from "node:fs"; +import { join, resolve, sep } from "node:path"; import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info"; import { TRPCError } from "@trpc/server"; import { and, eq } from "drizzle-orm"; -import simpleGit from "simple-git"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; import { @@ -15,6 +14,7 @@ import { } from "../../../runtime/git/refs"; import { createTerminalSessionInternal } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; +import type { ProjectNotSetupCause } from "../../error-types"; import { protectedProcedure, router } from "../../index"; import { generateBranchNameFromPrompt } from "./utils/ai-branch-name"; import { execGh } from "./utils/exec-gh"; @@ -71,6 +71,17 @@ function sweepStaleProgress(): void { // ── Helpers ────────────────────────────────────────────────────────── +function projectNotSetupError(projectId: string): TRPCError { + return new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Project is not set up on this host", + cause: { + kind: "PROJECT_NOT_SETUP", + projectId, + } satisfies ProjectNotSetupCause, + }); +} + function safeResolveWorktreePath(repoPath: string, branchName: string): string { const worktreesRoot = resolve(repoPath, ".worktrees"); const worktreePath = resolve(worktreesRoot, branchName); @@ -709,37 +720,11 @@ export const workspaceCreationRouter = router({ const deviceName = getDeviceName(); setProgress(input.pendingId, "ensuring_repo"); - // 1. Resolve / ensure project locally - let localProject = ctx.db.query.projects + const localProject = ctx.db.query.projects .findFirst({ where: eq(projects.id, input.projectId) }) .sync(); - if (!localProject) { - const cloudProject = await ctx.api.v2Project.get.query({ - organizationId: ctx.organizationId, - id: input.projectId, - }); - - if (!cloudProject.repoCloneUrl) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Project has no linked GitHub repository — cannot clone", - }); - } - - const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp"; - const repoPath = join(homeDir, ".superset", "repos", input.projectId); - - if (!existsSync(repoPath)) { - mkdirSync(dirname(repoPath), { recursive: true }); - await simpleGit().clone(cloudProject.repoCloneUrl, repoPath); - } - - localProject = ctx.db - .insert(projects) - .values({ id: input.projectId, repoPath }) - .returning() - .get(); + throw projectNotSetupError(input.projectId); } setProgress(input.pendingId, "creating_worktree"); @@ -1036,33 +1021,11 @@ export const workspaceCreationRouter = router({ .mutation(async ({ ctx, input }) => { setProgress(input.pendingId, "ensuring_repo"); - // Ensure project locally (clone if missing) — shared across both paths. - let localProject = ctx.db.query.projects + const localProject = ctx.db.query.projects .findFirst({ where: eq(projects.id, input.projectId) }) .sync(); - if (!localProject) { - const cloudProject = await ctx.api.v2Project.get.query({ - organizationId: ctx.organizationId, - id: input.projectId, - }); - if (!cloudProject.repoCloneUrl) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Project has no linked GitHub repository — cannot clone", - }); - } - const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp"; - const repoPath = join(homeDir, ".superset", "repos", input.projectId); - if (!existsSync(repoPath)) { - mkdirSync(dirname(repoPath), { recursive: true }); - await simpleGit().clone(cloudProject.repoCloneUrl, repoPath); - } - localProject = ctx.db - .insert(projects) - .values({ id: input.projectId, repoPath }) - .returning() - .get(); + throw projectNotSetupError(input.projectId); } setProgress(input.pendingId, "creating_worktree"); @@ -1343,10 +1306,7 @@ export const workspaceCreationRouter = router({ .findFirst({ where: eq(projects.id, input.projectId) }) .sync(); if (!localProject) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Project is not set up locally", - }); + throw projectNotSetupError(input.projectId); } const branch = input.branch.trim(); diff --git a/packages/shared/package.json b/packages/shared/package.json index ea93057b6a6..33065df0e84 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -64,6 +64,10 @@ "types": "./src/device-info.ts", "default": "./src/device-info.ts" }, + "./github-remote": { + "types": "./src/github-remote.ts", + "default": "./src/github-remote.ts" + }, "./shell-ready-scanner": { "types": "./src/shell-ready-scanner.ts", "default": "./src/shell-ready-scanner.ts" diff --git a/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/parse-github-remote.ts b/packages/shared/src/github-remote.ts similarity index 100% rename from packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/parse-github-remote.ts rename to packages/shared/src/github-remote.ts diff --git a/packages/trpc/src/router/v2-project/v2-project.ts b/packages/trpc/src/router/v2-project/v2-project.ts index 1359d06981a..b78fb4b060d 100644 --- a/packages/trpc/src/router/v2-project/v2-project.ts +++ b/packages/trpc/src/router/v2-project/v2-project.ts @@ -1,8 +1,13 @@ import { dbWs } from "@superset/db/client"; -import { githubRepositories, v2Projects } from "@superset/db/schema"; +import { + githubRepositories, + organizations, + v2Projects, +} from "@superset/db/schema"; +import { parseGitHubRemote } from "@superset/shared/github-remote"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; import { jwtProcedure, protectedProcedure } from "../../trpc"; import { @@ -104,40 +109,130 @@ export const v2ProjectRouter = { organizationId: input.organizationId, }, ); - const repoCloneUrl = row.githubRepository - ? `https://github.com/${row.githubRepository.fullName}.git` - : null; - return { ...row, repoCloneUrl }; + return row; + }), + + findByGitHubRemote: jwtProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + repoCloneUrl: z.string().min(1), + }), + ) + .query(async ({ ctx, input }) => { + if (!ctx.organizationIds.includes(input.organizationId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a member of this organization", + }); + } + const parsed = parseGitHubRemote(input.repoCloneUrl); + if (!parsed) return { candidates: [] }; + // GitHub slugs are case-insensitive; parseGitHubRemote returns a + // canonical https URL. Compare lower-cased on both sides. + const canonicalUrl = parsed.url.toLowerCase(); + + const rows = await dbWs + .select({ + id: v2Projects.id, + name: v2Projects.name, + slug: v2Projects.slug, + organizationId: v2Projects.organizationId, + organizationName: organizations.name, + }) + .from(v2Projects) + .innerJoin( + organizations, + eq(v2Projects.organizationId, organizations.id), + ) + .where( + and( + eq(sql`lower(${v2Projects.repoCloneUrl})`, canonicalUrl), + eq(v2Projects.organizationId, input.organizationId), + ), + ); + + return { candidates: rows }; }), - create: protectedProcedure + create: jwtProcedure .input( z.object({ + organizationId: z.string().uuid(), name: z.string().min(1), slug: z.string().min(1), - githubRepositoryId: z.string().uuid(), + // Optional — empty-mode and local-only imports have no + // remote yet. When provided we store the canonical https + // URL and try to link a matching github_repositories row. + repoCloneUrl: z.string().min(1).optional(), }), ) .mutation(async ({ ctx, input }) => { - const organizationId = await requireActiveOrgMembership( - ctx.session, - "No active organization", - ); + if (!ctx.organizationIds.includes(input.organizationId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a member of this organization", + }); + } - const repo = await getScopedGithubRepository( - organizationId, - input.githubRepositoryId, - ); + let canonicalUrl: string | null = null; + let linkedRepoId: string | null = null; + if (input.repoCloneUrl) { + const parsed = parseGitHubRemote(input.repoCloneUrl); + if (!parsed) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Could not parse GitHub remote URL", + }); + } + canonicalUrl = parsed.url; + const fullNameLower = `${parsed.owner}/${parsed.name}`.toLowerCase(); + const repo = await dbWs.query.githubRepositories.findFirst({ + columns: { id: true }, + where: and( + eq(sql`lower(${githubRepositories.fullName})`, fullNameLower), + eq(githubRepositories.organizationId, input.organizationId), + ), + }); + linkedRepoId = repo?.id ?? null; + } - const [project] = await dbWs - .insert(v2Projects) - .values({ - organizationId, - name: input.name, - slug: input.slug, - githubRepositoryId: repo.id, - }) - .returning(); + let project: typeof v2Projects.$inferSelect | undefined; + try { + [project] = await dbWs + .insert(v2Projects) + .values({ + organizationId: input.organizationId, + name: input.name, + slug: input.slug, + repoCloneUrl: canonicalUrl, + githubRepositoryId: linkedRepoId, + }) + .returning(); + } catch (err) { + // Unique violations surface as BAD_REQUEST with a hint about + // which constraint fired. The index on (organizationId, + // lower(repo_clone_url)) prevents duplicate repo imports per + // org; the (organizationId, slug) constraint catches name + // collisions. + if ( + err instanceof Error && + "code" in err && + (err as { code?: string }).code === "23505" + ) { + const constraint = (err as { constraint?: string }).constraint; + throw new TRPCError({ + code: "CONFLICT", + message: + constraint === "v2_projects_org_repo_clone_url_unique" + ? "A project with this repository URL already exists in this organization" + : constraint === "v2_projects_org_slug_unique" + ? "A project with this slug already exists in this organization" + : "Project already exists", + }); + } + throw err; + } if (!project) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/plans/done/20260417-v2-project-create-import-impl.md b/plans/done/20260417-v2-project-create-import-impl.md new file mode 100644 index 00000000000..6db56ef08aa --- /dev/null +++ b/plans/done/20260417-v2-project-create-import-impl.md @@ -0,0 +1,55 @@ +# V2 Project Create & Import — Implementation Plan + +Companion to [`docs/design/v2-project-create-import.md`](../../docs/design/v2-project-create-import.md). + +--- + +## Phase 1 — MVP + +### Cloud (packages/db, packages/trpc) + +- [x] `v2Projects.findByGitHubRemote({ organizationId, repoCloneUrl })` — scoped matcher against `githubRepositories.fullName`. + +No new tables. No new Electric collections. + +### Host-service (packages/host-service) + +- [x] `project.findByPath({ repoPath })` — validates git root, reads remote, forwards to cloud. +- [x] `project.create` — discriminated-union mode; Phase 1 ships `clone` + `importLocal`, others throw `not_implemented`. Clone-then-cloud ordering with rollback on cloud failure. +- [x] `project.setup` — discriminated-union mode (`clone` / `import`). Same-path is an idempotent no-op; different-path throws `CONFLICT` (v1 has no re-point escape hatch). +- [x] `project.remove` — deletes local worktrees + project row + repo directory. + +### Desktop renderer (apps/desktop) + +- [x] `useDashboardSidebarData` — pin-driven only. +- [x] Add-repository modals, mounted at the dashboard layout level: + - `NewProjectModal` — v1 new-project UI (Location + Clone/Template tabs) as a modal; drives `project.create` (clone). + - `FolderFirstImportModal` — drives the folder-first picker state machine. + - `useFolderFirstImport` — orchestration hook. +- [x] Sidebar `+` dropdown: "New project" and "Import existing folder". No "Pin existing project" action. +- [x] Folder-first picker branching (0 / 1-new / 1-already / >1). +- [x] Workspaces tab: lists every workspace in the active org. No Available section, no CTAs. +- [x] Remote-device workspace row click opens the normal workspace page — no gating. Local-fs operations degrade as they hit their limits. +- [x] Error-path for vanished `repoPath` surfaces as a toast; recovery UX deferred. +- [x] Sidebar pin reactivity after `project.create` / `project.setup` rides on `ensureProjectInSidebar` → `v2SidebarProjects` Electric collection. No React Query invalidation needed. + +### Acceptance + +- "New project" via sidebar dropdown creates the cloud row, clones locally, pins, and shows the project in the sidebar with no workspaces. +- "Import existing folder" against a repo that matches a cloud project sets it up and pins it; a non-matching folder offers create-as-new. +- A teammate's workspace on a remote device shows up in the workspaces tab; clicking it lands on the stub. +- Deleting the repo directory out of band surfaces a toast on the next git/workspace op. Recovery UX deferred. + +--- + +## Explicitly deferred + +- **Available section / rediscovery UX.** Workspaces tab only shows existing workspaces; cloud projects with no workspaces aren't surfaced. +- **Inline `project.setup` step inside New Workspace modal.** If `workspace.create` throws `PROJECT_NOT_SETUP`, the pending page shows a plain failure toast — no modal recovery loop. +- **Standalone pin UI.** Pin happens as a side-effect of `project.create` / `project.setup`. +- **Cross-device pin sync, auto-pin, unpin UX.** +- **GitHub repo creation** (`project.create` `empty` / `template` modes). +- **Template source.** +- **Preemptive "host offline" / "not set up here" hints.** +- **Orphaned `v2_projects` row cleanup.** +- **Wrong-remote detection** (rare; `project.setup` prevents entry).