From a4204f6a15c52b98069eaad128f95e274100421c Mon Sep 17 00:00:00 2001 From: Stevan Kosijer Date: Tue, 19 Aug 2025 15:07:54 +0200 Subject: [PATCH 01/20] Update schema --- prisma/schema.prisma | 186 ++++++++++++++++++++++++++++--------------- 1 file changed, 123 insertions(+), 63 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d027dcfc..6b4e3893 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,34 +16,55 @@ datasource db { } model DataSource { - id String @id @default(cuid()) - name String - connectionString String @map("connection_string") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) - createdById String @map("created_by_id") - conversations Conversation[] - environments EnvironmentDataSource[] + id String @id @default(cuid()) + name String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdById String @map("created_by_id") + environments EnvironmentDataSource[] directPermissions Permission[] @@map("data_source") } +enum DataSourcePropertyType { + CONNECTION_URL + ACCESS_TOKEN + REFRESH_TOKEN + CLIENT_ID + CLIENT_SECRET + API_KEY +} + +model DataSourceProperty { + id String @id @default(cuid()) + type String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + environmentVariableId String @map("environment_variable_id") + environmentVariable EnvironmentVariable @relation(fields: [environmentVariableId], references: [id], onDelete: Cascade) + + environmentId String @map("environment_id") + dataSourceId String @map("data_source_id") + environmentDataSource EnvironmentDataSource @relation(fields: [environmentId, dataSourceId], references: [environmentId, dataSourceId]) +} + model Website { - id String @id @default(cuid()) - siteId String? @map("site_id") - siteName String? @map("site_name") - siteUrl String? @map("site_url") - chatId String @map("chat_id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - isPublic Boolean @default(false) @map("is_public") - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) - createdById String @map("created_by_id") - environment Environment? @relation(fields: [environmentId], references: [id], onDelete: Cascade) - environmentId String? @map("environment_id") + id String @id @default(cuid()) + siteId String? @map("site_id") + siteName String? @map("site_name") + siteUrl String? @map("site_url") + chatId String @map("chat_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + isPublic Boolean @default(false) @map("is_public") + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdById String @map("created_by_id") + environmentId String? @map("environment_id") + environment Environment? @relation(fields: [environmentId], references: [id], onDelete: Cascade) directPermissions Permission[] @@ -51,29 +72,63 @@ model Website { } model Environment { - id String @id @default(cuid()) - name String - description String? - organizationId String? @map("organization_id") - organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) - dataSources EnvironmentDataSource[] - websites Website[] - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - directPermissions Permission[] + id String @id @default(cuid()) + name String + description String? + organizationId String? @map("organization_id") + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) + dataSources EnvironmentDataSource[] + websites Website[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + environmentVariables EnvironmentVariable[] + directPermissions Permission[] @@unique([name, organizationId]) @@index([organizationId], name: "idx_environment_organization_id") @@map("environment") } +enum EnvironmentVariableType { + GLOBAL + DATA_SOURCE +} + +model EnvironmentVariable { + id String @id @default(cuid()) + key String + value String + description String? + type EnvironmentVariableType + + environmentId String @map("environment_id") + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + + createdById String @map("created_by_id") + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + dataSourceProperties DataSourceProperty[] + + @@unique([key, environmentId]) + @@map("environment_variable") +} + model EnvironmentDataSource { environmentId String @map("environment_id") environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) - dataSourceId String @map("data_source_id") - dataSource DataSource @relation(fields: [dataSourceId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") + + dataSourceId String @map("data_source_id") + dataSource DataSource @relation(fields: [dataSourceId], references: [id], onDelete: Cascade) + + dataSourceProperties DataSourceProperty[] + + conversations Conversation[] + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@id([environmentId, dataSourceId]) @@index([environmentId], name: "idx_environment_data_source_environment_id") @@ -110,17 +165,20 @@ model Message { } model Conversation { - id String @id @default(cuid()) - description String? - starterId String @map("starter_id") - messages Message[] - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - snapshots Snapshot[] - dataSourceId String - dataSource DataSource @relation(fields: [dataSourceId], references: [id], onDelete: Cascade) - User User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String + id String @id @default(cuid()) + description String? + starterId String @map("starter_id") + messages Message[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + snapshots Snapshot[] + + environmentId String @map("environment_id") + dataSourceId String @map("data_source_id") + environmentDataSource EnvironmentDataSource @relation(fields: [environmentId, dataSourceId], references: [environmentId, dataSourceId]) + + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String @@map("conversation") } @@ -164,23 +222,24 @@ enum DeprecatedRole { } model User { - id String @id @default(uuid()) - name String - email String - emailVerified Boolean - image String? - createdAt DateTime - updatedAt DateTime - sessions Session[] - accounts Account[] - conversations Conversation[] - roles UserRole[] - role DeprecatedRole @default(MEMBER) - dataSources DataSource[] - websites Website[] - organizationId String? - organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) - telemetryEnabled Boolean? + id String @id @default(uuid()) + name String + email String + emailVerified Boolean + image String? + createdAt DateTime + updatedAt DateTime + sessions Session[] + accounts Account[] + conversations Conversation[] + roles UserRole[] + role DeprecatedRole @default(MEMBER) + dataSources DataSource[] + websites Website[] + environmentVariables EnvironmentVariable[] + organizationId String? + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) + telemetryEnabled Boolean? isAnonymous Boolean? @@ -216,6 +275,7 @@ enum PermissionResource { Website BuilderApp AdminApp + EnvironmentVariable } model Permission { From 1e9ecec0058d2992fd5e5a607a05a408d45359c2 Mon Sep 17 00:00:00 2001 From: Stevan Kosijer Date: Tue, 19 Aug 2025 15:31:07 +0200 Subject: [PATCH 02/20] Update schema --- prisma/schema.prisma | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b4e3893..672260bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,8 @@ model DataSourceProperty { environmentId String @map("environment_id") dataSourceId String @map("data_source_id") environmentDataSource EnvironmentDataSource @relation(fields: [environmentId, dataSourceId], references: [environmentId, dataSourceId]) + + @@map("data_source_property") } model Website { @@ -75,12 +77,12 @@ model Environment { id String @id @default(cuid()) name String description String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") organizationId String? @map("organization_id") organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) dataSources EnvironmentDataSource[] websites Website[] - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") environmentVariables EnvironmentVariable[] directPermissions Permission[] From 95cf898e7c17c1eef2c0975b2ff2c0f17e7a9567 Mon Sep 17 00:00:00 2001 From: Stevan Kosijer Date: Tue, 19 Aug 2025 15:47:49 +0200 Subject: [PATCH 03/20] Remove unused code --- .../chat/query-modal/AiGeneration.tsx | 30 -- .../chat/query-modal/QueryEditor.tsx | 59 ---- .../chat/query-modal/QueryModal.tsx | 272 ------------------ .../chat/query-modal/QueryResults.tsx | 30 -- app/lib/stores/dataSources.ts | 76 ----- app/utils/output-app.ts | 17 -- 6 files changed, 484 deletions(-) delete mode 100644 app/components/chat/query-modal/AiGeneration.tsx delete mode 100644 app/components/chat/query-modal/QueryEditor.tsx delete mode 100644 app/components/chat/query-modal/QueryModal.tsx delete mode 100644 app/components/chat/query-modal/QueryResults.tsx delete mode 100644 app/lib/stores/dataSources.ts delete mode 100644 app/utils/output-app.ts diff --git a/app/components/chat/query-modal/AiGeneration.tsx b/app/components/chat/query-modal/AiGeneration.tsx deleted file mode 100644 index cbb1799e..00000000 --- a/app/components/chat/query-modal/AiGeneration.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Sparkles } from 'lucide-react'; -import { Button } from '~/components/ui/Button'; - -interface AiGenerationProps { - userPrompt: string; - onPromptChange: (prompt: string) => void; - onGenerateQuery: () => void; - isGenerating: boolean; -} - -export const AiGeneration = ({ userPrompt, onPromptChange, onGenerateQuery, isGenerating }: AiGenerationProps) => ( -
- onPromptChange(e.target.value)} - /> - -
-); diff --git a/app/components/chat/query-modal/QueryEditor.tsx b/app/components/chat/query-modal/QueryEditor.tsx deleted file mode 100644 index 72395e8c..00000000 --- a/app/components/chat/query-modal/QueryEditor.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { List, Play } from 'lucide-react'; -import { CodeMirrorEditor } from '~/components/editor/codemirror/CodeMirrorEditor'; -import { Button } from '~/components/ui/Button'; - -export interface QueryEditorProps { - query: string | undefined; - onQueryChange: (query: string) => void; - onFormatQuery: () => void; - onTestQuery: () => void; - isTesting: boolean; - queryId: string | number; -} - -export const QueryEditor = ({ - query, - onQueryChange, - onFormatQuery, - onTestQuery, - isTesting, - queryId, -}: QueryEditorProps) => ( -
-
- Query -
- - -
-
-
- onQueryChange(content)} - /> -
-
-); diff --git a/app/components/chat/query-modal/QueryModal.tsx b/app/components/chat/query-modal/QueryModal.tsx deleted file mode 100644 index aa43b75a..00000000 --- a/app/components/chat/query-modal/QueryModal.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useEffect, useState } from 'react'; -import * as RadixDialog from '@radix-ui/react-dialog'; -import { Button } from '~/components/ui/Button'; -import type { ApiError } from '~/types/api-error'; -import { AiGeneration } from '~/components/chat/query-modal/AiGeneration'; -import { QueryResults } from '~/components/chat/query-modal/QueryResults'; -import { QueryEditor } from '~/components/chat/query-modal/QueryEditor'; -import type { DataSource } from '~/lib/services/datasourceService'; - -interface QueryModalProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - queryId: string | number; - onUpdateAndRegenerate?: (updatedQuery: string) => void; - dataSourceId: string; -} - -export const QueryModal = ({ isOpen, onOpenChange, queryId, onUpdateAndRegenerate, dataSourceId }: QueryModalProps) => { - const [query, setQuery] = useState(); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [queryResult, setQueryResult] = useState(null); - const [isTesting, setIsTesting] = useState(false); - const [userPrompt, setUserPrompt] = useState(''); - const [isGenerating, setIsGenerating] = useState(false); - const [dataSource, setDataSource] = useState(null); - - async function fetchDataSource() { - try { - const response = await fetch(`/api/data-sources/${dataSourceId}`); - - if (!response.ok) { - throw new Error('Failed to fetch data source'); - } - - const dataSourceData = (await response.json()) as DataSource; - setDataSource(dataSourceData); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch data source'); - } - } - - useEffect(() => { - fetchDataSource(); - }, [dataSourceId]); - - useEffect(() => { - if (isOpen) { - fetchQuery(); - } else { - // Reset state when modal is closed - setQuery(undefined); - setQueryResult(null); - setUserPrompt(''); - setError(null); - } - }, [isOpen, queryId]); - - const formatQuery = async (query: string): Promise => { - if (!dataSource || !query) { - return query; - } - - try { - const response = await fetch('/api/format-query', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query, - dataSourceId: dataSource.id, - }), - }); - - if (!response.ok) { - throw new Error('Failed to format query'); - } - - const { formattedQuery } = (await response.json()) as { formattedQuery: string }; - - return formattedQuery; - } catch (err) { - console.error('Failed to format query:', err); - - // Return unformatted query on error - return query; - } - }; - - const fetchQuery = async () => { - try { - setIsLoading(true); - setError(null); - - const response = await fetch(`/api/queries/${queryId}`); - - if (!response.ok) { - throw new Error('Failed to fetch query'); - } - - const data = (await response.json()) as string; - const formatted = await formatQuery(data); - setQuery(formatted); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch query'); - } finally { - setIsLoading(false); - } - }; - - const handleRegenerateComponent = async () => { - try { - setError(null); - - const formattedQuery = await formatQuery(query || ''); - const response = await fetch(`/api/queries/${queryId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ content: formattedQuery }), - }); - - if (!response.ok) { - throw new Error('Failed to update query'); - } - - const updatedQuery = (await response.json()) as string; - const formatted = await formatQuery(updatedQuery); - setQuery(formatted); - - if (onUpdateAndRegenerate) { - onUpdateAndRegenerate(updatedQuery); - } - - onOpenChange(false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update query'); - } - }; - - const handleTestQuery = async () => { - if (!query) { - return; - } - - try { - setIsTesting(true); - setError(null); - setQueryResult(null); - - const response = await fetch(`/api/execute-query?query=${encodeURIComponent(query)}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const responseClone = response.clone(); - - if (!response.ok) { - const body = (await responseClone.json()) as ApiError; - setError(body?.error || 'Failed to execute query'); - } - - const results = (await response.json()) as { data: unknown }; - setQueryResult(results.data); - } catch { - setError('Failed to execute query'); - setQueryResult(null); - } finally { - setIsTesting(false); - } - }; - - const handleGenerateQuery = async (dataSourceId: string) => { - if (!userPrompt.trim()) { - return; - } - - try { - setIsGenerating(true); - setError(null); - - const response = await fetch('/api/generate-query', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ prompt: userPrompt, existingQuery: query, dataSourceId }), - }); - - if (!response.ok) { - throw new Error('Failed to generate query'); - } - - const data = (await response.json()) as string; - const formatted = await formatQuery(data); - setQuery(formatted); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to generate query'); - } finally { - setIsGenerating(false); - } - }; - - const handleFormatQuery = async () => { - if (!query) { - return; - } - - try { - const formatted = await formatQuery(query); - setQuery(formatted); - } catch (err) { - console.error('Failed to format query:', err); - } - }; - - return ( - - - - -
- {isLoading ? ( -
-
-
- ) : ( - <> -
- - - {(queryResult !== null || error) && } -
- -
- handleGenerateQuery(dataSourceId)} - isGenerating={isGenerating} - /> - -
- - -
-
- - )} -
-
-
-
- ); -}; diff --git a/app/components/chat/query-modal/QueryResults.tsx b/app/components/chat/query-modal/QueryResults.tsx deleted file mode 100644 index 728cf966..00000000 --- a/app/components/chat/query-modal/QueryResults.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { CodeMirrorEditor } from '~/components/editor/codemirror/CodeMirrorEditor'; - -export interface QueryResultsProps { - results: unknown; - error: string | null; -} - -export const QueryResults = ({ results, error }: QueryResultsProps) => ( -
-
Query Results
-
- {error ? ( -
{error}
- ) : ( - - )} -
-
-); diff --git a/app/lib/stores/dataSources.ts b/app/lib/stores/dataSources.ts deleted file mode 100644 index b36bd3f0..00000000 --- a/app/lib/stores/dataSources.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { create } from 'zustand'; -import { useRouter } from 'next/navigation'; -import { persist } from 'zustand/middleware'; -import { DATA_SOURCE_CONNECTION_ROUTE } from '~/lib/constants/routes'; - -interface DataSource { - id: string; - name: string; - connectionString: string; - createdAt: string; - updatedAt: string; -} - -interface DataSourcesState { - dataSources: DataSource[]; - selectedDataSourceId: string | null; - setDataSources: (dataSources: DataSource[]) => void; - setSelectedDataSourceId: (id: string | null) => void; - clearDataSources: () => void; -} - -export const useDataSourcesStore = create()( - persist( - (set, getState) => ({ - dataSources: [], - selectedDataSourceId: null, - setDataSources: (dataSources) => { - set({ dataSources }); - - if (dataSources.length === 0) { - getState().setSelectedDataSourceId(null); - return; - } - - const selectedDataSourceId = getState().selectedDataSourceId; - - if (selectedDataSourceId && dataSources.some((dataSource) => dataSource.id === selectedDataSourceId)) { - return; - } - - getState().setSelectedDataSourceId(dataSources[0].id); - }, - setSelectedDataSourceId: (id) => set({ selectedDataSourceId: id }), - clearDataSources: () => set({ dataSources: [] }), - }), - { - name: 'data-sources-storage', - }, - ), -); - -export const useDataSourceActions = () => { - const { setDataSources } = useDataSourcesStore(); - const router = useRouter(); - - const refetchDataSources = async () => { - try { - const response = await fetch('/api/data-sources'); - const data = (await response.json()) as { success: boolean; dataSources: DataSource[] }; - - if (data.success) { - setDataSources(data.dataSources); - - if (!data.dataSources?.length) { - router.push(DATA_SOURCE_CONNECTION_ROUTE); - } - } - } catch (error) { - console.error('Failed to fetch data sources:', error); - } - }; - - return { - refetchDataSources, - }; -}; diff --git a/app/utils/output-app.ts b/app/utils/output-app.ts deleted file mode 100644 index c84f2f93..00000000 --- a/app/utils/output-app.ts +++ /dev/null @@ -1,17 +0,0 @@ -export enum OutputAppEventType { - EDIT_QUERY = 'edit_query', -} - -export interface OutputAppEvent { - eventType: OutputAppEventType; - queryId: string | number; -} - -export enum BuilderAppEventType { - SQL_INSPECTOR_TOGGLE = 'sql_inspector_toggle', -} - -export interface BuilderAppEvent { - eventType: BuilderAppEventType; - enabled: boolean; -} From 114526b2a6ee0fcd5b644adfb97600b9cba9f2dd Mon Sep 17 00:00:00 2001 From: Stevan Kosijer Date: Tue, 19 Aug 2025 21:21:09 +0200 Subject: [PATCH 04/20] Support environments, environment variables and environment data sources --- app/api/chat/route.ts | 53 ++- app/api/data-sources/[id]/route.ts | 36 ++- app/api/data-sources/[id]/url/route.ts | 19 +- app/api/data-sources/example/route.ts | 17 +- app/api/data-sources/route.ts | 14 +- app/api/format-query/route.ts | 42 --- app/api/generate-query/route.ts | 51 --- app/api/suggestions/route.ts | 40 ++- .../@settings/tabs/data/DataTab.tsx | 91 +++--- .../tabs/data/forms/AddDataSourceForm.tsx | 1 + .../tabs/data/forms/EditDataSourceForm.tsx | 5 +- app/components/DataLoader.tsx | 42 ++- app/components/chat/BaseChat.tsx | 12 +- app/components/chat/Chat.client.tsx | 81 +++-- app/components/chat/ChatTextarea.tsx | 6 +- app/components/chat/DataSourcePicker.tsx | 73 ++++- app/components/chat/HomepageTextarea.tsx | 14 +- app/data-source-connection/page.tsx | 114 ++++++- app/layout.tsx | 10 +- app/lib/.server/llm/stream-text.ts | 13 +- app/lib/.server/llm/utils.ts | 13 +- app/lib/persistence/conversations.ts | 2 + app/lib/persistence/useConversationHistory.ts | 11 +- app/lib/schema.ts | 10 +- app/lib/services/conversationService.ts | 9 +- app/lib/services/dataSourceService.ts | 302 ++++++++++++++++++ app/lib/services/datasourceService.ts | 103 ------ app/lib/services/environmentService.ts | 12 + .../services/environmentVariablesService.ts | 142 ++++++++ app/lib/services/suggestionService.ts | 2 +- app/lib/stores/environmentDataSources.ts | 120 +++++++ app/page.tsx | 8 +- app/utils/constants.ts | 1 + .../migration.sql | 78 +++++ prisma/seed.ts | 47 +-- 35 files changed, 1129 insertions(+), 465 deletions(-) delete mode 100644 app/api/format-query/route.ts delete mode 100644 app/api/generate-query/route.ts create mode 100644 app/lib/services/dataSourceService.ts delete mode 100644 app/lib/services/datasourceService.ts create mode 100644 app/lib/services/environmentVariablesService.ts create mode 100644 app/lib/stores/environmentDataSources.ts create mode 100644 prisma/migrations/20250819132659_add_environment_variables_and_data_source_properties_and_environments_support/migration.sql diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 042d7d0b..db3554d4 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -14,7 +14,6 @@ import type { StarterPluginId } from '~/lib/plugins/types'; import type { FileMap } from '~/lib/stores/files'; import { getTelemetry } from '~/lib/telemetry/telemetry-manager'; import { TelemetryEventType } from '~/lib/telemetry/telemetry-types'; -import { prisma } from '~/lib/prisma'; import { LLMManager } from '~/lib/modules/llm/manager'; import { DataSourcePluginManager } from '~/lib/plugins/data-access/data-access-plugin-manager'; import { type UserProfile, userService } from '~/lib/services/userService'; @@ -201,8 +200,13 @@ async function chatAction(request: NextRequest) { }; dataStream.writeData(currentProgressAnnotation); - const dataSource = await conversationService.getConversationDataSource(conversationId); - const databaseSchema = await getDatabaseSchema(dataSource.id, userId); + const environmentDataSource = + await conversationService.getConversationEnvironmentDataSource(conversationId); + const databaseSchema = await getDatabaseSchema( + environmentDataSource.dataSourceId, + environmentDataSource.environmentId, + userId, + ); implementationPlan = await createImplementationPlan({ isFirstUserMessage: !!userMessageProperties.isFirstUserMessage, @@ -280,6 +284,7 @@ async function chatAction(request: NextRequest) { const userId = await requireUserId(request); const user = await userService.getUser(userId); + await trackChatPrompt(conversationId, currentModel, user, userMessageProperties.content); } catch (error) { logger.error('Failed to save prompt', error); @@ -430,36 +435,22 @@ async function trackChatPrompt( userMessage: string, ): Promise { try { - const conversationWithDataSource = await prisma.conversation.findUnique({ - where: { id: conversationId }, - include: { - dataSource: { - select: { - connectionString: true, - }, + // TODO: @skos find the environment data source connection string and pass the pluginId (db type) to telemetry + const pluginId = DataSourcePluginManager.getAccessorPluginId('the current conversation datasource url'); + + const telemetry = await getTelemetry(); + await telemetry.trackTelemetryEvent( + { + eventType: TelemetryEventType.USER_CHAT_PROMPT, + properties: { + conversationId, + dataSourceType: pluginId, + llmModel, + userMessage, }, }, - }); - - if (conversationWithDataSource?.dataSource.connectionString) { - const pluginId = DataSourcePluginManager.getAccessorPluginId( - conversationWithDataSource.dataSource.connectionString, - ); - - const telemetry = await getTelemetry(); - await telemetry.trackTelemetryEvent( - { - eventType: TelemetryEventType.USER_CHAT_PROMPT, - properties: { - conversationId, - dataSourceType: pluginId, - llmModel, - userMessage, - }, - }, - user, - ); - } + user, + ); } catch (telemetryError) { logger.error('Failed to track telemetry event', telemetryError); } diff --git a/app/api/data-sources/[id]/route.ts b/app/api/data-sources/[id]/route.ts index 6097c5c8..e5b4125e 100644 --- a/app/api/data-sources/[id]/route.ts +++ b/app/api/data-sources/[id]/route.ts @@ -2,35 +2,47 @@ import { NextRequest, NextResponse } from 'next/server'; import { deleteDataSource, getConversationCount, - getDataSource, + getEnvironmentDataSource, updateDataSource, -} from '~/lib/services/datasourceService'; +} from '~/lib/services/dataSourceService'; import { requireUserId } from '~/auth/session'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const userId = await requireUserId(request); const { id } = await params; + const { searchParams } = new URL(request.url); + const environmentId = searchParams.get('environmentId'); - const dataSource = await getDataSource(id, userId); + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } + + const environmentDataSource = await getEnvironmentDataSource(id, userId, environmentId); - if (!dataSource) { + if (!environmentDataSource) { return NextResponse.json({ success: false, error: 'Data source not found' }, { status: 404 }); } const conversationCount = await getConversationCount(id, userId); - return NextResponse.json({ success: true, dataSource, conversationCount }); + return NextResponse.json({ success: true, environmentDataSource, conversationCount }); } export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const userId = await requireUserId(request); const { id } = await params; + const { searchParams } = new URL(request.url); + const environmentId = searchParams.get('environmentId'); + + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } - const dataSource = await getDataSource(id, userId); + const environmentDataSource = await getEnvironmentDataSource(id, userId, environmentId); - if (!dataSource) { + if (!environmentDataSource) { return NextResponse.json({ success: false, error: 'Data source not found' }, { status: 404 }); } @@ -53,10 +65,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const userId = await requireUserId(request); const { id } = await params; + const { searchParams } = new URL(request.url); + const environmentId = searchParams.get('environmentId'); + + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } - const dataSource = await getDataSource(id, userId); + const environmentDataSource = await getEnvironmentDataSource(id, userId, environmentId); - if (!dataSource) { + if (!environmentDataSource) { return NextResponse.json({ success: false, error: 'Data source not found' }, { status: 404 }); } diff --git a/app/api/data-sources/[id]/url/route.ts b/app/api/data-sources/[id]/url/route.ts index aa99a309..24d1e418 100644 --- a/app/api/data-sources/[id]/url/route.ts +++ b/app/api/data-sources/[id]/url/route.ts @@ -1,11 +1,24 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getDatabaseUrl } from '~/lib/services/datasourceService'; +import { getDatabaseUrl } from '~/lib/services/dataSourceService'; import { requireUserId } from '~/auth/session'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const userId = await requireUserId(request); const { id } = await params; - const url = await getDatabaseUrl(userId, id); + const { searchParams } = new URL(request.url); + const environmentId = searchParams.get('environmentId'); - return NextResponse.json({ url, success: true }); + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } + + try { + const url = await getDatabaseUrl(userId, id, environmentId); + return NextResponse.json({ url, success: true }); + } catch (error) { + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Failed to get database URL' }, + { status: 404 }, + ); + } } diff --git a/app/api/data-sources/example/route.ts b/app/api/data-sources/example/route.ts index 18d3f6ad..e800f843 100644 --- a/app/api/data-sources/example/route.ts +++ b/app/api/data-sources/example/route.ts @@ -1,11 +1,19 @@ import { prisma } from '~/lib/prisma'; import { NextRequest, NextResponse } from 'next/server'; import { requireUserId } from '~/auth/session'; +import { createSampleDataSource } from '~/lib/services/dataSourceService'; export async function POST(request: NextRequest) { const userId = await requireUserId(request); try { + const body = await request.json(); + const { environmentId } = body as { environmentId: string }; + + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } + const existingSampleDatabase = await prisma.dataSource.findFirst({ where: { createdById: userId, @@ -17,12 +25,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: 'Sample database already exists' }, { status: 400 }); } - const dataSource = await prisma.dataSource.create({ - data: { - createdById: userId, - name: 'Sample Database', - connectionString: 'sqlite://sample.db', - }, + const dataSource = await createSampleDataSource({ + createdById: userId, + environmentId, }); return NextResponse.json({ success: true, dataSource }); diff --git a/app/api/data-sources/route.ts b/app/api/data-sources/route.ts index d5491a81..8f442703 100644 --- a/app/api/data-sources/route.ts +++ b/app/api/data-sources/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createDataSource, getDataSources } from '~/lib/services/datasourceService'; +import { createDataSource, getEnvironmentDataSources } from '~/lib/services/dataSourceService'; import { requireUserAbility } from '~/auth/session'; import { PermissionAction, PermissionResource } from '@prisma/client'; @@ -10,9 +10,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); } - const dataSources = await getDataSources(userAbility); + const environmentDataSources = await getEnvironmentDataSources(userAbility); - return NextResponse.json({ success: true, dataSources }); + return NextResponse.json({ success: true, environmentDataSources }); } export async function POST(request: NextRequest) { @@ -25,12 +25,18 @@ export async function POST(request: NextRequest) { const formData = await request.formData(); const connectionString = formData.get('connectionString') as string; const name = formData.get('name') as string; + const environmentId = formData.get('environmentId') as string; + + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } try { const dataSource = await createDataSource({ name, - connectionString, createdById: userId, + environmentId, + connectionString, }); return NextResponse.json({ success: true, dataSource }); diff --git a/app/api/format-query/route.ts b/app/api/format-query/route.ts deleted file mode 100644 index 1a1ebfcd..00000000 --- a/app/api/format-query/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; -import { DataAccessor } from '@liblab/data-access/dataAccessor'; -import { getConnectionProtocol } from '@liblab/data-access/utils/connection'; -import { requireUserId } from '~/auth/session'; -import { prisma } from '~/lib/prisma'; - -const requestSchema = z.object({ - query: z.string(), - dataSourceId: z.string(), -}); - -export async function POST(request: NextRequest) { - const userId = await requireUserId(request); - - try { - const body = await request.json(); - const { query, dataSourceId } = requestSchema.parse(body); - - // Get the data source to determine the database type - const dataSource = await prisma.dataSource.findUniqueOrThrow({ - where: { id: dataSourceId, createdById: userId }, - }); - - const type = getConnectionProtocol(dataSource.connectionString); - const accessor = DataAccessor.getByDatabaseType(type); - - if (!accessor) { - return NextResponse.json({ error: 'Unsupported database type' }, { status: 400 }); - } - - const formattedQuery = accessor.formatQuery(query); - - return NextResponse.json({ formattedQuery }); - } catch (error) { - console.error('Error formatting query:', error); - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to format query' }, - { status: 500 }, - ); - } -} diff --git a/app/api/generate-query/route.ts b/app/api/generate-query/route.ts deleted file mode 100644 index 740ff680..00000000 --- a/app/api/generate-query/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { getDatabaseSchema } from '~/lib/schema'; -import { NextRequest, NextResponse } from 'next/server'; -import { generateSqlQueries } from '~/lib/.server/llm/database-source'; -import { createScopedLogger } from '~/utils/logger'; -import { z } from 'zod'; -import { prisma } from '~/lib/prisma'; -import { requireUserId } from '~/auth/session'; - -const logger = createScopedLogger('generate-sql'); - -const requestSchema = z.object({ - prompt: z.string(), - existingQuery: z.string().optional(), - dataSourceId: z.string(), - suggestedDatabaseType: z.string().optional(), -}); - -export async function POST(request: NextRequest) { - const userId = await requireUserId(request); - - try { - const body = await request.json(); - const { prompt, existingQuery, dataSourceId } = requestSchema.parse(body); - const existingQueries = existingQuery ? [existingQuery] : []; - - const schema = await getDatabaseSchema(dataSourceId, userId); - - const dataSource = await prisma.dataSource.findUniqueOrThrow({ - where: { id: dataSourceId, createdById: userId }, - }); - - const queries = await generateSqlQueries({ - schema, - userPrompt: prompt, - connectionString: dataSource.connectionString, - existingQueries, - }); - - if (!queries || queries.length === 0) { - return NextResponse.json({ error: 'Failed to generate SQL query' }, { status: 500 }); - } - - return NextResponse.json(queries[0].query); - } catch (error) { - logger.error('Error generating SQL:', error); - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to generate SQL query' }, - { status: 500 }, - ); - } -} diff --git a/app/api/suggestions/route.ts b/app/api/suggestions/route.ts index 59399165..47e34e71 100644 --- a/app/api/suggestions/route.ts +++ b/app/api/suggestions/route.ts @@ -1,28 +1,42 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getDataSource } from '~/lib/services/datasourceService'; +import { getEnvironmentDataSource } from '~/lib/services/dataSourceService'; import { generateSchemaBasedSuggestions } from '~/lib/services/suggestionService'; import { requireUserId } from '~/auth/session'; import { SAMPLE_DATABASE_NAME } from '@liblab/data-access/accessors/sqlite'; import { logger } from '~/utils/logger'; +import { DataSourcePropertyType } from '@prisma/client'; export async function POST(request: NextRequest) { try { const userId = await requireUserId(request); - const { dataSourceId } = await request.json<{ dataSourceId: string }>(); + const { dataSourceId, environmentId } = await request.json<{ dataSourceId: string; environmentId: string }>(); if (!dataSourceId) { return NextResponse.json({ success: false, error: 'Data source ID is required' }, { status: 400 }); } - // Fetch the data source using the service - const dataSource = await getDataSource(dataSourceId, userId); + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } + + // Fetch the environment data source using the service + const environmentDataSource = await getEnvironmentDataSource(dataSourceId, userId, environmentId); - if (!dataSource) { + if (!environmentDataSource) { return NextResponse.json({ success: false, error: 'Data source not found' }, { status: 404 }); } - // Check if it's the sample database - const isSampleDatabase = dataSource.connectionString === `sqlite://${SAMPLE_DATABASE_NAME}`; + const dataSourceProperty = environmentDataSource.dataSourceProperties.find( + (dsp) => dsp.type === DataSourcePropertyType.CONNECTION_URL, + ); + + if (!dataSourceProperty) { + return NextResponse.json({ success: false, error: 'Data source property not found' }, { status: 404 }); + } + + const connectionUrl = dataSourceProperty.environmentVariable.value; + + const isSampleDatabase = connectionUrl === `sqlite://${SAMPLE_DATABASE_NAME}`; let suggestions: string[]; @@ -34,7 +48,17 @@ export async function POST(request: NextRequest) { ]; } else { // Generate schema-based suggestions for non-sample databases - suggestions = await generateSchemaBasedSuggestions(dataSource); + // Create a compatible dataSource object for the suggestion service + const dataSourceForSuggestions = { + id: environmentDataSource.dataSource.id, + name: environmentDataSource.dataSource.name, + connectionString: connectionUrl || '', + environmentId: environmentDataSource.environmentId, + environmentName: environmentDataSource.environment.name, + createdAt: environmentDataSource.dataSource.createdAt, + updatedAt: environmentDataSource.dataSource.updatedAt, + }; + suggestions = await generateSchemaBasedSuggestions(dataSourceForSuggestions); } return NextResponse.json({ diff --git a/app/components/@settings/tabs/data/DataTab.tsx b/app/components/@settings/tabs/data/DataTab.tsx index 421778b3..83131126 100644 --- a/app/components/@settings/tabs/data/DataTab.tsx +++ b/app/components/@settings/tabs/data/DataTab.tsx @@ -6,21 +6,13 @@ import AddDataSourceForm from './forms/AddDataSourceForm'; import EditDataSourceForm from './forms/EditDataSourceForm'; import { classNames } from '~/utils/classNames'; import { toast } from 'sonner'; -import { useDataSourcesStore } from '~/lib/stores/dataSources'; +import { type EnvironmentDataSource, useEnvironmentDataSourcesStore } from '~/lib/stores/environmentDataSources'; import { settingsPanelStore, useSettingsStore } from '~/lib/stores/settings'; import { useStore } from '@nanostores/react'; -export interface DataSource { - id: string; - name: string; - connectionString: string; - createdAt: string; - updatedAt: string; -} - -interface DataSourcesResponse { +interface EnvironmentDataSourcesResponse { success: boolean; - dataSources: DataSource[]; + environmentDataSources: EnvironmentDataSource[]; } export interface TestConnectionResponse { @@ -33,10 +25,12 @@ export default function DataTab() { const [showAddFormLocal, setShowAddFormLocal] = useState(showAddForm); const [showEditForm, setShowEditForm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [selectedDataSource, setSelectedDataSource] = useState(null); + const [selectedEnvironmentDataSource, setSelectedEnvironmentDataSource] = useState( + null, + ); const [isSubmitting, setIsSubmitting] = useState(false); const [conversationCount, setConversationCount] = useState(0); - const { dataSources, setDataSources } = useDataSourcesStore(); + const { environmentDataSources, setEnvironmentDataSources } = useEnvironmentDataSourcesStore(); const { selectedTab } = useSettingsStore(); // Update local state when store changes @@ -56,10 +50,10 @@ export default function DataTab() { const loadDataSources = async () => { try { const response = await fetch('/api/data-sources'); - const data = (await response.json()) as DataSourcesResponse; + const data = (await response.json()) as EnvironmentDataSourcesResponse; if (data.success) { - setDataSources(data.dataSources); + setEnvironmentDataSources(data.environmentDataSources); } } catch (error) { console.error('Failed to load data sources:', error); @@ -67,16 +61,20 @@ export default function DataTab() { }; loadDataSources(); - }, [setDataSources]); + }, [setEnvironmentDataSources]); const handleDelete = async () => { - if (!selectedDataSource) { + if (!selectedEnvironmentDataSource) { return; } - const response = await fetch(`/api/data-sources/${selectedDataSource.id}`, { - method: 'DELETE', - }); + // TODO: @skos we should send environmentId as a path parameter + const response = await fetch( + `/api/data-sources/${selectedEnvironmentDataSource.dataSourceId}?environmentId=${selectedEnvironmentDataSource.dataSourceId}`, + { + method: 'DELETE', + }, + ); const data = (await response.json()) as { success: boolean; error?: string }; @@ -85,33 +83,36 @@ export default function DataTab() { // Reload data sources const reloadResponse = await fetch('/api/data-sources'); - const reloadData = (await reloadResponse.json()) as DataSourcesResponse; + const reloadData = (await reloadResponse.json()) as EnvironmentDataSourcesResponse; if (reloadData.success) { - setDataSources(reloadData.dataSources); + setEnvironmentDataSources(reloadData.environmentDataSources); } setShowDeleteConfirm(false); setShowEditForm(false); - setSelectedDataSource(null); + setSelectedEnvironmentDataSource(null); } else { toast.error(data.error || 'Failed to delete data source'); } }; - const handleEdit = (dataSource: DataSource) => { - setSelectedDataSource(dataSource); + const handleEdit = (environmentDataSource: EnvironmentDataSource) => { + setSelectedEnvironmentDataSource(environmentDataSource); setShowEditForm(true); setShowAddFormLocal(false); }; const handleDeleteClick = async () => { - if (!selectedDataSource) { + if (!selectedEnvironmentDataSource) { return; } try { - const response = await fetch(`/api/data-sources/${selectedDataSource.id}`); + // TODO: @skos we should send environmentId as a path parameter + const response = await fetch( + `/api/data-sources/${selectedEnvironmentDataSource.dataSourceId}?environmentId=${selectedEnvironmentDataSource.dataSourceId}`, + ); const data = await response.json<{ success: boolean; conversationCount?: number }>(); if (data.success) { @@ -131,13 +132,13 @@ export default function DataTab() { const handleBack = () => { setShowEditForm(false); setShowAddFormLocal(false); - setSelectedDataSource(null); + setSelectedEnvironmentDataSource(null); }; const handleAdd = () => { setShowAddFormLocal(true); setShowEditForm(false); - setSelectedDataSource(null); + setSelectedEnvironmentDataSource(null); }; return ( @@ -191,10 +192,10 @@ export default function DataTab() { reloadResponse .then((response) => response.json()) .then((data: unknown) => { - const typedData = data as DataSourcesResponse; + const typedData = data as EnvironmentDataSourcesResponse; if (typedData.success) { - setDataSources(typedData.dataSources); + setEnvironmentDataSources(typedData.environmentDataSources); } }) .catch((error) => console.error('Failed to reload data sources after add:', error)); @@ -204,7 +205,7 @@ export default function DataTab() { )} - {showEditForm && selectedDataSource && ( + {showEditForm && selectedEnvironmentDataSource && (
@@ -225,7 +226,8 @@ export default function DataTab() {
{ @@ -234,10 +236,10 @@ export default function DataTab() { reloadResponse .then((response) => response.json()) .then((data: unknown) => { - const typedData = data as DataSourcesResponse; + const typedData = data as EnvironmentDataSourcesResponse; if (typedData.success) { - setDataSources(typedData.dataSources); + setEnvironmentDataSources(typedData.environmentDataSources); } }) .catch((error) => console.error('Failed to reload data sources after edit:', error)); @@ -250,7 +252,7 @@ export default function DataTab() { {!showEditForm && !showAddFormLocal && (
- {dataSources.length === 0 ? ( + {environmentDataSources.length === 0 ? (

No Data Sources

@@ -259,9 +261,10 @@ export default function DataTab() {

) : ( - dataSources.map((dataSource) => ( + // TODO: @skos check if this is the correct way for the key concat + environmentDataSources.map((environmentDataSource) => ( handleEdit(dataSource)} + onClick={() => handleEdit(environmentDataSource)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { - handleEdit(dataSource); + handleEdit(environmentDataSource); } }} className="w-full flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-900 transition-all duration-200 cursor-pointer" >
-

{dataSource.name}

+

+ {environmentDataSource.dataSource.name} +

@@ -309,8 +314,8 @@ export default function DataTab() {

- Are you sure you want to delete the data source "{selectedDataSource?.name}"? This will remove all - associated data and cannot be undone. + Are you sure you want to delete the data source "{selectedEnvironmentDataSource?.dataSource.name}"? + This will This will remove all associated data and cannot be undone.

{conversationCount > 0 && ( diff --git a/app/components/@settings/tabs/data/forms/AddDataSourceForm.tsx b/app/components/@settings/tabs/data/forms/AddDataSourceForm.tsx index 9234c4cc..37365fc0 100644 --- a/app/components/@settings/tabs/data/forms/AddDataSourceForm.tsx +++ b/app/components/@settings/tabs/data/forms/AddDataSourceForm.tsx @@ -33,6 +33,7 @@ interface AddDataSourceFormProps { onSuccess: () => void; } +// TODO: @skos update the form to use EnvironmentDataSource export default function AddDataSourceForm({ isSubmitting, setIsSubmitting, onSuccess }: AddDataSourceFormProps) { const [dbType, setDbType] = useState(DEFAULT_DATA_SOURCES[0]); const [dbName, setDbName] = useState(''); diff --git a/app/components/@settings/tabs/data/forms/EditDataSourceForm.tsx b/app/components/@settings/tabs/data/forms/EditDataSourceForm.tsx index cb36c56e..c3b67ad4 100644 --- a/app/components/@settings/tabs/data/forms/EditDataSourceForm.tsx +++ b/app/components/@settings/tabs/data/forms/EditDataSourceForm.tsx @@ -1,8 +1,7 @@ import { classNames } from '~/utils/classNames'; import { useEffect, useState } from 'react'; -import { Info, XCircle, CheckCircle, Loader2, Plug, Trash2, Save, AlertTriangle } from 'lucide-react'; +import { AlertTriangle, CheckCircle, Info, Loader2, Plug, Save, Trash2, XCircle } from 'lucide-react'; import type { TestConnectionResponse } from '~/components/@settings/tabs/data/DataTab'; -import { type DataSource } from '~/components/@settings/tabs/data/DataTab'; import { toast } from 'sonner'; import { BaseSelect } from '~/components/ui/Select'; import { SelectDatabaseTypeOptions, SingleValueWithTooltip } from '~/components/database/SelectDatabaseTypeOptions'; @@ -13,6 +12,7 @@ import { SAMPLE_DATABASE, useDataSourceTypesPlugin, } from '~/lib/hooks/plugins/useDataSourceTypesPlugin'; +import type { DataSource } from '~/lib/services/dataSourceService'; interface DataSourceResponse { success: boolean; @@ -20,6 +20,7 @@ interface DataSourceResponse { error?: string; } +// TODO: @skos update the form to use EnvironmentDataSource interface EditDataSourceFormProps { selectedDataSource: DataSource | null; isSubmitting: boolean; diff --git a/app/components/DataLoader.tsx b/app/components/DataLoader.tsx index 3d363d80..48166299 100644 --- a/app/components/DataLoader.tsx +++ b/app/components/DataLoader.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import { signIn, useSession } from '~/auth/auth-client'; -import { useDataSourcesStore } from '~/lib/stores/dataSources'; +import { type EnvironmentDataSource, useEnvironmentDataSourcesStore } from '~/lib/stores/environmentDataSources'; import { usePluginStore } from '~/lib/plugins/plugin-store'; import type { DataSourceType } from '~/lib/stores/dataSourceTypes'; import { useDataSourceTypesStore } from '~/lib/stores/dataSourceTypes'; @@ -13,17 +13,10 @@ import { DATA_SOURCE_CONNECTION_ROUTE, TELEMETRY_CONSENT_ROUTE } from '~/lib/con import { initializeClientTelemetry } from '~/lib/telemetry/telemetry-client'; import type { UserProfile } from '~/lib/services/userService'; import { useAuthProvidersPlugin } from '~/lib/hooks/plugins/useAuthProvidersPlugin'; -import type { DataSource } from '~/components/@settings/tabs/data/DataTab'; export interface RootData { user: UserProfile | null; - dataSources: Array<{ - id: string; - name: string; - connectionString: string; - createdAt: string; - updatedAt: string; - }>; + environmentDataSources: EnvironmentDataSource[]; pluginAccess: PluginAccessMap; dataSourceTypes: DataSourceType[]; } @@ -35,7 +28,8 @@ interface DataLoaderProps { export function DataLoader({ children, rootData }: DataLoaderProps) { const { data: session } = useSession(); - const { setDataSources } = useDataSourcesStore(); + // TODO: @skos this is the main idea, keep it this way but fetch envId, envName and connectionUrl + const { setEnvironmentDataSources } = useEnvironmentDataSourcesStore(); const { setPluginAccess } = usePluginStore(); const { setDataSourceTypes } = useDataSourceTypesStore(); const { setUser } = useUserStore(); @@ -79,15 +73,15 @@ export function DataLoader({ children, rootData }: DataLoaderProps) { setUser(currentUser); } - // Handle data sources - let currentDataSources = rootData.dataSources || []; + // Handle environment data sources + let currentEnvironmentDataSources = rootData.environmentDataSources || []; - if ((!rootData.dataSources || rootData.dataSources.length === 0) && session?.user) { + if ((!rootData.environmentDataSources || rootData.environmentDataSources.length === 0) && session?.user) { console.debug('๐Ÿ”„ Fetching data sources...'); - currentDataSources = await fetchDataSources(); - setDataSources(currentDataSources); - } else if (rootData.dataSources) { - setDataSources(rootData.dataSources); + currentEnvironmentDataSources = await fetchEnvironmentDataSources(); + setEnvironmentDataSources(currentEnvironmentDataSources); + } else if (rootData.environmentDataSources) { + setEnvironmentDataSources(rootData.environmentDataSources); } // Handle user onboarding flow with telemetry and data sources @@ -108,7 +102,7 @@ export function DataLoader({ children, rootData }: DataLoaderProps) { } // Redirect to data source connection if no data sources exist - if (currentDataSources.length === 0) { + if (currentEnvironmentDataSources.length === 0) { const currentPath = window.location.pathname; if (currentPath !== DATA_SOURCE_CONNECTION_ROUTE) { @@ -146,22 +140,22 @@ export function DataLoader({ children, rootData }: DataLoaderProps) { } }; - const fetchDataSources = async (): Promise => { + const fetchEnvironmentDataSources = async (): Promise => { try { - const dataSourcesResponse = await fetch('/api/data-sources'); + const environmentDataSourcesResponse = await fetch('/api/data-sources'); - if (!dataSourcesResponse.ok) { + if (!environmentDataSourcesResponse.ok) { throw new Error('Failed to fetch data sources'); } - const dataSourcesData = (await dataSourcesResponse.json()) as { + const environmentDataSourcesData = (await environmentDataSourcesResponse.json()) as { success: boolean; - dataSources: DataSource[]; + environmentDataSources: EnvironmentDataSource[]; }; console.log('โœ… Data sources fetched successfully'); - return dataSourcesData.dataSources; + return environmentDataSourcesData.environmentDataSources; } catch (error) { console.error('โŒ Failed to fetch data sources:', error); throw new Error('Failed to fetch data sources'); diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 0cdb2e4e..4daab5a2 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -10,7 +10,7 @@ import { Workbench, WorkbenchProvider } from '~/components/workbench/Workbench.c import { classNames } from '~/utils/classNames'; import { Messages } from './Messages.client'; import * as Tooltip from '@radix-ui/react-tooltip'; -import { useDataSourcesStore } from '~/lib/stores/dataSources'; +import { useEnvironmentDataSourcesStore } from '~/lib/stores/environmentDataSources'; import type { CodeError } from '~/types/actions'; import ProgressCompilation from './ProgressCompilation'; @@ -21,14 +21,14 @@ import { AUTOFIX_ATTEMPT_EVENT } from '~/lib/error-handler'; import { useSession } from '~/auth/auth-client'; import type { SendMessageFn } from './Chat.client'; import { workbenchStore } from '~/lib/stores/workbench'; -import { detectBrowser, type BrowserInfo } from '~/lib/utils/browser-detection'; +import { type BrowserInfo, detectBrowser } from '~/lib/utils/browser-detection'; import { BrowserCompatibilityModal } from '~/components/ui/BrowserCompatibilityModal'; export interface PendingPrompt { input: string; files: string[]; images: string[]; - dataSourceId: string | null; + environmentDataSource: { dataSourceId: string | null; environmentId: string | null }; } const TEXTAREA_MIN_HEIGHT = 100; @@ -89,7 +89,7 @@ export const BaseChat = ({ const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; const [progressAnnotations, setProgressAnnotations] = useState([]); - const { dataSources } = useDataSourcesStore(); + const { environmentDataSources } = useEnvironmentDataSourcesStore(); const { data: session } = useSession(); const [browserInfo, setBrowserInfo] = useState(() => ({ name: 'Other', @@ -173,7 +173,7 @@ export const BaseChat = ({ return; } - if (!dataSources.length) { + if (!environmentDataSources.length) { return; } @@ -227,7 +227,7 @@ export const BaseChat = ({ } finally { sessionStorage.removeItem('pendingPrompt'); } - }, [dataSources]); + }, [environmentDataSources]); useEffect(() => { const handleAutofixAttempt = ({ detail: { errors } }: CustomEvent<{ errors: CodeError[] }>) => { diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index ef153a8c..84ac9c0e 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -21,9 +21,7 @@ import { createSampler } from '~/utils/sampler'; import { getStarterTemplateFiles, getStarterTemplateMessages } from '~/utils/selectStarterTemplate'; import { streamingState } from '~/lib/stores/streaming'; import { filesToArtifacts } from '~/utils/fileUtils'; -import { type OutputAppEvent, OutputAppEventType } from '~/utils/output-app'; -import { QueryModal } from '~/components/chat/query-modal/QueryModal'; -import { useDataSourcesStore } from '~/lib/stores/dataSources'; +import { useEnvironmentDataSourcesStore } from '~/lib/stores/environmentDataSources'; import { type Message, useChat } from '@ai-sdk/react'; import { generateId } from 'ai'; import { useGitPullSync } from '~/lib/stores/git'; @@ -130,7 +128,7 @@ export const ChatImpl = ({ chatStore.setKey('started', chatStarted); }, [chatStarted]); - const { selectedDataSourceId } = useDataSourcesStore(); + const { selectedEnvironmentDataSource } = useEnvironmentDataSourcesStore(); const { showChat } = useStore(chatStore); @@ -316,7 +314,15 @@ export const ChatImpl = ({ if (!chatStarted) { setChatStarted(true); await workbenchStore.initialize(); - await startChatWithInitialMessage(messageContent, selectedDataSourceId!, files, dataList); + await startChatWithInitialMessage( + messageContent, + { + dataSourceId: selectedEnvironmentDataSource.dataSourceId!, + environmentId: selectedEnvironmentDataSource.environmentId!, + }, + files, + dataList, + ); return; } @@ -337,7 +343,10 @@ export const ChatImpl = ({ role: 'user', content: formatMessageWithModelInfo({ messageContent: userUpdateArtifact + messageContent, - dataSourceId: selectedDataSourceId!, + environmentDataSource: { + dataSourceId: selectedEnvironmentDataSource.dataSourceId!, + environmentId: selectedEnvironmentDataSource.environmentId!, + }, dataList, }), annotations: isFixMessage ? [FIX_ANNOTATION] : undefined, @@ -357,7 +366,10 @@ export const ChatImpl = ({ role: 'user', content: formatMessageWithModelInfo({ messageContent, - dataSourceId: selectedDataSourceId!, + environmentDataSource: { + dataSourceId: selectedEnvironmentDataSource.dataSourceId!, + environmentId: selectedEnvironmentDataSource.environmentId!, + }, dataList, }), annotations: isFixMessage ? [FIX_ANNOTATION] : undefined, @@ -426,7 +438,10 @@ export const ChatImpl = ({ annotations: ['hidden', FIX_ANNOTATION], content: formatMessageWithModelInfo({ messageContent: `${userUpdateArtifact}${message}`, - dataSourceId: selectedDataSourceId!, + environmentDataSource: { + dataSourceId: selectedEnvironmentDataSource.dataSourceId!, + environmentId: selectedEnvironmentDataSource.environmentId!, + }, }), }, ]); @@ -450,7 +465,7 @@ export const ChatImpl = ({ async function startChatWithInitialMessage( messageContent: string, - datasourceId: string, + environmentDataSource: { dataSourceId: string; environmentId: string }, files: File[], dataList: string[], ) { @@ -460,14 +475,15 @@ export const ChatImpl = ({ let conversationId = chatId.get(); if (!conversationId) { - conversationId = await createConversation(datasourceId); + // TODO: @skos this needs to be updated when we introduce EnvironmentDataSource[] on Conversation + conversationId = await createConversation(environmentDataSource.dataSourceId); chatId.set(conversationId); // Don't update URL during active chat - causes component unmounting // Store the conversation ID for potential future navigation } - const dataSourceUrlResponse = await fetch(`/api/data-sources/${datasourceId}/url`); + const dataSourceUrlResponse = await fetch(`/api/data-sources/${environmentDataSource}/url`); if (!dataSourceUrlResponse.ok) { console.error('Failed to fetch database URL:', dataSourceUrlResponse.status); @@ -498,7 +514,11 @@ export const ChatImpl = ({ content: formatMessageWithModelInfo({ messageContent, firstUserMessage: true, - dataSourceId: selectedDataSourceId!, + // TODO: @skos check where is the selection validated. Can we omit ! ? + environmentDataSource: { + dataSourceId: selectedEnvironmentDataSource.dataSourceId!, + environmentId: selectedEnvironmentDataSource.environmentId!, + }, dataList, }), experimental_attachments: createExperimentalAttachments(dataList, files), @@ -571,9 +591,6 @@ export const ChatImpl = ({ } }, []); - const [isModalOpen, setIsModalOpen] = useState(false); - const [selectedQueryId, setSelectedQueryId] = useState(null); - const handleRetry = async (errorMessage: string) => { const messagesWithoutLastAssistant = messages.filter( (message, index) => !(message.role === 'assistant' && index === messages.length - 1), @@ -599,25 +616,6 @@ export const ChatImpl = ({ }); }; - useEffect(() => { - const handleIframeMessage = (event: MessageEvent) => { - try { - const data = event.data; - - if (data.eventType === OutputAppEventType.EDIT_QUERY) { - setSelectedQueryId(data.queryId); - setIsModalOpen(true); - } - } catch (error) { - console.error('Error handling iframe message:', error); - } - }; - - window.addEventListener('message', handleIframeMessage); - - return () => window.removeEventListener('message', handleIframeMessage); - }, []); - return ( <> - {selectedQueryId && ( - - )} ); }; interface MessageWithModelInfo { messageContent: string; - dataSourceId: string; + environmentDataSource: { dataSourceId: string; environmentId: string }; firstUserMessage?: boolean; dataList?: string[]; } @@ -688,7 +678,7 @@ const createExperimentalAttachments = (dataList: string[], files: File[]) => const formatMessageWithModelInfo = ({ messageContent, - dataSourceId, + environmentDataSource, firstUserMessage, dataList, }: MessageWithModelInfo) => { @@ -698,7 +688,8 @@ const formatMessageWithModelInfo = ({ formattedMessage += `\n\n[FirstUserMessage: true]`; } - formattedMessage += `\n\n[DataSourceId: ${dataSourceId}]`; + formattedMessage += `\n\n[DataSourceId: ${environmentDataSource.dataSourceId}]`; + formattedMessage += `\n\n[EnvironmentId: ${environmentDataSource.environmentId}]`; if (dataList) { formattedMessage += `\n\n[Files: ${dataList.join('## ')}]`; diff --git a/app/components/chat/ChatTextarea.tsx b/app/components/chat/ChatTextarea.tsx index 967483a2..ebc1be15 100644 --- a/app/components/chat/ChatTextarea.tsx +++ b/app/components/chat/ChatTextarea.tsx @@ -5,7 +5,7 @@ import { IconButton } from '~/components/ui/IconButton'; import WithTooltip from '~/components/ui/Tooltip'; import FilePreview from './FilePreview'; import { ScreenshotStateManager } from './ScreenshotStateManager'; -import { useDataSourcesStore } from '~/lib/stores/dataSources'; +import { useEnvironmentDataSourcesStore } from '~/lib/stores/environmentDataSources'; import { openSettingsPanel } from '~/lib/stores/settings'; import { SendButton } from './SendButton.client'; import { processImageFile } from '~/utils/fileUtils'; @@ -56,7 +56,7 @@ export const ChatTextarea = forwardRef( }, ref, ) => { - const { dataSources } = useDataSourcesStore(); + const { environmentDataSources } = useEnvironmentDataSourcesStore(); const handleConnectDataSource = () => { openSettingsPanel('data', true); @@ -157,7 +157,7 @@ export const ChatTextarea = forwardRef(
- {dataSources.length > 0 ? ( + {environmentDataSources.length > 0 ? ( ) : (
+
+ + { + setSelectedEnvironment(value); + setError(null); + }} + options={environmentOptions} + placeholder={isLoadingEnvironments ? 'Loading environments...' : 'Select environment'} + isDisabled={isLoadingEnvironments} + width="100%" + minWidth="100%" + isSearchable={false} + /> + {selectedEnvironment?.description && ( +
{selectedEnvironment.description}
+ )} +
{dbType.value !== 'sample' && (
@@ -207,7 +299,7 @@ export default function DataSourceConnectionPage() { type="submit" variant="primary" className={`min-w-[150px] max-w-[220px] transition-all duration-300 ${isSuccess ? 'bg-green-500 hover:bg-green-500 !disabled:opacity-100' : ''}`} - disabled={!dbName || !connStr || isTesting || isSuccess} + disabled={!selectedEnvironment || !dbName || !connStr || isTesting || isSuccess} > {isSuccess ? (
@@ -241,7 +333,7 @@ export default function DataSourceConnectionPage() { variant="primary" className={`min-w-[150px] transition-all duration-300 ${isSuccess ? 'bg-green-500 hover:bg-green-500 !disabled:opacity-100' : ''}`} onClick={handleSampleDatabase} - disabled={isSuccess} + disabled={!selectedEnvironment || isSuccess} > {isSuccess ? (
diff --git a/app/layout.tsx b/app/layout.tsx index 72701535..640bba3b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,7 @@ import './styles/index.scss'; import type { ReactNode } from 'react'; import { ClientProviders } from './components/ClientProviders'; import './globals.css'; -import { getDataSources } from '~/lib/services/datasourceService'; +import { getEnvironmentDataSources } from '~/lib/services/dataSourceService'; import { userService } from '~/lib/services/userService'; import { getUserAbility } from './lib/casl/user-ability'; import PluginManager, { FREE_PLUGIN_ACCESS } from '~/lib/plugins/plugin-manager'; @@ -31,7 +31,7 @@ async function getRootData() { }); let user = null; - let dataSources: any[] = []; + let environmentDataSources: any[] = []; let pluginAccess = FREE_PLUGIN_ACCESS; let dataSourceTypes: any[] = []; @@ -41,7 +41,7 @@ async function getRootData() { // Get data sources for the user const userAbility = await getUserAbility(session.user.id); - dataSources = await getDataSources(userAbility); + environmentDataSources = await getEnvironmentDataSources(userAbility); // Initialize plugin manager await PluginManager.getInstance().initialize(); @@ -53,7 +53,7 @@ async function getRootData() { return { user, - dataSources, + environmentDataSources, pluginAccess, dataSourceTypes, }; @@ -61,7 +61,7 @@ async function getRootData() { console.error('Error loading root data:', error); return { user: null, - dataSources: [], + environmentDataSources: [], pluginAccess: FREE_PLUGIN_ACCESS, dataSourceTypes: [], }; diff --git a/app/lib/.server/llm/stream-text.ts b/app/lib/.server/llm/stream-text.ts index 19d459a4..6c46b40c 100644 --- a/app/lib/.server/llm/stream-text.ts +++ b/app/lib/.server/llm/stream-text.ts @@ -51,8 +51,10 @@ export async function streamText(props: { let processedMessages = messages.map((message) => { if (message.role === MessageRole.User) { - const { content, isFirstUserMessage, dataSourceId } = extractPropertiesFromMessage(message); - currentDataSourceId = dataSourceId; + const { content, isFirstUserMessage } = extractPropertiesFromMessage(message); + + // TODO: @skos The current environmentDataSource from conversation should be passed in to streamText and used here! + currentDataSourceId = ''; return { ...message, content, isFirstUserMessage }; } else if (message.role == MessageRole.Assistant) { @@ -131,7 +133,9 @@ ${props.summary} if (isFirstUserMessage(processedMessages) || (await shouldGenerateSqlQueries(lastUserMessage, existingQueries))) { const userId = await requireUserId(request); - const schema = await getDatabaseSchema(currentDataSourceId, userId); + + // TODO: @skos the schema should be fetched here + the connection string from the top + const schema = await getDatabaseSchema(currentDataSourceId, 'pass in environment id', userId); const dataSource = await prisma.dataSource.findUniqueOrThrow({ where: { id: currentDataSourceId, createdById: userId }, }); @@ -139,7 +143,8 @@ ${props.summary} const sqlQueries = await generateSqlQueries({ schema, userPrompt: lastUserMessage, - connectionString: dataSource.connectionString, + // TODO: @skos pass the actual connection url here, not name!! + connectionString: dataSource.name, implementationPlan, existingQueries, }); diff --git a/app/lib/.server/llm/utils.ts b/app/lib/.server/llm/utils.ts index 745cd430..0fda7152 100644 --- a/app/lib/.server/llm/utils.ts +++ b/app/lib/.server/llm/utils.ts @@ -1,5 +1,5 @@ import { type Message } from 'ai'; -import { DATA_SOURCE_ID_REGEX, FILES_REGEX, FIRST_USER_MESSAGE_REGEX, FIX_ANNOTATION } from '~/utils/constants'; +import { FILES_REGEX, FIRST_USER_MESSAGE_REGEX, FIX_ANNOTATION } from '~/utils/constants'; import { type FileMap, IGNORE_PATTERNS } from './constants'; import ignore from 'ignore'; import type { ContextAnnotation } from '~/types/context'; @@ -7,7 +7,6 @@ import { freeEmailDomains } from 'free-email-domains-typescript'; export function extractPropertiesFromMessage(message: Omit): { isFirstUserMessage?: boolean; - dataSourceId?: string; content: string; isFixMessage: boolean; } { @@ -16,32 +15,26 @@ export function extractPropertiesFromMessage(message: Omit): { : message.content; const isFirstUserMessageMatch = textContent.match(FIRST_USER_MESSAGE_REGEX); - const dataSourceIdMatch = textContent.match(DATA_SOURCE_ID_REGEX); const isFixMessage = !!message.annotations?.includes(FIX_ANNOTATION); const isFirstUserMessage = isFirstUserMessageMatch ? isFirstUserMessageMatch[1] === 'true' : false; - const dataSourceId = dataSourceIdMatch ? dataSourceIdMatch[1] : undefined; const cleanedContent = Array.isArray(message.content) ? message.content.map((item) => { if (item.type === 'text') { return { type: 'text', - text: item.text - ?.replace(FIRST_USER_MESSAGE_REGEX, '') - .replace(DATA_SOURCE_ID_REGEX, '') - .replace(FILES_REGEX, ''), + text: item.text?.replace(FIRST_USER_MESSAGE_REGEX, '').replace(FILES_REGEX, ''), }; } return item; // Preserve image_url and other types as is }) - : textContent.replace(FIRST_USER_MESSAGE_REGEX, '').replace(DATA_SOURCE_ID_REGEX, '').replace(FILES_REGEX, ''); + : textContent.replace(FIRST_USER_MESSAGE_REGEX, '').replace(FILES_REGEX, ''); return { content: cleanedContent, isFirstUserMessage, - dataSourceId, isFixMessage, }; } diff --git a/app/lib/persistence/conversations.ts b/app/lib/persistence/conversations.ts index 9e64de04..d8061d87 100644 --- a/app/lib/persistence/conversations.ts +++ b/app/lib/persistence/conversations.ts @@ -33,6 +33,7 @@ type ConversationResponse = { createdAt: number; updatedAt: number; dataSourceId: string; + environmentId: string; }; export type SimpleConversationResponse = Omit; @@ -87,6 +88,7 @@ export async function getConversation(id: string): Promise { }; } +// TODO: @skos this needs to be updated when we introduce EnvironmentDataSource[] on Conversation export async function createConversation(dataSourceId: string, messages?: MessageRequest[]): Promise { const response = await fetch(CONVERSATIONS_API, { method: 'POST', diff --git a/app/lib/persistence/useConversationHistory.ts b/app/lib/persistence/useConversationHistory.ts index b9977a7e..76fa74cd 100644 --- a/app/lib/persistence/useConversationHistory.ts +++ b/app/lib/persistence/useConversationHistory.ts @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'; import { atom } from 'nanostores'; import { type Message } from 'ai'; import { toast } from 'sonner'; -import { useDataSourcesStore } from '~/lib/stores/dataSources'; +import { useEnvironmentDataSourcesStore } from '~/lib/stores/environmentDataSources'; import { saveSnapshot, type SnapshotResponse } from '~/lib/persistence/snapshots'; import { createCommandsMessage, detectProjectCommandsFromFileMap } from '~/utils/projectCommands'; import { loadFileMapIntoContainer } from '~/lib/webcontainer/load-file-map'; @@ -35,7 +35,7 @@ export const description = atom(undefined); export function useConversationHistory(id?: string) { const router = useRouter(); - const { selectedDataSourceId, setSelectedDataSourceId } = useDataSourcesStore(); + const { selectedEnvironmentDataSource, setSelectedEnvironmentDataSource } = useEnvironmentDataSourcesStore(); const [initialMessages, setInitialMessages] = useState([]); const [commandMessage, setCommandMessage] = useState(); @@ -81,8 +81,9 @@ export function useConversationHistory(id?: string) { logger.error('Failed to load snapshot into container', reason); }); - if (conversation.dataSourceId && conversation.dataSourceId !== selectedDataSourceId) { - setSelectedDataSourceId(conversation.dataSourceId); + // TODO: @skos we need to use environmentId and dataSourceId from conversation + if (conversation.dataSourceId && conversation.dataSourceId !== selectedEnvironmentDataSource) { + setSelectedEnvironmentDataSource(conversation.dataSourceId); } chatId.set(conversation.id); @@ -97,7 +98,7 @@ export function useConversationHistory(id?: string) { // Redirect to main chat page if conversation not found router.replace('/chat'); }); - }, [id, router, selectedDataSourceId, setSelectedDataSourceId]); + }, [id, router, selectedEnvironmentDataSource, setSelectedEnvironmentDataSource]); return { ready: !id || ready, diff --git a/app/lib/schema.ts b/app/lib/schema.ts index 89dc688d..efec08ff 100644 --- a/app/lib/schema.ts +++ b/app/lib/schema.ts @@ -3,7 +3,7 @@ import { createScopedLogger } from '~/utils/logger'; import { prisma } from '~/lib/prisma'; import crypto from 'crypto'; -import { getDatabaseUrl } from '~/lib/services/datasourceService'; +import { getDatabaseUrl } from '~/lib/services/dataSourceService'; import { DataSourcePluginManager } from '~/lib/plugins/data-access/data-access-plugin-manager'; // Cache duration in seconds (31 days) @@ -11,8 +11,12 @@ const SCHEMA_CACHE_TTL = 60 * 60 * 24 * 31; const logger = createScopedLogger('get-database-schema'); -export const getDatabaseSchema = async (dataSourceId: string, userId: string): Promise => { - const connectionUrl = await getDatabaseUrl(userId, dataSourceId); +export const getDatabaseSchema = async ( + dataSourceId: string, + environmentId: string, + userId: string, +): Promise => { + const connectionUrl = await getDatabaseUrl(userId, dataSourceId, environmentId); if (!connectionUrl) { throw new Error('Missing required connection parameters'); diff --git a/app/lib/services/conversationService.ts b/app/lib/services/conversationService.ts index f8713cd7..79542283 100644 --- a/app/lib/services/conversationService.ts +++ b/app/lib/services/conversationService.ts @@ -1,7 +1,8 @@ import { prisma } from '~/lib/prisma'; -import type { Conversation, Prisma } from '@prisma/client'; +import { type Conversation, type EnvironmentDataSource, type Prisma } from '@prisma/client'; import { StarterPluginManager } from '~/lib/plugins/starter/starter-plugin-manager'; +// TODO: @skos Update the whole service based on the new schema design export const conversationService = { async getConversation(conversationId: string): Promise { return await prisma.conversation.findUnique({ @@ -9,12 +10,12 @@ export const conversationService = { }); }, - async getConversationDataSource(conversationId: string) { + async getConversationEnvironmentDataSource(conversationId: string): Promise { return await prisma.conversation .findUniqueOrThrow({ where: { id: conversationId }, }) - .dataSource(); + .environmentDataSource(); }, async createConversation( @@ -56,7 +57,7 @@ export const conversationService = { id: true, description: true, starterId: true, - dataSourceId: true, + environmentDataSource: true, snapshots: true, userId: true, createdAt: true, diff --git a/app/lib/services/dataSourceService.ts b/app/lib/services/dataSourceService.ts new file mode 100644 index 00000000..3e7fd0bb --- /dev/null +++ b/app/lib/services/dataSourceService.ts @@ -0,0 +1,302 @@ +import { prisma } from '~/lib/prisma'; +import { DataSourcePluginManager } from '~/lib/plugins/data-access/data-access-plugin-manager'; +import { buildResourceWhereClause } from '@/lib/casl/prisma-helpers'; +import { + DataSourcePropertyType, + type EnvironmentDataSource, + EnvironmentVariableType, + PermissionAction, + PermissionResource, + Prisma, +} from '@prisma/client'; +import type { AppAbility } from '~/lib/casl/user-ability'; +import { createEnvironmentVariable, decryptEnvironmentVariable } from './environmentVariablesService'; +import { getEnvironmentName } from './environmentService'; + +export interface DataSource { + id: string; + name: string; + connectionString: string; + environmentId: string; + environmentName: string; + createdAt: Date; + updatedAt: Date; +} + +const SAMPLE_DATABASE_CONNECTION_STRING = 'sqlite://sample.db'; + +export async function getEnvironmentDataSources(userAbility: AppAbility): Promise { + // TODO: @skos Update specific permissions + const whereClause = buildResourceWhereClause( + userAbility, + PermissionAction.read, + PermissionResource.DataSource, + ) as Prisma.DataSourceWhereInput; + + const environmentDataSources = await prisma.environmentDataSource.findMany({ + where: { + dataSource: whereClause, + }, + include: { + environment: true, + dataSource: true, + dataSourceProperties: { + include: { + environmentVariable: true, + }, + }, + conversations: true, + }, + orderBy: [{ environment: { name: 'asc' } }, { dataSource: { name: 'asc' } }], + }); + + return environmentDataSources.map((eds) => ({ + ...eds, + dataSourceProperties: eds.dataSourceProperties.map((dsp) => ({ + ...dsp, + environmentVariable: decryptEnvironmentVariable(dsp.environmentVariable), + })), + })); +} + +export async function getEnvironmentDataSource(dataSourceId: string, userId: string, environmentId: string) { + const environmentDataSource = await prisma.environmentDataSource.findUnique({ + where: { + environmentId_dataSourceId: { + environmentId, + dataSourceId, + }, + }, + include: { + environment: true, + dataSource: true, + dataSourceProperties: { + include: { + environmentVariable: true, + }, + }, + conversations: true, + }, + }); + + // TODO: @skos Update specific permissions + // Verify user has access to this data source + if (!environmentDataSource || environmentDataSource.dataSource.createdById !== userId) { + return null; + } + + return { + ...environmentDataSource, + dataSourceProperties: environmentDataSource.dataSourceProperties.map((dsp) => ({ + ...dsp, + environmentVariable: decryptEnvironmentVariable(dsp.environmentVariable), + })), + } +} + +export async function createDataSource(data: { + name: string; + createdById: string; + environmentId: string; + connectionString: string; +}): Promise { + validateDataSource(data.connectionString); + + // Get environment details for naming + const environmentName = await getEnvironmentName(data.environmentId); + + if (!environmentName) { + throw new Error('Environment not found'); + } + + // Use Prisma transaction to ensure full atomicity + return prisma.$transaction(async (tx) => { + // Create data source + const dataSource = await tx.dataSource.create({ + data: { + name: data.name, + createdById: data.createdById, + }, + select: { + id: true, + name: true, + createdAt: true, + updatedAt: true, + }, + }); + + // Create EnvironmentDataSource relationship + await tx.environmentDataSource.create({ + data: { + environmentId: data.environmentId, + dataSourceId: dataSource.id, + }, + }); + + // Create environment variable for connection url (if provided) + const envVarKey = `${environmentName}_${data.name}_${DataSourcePropertyType.CONNECTION_URL}` + .toUpperCase() + .replace(/\s+/g, '_'); + + const environmentVariable = await createEnvironmentVariable( + envVarKey, + data.connectionString!, + EnvironmentVariableType.DATA_SOURCE, + data.environmentId, + data.createdById, + `Database connection URL for ${data.name} in ${environmentName}`, + dataSource.id, + tx, // Pass transaction for atomicity + ); + + await tx.dataSourceProperty.create({ + data: { + type: DataSourcePropertyType.CONNECTION_URL, + environmentId: data.environmentId, + dataSourceId: dataSource.id, + environmentVariableId: environmentVariable.id, + }, + }); + + return { + ...dataSource, + connectionString: data.connectionString!, + environmentId: data.environmentId, + environmentName, + }; + }); +} + +export async function createSampleDataSource(data: { + createdById: string; + environmentId: string; +}): Promise { + const environmentName = await getEnvironmentName(data.environmentId); + + if (!environmentName) { + throw new Error('Environment not found'); + } + + return prisma.$transaction(async (tx) => { + // Create data source + const dataSource = await tx.dataSource.create({ + data: { + name: 'Sample Database', + createdById: data.createdById, + }, + select: { + id: true, + name: true, + createdAt: true, + updatedAt: true, + }, + }); + + await tx.environmentDataSource.create({ + data: { + environmentId: data.environmentId, + dataSourceId: dataSource.id, + }, + }); + + // Create environment variable for sample db connection url + const envVarKey = `${environmentName}_SAMPLE_${DataSourcePropertyType.CONNECTION_URL}` + .toUpperCase() + .replace(/\s+/g, '_'); + + const environmentVariable = await createEnvironmentVariable( + envVarKey, + SAMPLE_DATABASE_CONNECTION_STRING, + EnvironmentVariableType.DATA_SOURCE, + data.environmentId, + data.createdById, + `Database connection URL for Sample Database in ${environmentName}`, + dataSource.id, + tx, // Pass transaction for atomicity + ); + + await tx.dataSourceProperty.create({ + data: { + type: DataSourcePropertyType.CONNECTION_URL, + environmentId: data.environmentId, + dataSourceId: dataSource.id, + environmentVariableId: environmentVariable.id, + }, + }); + + return { + ...dataSource, + connectionString: SAMPLE_DATABASE_CONNECTION_STRING, + environmentId: data.environmentId, + environmentName, + }; + }); +} + +// TODO: @skos update this and the routes and UI +export async function updateDataSource(data: { id: string; name: string; connectionString: string; userId: string }) { + validateDataSource(data.connectionString); + + return prisma.dataSource.update({ + where: { id: data.id, createdById: data.userId }, + data: { name: data.name }, + }); +} + +// TODO: @skos update this with environmentId (routes and UI as well) +export async function deleteDataSource(id: string, userId: string) { + return prisma.dataSource.delete({ where: { id, createdById: userId } }); +} + +export async function getDatabaseUrl( + userId: string, + dataSourceId: string, + environmentId: string, +): Promise { + const eds = await prisma.environmentDataSource.findFirst({ + where: { + environmentId, + dataSourceId, + dataSource: { + createdById: userId, // ownership check + }, + }, + include: { + dataSourceProperties: { + where: { + type: DataSourcePropertyType.CONNECTION_URL, // only connection URL property + }, + include: { + environmentVariable: true, // pull the encrypted env var + }, + }, + }, + }); + + if (!eds || eds.dataSourceProperties.length === 0) { + return null; + } + + const connectionProperty = eds.dataSourceProperties[0]; + const envVar = connectionProperty.environmentVariable; + + if (!envVar) { + return null; + } + + return decryptEnvironmentVariable(envVar).value; +} + +export async function getConversationCount(dataSourceId: string, userId: string): Promise { + return prisma.conversation.count({ + where: { + dataSourceId, + userId, + }, + }); +} + +function validateDataSource(connectionString: string) { + const accessor = DataSourcePluginManager.getAccessor(connectionString); + accessor.validate(connectionString); +} diff --git a/app/lib/services/datasourceService.ts b/app/lib/services/datasourceService.ts deleted file mode 100644 index 0af19b46..00000000 --- a/app/lib/services/datasourceService.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { prisma } from '~/lib/prisma'; -import { DataSourcePluginManager } from '~/lib/plugins/data-access/data-access-plugin-manager'; -import { buildResourceWhereClause } from '@/lib/casl/prisma-helpers'; -import { PermissionAction, PermissionResource, Prisma } from '@prisma/client'; -import type { AppAbility } from '~/lib/casl/user-ability'; - -export interface DataSource { - id: string; - name: string; - connectionString: string; - createdAt: Date; - updatedAt: Date; -} - -export async function getDataSource(id: string, userId: string): Promise { - return prisma.dataSource.findFirst({ - where: { id, createdById: userId }, - select: { - id: true, - name: true, - connectionString: true, - createdAt: true, - updatedAt: true, - }, - }); -} - -export async function getDataSources(userAbility: AppAbility): Promise { - const whereClause = buildResourceWhereClause( - userAbility, - PermissionAction.read, - PermissionResource.DataSource, - ) as Prisma.DataSourceWhereInput; - - return prisma.dataSource.findMany({ - where: whereClause, - select: { - id: true, - name: true, - connectionString: true, - createdAt: true, - updatedAt: true, - }, - }); -} - -export async function createDataSource(data: { - name: string; - connectionString: string; - createdById: string; -}): Promise { - validateDataSource(data.connectionString); - - return prisma.dataSource.create({ - data: { - name: data.name, - connectionString: data.connectionString, - createdById: data.createdById, - }, - }); -} - -export async function updateDataSource(data: { id: string; name: string; connectionString: string; userId: string }) { - validateDataSource(data.connectionString); - - return prisma.dataSource.update({ - where: { id: data.id, createdById: data.userId }, - data: { name: data.name, connectionString: data.connectionString }, - }); -} - -export async function deleteDataSource(id: string, userId: string) { - return prisma.dataSource.delete({ where: { id, createdById: userId } }); -} - -export async function getDatabaseUrl(userId: string, datasourceId: string) { - const dataSource = await prisma.dataSource.findUnique({ - where: { id: datasourceId, createdById: userId }, - select: { - connectionString: true, - }, - }); - - if (!dataSource) { - throw new Error('Data source not found'); - } - - return dataSource.connectionString; -} - -export async function getConversationCount(dataSourceId: string, userId: string): Promise { - return prisma.conversation.count({ - where: { - dataSourceId, - userId, - }, - }); -} - -function validateDataSource(connectionString: string) { - const accessor = DataSourcePluginManager.getAccessor(connectionString); - accessor.validate(connectionString); -} diff --git a/app/lib/services/environmentService.ts b/app/lib/services/environmentService.ts index 9d38f58a..9d5c3998 100644 --- a/app/lib/services/environmentService.ts +++ b/app/lib/services/environmentService.ts @@ -11,12 +11,24 @@ export async function getEnvironment(id: string): Promise { }); } +export async function getEnvironmentName(id: string): Promise { + const env = await prisma.environment.findUnique({ + where: { id }, + select: { + name: true, + }, + }); + + return env?.name ?? null; +} + export async function getEnvironments(): Promise { return prisma.environment.findMany({ include: { dataSources: true, websites: true, }, + orderBy: { name: 'asc' }, }); } diff --git a/app/lib/services/environmentVariablesService.ts b/app/lib/services/environmentVariablesService.ts new file mode 100644 index 00000000..2bdffa71 --- /dev/null +++ b/app/lib/services/environmentVariablesService.ts @@ -0,0 +1,142 @@ +import { prisma } from '~/lib/prisma'; +import type { EnvironmentVariable, EnvironmentVariableType } from '@prisma/client'; +import { encryptData, decryptData } from '@liblab/encryption/encryption'; +import { env } from '~/env'; +import { logger } from '~/utils/logger'; + +type PrismaTransaction = Omit; + +export async function getEnvironmentVariable(id: string): Promise { + const envVar = await prisma.environmentVariable.findUnique({ + where: { id }, + include: { + environment: true, + createdBy: true, + }, + }); + + return envVar ? decryptEnvironmentVariable(envVar) : null; +} + +export async function getEnvironmentVariables(environmentId: string): Promise { + const envVars = await prisma.environmentVariable.findMany({ + where: { environmentId }, + include: { + environment: true, + createdBy: true, + }, + orderBy: { key: 'asc' }, + }); + + return envVars.map(decryptEnvironmentVariable); +} + +export async function createEnvironmentVariable( + key: string, + value: string, + type: EnvironmentVariableType, + environmentId: string, + createdById: string, + description?: string, + dataSourceId?: string, + tx?: PrismaTransaction, +): Promise { + const encryptedValue = encryptValue(value); + const client = tx || prisma; + + const envVar = await client.environmentVariable.create({ + data: { + key, + value: encryptedValue, + description: description || null, + type, + environmentId, + createdById, + }, + }); + + return decryptEnvironmentVariable(envVar); +} + +export async function updateEnvironmentVariable( + id: string, + key: string, + value: string, + type: EnvironmentVariableType, + description?: string, +): Promise { + const valueToStore = encryptValue(value); + + const envVar = await prisma.environmentVariable.update({ + where: { id }, + data: { + key, + value: valueToStore, + description: description || null, + type, + }, + }); + + return decryptEnvironmentVariable(envVar); +} + +export async function deleteEnvironmentVariable(id: string) { + return prisma.environmentVariable.delete({ where: { id } }); +} + +export async function getEnvironmentVariableByKey( + key: string, + environmentId: string, +): Promise { + const envVar = await prisma.environmentVariable.findUnique({ + where: { + key_environmentId: { + key, + environmentId, + }, + }, + include: { + environment: true, + createdBy: true, + }, + }); + + return envVar ? decryptEnvironmentVariable(envVar) : null; +} + +export function decryptEnvironmentVariable(envVar: EnvironmentVariable): EnvironmentVariable { + try { + return { + ...envVar, + value: decryptValue(envVar.value), + }; + } catch (error) { + logger.error('Failed to decrypt environment variable:', envVar.id, error); + + throw new Error(`Failed to decrypt environment variable: ${envVar.key}`); + } +} + +function encryptValue(value: string): string { + const encryptionKey = env.server.ENCRYPTION_KEY; + + if (!encryptionKey) { + throw new Error('Encryption key not found'); + } + + const dataBuffer = Buffer.from(value); + + return encryptData(encryptionKey, dataBuffer); +} + +function decryptValue(encryptedValue: string): string { + const encryptionKey = env.server.ENCRYPTION_KEY; + + if (!encryptionKey) { + throw new Error('Encryption key not found'); + } + + const decryptedBuffer = decryptData(encryptionKey, encryptedValue); + + return decryptedBuffer.toString(); +} diff --git a/app/lib/services/suggestionService.ts b/app/lib/services/suggestionService.ts index 0c1834c8..644e9f66 100644 --- a/app/lib/services/suggestionService.ts +++ b/app/lib/services/suggestionService.ts @@ -1,7 +1,7 @@ import { generateObject } from 'ai'; import { z } from 'zod'; import { DataAccessor } from 'shared/src/data-access/dataAccessor'; -import { type DataSource } from './datasourceService'; +import { type DataSource } from './dataSourceService'; import { getLlm } from '~/lib/.server/llm/get-llm'; import { logger } from '~/utils/logger'; diff --git a/app/lib/stores/environmentDataSources.ts b/app/lib/stores/environmentDataSources.ts new file mode 100644 index 00000000..537a60e6 --- /dev/null +++ b/app/lib/stores/environmentDataSources.ts @@ -0,0 +1,120 @@ +import { create } from 'zustand'; +import { useRouter } from 'next/navigation'; +import { persist } from 'zustand/middleware'; +import { DATA_SOURCE_CONNECTION_ROUTE } from '~/lib/constants/routes'; + +// TODO: @skos we could reassign the prisma types in lib/ to a variable and import here as a type +export interface EnvironmentVariable { + id: string; + key: string; + value: string; + description: string | null; + // TODO: @skos the type should be EnvironmentVariableType (but can't import directly from prisma) + type: any; + environmentId: string; + dataSourceId: string | null; + createdById: string; + createdAt: Date; + updatedAt: Date; +} + +export interface EnvironmentDataSource { + createdAt: Date; + updatedAt: Date; + dataSourceId: string; + environmentId: string; + environment: { + id: string; + name: string; + description: string | null; + }; + dataSource: { + id: string; + name: string; + createdAt: Date; + updatedAt: Date; + }; + dataSourceProperties: [ + { + type: any; + environmentVariables: EnvironmentVariable[]; + }, + ]; + // TODO: @skos this should be DataSourcePropertyType (but can't import from prisma directly) +} + +interface EnvironmentDataSourcesStore { + environmentDataSources: EnvironmentDataSource[]; + selectedEnvironmentDataSource: { dataSourceId: string | null; environmentId: string | null }; + setEnvironmentDataSources: (environmentDataSources: EnvironmentDataSource[]) => void; + setSelectedEnvironmentDataSource: (dataSourceId: string | null, environmentId: string | null) => void; + clearEnvironmentDataSources: () => void; +} + +export const useEnvironmentDataSourcesStore = create()( + persist( + (set, getState) => ({ + environmentDataSources: [], + selectedEnvironmentDataSource: { dataSourceId: null, environmentId: null }, + setEnvironmentDataSources: (environmentDataSources) => { + set({ environmentDataSources }); + + if (environmentDataSources.length === 0) { + getState().setSelectedEnvironmentDataSource(null, null); + return; + } + + const selectedEnvironmentDataSource = getState().selectedEnvironmentDataSource; + + if ( + selectedEnvironmentDataSource.dataSourceId && + selectedEnvironmentDataSource.environmentId && + environmentDataSources.some( + (eds) => + eds.dataSourceId === selectedEnvironmentDataSource.dataSourceId && + eds.environmentId === selectedEnvironmentDataSource.environmentId, + ) + ) { + return; + } + + getState().setSelectedEnvironmentDataSource( + environmentDataSources[0].dataSourceId, + environmentDataSources[0].environmentId, + ); + }, + setSelectedEnvironmentDataSource: (dataSourceId: string | null, environmentId: string | null) => + set({ selectedEnvironmentDataSource: { dataSourceId, environmentId } }), + clearEnvironmentDataSources: () => set({ environmentDataSources: [] }), + }), + { + name: 'data-sources-storage', + }, + ), +); + +export const useDataSourceActions = () => { + const { setEnvironmentDataSources } = useEnvironmentDataSourcesStore(); + const router = useRouter(); + + const refetchEnvironmentDataSources = async () => { + try { + const response = await fetch('/api/data-sources'); + const data = (await response.json()) as { success: boolean; environmentDataSources: EnvironmentDataSource[] }; + + if (data.success) { + setEnvironmentDataSources(data.environmentDataSources); + + if (!data.environmentDataSources?.length) { + router.push(DATA_SOURCE_CONNECTION_ROUTE); + } + } + } catch (error) { + console.error('Failed to fetch data sources:', error); + } + }; + + return { + refetchEnvironmentDataSources, + }; +}; diff --git a/app/page.tsx b/app/page.tsx index 278262c4..90b62217 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,7 +5,7 @@ import { Header } from '~/components/header/Header'; import { Background } from '~/components/ui/Background'; import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { useDataSourcesStore } from '~/lib/stores/dataSources'; +import { useEnvironmentDataSourcesStore } from '~/lib/stores/environmentDataSources'; import { Menu } from '~/components/sidebar/Menu.client'; import * as Tooltip from '@radix-ui/react-tooltip'; import type { PendingPrompt } from '~/components/chat/BaseChat'; @@ -19,7 +19,7 @@ import { toast } from 'sonner'; export default function Index() { const router = useRouter(); - const { selectedDataSourceId, dataSources } = useDataSourcesStore(); + const { selectedEnvironmentDataSource, environmentDataSources } = useEnvironmentDataSourcesStore(); const [input, setInput] = useState(''); const [uploadedFiles, setUploadedFiles] = useState([]); const [imageDataList, setImageDataList] = useState([]); @@ -55,7 +55,7 @@ export default function Index() { input, files: uploadedFiles.map((f) => f.name), images: imageDataList, - dataSourceId: selectedDataSourceId, + environmentDataSource: selectedEnvironmentDataSource, }; sessionStorage.setItem('pendingPrompt', JSON.stringify(pendingPrompt)); @@ -66,7 +66,7 @@ export default function Index() { return; } - if (dataSources.length === 0 || !selectedDataSourceId) { + if (environmentDataSources.length === 0 || !selectedEnvironmentDataSource) { router.push(DATA_SOURCE_CONNECTION_ROUTE); return; diff --git a/app/utils/constants.ts b/app/utils/constants.ts index 9cbbf659..bf3ae667 100644 --- a/app/utils/constants.ts +++ b/app/utils/constants.ts @@ -4,6 +4,7 @@ export const MODIFICATIONS_TAG_NAME = 'liblab_file_modifications'; export const FIRST_USER_MESSAGE_REGEX = /\[FirstUserMessage: (.*?)\]\n\n/; export const PROMPT_COOKIE_KEY = 'cachedPrompt'; export const DATA_SOURCE_ID_REGEX = /\[DataSourceId: (.*?)\]\n\n/; +export const ENVIRONMENT_ID_REGEX = /\[EnvironmentId: (.*?)\]\n\n/; export const FILES_REGEX = /\[Files: (.*?)\]\n\n/; export const PROJECT_SETUP_ANNOTATION = 'project-setup'; export const FIX_ANNOTATION = 'fix'; diff --git a/prisma/migrations/20250819132659_add_environment_variables_and_data_source_properties_and_environments_support/migration.sql b/prisma/migrations/20250819132659_add_environment_variables_and_data_source_properties_and_environments_support/migration.sql new file mode 100644 index 00000000..5d8887cf --- /dev/null +++ b/prisma/migrations/20250819132659_add_environment_variables_and_data_source_properties_and_environments_support/migration.sql @@ -0,0 +1,78 @@ +/* + Warnings: + + - You are about to drop the column `dataSourceId` on the `conversation` table. All the data in the column will be lost. + - You are about to drop the column `connection_string` on the `data_source` table. All the data in the column will be lost. + - Added the required column `data_source_id` to the `conversation` table without a default value. This is not possible if the table is not empty. + - Added the required column `environment_id` to the `conversation` table without a default value. This is not possible if the table is not empty. + - Added the required column `updated_at` to the `environment_data_source` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "public"."DataSourcePropertyType" AS ENUM ('CONNECTION_URL', 'ACCESS_TOKEN', 'REFRESH_TOKEN', 'CLIENT_ID', 'CLIENT_SECRET', 'API_KEY'); + +-- CreateEnum +CREATE TYPE "public"."EnvironmentVariableType" AS ENUM ('GLOBAL', 'DATA_SOURCE'); + +-- AlterEnum +ALTER TYPE "public"."PermissionResource" ADD VALUE 'EnvironmentVariable'; + +-- DropForeignKey +ALTER TABLE "public"."conversation" DROP CONSTRAINT "conversation_dataSourceId_fkey"; + +-- AlterTable +ALTER TABLE "public"."conversation" DROP COLUMN "dataSourceId", +ADD COLUMN "data_source_id" TEXT NOT NULL, +ADD COLUMN "environment_id" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "public"."data_source" DROP COLUMN "connection_string"; + +-- AlterTable +ALTER TABLE "public"."environment_data_source" ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL; + +-- CreateTable +CREATE TABLE "public"."data_source_property" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "environment_variable_id" TEXT NOT NULL, + "environment_id" TEXT NOT NULL, + "data_source_id" TEXT NOT NULL, + + CONSTRAINT "data_source_property_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."environment_variable" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "description" TEXT, + "type" "public"."EnvironmentVariableType" NOT NULL, + "environment_id" TEXT NOT NULL, + "created_by_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "environment_variable_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "environment_variable_key_environment_id_key" ON "public"."environment_variable"("key", "environment_id"); + +-- AddForeignKey +ALTER TABLE "public"."data_source_property" ADD CONSTRAINT "data_source_property_environment_variable_id_fkey" FOREIGN KEY ("environment_variable_id") REFERENCES "public"."environment_variable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."data_source_property" ADD CONSTRAINT "data_source_property_environment_id_data_source_id_fkey" FOREIGN KEY ("environment_id", "data_source_id") REFERENCES "public"."environment_data_source"("environment_id", "data_source_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."environment_variable" ADD CONSTRAINT "environment_variable_environment_id_fkey" FOREIGN KEY ("environment_id") REFERENCES "public"."environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."environment_variable" ADD CONSTRAINT "environment_variable_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."conversation" ADD CONSTRAINT "conversation_environment_id_data_source_id_fkey" FOREIGN KEY ("environment_id", "data_source_id") REFERENCES "public"."environment_data_source"("environment_id", "data_source_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/seed.ts b/prisma/seed.ts index 42e5d09a..ea983f8c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -8,7 +8,7 @@ async function seed() { const initialUser = await seedInitialUser(organization.id); await seedInitialAccount(initialUser); await seedDefaultAdmin(initialUser.id, organization.id); - await seedDefaultEnvironment(organization.id); + await seedDefaultEnvironments(organization.id); await seedBuilderRole(organization.id); await seedOperatorRole(organization.id); @@ -107,28 +107,39 @@ async function seedInitialAccount(initialUser: User): Promise { } } -async function seedDefaultEnvironment(organizationId: string): Promise { +async function seedDefaultEnvironments(organizationId: string): Promise { try { - let environment = await prisma.environment.findFirst({ - where: { name: 'Default' }, - }); + const environments = [ + { + name: 'Development', + description: 'Default development environment', + }, + { + name: 'Production', + description: 'Default production environment', + }, + ]; - if (!environment) { - environment = await prisma.environment.create({ - data: { - name: 'Default', - description: 'Default environment', - organizationId, - }, + for (const envData of environments) { + let environment = await prisma.environment.findFirst({ + where: { name: envData.name, organizationId }, }); - console.log('โœ… Created default environment'); - } else { - console.log('โœ… Default environment already exists'); - } - return environment; + if (!environment) { + environment = await prisma.environment.create({ + data: { + name: envData.name, + description: envData.description, + organizationId, + }, + }); + console.log(`โœ… Created default ${envData.name} environment`); + } else { + console.log(`โœ… ${envData.name} environment already exists`); + } + } } catch (error) { - console.error('โŒ Error creating default environment:', error); + console.error('โŒ Error creating default environments:', error); throw error; } } From 2f669d5e9e2d7d72cf4aaffb4ae5d4ef1533b4fc Mon Sep 17 00:00:00 2001 From: Stevan Kapicic Date: Thu, 21 Aug 2025 16:28:52 +0200 Subject: [PATCH 05/20] Add actual database type to telemetry call, add create conversation functionality --- app/api/chat/route.ts | 16 ++++++++++++++-- app/api/conversations/route.ts | 2 ++ app/components/@settings/tabs/data/DataTab.tsx | 3 +-- app/components/chat/Chat.client.tsx | 6 ++++-- app/lib/persistence/conversations.ts | 13 +++++++++++-- app/lib/persistence/useConversationHistory.ts | 5 ++--- app/lib/services/conversationService.ts | 12 ++++++++++-- 7 files changed, 44 insertions(+), 13 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index db3554d4..0bf5ba40 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -22,6 +22,7 @@ import { getDatabaseSchema } from '~/lib/schema'; import { requireUserId } from '~/auth/session'; import { formatDbSchemaForLLM } from '~/lib/.server/llm/database-source'; import { AI_SDK_INVALID_KEY_ERROR } from '~/utils/constants'; +import { getDatabaseUrl } from '~/lib/services/dataSourceService'; const WORK_DIR = '/home/project'; @@ -435,8 +436,19 @@ async function trackChatPrompt( userMessage: string, ): Promise { try { - // TODO: @skos find the environment data source connection string and pass the pluginId (db type) to telemetry - const pluginId = DataSourcePluginManager.getAccessorPluginId('the current conversation datasource url'); + const environmentDataSource = await conversationService.getConversationEnvironmentDataSource(conversationId); + const dataSourceUrl = await getDatabaseUrl( + user.id, + environmentDataSource.dataSourceId, + environmentDataSource.environmentId, + ); + + if (!dataSourceUrl) { + logger.warn('No data source URL found for telemetry tracking'); + return; + } + + const pluginId = DataSourcePluginManager.getAccessorPluginId(dataSourceUrl); const telemetry = await getTelemetry(); await telemetry.trackTelemetryEvent( diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts index c0f9c1d1..547f9666 100644 --- a/app/api/conversations/route.ts +++ b/app/api/conversations/route.ts @@ -16,6 +16,7 @@ export async function POST(request: NextRequest) { dataSourceId: string; description?: string; messages?: Message[]; + environmentId: string; }; if (!body?.dataSourceId) { @@ -27,6 +28,7 @@ export async function POST(request: NextRequest) { const conversation = await conversationService.createConversation( body.dataSourceId, + body.environmentId, userId, body.description, tx, diff --git a/app/components/@settings/tabs/data/DataTab.tsx b/app/components/@settings/tabs/data/DataTab.tsx index 83131126..4c226f68 100644 --- a/app/components/@settings/tabs/data/DataTab.tsx +++ b/app/components/@settings/tabs/data/DataTab.tsx @@ -226,7 +226,7 @@ export default function DataTab() {
) : ( - // TODO: @skos check if this is the correct way for the key concat environmentDataSources.map((environmentDataSource) => ( { } // TODO: @skos this needs to be updated when we introduce EnvironmentDataSource[] on Conversation -export async function createConversation(dataSourceId: string, messages?: MessageRequest[]): Promise { +export async function createConversation( + dataSourceId: string, + environmentId: string, + messages?: MessageRequest[], +): Promise { const response = await fetch(CONVERSATIONS_API, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ + environmentId, // This should be set based on your application logic dataSourceId, messages, }), @@ -168,7 +173,11 @@ export async function forkConversation(conversationId: string, messageId: string snapshot: undefined, })); - const forkedConversationId = await createConversation(conversation.dataSourceId, messages); + const forkedConversationId = await createConversation( + conversation.dataSourceId, + conversation.environmentId, + messages, + ); await trackTelemetryEvent({ eventType: TelemetryEventType.USER_CHAT_FORK, diff --git a/app/lib/persistence/useConversationHistory.ts b/app/lib/persistence/useConversationHistory.ts index 76fa74cd..ca7f08d3 100644 --- a/app/lib/persistence/useConversationHistory.ts +++ b/app/lib/persistence/useConversationHistory.ts @@ -81,9 +81,8 @@ export function useConversationHistory(id?: string) { logger.error('Failed to load snapshot into container', reason); }); - // TODO: @skos we need to use environmentId and dataSourceId from conversation - if (conversation.dataSourceId && conversation.dataSourceId !== selectedEnvironmentDataSource) { - setSelectedEnvironmentDataSource(conversation.dataSourceId); + if (conversation.dataSourceId && conversation.dataSourceId !== selectedEnvironmentDataSource.dataSourceId) { + setSelectedEnvironmentDataSource(conversation.dataSourceId, conversation.environmentId); } chatId.set(conversation.id); diff --git a/app/lib/services/conversationService.ts b/app/lib/services/conversationService.ts index 79542283..5c69b7c9 100644 --- a/app/lib/services/conversationService.ts +++ b/app/lib/services/conversationService.ts @@ -20,6 +20,7 @@ export const conversationService = { async createConversation( dataSourceId: string, + environmentId: string, userId: string, description?: string, tx?: Prisma.TransactionClient, @@ -31,8 +32,13 @@ export const conversationService = { User: { connect: { id: userId }, }, - dataSource: { - connect: { id: dataSourceId }, + environmentDataSource: { + connect: { + environmentId_dataSourceId: { + environmentId, + dataSourceId, + }, + }, }, description, starterId, @@ -62,6 +68,8 @@ export const conversationService = { userId: true, createdAt: true, updatedAt: true, + dataSourceId: true, + environmentId: true, }, where: { userId, From a3a8c2192862e763c63e7fea42d33641957df23a Mon Sep 17 00:00:00 2001 From: Stevan Kapicic Date: Fri, 22 Aug 2025 14:19:03 +0200 Subject: [PATCH 06/20] Stable: Migrate without braking changes, add and edit environment forms --- app/api/chat/route.ts | 3 +- app/api/environments/route.ts | 17 + .../@settings/core/ControlPanel.tsx | 3 + app/components/@settings/core/constants.ts | 10 +- app/components/@settings/core/types.ts | 3 +- .../tabs/environments/EnvironmentsTab.tsx | 357 ++++++++++++++++++ .../environments/forms/AddEnvironmentForm.tsx | 130 +++++++ .../forms/EditEnvironmentForm.tsx | 244 ++++++++++++ .../@settings/tabs/environments/index.ts | 1 + app/components/chat/Chat.client.tsx | 1 - app/lib/.server/llm/stream-text.ts | 25 +- app/lib/persistence/conversations.ts | 1 - app/lib/services/conversationService.ts | 1 - app/lib/services/environmentService.ts | 28 +- app/lib/stores/environments.ts | 29 ++ .../migration.sql | 71 +++- prisma/schema.prisma | 1 + 17 files changed, 894 insertions(+), 31 deletions(-) create mode 100644 app/components/@settings/tabs/environments/EnvironmentsTab.tsx create mode 100644 app/components/@settings/tabs/environments/forms/AddEnvironmentForm.tsx create mode 100644 app/components/@settings/tabs/environments/forms/EditEnvironmentForm.tsx create mode 100644 app/components/@settings/tabs/environments/index.ts create mode 100644 app/lib/stores/environments.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 0bf5ba40..ab5c6b40 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -10,7 +10,6 @@ import { messageService } from '~/lib/services/messageService'; import { MESSAGE_ROLE } from '~/types/database'; import { createId } from '@paralleldrive/cuid2'; import { conversationService } from '~/lib/services/conversationService'; -import type { StarterPluginId } from '~/lib/plugins/types'; import type { FileMap } from '~/lib/stores/files'; import { getTelemetry } from '~/lib/telemetry/telemetry-manager'; import { TelemetryEventType } from '~/lib/telemetry/telemetry-types'; @@ -331,7 +330,7 @@ async function chatAction(request: NextRequest) { options, files, promptId, - starterId: conversation?.starterId as StarterPluginId, + conversation: conversation!, contextOptimization, contextFiles: filteredFiles, summary, diff --git a/app/api/environments/route.ts b/app/api/environments/route.ts index 7168b5d5..14382d9e 100644 --- a/app/api/environments/route.ts +++ b/app/api/environments/route.ts @@ -4,6 +4,23 @@ import { organizationService } from '~/lib/services/organizationService'; import { requireUserAbility } from '~/auth/session'; import { PermissionAction, PermissionResource, Prisma } from '@prisma/client'; +export type CreateEnvironmentResponse = + | { + success: true; + environment?: { + id: string; + name: string; + description?: string; + organizationId: string; + createdAt: string; + updatedAt: string; + }; + } + | { + success: false; + error: string; + }; + export async function GET(request: NextRequest) { const { userAbility } = await requireUserAbility(request); diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 5043e862..585b046d 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -22,6 +22,7 @@ import { useUserStore } from '~/lib/stores/user'; import { DeprecatedRole } from '@prisma/client'; import OrganizationTab from '~/components/@settings/tabs/organization/OrganizationTab'; import MembersTab from '~/components/@settings/tabs/members/MembersTab'; +import EnvironmentsTab from '~/components/@settings/tabs/environments'; const LAST_ACCESSED_TAB_KEY = 'control-panel-last-tab'; @@ -158,6 +159,8 @@ export const ControlPanel = () => { switch (tabId) { case 'data': return ; + case 'environments': + return ; case 'deployed-apps': return ; case 'github': diff --git a/app/components/@settings/core/constants.ts b/app/components/@settings/core/constants.ts index 850fa2c4..dd3beb3e 100644 --- a/app/components/@settings/core/constants.ts +++ b/app/components/@settings/core/constants.ts @@ -1,5 +1,5 @@ import type { TabType, TabVisibilityConfig } from './types'; -import { Building, Database, GitBranch, type LucideIcon, Rocket, Users } from 'lucide-react'; +import { Building, Database, GitBranch, type LucideIcon, Rocket, Server, Users } from 'lucide-react'; export const TAB_ICONS: Record = { data: Database, @@ -7,6 +7,7 @@ export const TAB_ICONS: Record = { 'deployed-apps': Rocket, organization: Building, members: Users, + environments: Server, }; export const TAB_LABELS: Record = { @@ -15,6 +16,7 @@ export const TAB_LABELS: Record = { 'deployed-apps': 'Deployed Apps', organization: 'Organization', members: 'Members', + environments: 'Environments', }; export const TAB_DESCRIPTIONS: Record = { @@ -23,12 +25,14 @@ export const TAB_DESCRIPTIONS: Record = { 'deployed-apps': 'View and manage your deployed applications', organization: 'Manage your organization', members: 'Manage your organization members', + environments: 'Manage your environments', }; export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [ { id: 'data', visible: true, window: 'user', order: 0 }, - { id: 'github', visible: true, window: 'user', order: 1 }, - { id: 'deployed-apps', visible: true, window: 'user', order: 2 }, + { id: 'environments', visible: true, window: 'user', order: 1 }, + { id: 'github', visible: true, window: 'user', order: 2 }, + { id: 'deployed-apps', visible: true, window: 'user', order: 3 }, { id: 'organization', visible: true, window: 'admin', order: 0 }, { id: 'members', visible: true, window: 'admin', order: 1 }, diff --git a/app/components/@settings/core/types.ts b/app/components/@settings/core/types.ts index 1faf5b5c..cf9b68cf 100644 --- a/app/components/@settings/core/types.ts +++ b/app/components/@settings/core/types.ts @@ -1,4 +1,4 @@ -export type TabType = 'data' | 'github' | 'deployed-apps' | 'organization' | 'members'; +export type TabType = 'data' | 'github' | 'deployed-apps' | 'organization' | 'members' | 'environments'; export type WindowType = 'user' | 'admin'; @@ -30,4 +30,5 @@ export const TAB_LABELS: Record = { 'deployed-apps': 'Deployed Apps', organization: 'Organization', members: 'Members', + environments: 'Environments', }; diff --git a/app/components/@settings/tabs/environments/EnvironmentsTab.tsx b/app/components/@settings/tabs/environments/EnvironmentsTab.tsx new file mode 100644 index 00000000..085bc92d --- /dev/null +++ b/app/components/@settings/tabs/environments/EnvironmentsTab.tsx @@ -0,0 +1,357 @@ +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { AlertTriangle, ArrowLeft, Edit, Globe, Plus, Trash2 } from 'lucide-react'; +import { Dialog, DialogClose, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; +import AddEnvironmentForm from './forms/AddEnvironmentForm'; +import EditEnvironmentForm from './forms/EditEnvironmentForm'; +import { classNames } from '~/utils/classNames'; +import { toast } from 'sonner'; +import { useEnvironmentsStore } from '~/lib/stores/environments'; +import { settingsPanelStore, useSettingsStore } from '~/lib/stores/settings'; +import { useStore } from '@nanostores/react'; +import type { EnvironmentWithRelations } from '~/lib/services/environmentService'; +import { logger } from '~/utils/logger'; + +interface EnvironmentsResponse { + success: boolean; + environments: EnvironmentWithRelations[]; +} + +export default function EnvironmentsTab() { + const { showAddForm } = useStore(settingsPanelStore); + const [showAddFormLocal, setShowAddFormLocal] = useState(showAddForm); + const [showEditForm, setShowEditForm] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [selectedEnvironment, setSelectedEnvironment] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const { environments, setEnvironments } = useEnvironmentsStore(); + const { selectedTab } = useSettingsStore(); + + // Update local state when store changes + useEffect(() => { + setShowAddFormLocal(showAddForm); + }, [showAddForm]); + + // Show add form when opened from chat + useEffect(() => { + if (selectedTab === 'environments') { + setShowAddFormLocal(true); + } + }, [selectedTab]); + + // Load environments on mount + useEffect(() => { + const loadEnvironments = async () => { + try { + const response = await fetch('/api/environments'); + const data = (await response.json()) as EnvironmentsResponse; + + if (data.success) { + setEnvironments(data.environments); + } + } catch (error) { + console.error('Failed to load environments:', error); + } + }; + + loadEnvironments(); + }, [setEnvironments]); + + const handleDelete = async () => { + if (!selectedEnvironment) { + return; + } + + try { + const response = await fetch(`/api/environments/${selectedEnvironment.id}`, { + method: 'DELETE', + }); + + const data = (await response.json()) as { success: boolean; error?: string }; + + if (data.success) { + toast.success('Environment deleted successfully'); + + // Reload environments + const reloadResponse = await fetch('/api/environments'); + const reloadData = (await reloadResponse.json()) as EnvironmentsResponse; + + if (reloadData.success) { + setEnvironments(reloadData.environments); + } + + setShowDeleteConfirm(false); + setShowEditForm(false); + setSelectedEnvironment(null); + } else { + toast.error(data.error || 'Failed to delete environment'); + } + } catch (error) { + logger.error('Failed to load environments:', JSON.stringify(error)); + toast.error('Failed to delete environment'); + } + }; + + const handleEdit = (environment: EnvironmentWithRelations) => { + setSelectedEnvironment(environment); + setShowEditForm(true); + setShowAddFormLocal(false); + }; + + const handleDeleteClick = (environment: EnvironmentWithRelations) => { + setSelectedEnvironment(environment); + setShowDeleteConfirm(true); + }; + + const handleBack = () => { + setShowEditForm(false); + setShowAddFormLocal(false); + setSelectedEnvironment(null); + }; + + const handleAdd = () => { + setShowAddFormLocal(true); + setShowEditForm(false); + setSelectedEnvironment(null); + }; + + return ( +
+ {!showEditForm && !showAddFormLocal && ( +
+
+

Environments

+

Manage your environments

+
+ +
+ )} + + {showAddFormLocal && ( +
+
+
+ +
+

Create Environment

+

Add a new environment

+
+
+
+ { + // Reload environments + const reloadResponse = fetch('/api/environments'); + reloadResponse + .then((response) => response.json()) + .then((data: unknown) => { + const typedData = data as EnvironmentsResponse; + + if (typedData.success) { + setEnvironments(typedData.environments); + } + }) + .catch((error) => console.error('Failed to reload environments after add:', error)); + handleBack(); + }} + /> +
+ )} + + {showEditForm && selectedEnvironment && ( +
+
+
+ +
+

Edit Environment

+

Modify your environment settings

+
+
+
+ { + // Reload environments + const reloadResponse = fetch('/api/environments'); + reloadResponse + .then((response) => response.json()) + .then((data: unknown) => { + const typedData = data as EnvironmentsResponse; + + if (typedData.success) { + setEnvironments(typedData.environments); + } + }) + .catch((error) => console.error('Failed to reload environments after edit:', error)); + handleBack(); + }} + onDelete={() => handleDeleteClick(selectedEnvironment)} + /> +
+ )} + + {!showEditForm && !showAddFormLocal && ( +
+ {environments.length === 0 ? ( +
+ +

No Environments

+

+ Get started by adding your first environment. +

+
+ ) : ( + environments.map((environment) => ( + +
+
handleEdit(environment)} + > + +
+

{environment.name}

+ {environment.description && ( +

{environment.description}

+ )} +
+
+
+ + +
+
+
+ )) + )} +
+ )} + + + +
+
+
+
+
+ +
+
+ +

This action cannot be undone

+
+
+
+ +
+

+ Are you sure you want to delete the environment "{selectedEnvironment?.name}"? This will remove all + associated data and cannot be undone. +

+ + {selectedEnvironment?.dataSources && selectedEnvironment.dataSources.length > 0 && ( +
+
+ +
+

+ Warning: This environment has {selectedEnvironment.dataSources.length} data source + {selectedEnvironment.dataSources.length === 1 ? '' : 's'}! +

+

+ All data sources and associated conversations will be permanently deleted. +

+
+
+
+ )} +
+ +
+ + + + +
+
+
+
+
+
+ ); +} diff --git a/app/components/@settings/tabs/environments/forms/AddEnvironmentForm.tsx b/app/components/@settings/tabs/environments/forms/AddEnvironmentForm.tsx new file mode 100644 index 00000000..b9e61659 --- /dev/null +++ b/app/components/@settings/tabs/environments/forms/AddEnvironmentForm.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; +import { toast } from 'sonner'; +import type { CreateEnvironmentResponse } from '~/api/environments/route'; + +interface AddEnvironmentFormProps { + isSubmitting: boolean; + setIsSubmitting: (value: boolean) => void; + onSuccess: () => void; +} + +export default function AddEnvironmentForm({ isSubmitting, setIsSubmitting, onSuccess }: AddEnvironmentFormProps) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim()) { + toast.error('Environment name is required'); + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch('/api/environments', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: name.trim(), + description: description.trim() || undefined, + }), + }); + + const data = await response.json(); + + if (data.success) { + toast.success('Environment created successfully'); + onSuccess(); + } else { + toast.error(data.error || 'Failed to create environment'); + } + } catch (error) { + console.error('Failed to create environment:', error); + toast.error('Failed to create environment'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+
+ + setName(e.target.value)} + className={classNames( + 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg', + 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white', + 'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent', + 'placeholder-gray-500 dark:placeholder-gray-400', + )} + placeholder="e.g., Development, Staging, Production" + disabled={isSubmitting} + required + /> +
+ +
+ +