mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
30 Commits
improvemen
...
v0.5.38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4827866f9a | ||
|
|
214632604d | ||
|
|
1ddbac1d2e | ||
|
|
35a57bfad4 | ||
|
|
f8678b179a | ||
|
|
0ebb45b2db | ||
|
|
6247f421bc | ||
|
|
3e697d9ed9 | ||
|
|
6385d82b85 | ||
|
|
f91beb324e | ||
|
|
4f69b171f2 | ||
|
|
a1a189f328 | ||
|
|
7dc48510dc | ||
|
|
4431a1a484 | ||
|
|
93fe68785e | ||
|
|
50c1c6775b | ||
|
|
df5f823d1c | ||
|
|
094f87fa1f | ||
|
|
65efa039da | ||
|
|
6b15a50311 | ||
|
|
65787d7cc3 | ||
|
|
4d1a9a3f22 | ||
|
|
656a6b8abd | ||
|
|
889b44c90a | ||
|
|
3a33ec929f | ||
|
|
24356d99ec | ||
|
|
6de1c04517 | ||
|
|
38be2b76c4 | ||
|
|
a2f14cab54 | ||
|
|
eb07a080fb |
@@ -6,7 +6,10 @@ import { source } from '@/lib/source'
|
||||
|
||||
export const revalidate = false
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug?: string[] }> }) {
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug?: string[] }> }
|
||||
) {
|
||||
const { slug } = await params
|
||||
|
||||
let lang: (typeof i18n.languages)[number] = i18n.defaultLanguage
|
||||
|
||||
@@ -120,117 +120,117 @@ import {
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
zoom: ZoomIcon,
|
||||
zep: ZepIcon,
|
||||
zendesk: ZendeskIcon,
|
||||
youtube: YouTubeIcon,
|
||||
x: xIcon,
|
||||
wordpress: WordpressIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
webflow: WebflowIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
vision: EyeIcon,
|
||||
video_generator: VideoIcon,
|
||||
typeform: TypeformIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
tts: TTSIcon,
|
||||
trello: TrelloIcon,
|
||||
translate: TranslateIcon,
|
||||
thinking: BrainIcon,
|
||||
telegram: TelegramIcon,
|
||||
tavily: TavilyIcon,
|
||||
supabase: SupabaseIcon,
|
||||
stt: STTIcon,
|
||||
stripe: StripeIcon,
|
||||
stagehand: StagehandIcon,
|
||||
ssh: SshIcon,
|
||||
sqs: SQSIcon,
|
||||
spotify: SpotifyIcon,
|
||||
smtp: SmtpIcon,
|
||||
slack: SlackIcon,
|
||||
shopify: ShopifyIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
sftp: SftpIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
serper: SerperIcon,
|
||||
sentry: SentryIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
search: SearchIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
s3: S3Icon,
|
||||
resend: ResendIcon,
|
||||
reddit: RedditIcon,
|
||||
rds: RDSIcon,
|
||||
qdrant: QdrantIcon,
|
||||
posthog: PosthogIcon,
|
||||
postgresql: PostgresIcon,
|
||||
polymarket: PolymarketIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
pinecone: PineconeIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
outlook: OutlookIcon,
|
||||
openai: OpenAIIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
notion: NotionIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
mysql: MySQLIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
memory: BrainIcon,
|
||||
mem0: Mem0Icon,
|
||||
mailgun: MailgunIcon,
|
||||
mailchimp: MailchimpIcon,
|
||||
linkup: LinkupIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
linear: LinearIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
kalshi: KalshiIcon,
|
||||
jira: JiraIcon,
|
||||
jina: JinaAIIcon,
|
||||
intercom: IntercomIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
image_generator: ImageIcon,
|
||||
hunter: HunterIOIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hubspot: HubspotIcon,
|
||||
grafana: GrafanaIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
google_slides: GoogleSlidesIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
google_groups: GoogleGroupsIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
google_search: GoogleIcon,
|
||||
gmail: GmailIcon,
|
||||
gitlab: GitLabIcon,
|
||||
github: GithubIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
file: DocumentIcon,
|
||||
exa: ExaAIIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
dynamodb: DynamoDBIcon,
|
||||
duckduckgo: DuckDuckGoIcon,
|
||||
dropbox: DropboxIcon,
|
||||
discord: DiscordIcon,
|
||||
datadog: DatadogIcon,
|
||||
cursor: CursorIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
clay: ClayIcon,
|
||||
calendly: CalendlyIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
asana: AsanaIcon,
|
||||
arxiv: ArxivIcon,
|
||||
apollo: ApolloIcon,
|
||||
apify: ApifyIcon,
|
||||
airtable: AirtableIcon,
|
||||
ahrefs: AhrefsIcon,
|
||||
airtable: AirtableIcon,
|
||||
apify: ApifyIcon,
|
||||
apollo: ApolloIcon,
|
||||
arxiv: ArxivIcon,
|
||||
asana: AsanaIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
calendly: CalendlyIcon,
|
||||
clay: ClayIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
cursor: CursorIcon,
|
||||
datadog: DatadogIcon,
|
||||
discord: DiscordIcon,
|
||||
dropbox: DropboxIcon,
|
||||
duckduckgo: DuckDuckGoIcon,
|
||||
dynamodb: DynamoDBIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
exa: ExaAIIcon,
|
||||
file: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
github: GithubIcon,
|
||||
gitlab: GitLabIcon,
|
||||
gmail: GmailIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
google_groups: GoogleGroupsIcon,
|
||||
google_search: GoogleIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
google_slides: GoogleSlidesIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
grafana: GrafanaIcon,
|
||||
hubspot: HubspotIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hunter: HunterIOIcon,
|
||||
image_generator: ImageIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
intercom: IntercomIcon,
|
||||
jina: JinaAIIcon,
|
||||
jira: JiraIcon,
|
||||
kalshi: KalshiIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
linear: LinearIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
linkup: LinkupIcon,
|
||||
mailchimp: MailchimpIcon,
|
||||
mailgun: MailgunIcon,
|
||||
mem0: Mem0Icon,
|
||||
memory: BrainIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mysql: MySQLIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
notion: NotionIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
openai: OpenAIIcon,
|
||||
outlook: OutlookIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
pinecone: PineconeIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
polymarket: PolymarketIcon,
|
||||
postgresql: PostgresIcon,
|
||||
posthog: PosthogIcon,
|
||||
qdrant: QdrantIcon,
|
||||
rds: RDSIcon,
|
||||
reddit: RedditIcon,
|
||||
resend: ResendIcon,
|
||||
s3: S3Icon,
|
||||
salesforce: SalesforceIcon,
|
||||
search: SearchIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
sentry: SentryIcon,
|
||||
serper: SerperIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
sftp: SftpIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
shopify: ShopifyIcon,
|
||||
slack: SlackIcon,
|
||||
smtp: SmtpIcon,
|
||||
spotify: SpotifyIcon,
|
||||
sqs: SQSIcon,
|
||||
ssh: SshIcon,
|
||||
stagehand: StagehandIcon,
|
||||
stripe: StripeIcon,
|
||||
stt: STTIcon,
|
||||
supabase: SupabaseIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
thinking: BrainIcon,
|
||||
translate: TranslateIcon,
|
||||
trello: TrelloIcon,
|
||||
tts: TTSIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
typeform: TypeformIcon,
|
||||
video_generator: VideoIcon,
|
||||
vision: EyeIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
webflow: WebflowIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
wordpress: WordpressIcon,
|
||||
x: xIcon,
|
||||
youtube: YouTubeIcon,
|
||||
zendesk: ZendeskIcon,
|
||||
zep: ZepIcon,
|
||||
zoom: ZoomIcon,
|
||||
}
|
||||
|
||||
@@ -109,12 +109,12 @@ Lesen Sie die neuesten Nachrichten aus Slack-Kanälen. Rufen Sie den Konversatio
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | Nein | Authentifizierungsmethode: oauth oder bot_token |
|
||||
| `botToken` | string | Nein | Bot-Token für benutzerdefinierten Bot |
|
||||
| `botToken` | string | Nein | Bot-Token für Custom Bot |
|
||||
| `channel` | string | Nein | Slack-Kanal, aus dem Nachrichten gelesen werden sollen \(z.B. #general\) |
|
||||
| `userId` | string | Nein | Benutzer-ID für DM-Konversation \(z.B. U1234567890\) |
|
||||
| `limit` | number | Nein | Anzahl der abzurufenden Nachrichten \(Standard: 10, max: 100\) |
|
||||
| `oldest` | string | Nein | Beginn des Zeitraums \(Zeitstempel\) |
|
||||
| `latest` | string | Nein | Ende des Zeitraums \(Zeitstempel\) |
|
||||
| `limit` | number | Nein | Anzahl der abzurufenden Nachrichten \(Standard: 10, max: 15\) |
|
||||
| `oldest` | string | Nein | Beginn des Zeitbereichs \(Zeitstempel\) |
|
||||
| `latest` | string | Nein | Ende des Zeitbereichs \(Zeitstempel\) |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ Read the latest messages from Slack channels. Retrieve conversation history with
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | No | Slack channel to read messages from \(e.g., #general\) |
|
||||
| `userId` | string | No | User ID for DM conversation \(e.g., U1234567890\) |
|
||||
| `limit` | number | No | Number of messages to retrieve \(default: 10, max: 100\) |
|
||||
| `limit` | number | No | Number of messages to retrieve \(default: 10, max: 15\) |
|
||||
| `oldest` | string | No | Start of time range \(timestamp\) |
|
||||
| `latest` | string | No | End of time range \(timestamp\) |
|
||||
|
||||
|
||||
@@ -111,8 +111,8 @@ Lee los últimos mensajes de los canales de Slack. Recupera el historial de conv
|
||||
| `authMethod` | string | No | Método de autenticación: oauth o bot_token |
|
||||
| `botToken` | string | No | Token del bot para Bot personalizado |
|
||||
| `channel` | string | No | Canal de Slack del que leer mensajes (p. ej., #general) |
|
||||
| `userId` | string | No | ID de usuario para conversación por MD (p. ej., U1234567890) |
|
||||
| `limit` | number | No | Número de mensajes a recuperar (predeterminado: 10, máx: 100) |
|
||||
| `userId` | string | No | ID de usuario para conversación de mensaje directo (p. ej., U1234567890) |
|
||||
| `limit` | number | No | Número de mensajes a recuperar (predeterminado: 10, máx: 15) |
|
||||
| `oldest` | string | No | Inicio del rango de tiempo (marca de tiempo) |
|
||||
| `latest` | string | No | Fin del rango de tiempo (marca de tiempo) |
|
||||
|
||||
|
||||
@@ -107,14 +107,14 @@ Lisez les derniers messages des canaux Slack. Récupérez l'historique des conve
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `authMethod` | chaîne | Non | Méthode d'authentification : oauth ou bot_token |
|
||||
| `botToken` | chaîne | Non | Jeton du bot pour Bot personnalisé |
|
||||
| `channel` | chaîne | Non | Canal Slack pour lire les messages \(ex. : #general\) |
|
||||
| `userId` | chaîne | Non | ID utilisateur pour la conversation en MP \(ex. : U1234567890\) |
|
||||
| `limit` | nombre | Non | Nombre de messages à récupérer \(par défaut : 10, max : 100\) |
|
||||
| `oldest` | chaîne | Non | Début de la plage temporelle \(horodatage\) |
|
||||
| `latest` | chaîne | Non | Fin de la plage temporelle \(horodatage\) |
|
||||
| `channel` | chaîne | Non | Canal Slack depuis lequel lire les messages \(ex. : #general\) |
|
||||
| `userId` | chaîne | Non | ID utilisateur pour la conversation en message direct \(ex. : U1234567890\) |
|
||||
| `limit` | nombre | Non | Nombre de messages à récupérer \(par défaut : 10, max : 15\) |
|
||||
| `oldest` | chaîne | Non | Début de la plage horaire \(horodatage\) |
|
||||
| `latest` | chaîne | Non | Fin de la plage horaire \(horodatage\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
|
||||
@@ -110,8 +110,8 @@ Slackチャンネルから最新のメッセージを読み取ります。フィ
|
||||
| `authMethod` | string | いいえ | 認証方法:oauthまたはbot_token |
|
||||
| `botToken` | string | いいえ | カスタムボット用のボットトークン |
|
||||
| `channel` | string | いいえ | メッセージを読み取るSlackチャンネル(例:#general) |
|
||||
| `userId` | string | いいえ | DMの会話用のユーザーID(例:U1234567890) |
|
||||
| `limit` | number | いいえ | 取得するメッセージ数(デフォルト:10、最大:100) |
|
||||
| `userId` | string | いいえ | DM会話用のユーザーID(例:U1234567890) |
|
||||
| `limit` | number | いいえ | 取得するメッセージ数(デフォルト:10、最大:15) |
|
||||
| `oldest` | string | いいえ | 時間範囲の開始(タイムスタンプ) |
|
||||
| `latest` | string | いいえ | 時間範囲の終了(タイムスタンプ) |
|
||||
|
||||
|
||||
@@ -109,10 +109,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
| `authMethod` | string | 否 | 认证方法:oauth 或 bot_token |
|
||||
| `botToken` | string | 否 | 自定义 Bot 的令牌 |
|
||||
| `channel` | string | 否 | 要读取消息的 Slack 频道(例如,#general) |
|
||||
| `userId` | string | 否 | DM 对话的用户 ID(例如,U1234567890) |
|
||||
| `limit` | number | 否 | 要检索的消息数量(默认:10,最大:100) |
|
||||
| `oldest` | string | 否 | 时间范围的开始(时间戳) |
|
||||
| `latest` | string | 否 | 时间范围的结束(时间戳) |
|
||||
| `userId` | string | 否 | DM 会话的用户 ID(例如,U1234567890) |
|
||||
| `limit` | number | 否 | 要检索的消息数量(默认:10,最大:15) |
|
||||
| `oldest` | string | 否 | 时间范围起始(时间戳) |
|
||||
| `latest` | string | 否 | 时间范围结束(时间戳) |
|
||||
|
||||
#### 输出
|
||||
|
||||
|
||||
@@ -903,7 +903,7 @@ checksums:
|
||||
content/24: 228a8ece96627883153b826a1cbaa06c
|
||||
content/25: 53abe061a259c296c82676b4770ddd1b
|
||||
content/26: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/27: 03e8b10ec08b354de98e360b66b779e3
|
||||
content/27: 5b9546f77fbafc0741f3fc2548f81c7e
|
||||
content/28: bcadfc362b69078beee0088e5936c98b
|
||||
content/29: b82def7d82657f941fbe60df3924eeeb
|
||||
content/30: 1ca7ee3856805fa1718031c5f75b6ffb
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('SSO-Register')
|
||||
@@ -236,13 +237,13 @@ export async function POST(request: NextRequest) {
|
||||
oidcConfig: providerConfig.oidcConfig
|
||||
? {
|
||||
...providerConfig.oidcConfig,
|
||||
clientSecret: '[REDACTED]',
|
||||
clientSecret: REDACTED_MARKER,
|
||||
}
|
||||
: undefined,
|
||||
samlConfig: providerConfig.samlConfig
|
||||
? {
|
||||
...providerConfig.samlConfig,
|
||||
cert: '[REDACTED]',
|
||||
cert: REDACTED_MARKER,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { userStats } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { logModelUsage } from '@/lib/billing/core/usage-log'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
@@ -14,6 +15,9 @@ const logger = createLogger('BillingUpdateCostAPI')
|
||||
const UpdateCostSchema = z.object({
|
||||
userId: z.string().min(1, 'User ID is required'),
|
||||
cost: z.number().min(0, 'Cost must be a non-negative number'),
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
inputTokens: z.number().min(0).default(0),
|
||||
outputTokens: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -71,11 +75,12 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, cost } = validation.data
|
||||
const { userId, cost, model, inputTokens, outputTokens } = validation.data
|
||||
|
||||
logger.info(`[${requestId}] Processing cost update`, {
|
||||
userId,
|
||||
cost,
|
||||
model,
|
||||
})
|
||||
|
||||
// Check if user stats record exists (same as ExecutionLogger)
|
||||
@@ -107,6 +112,16 @@ export async function POST(req: NextRequest) {
|
||||
addedCost: cost,
|
||||
})
|
||||
|
||||
// Log usage for complete audit trail
|
||||
await logModelUsage({
|
||||
userId,
|
||||
source: 'copilot',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cost,
|
||||
})
|
||||
|
||||
// Check if user has hit overage threshold and bill incrementally
|
||||
await checkAndBillOverageThreshold(userId)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { chat, workflow } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
@@ -94,6 +94,21 @@ export async function POST(
|
||||
if (!deployment.isActive) {
|
||||
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, deployment.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const workspaceId = workflowRecord?.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This chat is currently unavailable', 403),
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
const executionId = randomUUID()
|
||||
const loggingSession = new LoggingSession(
|
||||
deployment.workflowId,
|
||||
@@ -104,7 +119,7 @@ export async function POST(
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: deployment.userId,
|
||||
workspaceId: '', // Will be resolved if needed
|
||||
workspaceId,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
@@ -169,7 +184,14 @@ export async function POST(
|
||||
|
||||
const { actorUserId, workflowRecord } = preprocessResult
|
||||
const workspaceOwnerId = actorUserId!
|
||||
const workspaceId = workflowRecord?.workspaceId || ''
|
||||
const workspaceId = workflowRecord?.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Workflow has no associated workspace', 500),
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedOutputs: string[] = []
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CopilotChatsListAPI')
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
export async function GET(_request: NextRequest) {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
|
||||
@@ -38,14 +38,13 @@ export async function GET(
|
||||
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
|
||||
|
||||
const contextParam = request.nextUrl.searchParams.get('context')
|
||||
const legacyBucketType = request.nextUrl.searchParams.get('bucket')
|
||||
|
||||
const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined)
|
||||
|
||||
if (context === 'profile-pictures') {
|
||||
logger.info('Serving public profile picture:', { cloudKey })
|
||||
if (context === 'profile-pictures' || context === 'og-images') {
|
||||
logger.info(`Serving public ${context}:`, { cloudKey })
|
||||
if (isUsingCloudStorage() || isCloudPath) {
|
||||
return await handleCloudProxyPublic(cloudKey, context, legacyBucketType)
|
||||
return await handleCloudProxyPublic(cloudKey, context)
|
||||
}
|
||||
return await handleLocalFilePublic(fullPath)
|
||||
}
|
||||
@@ -182,8 +181,7 @@ async function handleCloudProxy(
|
||||
|
||||
async function handleCloudProxyPublic(
|
||||
cloudKey: string,
|
||||
context: StorageContext,
|
||||
legacyBucketType?: string | null
|
||||
context: StorageContext
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
let fileBuffer: Buffer
|
||||
|
||||
@@ -141,6 +141,23 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Check if deleting this folder would delete the last workflow(s) in the workspace
|
||||
const workflowsInFolder = await countWorkflowsInFolderRecursively(
|
||||
id,
|
||||
existingFolder.workspaceId
|
||||
)
|
||||
const totalWorkflowsInWorkspace = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, existingFolder.workspaceId))
|
||||
|
||||
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Recursively delete folder and all its contents
|
||||
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
|
||||
|
||||
@@ -202,6 +219,34 @@ async function deleteFolderRecursively(
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of workflows in a folder and all its subfolders recursively.
|
||||
*/
|
||||
async function countWorkflowsInFolderRecursively(
|
||||
folderId: string,
|
||||
workspaceId: string
|
||||
): Promise<number> {
|
||||
let count = 0
|
||||
|
||||
const workflowsInFolder = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
|
||||
|
||||
count += workflowsInFolder.length
|
||||
|
||||
const childFolders = await db
|
||||
.select({ id: workflowFolder.id })
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
|
||||
|
||||
for (const childFolder of childFolders) {
|
||||
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// Helper function to check for circular references
|
||||
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
|
||||
let currentParentId: string | null = parentId
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { runs } from '@trigger.dev/sdk'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
@@ -18,38 +17,44 @@ export async function GET(
|
||||
try {
|
||||
logger.debug(`[${requestId}] Getting status for task: ${taskId}`)
|
||||
|
||||
// Try session auth first (for web UI)
|
||||
const session = await getSession()
|
||||
let authenticatedUserId: string | null = session?.user?.id || null
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
const apiKeyHeader = request.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
const authResult = await authenticateApiKeyFromHeader(apiKeyHeader)
|
||||
if (authResult.success && authResult.userId) {
|
||||
authenticatedUserId = authResult.userId
|
||||
if (authResult.keyId) {
|
||||
await updateApiKeyLastUsed(authResult.keyId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to update API key last used timestamp:`, {
|
||||
keyId: authResult.keyId,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized task status request`)
|
||||
return createErrorResponse(authResult.error || 'Authentication required', 401)
|
||||
}
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
return createErrorResponse('Authentication required', 401)
|
||||
}
|
||||
const authenticatedUserId = authResult.userId
|
||||
|
||||
// Fetch task status from Trigger.dev
|
||||
const run = await runs.retrieve(taskId)
|
||||
|
||||
logger.debug(`[${requestId}] Task ${taskId} status: ${run.status}`)
|
||||
|
||||
// Map Trigger.dev status to our format
|
||||
const payload = run.payload as any
|
||||
if (payload?.workflowId) {
|
||||
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
|
||||
const accessCheck = await verifyWorkflowAccess(authenticatedUserId, payload.workflowId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
logger.warn(`[${requestId}] User ${authenticatedUserId} denied access to task ${taskId}`, {
|
||||
workflowId: payload.workflowId,
|
||||
})
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
logger.debug(`[${requestId}] User ${authenticatedUserId} has access to task ${taskId}`)
|
||||
} else {
|
||||
if (payload?.userId && payload.userId !== authenticatedUserId) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${authenticatedUserId} attempted to access task ${taskId} owned by ${payload.userId}`
|
||||
)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
if (!payload?.userId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Task ${taskId} has no ownership information in payload. Denying access for security.`
|
||||
)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
QUEUED: 'queued',
|
||||
WAITING_FOR_DEPLOY: 'queued',
|
||||
@@ -67,7 +72,6 @@ export async function GET(
|
||||
|
||||
const mappedStatus = statusMap[run.status as keyof typeof statusMap] || 'unknown'
|
||||
|
||||
// Build response based on status
|
||||
const response: any = {
|
||||
success: true,
|
||||
taskId,
|
||||
@@ -77,21 +81,18 @@ export async function GET(
|
||||
},
|
||||
}
|
||||
|
||||
// Add completion details if finished
|
||||
if (mappedStatus === 'completed') {
|
||||
response.output = run.output // This contains the workflow execution results
|
||||
response.metadata.completedAt = run.finishedAt
|
||||
response.metadata.duration = run.durationMs
|
||||
}
|
||||
|
||||
// Add error details if failed
|
||||
if (mappedStatus === 'failed') {
|
||||
response.error = run.error
|
||||
response.metadata.completedAt = run.finishedAt
|
||||
response.metadata.duration = run.durationMs
|
||||
}
|
||||
|
||||
// Add progress info if still processing
|
||||
if (mappedStatus === 'processing' || mappedStatus === 'queued') {
|
||||
response.estimatedDuration = 180000 // 3 minutes max from our config
|
||||
}
|
||||
@@ -107,6 +108,3 @@ export async function GET(
|
||||
return createErrorResponse('Failed to fetch task status', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement task cancellation via Trigger.dev API if needed
|
||||
// export async function DELETE() { ... }
|
||||
|
||||
@@ -156,6 +156,7 @@ export async function POST(
|
||||
const validatedData = CreateChunkSchema.parse(searchParams)
|
||||
|
||||
const docTags = {
|
||||
// Text tags (7 slots)
|
||||
tag1: doc.tag1 ?? null,
|
||||
tag2: doc.tag2 ?? null,
|
||||
tag3: doc.tag3 ?? null,
|
||||
@@ -163,6 +164,19 @@ export async function POST(
|
||||
tag5: doc.tag5 ?? null,
|
||||
tag6: doc.tag6 ?? null,
|
||||
tag7: doc.tag7 ?? null,
|
||||
// Number tags (5 slots)
|
||||
number1: doc.number1 ?? null,
|
||||
number2: doc.number2 ?? null,
|
||||
number3: doc.number3 ?? null,
|
||||
number4: doc.number4 ?? null,
|
||||
number5: doc.number5 ?? null,
|
||||
// Date tags (2 slots)
|
||||
date1: doc.date1 ?? null,
|
||||
date2: doc.date2 ?? null,
|
||||
// Boolean tags (3 slots)
|
||||
boolean1: doc.boolean1 ?? null,
|
||||
boolean2: doc.boolean2 ?? null,
|
||||
boolean3: doc.boolean3 ?? null,
|
||||
}
|
||||
|
||||
const newChunk = await createChunk(
|
||||
|
||||
@@ -72,6 +72,16 @@ describe('Document By ID API Route', () => {
|
||||
tag5: null,
|
||||
tag6: null,
|
||||
tag7: null,
|
||||
number1: null,
|
||||
number2: null,
|
||||
number3: null,
|
||||
number4: null,
|
||||
number5: null,
|
||||
date1: null,
|
||||
date2: null,
|
||||
boolean1: null,
|
||||
boolean2: null,
|
||||
boolean3: null,
|
||||
deletedAt: null,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const UpdateDocumentSchema = z.object({
|
||||
processingError: z.string().optional(),
|
||||
markFailedDueToTimeout: z.boolean().optional(),
|
||||
retryProcessing: z.boolean().optional(),
|
||||
// Tag fields
|
||||
// Text tag fields
|
||||
tag1: z.string().optional(),
|
||||
tag2: z.string().optional(),
|
||||
tag3: z.string().optional(),
|
||||
@@ -31,6 +31,19 @@ const UpdateDocumentSchema = z.object({
|
||||
tag5: z.string().optional(),
|
||||
tag6: z.string().optional(),
|
||||
tag7: z.string().optional(),
|
||||
// Number tag fields
|
||||
number1: z.string().optional(),
|
||||
number2: z.string().optional(),
|
||||
number3: z.string().optional(),
|
||||
number4: z.string().optional(),
|
||||
number5: z.string().optional(),
|
||||
// Date tag fields
|
||||
date1: z.string().optional(),
|
||||
date2: z.string().optional(),
|
||||
// Boolean tag fields
|
||||
boolean1: z.string().optional(),
|
||||
boolean2: z.string().optional(),
|
||||
boolean3: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function GET(
|
||||
|
||||
@@ -80,6 +80,16 @@ describe('Knowledge Base Documents API Route', () => {
|
||||
tag5: null,
|
||||
tag6: null,
|
||||
tag7: null,
|
||||
number1: null,
|
||||
number2: null,
|
||||
number3: null,
|
||||
number4: null,
|
||||
number5: null,
|
||||
date1: null,
|
||||
date2: null,
|
||||
boolean1: null,
|
||||
boolean2: null,
|
||||
boolean3: null,
|
||||
deletedAt: null,
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const UpdateKnowledgeBaseSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
@@ -133,7 +133,10 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
|
||||
@@ -64,6 +64,11 @@ vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseAccess: mockCheckKnowledgeBaseAccess,
|
||||
}))
|
||||
|
||||
const mockGetDocumentTagDefinitions = vi.fn()
|
||||
vi.mock('@/lib/knowledge/tags/service', () => ({
|
||||
getDocumentTagDefinitions: mockGetDocumentTagDefinitions,
|
||||
}))
|
||||
|
||||
const mockHandleTagOnlySearch = vi.fn()
|
||||
const mockHandleVectorOnlySearch = vi.fn()
|
||||
const mockHandleTagAndVectorSearch = vi.fn()
|
||||
@@ -156,6 +161,7 @@ describe('Knowledge Search API Route', () => {
|
||||
doc1: 'Document 1',
|
||||
doc2: 'Document 2',
|
||||
})
|
||||
mockGetDocumentTagDefinitions.mockClear()
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
|
||||
@@ -659,8 +665,8 @@ describe('Knowledge Search API Route', () => {
|
||||
|
||||
describe('Optional Query Search', () => {
|
||||
const mockTagDefinitions = [
|
||||
{ tagSlot: 'tag1', displayName: 'category' },
|
||||
{ tagSlot: 'tag2', displayName: 'priority' },
|
||||
{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' },
|
||||
{ tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' },
|
||||
]
|
||||
|
||||
const mockTaggedResults = [
|
||||
@@ -689,9 +695,7 @@ describe('Knowledge Search API Route', () => {
|
||||
it('should perform tag-only search without query', async () => {
|
||||
const tagOnlyData = {
|
||||
knowledgeBaseIds: 'kb-123',
|
||||
filters: {
|
||||
category: 'api',
|
||||
},
|
||||
tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }],
|
||||
topK: 10,
|
||||
}
|
||||
|
||||
@@ -706,10 +710,11 @@ describe('Knowledge Search API Route', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock tag definitions queries for filter mapping and display mapping
|
||||
mockDbChain.limit
|
||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for filter mapping
|
||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for display mapping
|
||||
// Mock tag definitions for validation
|
||||
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
||||
|
||||
// Mock tag definitions queries for display mapping
|
||||
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
||||
|
||||
// Mock the tag-only search handler
|
||||
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
|
||||
@@ -729,7 +734,9 @@ describe('Knowledge Search API Route', () => {
|
||||
expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { category: 'api' }, // Note: When no tag definitions are found, it uses the original filter key
|
||||
structuredFilters: [
|
||||
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -737,9 +744,7 @@ describe('Knowledge Search API Route', () => {
|
||||
const combinedData = {
|
||||
knowledgeBaseIds: 'kb-123',
|
||||
query: 'test search',
|
||||
filters: {
|
||||
category: 'api',
|
||||
},
|
||||
tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }],
|
||||
topK: 10,
|
||||
}
|
||||
|
||||
@@ -754,10 +759,11 @@ describe('Knowledge Search API Route', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock tag definitions queries for filter mapping and display mapping
|
||||
mockDbChain.limit
|
||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for filter mapping
|
||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for display mapping
|
||||
// Mock tag definitions for validation
|
||||
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
||||
|
||||
// Mock tag definitions queries for display mapping
|
||||
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
||||
|
||||
// Mock the tag + vector search handler
|
||||
mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults)
|
||||
@@ -784,7 +790,9 @@ describe('Knowledge Search API Route', () => {
|
||||
expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { category: 'api' }, // Note: When no tag definitions are found, it uses the original filter key
|
||||
structuredFilters: [
|
||||
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
|
||||
],
|
||||
queryVector: JSON.stringify(mockEmbedding),
|
||||
distanceThreshold: 1, // Single KB uses threshold of 1.0
|
||||
})
|
||||
@@ -928,10 +936,10 @@ describe('Knowledge Search API Route', () => {
|
||||
it('should handle tag-only search with multiple knowledge bases', async () => {
|
||||
const multiKbTagData = {
|
||||
knowledgeBaseIds: ['kb-123', 'kb-456'],
|
||||
filters: {
|
||||
category: 'docs',
|
||||
priority: 'high',
|
||||
},
|
||||
tagFilters: [
|
||||
{ tagName: 'category', value: 'docs', fieldType: 'text', operator: 'eq' },
|
||||
{ tagName: 'priority', value: 'high', fieldType: 'text', operator: 'eq' },
|
||||
],
|
||||
topK: 10,
|
||||
}
|
||||
|
||||
@@ -951,37 +959,14 @@ describe('Knowledge Search API Route', () => {
|
||||
knowledgeBase: { id: 'kb-456', userId: 'user-123', name: 'Test KB 2' },
|
||||
})
|
||||
|
||||
// Reset all mocks before setting up specific behavior
|
||||
Object.values(mockDbChain).forEach((fn) => {
|
||||
if (typeof fn === 'function') {
|
||||
fn.mockClear().mockReturnThis()
|
||||
}
|
||||
})
|
||||
// Mock tag definitions for validation
|
||||
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
||||
|
||||
// Create fresh mocks for multiple database calls needed for multi-KB tag search
|
||||
const mockTagDefsQuery1 = {
|
||||
...mockDbChain,
|
||||
limit: vi.fn().mockResolvedValue(mockTagDefinitions),
|
||||
}
|
||||
const mockTagSearchQuery = {
|
||||
...mockDbChain,
|
||||
limit: vi.fn().mockResolvedValue(mockTaggedResults),
|
||||
}
|
||||
const mockTagDefsQuery2 = {
|
||||
...mockDbChain,
|
||||
limit: vi.fn().mockResolvedValue(mockTagDefinitions),
|
||||
}
|
||||
const mockTagDefsQuery3 = {
|
||||
...mockDbChain,
|
||||
limit: vi.fn().mockResolvedValue(mockTagDefinitions),
|
||||
}
|
||||
// Mock the tag-only search handler
|
||||
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
|
||||
|
||||
// Chain the mocks for: tag defs, search, display mapping KB1, display mapping KB2
|
||||
mockDbChain.select
|
||||
.mockReturnValueOnce(mockTagDefsQuery1)
|
||||
.mockReturnValueOnce(mockTagSearchQuery)
|
||||
.mockReturnValueOnce(mockTagDefsQuery2)
|
||||
.mockReturnValueOnce(mockTagDefsQuery3)
|
||||
// Mock tag definitions queries for display mapping
|
||||
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
||||
|
||||
const req = createMockRequest('POST', multiKbTagData)
|
||||
const { POST } = await import('@/app/api/knowledge/search/route')
|
||||
@@ -1076,6 +1061,11 @@ describe('Knowledge Search API Route', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock tag definitions for validation
|
||||
mockGetDocumentTagDefinitions.mockResolvedValue([
|
||||
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
|
||||
])
|
||||
|
||||
mockHandleTagOnlySearch.mockResolvedValue([
|
||||
{
|
||||
id: 'chunk-2',
|
||||
@@ -1108,13 +1098,15 @@ describe('Knowledge Search API Route', () => {
|
||||
const mockTagDefs = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
where: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]),
|
||||
}
|
||||
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
filters: { tag1: 'api' },
|
||||
tagFilters: [{ tagName: 'tag1', value: 'api', fieldType: 'text', operator: 'eq' }],
|
||||
topK: 10,
|
||||
})
|
||||
|
||||
@@ -1143,6 +1135,11 @@ describe('Knowledge Search API Route', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock tag definitions for validation
|
||||
mockGetDocumentTagDefinitions.mockResolvedValue([
|
||||
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
|
||||
])
|
||||
|
||||
mockHandleTagAndVectorSearch.mockResolvedValue([
|
||||
{
|
||||
id: 'chunk-3',
|
||||
@@ -1176,14 +1173,16 @@ describe('Knowledge Search API Route', () => {
|
||||
const mockTagDefs = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
where: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]),
|
||||
}
|
||||
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
query: 'relevant content',
|
||||
filters: { tag1: 'guide' },
|
||||
tagFilters: [{ tagName: 'tag1', value: 'guide', fieldType: 'text', operator: 'eq' }],
|
||||
topK: 10,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils'
|
||||
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { estimateTokenCount } from '@/lib/tokenization/estimators'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
@@ -20,6 +22,16 @@ import { calculateCost } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('VectorSearchAPI')
|
||||
|
||||
/** Structured tag filter with operator support */
|
||||
const StructuredTagFilterSchema = z.object({
|
||||
tagName: z.string(),
|
||||
tagSlot: z.string().optional(),
|
||||
fieldType: z.enum(['text', 'number', 'date', 'boolean']).default('text'),
|
||||
operator: z.string().default('eq'),
|
||||
value: z.union([z.string(), z.number(), z.boolean()]),
|
||||
valueTo: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
|
||||
const VectorSearchSchema = z
|
||||
.object({
|
||||
knowledgeBaseIds: z.union([
|
||||
@@ -39,18 +51,17 @@ const VectorSearchSchema = z
|
||||
.nullable()
|
||||
.default(10)
|
||||
.transform((val) => val ?? 10),
|
||||
filters: z
|
||||
.record(z.string())
|
||||
tagFilters: z
|
||||
.array(StructuredTagFilterSchema)
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((val) => val || undefined), // Allow dynamic filter keys (display names)
|
||||
.transform((val) => val || undefined),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Ensure at least query or filters are provided
|
||||
const hasQuery = data.query && data.query.trim().length > 0
|
||||
const hasFilters = data.filters && Object.keys(data.filters).length > 0
|
||||
return hasQuery || hasFilters
|
||||
const hasTagFilters = data.tagFilters && data.tagFilters.length > 0
|
||||
return hasQuery || hasTagFilters
|
||||
},
|
||||
{
|
||||
message: 'Please provide either a search query or tag filters to search your knowledge base',
|
||||
@@ -88,45 +99,81 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
|
||||
// Map display names to tag slots for filtering
|
||||
let mappedFilters: Record<string, string> = {}
|
||||
if (validatedData.filters && accessibleKbIds.length > 0) {
|
||||
try {
|
||||
// Fetch tag definitions for the first accessible KB (since we're using single KB now)
|
||||
const kbId = accessibleKbIds[0]
|
||||
const tagDefs = await getDocumentTagDefinitions(kbId)
|
||||
let structuredFilters: StructuredFilter[] = []
|
||||
|
||||
logger.debug(`[${requestId}] Found tag definitions:`, tagDefs)
|
||||
logger.debug(`[${requestId}] Original filters:`, validatedData.filters)
|
||||
// Handle tag filters
|
||||
if (validatedData.tagFilters && accessibleKbIds.length > 0) {
|
||||
const kbId = accessibleKbIds[0]
|
||||
const tagDefs = await getDocumentTagDefinitions(kbId)
|
||||
|
||||
// Create mapping from display name to tag slot
|
||||
const displayNameToSlot: Record<string, string> = {}
|
||||
tagDefs.forEach((def) => {
|
||||
displayNameToSlot[def.displayName] = def.tagSlot
|
||||
})
|
||||
// Create mapping from display name to tag slot and fieldType
|
||||
const displayNameToTagDef: Record<string, { tagSlot: string; fieldType: string }> = {}
|
||||
tagDefs.forEach((def) => {
|
||||
displayNameToTagDef[def.displayName] = {
|
||||
tagSlot: def.tagSlot,
|
||||
fieldType: def.fieldType,
|
||||
}
|
||||
})
|
||||
|
||||
// Map the filters and handle OR logic
|
||||
Object.entries(validatedData.filters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
const tagSlot = displayNameToSlot[key] || key // Fallback to key if no mapping found
|
||||
// Validate all tag filters first
|
||||
const undefinedTags: string[] = []
|
||||
const typeErrors: string[] = []
|
||||
|
||||
// Check if this is an OR filter (contains |OR| separator)
|
||||
if (value.includes('|OR|')) {
|
||||
logger.debug(
|
||||
`[${requestId}] OR filter detected: "${key}" -> "${tagSlot}" = "${value}"`
|
||||
)
|
||||
}
|
||||
for (const filter of validatedData.tagFilters) {
|
||||
const tagDef = displayNameToTagDef[filter.tagName]
|
||||
|
||||
mappedFilters[tagSlot] = value
|
||||
logger.debug(`[${requestId}] Mapped filter: "${key}" -> "${tagSlot}" = "${value}"`)
|
||||
}
|
||||
})
|
||||
// Check if tag exists
|
||||
if (!tagDef) {
|
||||
undefinedTags.push(filter.tagName)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Final mapped filters:`, mappedFilters)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Filter mapping error:`, error)
|
||||
// If mapping fails, use original filters
|
||||
mappedFilters = validatedData.filters
|
||||
// Validate value type using shared validation
|
||||
const validationError = validateTagValue(
|
||||
filter.tagName,
|
||||
String(filter.value),
|
||||
tagDef.fieldType
|
||||
)
|
||||
if (validationError) {
|
||||
typeErrors.push(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
// Throw combined error if there are any validation issues
|
||||
if (undefinedTags.length > 0 || typeErrors.length > 0) {
|
||||
const errorParts: string[] = []
|
||||
|
||||
if (undefinedTags.length > 0) {
|
||||
errorParts.push(buildUndefinedTagsError(undefinedTags))
|
||||
}
|
||||
|
||||
if (typeErrors.length > 0) {
|
||||
errorParts.push(...typeErrors)
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 })
|
||||
}
|
||||
|
||||
// Build structured filters with validated data
|
||||
structuredFilters = validatedData.tagFilters.map((filter) => {
|
||||
const tagDef = displayNameToTagDef[filter.tagName]!
|
||||
const tagSlot = filter.tagSlot || tagDef.tagSlot
|
||||
const fieldType = filter.fieldType || tagDef.fieldType
|
||||
|
||||
logger.debug(
|
||||
`[${requestId}] Structured filter: ${filter.tagName} -> ${tagSlot} (${fieldType}) ${filter.operator} ${filter.value}`
|
||||
)
|
||||
|
||||
return {
|
||||
tagSlot,
|
||||
fieldType,
|
||||
operator: filter.operator,
|
||||
value: filter.value,
|
||||
valueTo: filter.valueTo,
|
||||
}
|
||||
})
|
||||
|
||||
logger.debug(`[${requestId}] Processed ${structuredFilters.length} structured filters`)
|
||||
}
|
||||
|
||||
if (accessibleKbIds.length === 0) {
|
||||
@@ -155,26 +202,29 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let results: SearchResult[]
|
||||
|
||||
const hasFilters = mappedFilters && Object.keys(mappedFilters).length > 0
|
||||
const hasFilters = structuredFilters && structuredFilters.length > 0
|
||||
|
||||
if (!hasQuery && hasFilters) {
|
||||
// Tag-only search without vector similarity
|
||||
logger.debug(`[${requestId}] Executing tag-only search with filters:`, mappedFilters)
|
||||
logger.debug(`[${requestId}] Executing tag-only search with filters:`, structuredFilters)
|
||||
results = await handleTagOnlySearch({
|
||||
knowledgeBaseIds: accessibleKbIds,
|
||||
topK: validatedData.topK,
|
||||
filters: mappedFilters,
|
||||
structuredFilters,
|
||||
})
|
||||
} else if (hasQuery && hasFilters) {
|
||||
// Tag + Vector search
|
||||
logger.debug(`[${requestId}] Executing tag + vector search with filters:`, mappedFilters)
|
||||
logger.debug(
|
||||
`[${requestId}] Executing tag + vector search with filters:`,
|
||||
structuredFilters
|
||||
)
|
||||
const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK)
|
||||
const queryVector = JSON.stringify(await queryEmbeddingPromise)
|
||||
|
||||
results = await handleTagAndVectorSearch({
|
||||
knowledgeBaseIds: accessibleKbIds,
|
||||
topK: validatedData.topK,
|
||||
filters: mappedFilters,
|
||||
structuredFilters,
|
||||
queryVector,
|
||||
distanceThreshold: strategy.distanceThreshold,
|
||||
})
|
||||
@@ -257,9 +307,9 @@ export async function POST(request: NextRequest) {
|
||||
// Create tags object with display names
|
||||
const tags: Record<string, any> = {}
|
||||
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const tagValue = (result as any)[slot]
|
||||
if (tagValue) {
|
||||
if (tagValue !== null && tagValue !== undefined) {
|
||||
const displayName = kbTagMap[slot] || slot
|
||||
logger.debug(
|
||||
`[${requestId}] Mapping ${slot}="${tagValue}" -> "${displayName}"="${tagValue}"`
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: {},
|
||||
structuredFilters: [],
|
||||
}
|
||||
|
||||
await expect(handleTagOnlySearch(params)).rejects.toThrow(
|
||||
@@ -66,14 +66,14 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { tag1: 'api' },
|
||||
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||
}
|
||||
|
||||
// This test validates the function accepts the right parameters
|
||||
// The actual database interaction is tested via route tests
|
||||
expect(params.knowledgeBaseIds).toEqual(['kb-123'])
|
||||
expect(params.topK).toBe(10)
|
||||
expect(params.filters).toEqual({ tag1: 'api' })
|
||||
expect(params.structuredFilters).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: {},
|
||||
structuredFilters: [],
|
||||
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
||||
distanceThreshold: 0.8,
|
||||
}
|
||||
@@ -137,7 +137,7 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { tag1: 'api' },
|
||||
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||
distanceThreshold: 0.8,
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { tag1: 'api' },
|
||||
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { tag1: 'api' },
|
||||
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
||||
distanceThreshold: 0.8,
|
||||
}
|
||||
@@ -171,7 +171,7 @@ describe('Knowledge Search Utils', () => {
|
||||
// This test validates the function accepts the right parameters
|
||||
expect(params.knowledgeBaseIds).toEqual(['kb-123'])
|
||||
expect(params.topK).toBe(10)
|
||||
expect(params.filters).toEqual({ tag1: 'api' })
|
||||
expect(params.structuredFilters).toHaveLength(1)
|
||||
expect(params.queryVector).toBe(JSON.stringify([0.1, 0.2, 0.3]))
|
||||
expect(params.distanceThreshold).toBe(0.8)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { document, embedding } from '@sim/db/schema'
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('KnowledgeSearchUtils')
|
||||
@@ -34,6 +35,7 @@ export interface SearchResult {
|
||||
content: string
|
||||
documentId: string
|
||||
chunkIndex: number
|
||||
// Text tags
|
||||
tag1: string | null
|
||||
tag2: string | null
|
||||
tag3: string | null
|
||||
@@ -41,6 +43,19 @@ export interface SearchResult {
|
||||
tag5: string | null
|
||||
tag6: string | null
|
||||
tag7: string | null
|
||||
// Number tags (5 slots)
|
||||
number1: number | null
|
||||
number2: number | null
|
||||
number3: number | null
|
||||
number4: number | null
|
||||
number5: number | null
|
||||
// Date tags (2 slots)
|
||||
date1: Date | null
|
||||
date2: Date | null
|
||||
// Boolean tags (3 slots)
|
||||
boolean1: boolean | null
|
||||
boolean2: boolean | null
|
||||
boolean3: boolean | null
|
||||
distance: number
|
||||
knowledgeBaseId: string
|
||||
}
|
||||
@@ -48,7 +63,7 @@ export interface SearchResult {
|
||||
export interface SearchParams {
|
||||
knowledgeBaseIds: string[]
|
||||
topK: number
|
||||
filters?: Record<string, string>
|
||||
structuredFilters?: StructuredFilter[]
|
||||
queryVector?: string
|
||||
distanceThreshold?: number
|
||||
}
|
||||
@@ -56,46 +71,230 @@ export interface SearchParams {
|
||||
// Use shared embedding utility
|
||||
export { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
|
||||
|
||||
function getTagFilters(filters: Record<string, string>, embedding: any) {
|
||||
return Object.entries(filters).map(([key, value]) => {
|
||||
// Handle OR logic within same tag
|
||||
const values = value.includes('|OR|') ? value.split('|OR|') : [value]
|
||||
logger.debug(`[getTagFilters] Processing ${key}="${value}" -> values:`, values)
|
||||
/** All valid tag slot keys */
|
||||
const TAG_SLOT_KEYS = [
|
||||
// Text tags (7 slots)
|
||||
'tag1',
|
||||
'tag2',
|
||||
'tag3',
|
||||
'tag4',
|
||||
'tag5',
|
||||
'tag6',
|
||||
'tag7',
|
||||
// Number tags (5 slots)
|
||||
'number1',
|
||||
'number2',
|
||||
'number3',
|
||||
'number4',
|
||||
'number5',
|
||||
// Date tags (2 slots)
|
||||
'date1',
|
||||
'date2',
|
||||
// Boolean tags (3 slots)
|
||||
'boolean1',
|
||||
'boolean2',
|
||||
'boolean3',
|
||||
] as const
|
||||
|
||||
const getColumnForKey = (key: string) => {
|
||||
switch (key) {
|
||||
case 'tag1':
|
||||
return embedding.tag1
|
||||
case 'tag2':
|
||||
return embedding.tag2
|
||||
case 'tag3':
|
||||
return embedding.tag3
|
||||
case 'tag4':
|
||||
return embedding.tag4
|
||||
case 'tag5':
|
||||
return embedding.tag5
|
||||
case 'tag6':
|
||||
return embedding.tag6
|
||||
case 'tag7':
|
||||
return embedding.tag7
|
||||
default:
|
||||
return null
|
||||
}
|
||||
type TagSlotKey = (typeof TAG_SLOT_KEYS)[number]
|
||||
|
||||
function isTagSlotKey(key: string): key is TagSlotKey {
|
||||
return TAG_SLOT_KEYS.includes(key as TagSlotKey)
|
||||
}
|
||||
|
||||
/** Common fields selected for search results */
|
||||
const getSearchResultFields = (distanceExpr: any) => ({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
// Text tags
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
// Number tags (5 slots)
|
||||
number1: embedding.number1,
|
||||
number2: embedding.number2,
|
||||
number3: embedding.number3,
|
||||
number4: embedding.number4,
|
||||
number5: embedding.number5,
|
||||
// Date tags (2 slots)
|
||||
date1: embedding.date1,
|
||||
date2: embedding.date2,
|
||||
// Boolean tags (3 slots)
|
||||
boolean1: embedding.boolean1,
|
||||
boolean2: embedding.boolean2,
|
||||
boolean3: embedding.boolean3,
|
||||
distance: distanceExpr,
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
|
||||
/**
|
||||
* Build a single SQL condition for a filter
|
||||
*/
|
||||
function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) {
|
||||
const { tagSlot, fieldType, operator, value, valueTo } = filter
|
||||
|
||||
if (!isTagSlotKey(tagSlot)) {
|
||||
logger.debug(`[getStructuredTagFilters] Unknown tag slot: ${tagSlot}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const column = embeddingTable[tagSlot]
|
||||
if (!column) return null
|
||||
|
||||
logger.debug(
|
||||
`[getStructuredTagFilters] Processing ${tagSlot} (${fieldType}) ${operator} ${value}`
|
||||
)
|
||||
|
||||
// Handle text operators
|
||||
if (fieldType === 'text') {
|
||||
const stringValue = String(value)
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return sql`LOWER(${column}) = LOWER(${stringValue})`
|
||||
case 'neq':
|
||||
return sql`LOWER(${column}) != LOWER(${stringValue})`
|
||||
case 'contains':
|
||||
return sql`LOWER(${column}) LIKE LOWER(${`%${stringValue}%`})`
|
||||
case 'not_contains':
|
||||
return sql`LOWER(${column}) NOT LIKE LOWER(${`%${stringValue}%`})`
|
||||
case 'starts_with':
|
||||
return sql`LOWER(${column}) LIKE LOWER(${`${stringValue}%`})`
|
||||
case 'ends_with':
|
||||
return sql`LOWER(${column}) LIKE LOWER(${`%${stringValue}`})`
|
||||
default:
|
||||
return sql`LOWER(${column}) = LOWER(${stringValue})`
|
||||
}
|
||||
}
|
||||
|
||||
// Handle number operators
|
||||
if (fieldType === 'number') {
|
||||
const numValue = typeof value === 'number' ? value : Number.parseFloat(String(value))
|
||||
if (Number.isNaN(numValue)) return null
|
||||
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return sql`${column} = ${numValue}`
|
||||
case 'neq':
|
||||
return sql`${column} != ${numValue}`
|
||||
case 'gt':
|
||||
return sql`${column} > ${numValue}`
|
||||
case 'gte':
|
||||
return sql`${column} >= ${numValue}`
|
||||
case 'lt':
|
||||
return sql`${column} < ${numValue}`
|
||||
case 'lte':
|
||||
return sql`${column} <= ${numValue}`
|
||||
case 'between':
|
||||
if (valueTo !== undefined) {
|
||||
const numValueTo =
|
||||
typeof valueTo === 'number' ? valueTo : Number.parseFloat(String(valueTo))
|
||||
if (Number.isNaN(numValueTo)) return sql`${column} = ${numValue}`
|
||||
return sql`${column} >= ${numValue} AND ${column} <= ${numValueTo}`
|
||||
}
|
||||
return sql`${column} = ${numValue}`
|
||||
default:
|
||||
return sql`${column} = ${numValue}`
|
||||
}
|
||||
}
|
||||
|
||||
// Handle date operators - expects YYYY-MM-DD format from frontend
|
||||
if (fieldType === 'date') {
|
||||
const dateStr = String(value)
|
||||
// Validate YYYY-MM-DD format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`)
|
||||
return null
|
||||
}
|
||||
|
||||
const column = getColumnForKey(key)
|
||||
if (!column) return sql`1=1` // No-op for unknown keys
|
||||
|
||||
if (values.length === 1) {
|
||||
// Single value - simple equality
|
||||
logger.debug(`[getTagFilters] Single value filter: ${key} = ${values[0]}`)
|
||||
return sql`LOWER(${column}) = LOWER(${values[0]})`
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
case 'neq':
|
||||
return sql`${column}::date != ${dateStr}::date`
|
||||
case 'gt':
|
||||
return sql`${column}::date > ${dateStr}::date`
|
||||
case 'gte':
|
||||
return sql`${column}::date >= ${dateStr}::date`
|
||||
case 'lt':
|
||||
return sql`${column}::date < ${dateStr}::date`
|
||||
case 'lte':
|
||||
return sql`${column}::date <= ${dateStr}::date`
|
||||
case 'between':
|
||||
if (valueTo !== undefined) {
|
||||
const dateStrTo = String(valueTo)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStrTo)) {
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
}
|
||||
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
|
||||
}
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
default:
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
}
|
||||
// Multiple values - OR logic
|
||||
logger.debug(`[getTagFilters] OR filter: ${key} IN (${values.join(', ')})`)
|
||||
const orConditions = values.map((v) => sql`LOWER(${column}) = LOWER(${v})`)
|
||||
return sql`(${sql.join(orConditions, sql` OR `)})`
|
||||
})
|
||||
}
|
||||
|
||||
// Handle boolean operators
|
||||
if (fieldType === 'boolean') {
|
||||
const boolValue = value === true || value === 'true'
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return sql`${column} = ${boolValue}`
|
||||
case 'neq':
|
||||
return sql`${column} != ${boolValue}`
|
||||
default:
|
||||
return sql`${column} = ${boolValue}`
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to equality
|
||||
return sql`${column} = ${value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SQL conditions from structured filters with operator support
|
||||
* - Same tag multiple times: OR logic
|
||||
* - Different tags: AND logic
|
||||
*/
|
||||
function getStructuredTagFilters(filters: StructuredFilter[], embeddingTable: any) {
|
||||
// Group filters by tagSlot
|
||||
const filtersBySlot = new Map<string, StructuredFilter[]>()
|
||||
for (const filter of filters) {
|
||||
const slot = filter.tagSlot
|
||||
if (!filtersBySlot.has(slot)) {
|
||||
filtersBySlot.set(slot, [])
|
||||
}
|
||||
filtersBySlot.get(slot)!.push(filter)
|
||||
}
|
||||
|
||||
// Build conditions: OR within same slot, AND across different slots
|
||||
const conditions: ReturnType<typeof sql>[] = []
|
||||
|
||||
for (const [slot, slotFilters] of filtersBySlot) {
|
||||
const slotConditions = slotFilters
|
||||
.map((f) => buildFilterCondition(f, embeddingTable))
|
||||
.filter((c): c is ReturnType<typeof sql> => c !== null)
|
||||
|
||||
if (slotConditions.length === 0) continue
|
||||
|
||||
if (slotConditions.length === 1) {
|
||||
// Single condition for this slot
|
||||
conditions.push(slotConditions[0])
|
||||
} else {
|
||||
// Multiple conditions for same slot - OR them together
|
||||
logger.debug(
|
||||
`[getStructuredTagFilters] OR'ing ${slotConditions.length} conditions for ${slot}`
|
||||
)
|
||||
conditions.push(sql`(${sql.join(slotConditions, sql` OR `)})`)
|
||||
}
|
||||
}
|
||||
|
||||
return conditions
|
||||
}
|
||||
|
||||
export function getQueryStrategy(kbCount: number, topK: number) {
|
||||
@@ -113,8 +312,10 @@ export function getQueryStrategy(kbCount: number, topK: number) {
|
||||
|
||||
async function executeTagFilterQuery(
|
||||
knowledgeBaseIds: string[],
|
||||
filters: Record<string, string>
|
||||
structuredFilters: StructuredFilter[]
|
||||
): Promise<{ id: string }[]> {
|
||||
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
|
||||
|
||||
if (knowledgeBaseIds.length === 1) {
|
||||
return await db
|
||||
.select({ id: embedding.id })
|
||||
@@ -125,7 +326,7 @@ async function executeTagFilterQuery(
|
||||
eq(embedding.knowledgeBaseId, knowledgeBaseIds[0]),
|
||||
eq(embedding.enabled, true),
|
||||
isNull(document.deletedAt),
|
||||
...getTagFilters(filters, embedding)
|
||||
...tagFilterConditions
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -138,7 +339,7 @@ async function executeTagFilterQuery(
|
||||
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
|
||||
eq(embedding.enabled, true),
|
||||
isNull(document.deletedAt),
|
||||
...getTagFilters(filters, embedding)
|
||||
...tagFilterConditions
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -154,21 +355,11 @@ async function executeVectorSearchOnIds(
|
||||
}
|
||||
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'),
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.select(
|
||||
getSearchResultFields(
|
||||
sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
|
||||
)
|
||||
)
|
||||
.from(embedding)
|
||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||
.where(
|
||||
@@ -183,15 +374,16 @@ async function executeVectorSearchOnIds(
|
||||
}
|
||||
|
||||
export async function handleTagOnlySearch(params: SearchParams): Promise<SearchResult[]> {
|
||||
const { knowledgeBaseIds, topK, filters } = params
|
||||
const { knowledgeBaseIds, topK, structuredFilters } = params
|
||||
|
||||
if (!filters || Object.keys(filters).length === 0) {
|
||||
if (!structuredFilters || structuredFilters.length === 0) {
|
||||
throw new Error('Tag filters are required for tag-only search')
|
||||
}
|
||||
|
||||
logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, filters)
|
||||
logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, structuredFilters)
|
||||
|
||||
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
|
||||
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
|
||||
|
||||
if (strategy.useParallel) {
|
||||
// Parallel approach for many KBs
|
||||
@@ -199,21 +391,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
||||
|
||||
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`0`.as('distance'), // No distance for tag-only searches
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.select(getSearchResultFields(sql<number>`0`.as('distance')))
|
||||
.from(embedding)
|
||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||
.where(
|
||||
@@ -221,7 +399,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
||||
eq(embedding.knowledgeBaseId, kbId),
|
||||
eq(embedding.enabled, true),
|
||||
isNull(document.deletedAt),
|
||||
...getTagFilters(filters, embedding)
|
||||
...tagFilterConditions
|
||||
)
|
||||
)
|
||||
.limit(parallelLimit)
|
||||
@@ -232,21 +410,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
||||
}
|
||||
// Single query for fewer KBs
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`0`.as('distance'), // No distance for tag-only searches
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.select(getSearchResultFields(sql<number>`0`.as('distance')))
|
||||
.from(embedding)
|
||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||
.where(
|
||||
@@ -254,7 +418,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
||||
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
|
||||
eq(embedding.enabled, true),
|
||||
isNull(document.deletedAt),
|
||||
...getTagFilters(filters, embedding)
|
||||
...tagFilterConditions
|
||||
)
|
||||
)
|
||||
.limit(topK)
|
||||
@@ -271,27 +435,15 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
|
||||
|
||||
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
|
||||
|
||||
const distanceExpr = sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
|
||||
|
||||
if (strategy.useParallel) {
|
||||
// Parallel approach for many KBs
|
||||
const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5
|
||||
|
||||
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'),
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.select(getSearchResultFields(distanceExpr))
|
||||
.from(embedding)
|
||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||
.where(
|
||||
@@ -312,21 +464,7 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
|
||||
}
|
||||
// Single query for fewer KBs
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'),
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.select(getSearchResultFields(distanceExpr))
|
||||
.from(embedding)
|
||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||
.where(
|
||||
@@ -342,19 +480,22 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
|
||||
}
|
||||
|
||||
export async function handleTagAndVectorSearch(params: SearchParams): Promise<SearchResult[]> {
|
||||
const { knowledgeBaseIds, topK, filters, queryVector, distanceThreshold } = params
|
||||
const { knowledgeBaseIds, topK, structuredFilters, queryVector, distanceThreshold } = params
|
||||
|
||||
if (!filters || Object.keys(filters).length === 0) {
|
||||
if (!structuredFilters || structuredFilters.length === 0) {
|
||||
throw new Error('Tag filters are required for tag and vector search')
|
||||
}
|
||||
if (!queryVector || !distanceThreshold) {
|
||||
throw new Error('Query vector and distance threshold are required for tag and vector search')
|
||||
}
|
||||
|
||||
logger.debug(`[handleTagAndVectorSearch] Executing tag + vector search with filters:`, filters)
|
||||
logger.debug(
|
||||
`[handleTagAndVectorSearch] Executing tag + vector search with filters:`,
|
||||
structuredFilters
|
||||
)
|
||||
|
||||
// Step 1: Filter by tags first
|
||||
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, filters)
|
||||
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, structuredFilters)
|
||||
|
||||
if (tagFilteredIds.length === 0) {
|
||||
logger.debug(`[handleTagAndVectorSearch] No results found after tag filtering`)
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface DocumentData {
|
||||
enabled: boolean
|
||||
deletedAt?: Date | null
|
||||
uploadedAt: Date
|
||||
// Document tags
|
||||
// Text tags
|
||||
tag1?: string | null
|
||||
tag2?: string | null
|
||||
tag3?: string | null
|
||||
@@ -43,6 +43,19 @@ export interface DocumentData {
|
||||
tag5?: string | null
|
||||
tag6?: string | null
|
||||
tag7?: string | null
|
||||
// Number tags (5 slots)
|
||||
number1?: number | null
|
||||
number2?: number | null
|
||||
number3?: number | null
|
||||
number4?: number | null
|
||||
number5?: number | null
|
||||
// Date tags (2 slots)
|
||||
date1?: Date | null
|
||||
date2?: Date | null
|
||||
// Boolean tags (3 slots)
|
||||
boolean1?: boolean | null
|
||||
boolean2?: boolean | null
|
||||
boolean3?: boolean | null
|
||||
}
|
||||
|
||||
export interface EmbeddingData {
|
||||
@@ -58,7 +71,7 @@ export interface EmbeddingData {
|
||||
embeddingModel: string
|
||||
startOffset: number
|
||||
endOffset: number
|
||||
// Tag fields for filtering
|
||||
// Text tags
|
||||
tag1?: string | null
|
||||
tag2?: string | null
|
||||
tag3?: string | null
|
||||
@@ -66,6 +79,19 @@ export interface EmbeddingData {
|
||||
tag5?: string | null
|
||||
tag6?: string | null
|
||||
tag7?: string | null
|
||||
// Number tags (5 slots)
|
||||
number1?: number | null
|
||||
number2?: number | null
|
||||
number3?: number | null
|
||||
number4?: number | null
|
||||
number5?: number | null
|
||||
// Date tags (2 slots)
|
||||
date1?: Date | null
|
||||
date2?: Date | null
|
||||
// Boolean tags (3 slots)
|
||||
boolean1?: boolean | null
|
||||
boolean2?: boolean | null
|
||||
boolean3?: boolean | null
|
||||
enabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -232,6 +258,27 @@ export async function checkDocumentWriteAccess(
|
||||
processingStartedAt: document.processingStartedAt,
|
||||
processingCompletedAt: document.processingCompletedAt,
|
||||
knowledgeBaseId: document.knowledgeBaseId,
|
||||
// Text tags
|
||||
tag1: document.tag1,
|
||||
tag2: document.tag2,
|
||||
tag3: document.tag3,
|
||||
tag4: document.tag4,
|
||||
tag5: document.tag5,
|
||||
tag6: document.tag6,
|
||||
tag7: document.tag7,
|
||||
// Number tags (5 slots)
|
||||
number1: document.number1,
|
||||
number2: document.number2,
|
||||
number3: document.number3,
|
||||
number4: document.number4,
|
||||
number5: document.number5,
|
||||
// Date tags (2 slots)
|
||||
date1: document.date1,
|
||||
date2: document.date2,
|
||||
// Boolean tags (3 slots)
|
||||
boolean1: document.boolean1,
|
||||
boolean2: document.boolean2,
|
||||
boolean3: document.boolean3,
|
||||
})
|
||||
.from(document)
|
||||
.where(and(eq(document.id, documentId), isNull(document.deletedAt)))
|
||||
|
||||
@@ -1,32 +1,72 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import {
|
||||
permissions,
|
||||
workflow,
|
||||
workflowExecutionLogs,
|
||||
workflowExecutionSnapshots,
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('LogsByExecutionIdAPI')
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ executionId: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const { executionId } = await params
|
||||
|
||||
logger.debug(`Fetching execution data for: ${executionId}`)
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized execution data access attempt for: ${executionId}`)
|
||||
return NextResponse.json(
|
||||
{ error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const authenticatedUserId = authResult.userId
|
||||
|
||||
logger.debug(
|
||||
`[${requestId}] Fetching execution data for: ${executionId} (auth: ${authResult.authType})`
|
||||
)
|
||||
|
||||
// Get the workflow execution log to find the snapshot
|
||||
const [workflowLog] = await db
|
||||
.select()
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.userId, authenticatedUserId)
|
||||
)
|
||||
)
|
||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowLog) {
|
||||
logger.warn(`[${requestId}] Execution not found or access denied: ${executionId}`)
|
||||
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get the workflow state snapshot
|
||||
const [snapshot] = await db
|
||||
.select()
|
||||
.from(workflowExecutionSnapshots)
|
||||
@@ -34,6 +74,7 @@ export async function GET(
|
||||
.limit(1)
|
||||
|
||||
if (!snapshot) {
|
||||
logger.warn(`[${requestId}] Workflow state snapshot not found for execution: ${executionId}`)
|
||||
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
@@ -50,14 +91,14 @@ export async function GET(
|
||||
},
|
||||
}
|
||||
|
||||
logger.debug(`Successfully fetched execution data for: ${executionId}`)
|
||||
logger.debug(`[${requestId}] Successfully fetched execution data for: ${executionId}`)
|
||||
logger.debug(
|
||||
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
|
||||
`[${requestId}] Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
|
||||
)
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching execution data:', error)
|
||||
logger.error(`[${requestId}] Error fetching execution data:`, error)
|
||||
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function GET(request: NextRequest) {
|
||||
workflowName: workflow.name,
|
||||
}
|
||||
|
||||
let conditions: SQL | undefined = eq(workflow.workspaceId, params.workspaceId)
|
||||
let conditions: SQL | undefined = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
|
||||
|
||||
if (params.level && params.level !== 'all') {
|
||||
const levels = params.level.split(',').filter(Boolean)
|
||||
@@ -134,7 +134,7 @@ export async function GET(request: NextRequest) {
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -130,6 +130,8 @@ export async function GET(request: NextRequest) {
|
||||
deploymentVersionName: sql<null>`NULL`,
|
||||
}
|
||||
|
||||
const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
|
||||
|
||||
const baseQuery = db
|
||||
.select(selectColumns)
|
||||
.from(workflowExecutionLogs)
|
||||
@@ -141,18 +143,12 @@ export async function GET(request: NextRequest) {
|
||||
workflowDeploymentVersion,
|
||||
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
|
||||
)
|
||||
.innerJoin(
|
||||
workflow,
|
||||
and(
|
||||
eq(workflowExecutionLogs.workflowId, workflow.id),
|
||||
eq(workflow.workspaceId, params.workspaceId)
|
||||
)
|
||||
)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
@@ -300,7 +296,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const logs = await baseQuery
|
||||
.where(conditions)
|
||||
.where(and(workspaceFilter, conditions))
|
||||
.orderBy(desc(workflowExecutionLogs.startedAt))
|
||||
.limit(params.limit)
|
||||
.offset(params.offset)
|
||||
@@ -312,22 +308,16 @@ export async function GET(request: NextRequest) {
|
||||
pausedExecutions,
|
||||
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
|
||||
)
|
||||
.innerJoin(
|
||||
workflow,
|
||||
and(
|
||||
eq(workflowExecutionLogs.workflowId, workflow.id),
|
||||
eq(workflow.workspaceId, params.workspaceId)
|
||||
)
|
||||
)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(conditions)
|
||||
.where(and(eq(workflowExecutionLogs.workspaceId, params.workspaceId), conditions))
|
||||
|
||||
const countResult = await countQuery
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { permissions, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { and, eq, isNotNull, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
@@ -42,23 +42,17 @@ export async function GET(request: NextRequest) {
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(
|
||||
workflow,
|
||||
and(
|
||||
eq(workflowExecutionLogs.workflowId, workflow.id),
|
||||
eq(workflow.workspaceId, params.workspaceId)
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowExecutionLogs.workspaceId, params.workspaceId),
|
||||
isNotNull(workflowExecutionLogs.trigger),
|
||||
sql`${workflowExecutionLogs.trigger} NOT IN ('api', 'manual', 'webhook', 'chat', 'schedule')`
|
||||
)
|
||||
|
||||
@@ -3,8 +3,10 @@ import { memory, workflowBlocks } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('MemoryByIdAPI')
|
||||
|
||||
@@ -65,6 +67,65 @@ const memoryPutBodySchema = z.object({
|
||||
workflowId: z.string().uuid('Invalid workflow ID format'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validates authentication and workflow access for memory operations
|
||||
* @param request - The incoming request
|
||||
* @param workflowId - The workflow ID to check access for
|
||||
* @param requestId - Request ID for logging
|
||||
* @param action - 'read' for GET, 'write' for PUT/DELETE
|
||||
* @returns Object with userId if successful, or error response if failed
|
||||
*/
|
||||
async function validateMemoryAccess(
|
||||
request: NextRequest,
|
||||
workflowId: string,
|
||||
requestId: string,
|
||||
action: 'read' | 'write'
|
||||
): Promise<{ userId: string } | { error: NextResponse }> {
|
||||
const authResult = await checkHybridAuth(request, {
|
||||
requireWorkflowId: false,
|
||||
})
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized memory ${action} attempt`)
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ success: false, error: { message: 'Authentication required' } },
|
||||
{ status: 401 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, authResult.userId)
|
||||
if (!accessContext) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ success: false, error: { message: 'Workflow not found' } },
|
||||
{ status: 404 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const { isOwner, workspacePermission } = accessContext
|
||||
const hasAccess =
|
||||
action === 'read'
|
||||
? isOwner || workspacePermission !== null
|
||||
: isOwner || workspacePermission === 'write' || workspacePermission === 'admin'
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${authResult.userId} denied ${action} access to workflow ${workflowId}`
|
||||
)
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ success: false, error: { message: 'Access denied' } },
|
||||
{ status: 403 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return { userId: authResult.userId }
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
@@ -101,6 +162,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const { workflowId: validatedWorkflowId } = validation.data
|
||||
|
||||
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'read')
|
||||
if ('error' in accessCheck) {
|
||||
return accessCheck.error
|
||||
}
|
||||
|
||||
const memories = await db
|
||||
.select()
|
||||
.from(memory)
|
||||
@@ -203,6 +269,11 @@ export async function DELETE(
|
||||
|
||||
const { workflowId: validatedWorkflowId } = validation.data
|
||||
|
||||
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'write')
|
||||
if ('error' in accessCheck) {
|
||||
return accessCheck.error
|
||||
}
|
||||
|
||||
const existingMemory = await db
|
||||
.select({ id: memory.id })
|
||||
.from(memory)
|
||||
@@ -296,6 +367,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'write')
|
||||
if ('error' in accessCheck) {
|
||||
return accessCheck.error
|
||||
}
|
||||
|
||||
const existingMemories = await db
|
||||
.select()
|
||||
.from(memory)
|
||||
|
||||
@@ -28,7 +28,7 @@ const updateInvitationSchema = z.object({
|
||||
|
||||
// Get invitation details
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; invitationId: string }> }
|
||||
) {
|
||||
const { id: organizationId, invitationId } = await params
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templates, user } from '@sim/db/schema'
|
||||
import { templates } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
||||
|
||||
const logger = createLogger('TemplateApprovalAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
// POST /api/templates/[id]/approve - Approve a template (super users only)
|
||||
/**
|
||||
* POST /api/templates/[id]/approve - Approve a template (super users only)
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
@@ -22,23 +25,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is a super user
|
||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
|
||||
if (!currentUser[0]?.isSuperUser) {
|
||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||
if (!isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (existingTemplate.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for approval: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update template status to approved
|
||||
await db
|
||||
.update(templates)
|
||||
.set({ status: 'approved', updatedAt: new Date() })
|
||||
@@ -56,9 +54,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/templates/[id]/reject - Reject a template (super users only)
|
||||
/**
|
||||
* DELETE /api/templates/[id]/approve - Unapprove a template (super users only)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
@@ -71,23 +71,18 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is a super user
|
||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
|
||||
if (!currentUser[0]?.isSuperUser) {
|
||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||
if (!isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (existingTemplate.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for rejection: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update template status to rejected
|
||||
await db
|
||||
.update(templates)
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
|
||||
142
apps/sim/app/api/templates/[id]/og-image/route.ts
Normal file
142
apps/sim/app/api/templates/[id]/og-image/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templates } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { verifyTemplateOwnership } from '@/lib/templates/permissions'
|
||||
import { uploadFile } from '@/lib/uploads/core/storage-service'
|
||||
import { isValidPng } from '@/lib/uploads/utils/validation'
|
||||
|
||||
const logger = createLogger('TemplateOGImageAPI')
|
||||
|
||||
/**
|
||||
* PUT /api/templates/[id]/og-image
|
||||
* Upload a pre-generated OG image for a template.
|
||||
* Accepts base64-encoded image data in the request body.
|
||||
*/
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized OG image upload attempt for template: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { authorized, error, status } = await verifyTemplateOwnership(
|
||||
id,
|
||||
session.user.id,
|
||||
'admin'
|
||||
)
|
||||
if (!authorized) {
|
||||
logger.warn(`[${requestId}] User denied permission to upload OG image for template ${id}`)
|
||||
return NextResponse.json({ error }, { status: status || 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { imageData } = body
|
||||
|
||||
if (!imageData || typeof imageData !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing or invalid imageData (expected base64 string)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const base64Data = imageData.includes(',') ? imageData.split(',')[1] : imageData
|
||||
const imageBuffer = Buffer.from(base64Data, 'base64')
|
||||
|
||||
if (!isValidPng(imageBuffer)) {
|
||||
return NextResponse.json({ error: 'Invalid PNG image data' }, { status: 400 })
|
||||
}
|
||||
|
||||
const maxSize = 5 * 1024 * 1024
|
||||
if (imageBuffer.length > maxSize) {
|
||||
return NextResponse.json({ error: 'Image too large. Maximum size is 5MB.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const timestamp = Date.now()
|
||||
const storageKey = `og-images/templates/${id}/${timestamp}.png`
|
||||
|
||||
logger.info(`[${requestId}] Uploading OG image for template ${id}: ${storageKey}`)
|
||||
|
||||
const uploadResult = await uploadFile({
|
||||
file: imageBuffer,
|
||||
fileName: storageKey,
|
||||
contentType: 'image/png',
|
||||
context: 'og-images',
|
||||
preserveKey: true,
|
||||
customKey: storageKey,
|
||||
})
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const ogImageUrl = `${baseUrl}${uploadResult.path}?context=og-images`
|
||||
|
||||
await db
|
||||
.update(templates)
|
||||
.set({
|
||||
ogImageUrl,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Successfully uploaded OG image for template ${id}: ${ogImageUrl}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ogImageUrl,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Error uploading OG image for template ${id}:`, error)
|
||||
return NextResponse.json({ error: 'Failed to upload OG image' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/templates/[id]/og-image
|
||||
* Remove the OG image for a template.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { authorized, error, status } = await verifyTemplateOwnership(
|
||||
id,
|
||||
session.user.id,
|
||||
'admin'
|
||||
)
|
||||
if (!authorized) {
|
||||
logger.warn(`[${requestId}] User denied permission to delete OG image for template ${id}`)
|
||||
return NextResponse.json({ error }, { status: status || 403 })
|
||||
}
|
||||
|
||||
await db
|
||||
.update(templates)
|
||||
.set({
|
||||
ogImageUrl: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Removed OG image for template ${id}`)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Error removing OG image for template ${id}:`, error)
|
||||
return NextResponse.json({ error: 'Failed to remove OG image' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templates, user } from '@sim/db/schema'
|
||||
import { templates } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
||||
|
||||
const logger = createLogger('TemplateRejectionAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
// POST /api/templates/[id]/reject - Reject a template (super users only)
|
||||
/**
|
||||
* POST /api/templates/[id]/reject - Reject a template (super users only)
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
@@ -22,23 +25,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is a super user
|
||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
|
||||
if (!currentUser[0]?.isSuperUser) {
|
||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||
if (!isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (existingTemplate.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for rejection: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update template status to rejected
|
||||
await db
|
||||
.update(templates)
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, templateCreators, templates, workflow } from '@sim/db/schema'
|
||||
import { and, eq, or, sql } from 'drizzle-orm'
|
||||
import { templateCreators, templates, workflow } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -15,7 +15,6 @@ const logger = createLogger('TemplateByIdAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
// GET /api/templates/[id] - Retrieve a single template by ID
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
@@ -25,7 +24,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.debug(`[${requestId}] Fetching template: ${id}`)
|
||||
|
||||
// Fetch the template by ID with creator info
|
||||
const result = await db
|
||||
.select({
|
||||
template: templates,
|
||||
@@ -47,12 +45,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
creator: creator || undefined,
|
||||
}
|
||||
|
||||
// Only show approved templates to non-authenticated users
|
||||
if (!session?.user?.id && template.status !== 'approved') {
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has starred (only if authenticated)
|
||||
let isStarred = false
|
||||
if (session?.user?.id) {
|
||||
const { templateStars } = await import('@sim/db/schema')
|
||||
@@ -80,7 +76,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.debug(`[${requestId}] Incremented view count for template: ${id}`)
|
||||
} catch (viewError) {
|
||||
// Log the error but don't fail the request
|
||||
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +133,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const { name, details, creatorId, tags, updateState } = validationResult.data
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (existingTemplate.length === 0) {
|
||||
@@ -146,32 +140,54 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// No permission check needed - template updates only happen from within the workspace
|
||||
// where the user is already editing the connected workflow
|
||||
const template = existingTemplate[0]
|
||||
|
||||
if (!template.creatorId) {
|
||||
logger.warn(`[${requestId}] Template ${id} has no creator, denying update`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
|
||||
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
|
||||
session.user.id,
|
||||
template.creatorId,
|
||||
'admin'
|
||||
)
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
|
||||
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Prepare update data - only include fields that were provided
|
||||
const updateData: any = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
// Only update fields that were provided
|
||||
if (name !== undefined) updateData.name = name
|
||||
if (details !== undefined) updateData.details = details
|
||||
if (tags !== undefined) updateData.tags = tags
|
||||
if (creatorId !== undefined) updateData.creatorId = creatorId
|
||||
|
||||
// Only update the state if explicitly requested and the template has a connected workflow
|
||||
if (updateState && existingTemplate[0].workflowId) {
|
||||
// Load the current workflow state from normalized tables
|
||||
if (updateState && template.workflowId) {
|
||||
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
|
||||
const { hasAccess: hasWorkflowAccess } = await verifyWorkflowAccess(
|
||||
session.user.id,
|
||||
template.workflowId
|
||||
)
|
||||
|
||||
if (!hasWorkflowAccess) {
|
||||
logger.warn(`[${requestId}] User denied workflow access for state sync on template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied to workflow' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(existingTemplate[0].workflowId)
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(template.workflowId)
|
||||
|
||||
if (normalizedData) {
|
||||
// Also fetch workflow variables
|
||||
const [workflowRecord] = await db
|
||||
.select({ variables: workflow.variables })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingTemplate[0].workflowId))
|
||||
.where(eq(workflow.id, template.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const currentState = {
|
||||
@@ -183,17 +199,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
// Extract credential requirements from the new state
|
||||
const requiredCredentials = extractRequiredCredentials(currentState)
|
||||
|
||||
// Sanitize the state before storing
|
||||
const sanitizedState = sanitizeCredentials(currentState)
|
||||
|
||||
updateData.state = sanitizedState
|
||||
updateData.requiredCredentials = requiredCredentials
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Updating template state and credentials from current workflow: ${existingTemplate[0].workflowId}`
|
||||
`[${requestId}] Updating template state and credentials from current workflow: ${template.workflowId}`
|
||||
)
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Could not load workflow state for template: ${id}`)
|
||||
@@ -233,7 +247,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch template
|
||||
const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
if (existing.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for delete: ${id}`)
|
||||
@@ -242,41 +255,21 @@ export async function DELETE(
|
||||
|
||||
const template = existing[0]
|
||||
|
||||
// Permission: Only admin/owner of creator profile can delete
|
||||
if (template.creatorId) {
|
||||
const creatorProfile = await db
|
||||
.select()
|
||||
.from(templateCreators)
|
||||
.where(eq(templateCreators.id, template.creatorId))
|
||||
.limit(1)
|
||||
if (!template.creatorId) {
|
||||
logger.warn(`[${requestId}] Template ${id} has no creator, denying delete`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (creatorProfile.length > 0) {
|
||||
const creator = creatorProfile[0]
|
||||
let hasPermission = false
|
||||
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
|
||||
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
|
||||
session.user.id,
|
||||
template.creatorId,
|
||||
'admin'
|
||||
)
|
||||
|
||||
if (creator.referenceType === 'user') {
|
||||
hasPermission = creator.referenceId === session.user.id
|
||||
} else if (creator.referenceType === 'organization') {
|
||||
// For delete, require admin/owner role
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, session.user.id),
|
||||
eq(member.organizationId, creator.referenceId),
|
||||
or(eq(member.role, 'admin'), eq(member.role, 'owner'))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
hasPermission = membership.length > 0
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
if (!hasPermission) {
|
||||
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
|
||||
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
await db.delete(templates).where(eq(templates.id, id))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
member,
|
||||
templateCreators,
|
||||
templateStars,
|
||||
templates,
|
||||
@@ -204,51 +203,18 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validate creator profile - required for all templates
|
||||
const creatorProfile = await db
|
||||
.select()
|
||||
.from(templateCreators)
|
||||
.where(eq(templateCreators.id, data.creatorId))
|
||||
.limit(1)
|
||||
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
|
||||
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
|
||||
session.user.id,
|
||||
data.creatorId,
|
||||
'member'
|
||||
)
|
||||
|
||||
if (creatorProfile.length === 0) {
|
||||
logger.warn(`[${requestId}] Creator profile not found: ${data.creatorId}`)
|
||||
return NextResponse.json({ error: 'Creator profile not found' }, { status: 404 })
|
||||
if (!hasPermission) {
|
||||
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
|
||||
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const creator = creatorProfile[0]
|
||||
|
||||
// Verify user has permission to use this creator profile
|
||||
if (creator.referenceType === 'user') {
|
||||
if (creator.referenceId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'You do not have permission to use this creator profile' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
} else if (creator.referenceType === 'organization') {
|
||||
// Verify user is a member of the organization
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(eq(member.userId, session.user.id), eq(member.organizationId, creator.referenceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (membership.length === 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] User not a member of organization for creator: ${data.creatorId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'You must be a member of the organization to use its creator profile' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the template
|
||||
const templateId = uuidv4()
|
||||
const now = new Date()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -108,6 +109,14 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
const folderIdValidation = validateAlphanumericId(folderId, 'folderId', 50)
|
||||
if (!folderIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid folderId`, { error: folderIdValidation.error })
|
||||
return NextResponse.json({ error: folderIdValidation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const qParts: string[] = ['trashed = false']
|
||||
if (folderId) {
|
||||
qParts.push(`'${escapeForDriveQuery(folderId)}' in parents`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -50,6 +51,29 @@ export async function POST(request: NextRequest) {
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0)
|
||||
|
||||
for (const labelId of labelIds) {
|
||||
const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255)
|
||||
if (!labelIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: labelIdValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const messageIdValidation = validateAlphanumericId(validatedData.messageId, 'messageId', 255)
|
||||
if (!messageIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid message ID: ${messageIdValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: messageIdValidation.error },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const gmailResponse = await fetch(
|
||||
`${GMAIL_API_BASE}/messages/${validatedData.messageId}/modify`,
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { account } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -38,6 +39,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255)
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`)
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
let credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -53,6 +54,29 @@ export async function POST(request: NextRequest) {
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0)
|
||||
|
||||
for (const labelId of labelIds) {
|
||||
const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255)
|
||||
if (!labelIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: labelIdValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const messageIdValidation = validateAlphanumericId(validatedData.messageId, 'messageId', 255)
|
||||
if (!messageIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid message ID: ${messageIdValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: messageIdValidation.error },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const gmailResponse = await fetch(
|
||||
`${GMAIL_API_BASE}/messages/${validatedData.messageId}/modify`,
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateUUID } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -25,7 +26,6 @@ export async function GET(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Google Calendar calendars request received`)
|
||||
|
||||
try {
|
||||
// Get the credential ID from the query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
@@ -34,12 +34,25 @@ export async function GET(request: NextRequest) {
|
||||
logger.warn(`[${requestId}] Missing credentialId parameter`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialValidation = validateUUID(credentialId, 'credentialId')
|
||||
if (!credentialValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credentialId format`, { credentialId })
|
||||
return NextResponse.json({ error: credentialValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
const workflowValidation = validateUUID(workflowId, 'workflowId')
|
||||
if (!workflowValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid workflowId format`, { workflowId })
|
||||
return NextResponse.json({ error: workflowValidation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
@@ -50,7 +63,6 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch calendars from Google Calendar API
|
||||
logger.info(`[${requestId}] Fetching calendars from Google Calendar API`)
|
||||
const calendarResponse = await fetch(
|
||||
'https://www.googleapis.com/calendar/v3/users/me/calendarList',
|
||||
@@ -81,7 +93,6 @@ export async function GET(request: NextRequest) {
|
||||
const data = await calendarResponse.json()
|
||||
const calendars: CalendarListItem[] = data.items || []
|
||||
|
||||
// Sort calendars with primary first, then alphabetically
|
||||
calendars.sort((a, b) => {
|
||||
if (a.primary && !b.primary) return -1
|
||||
if (!a.primary && b.primary) return 1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -23,6 +24,12 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Team ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const teamIdValidation = validateMicrosoftGraphId(teamId, 'Team ID')
|
||||
if (!teamIdValidation.isValid) {
|
||||
logger.warn('Invalid team ID provided', { teamId, error: teamIdValidation.error })
|
||||
return NextResponse.json({ error: teamIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
@@ -70,7 +77,6 @@ export async function POST(request: Request) {
|
||||
endpoint: `https://graph.microsoft.com/v1.0/teams/${teamId}/channels`,
|
||||
})
|
||||
|
||||
// Check for auth errors specifically
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -93,7 +99,6 @@ export async function POST(request: Request) {
|
||||
} catch (innerError) {
|
||||
logger.error('Error during API requests:', innerError)
|
||||
|
||||
// Check if it's an authentication error
|
||||
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
|
||||
if (
|
||||
errorMessage.includes('auth') ||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -7,21 +8,35 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('TeamsChatsAPI')
|
||||
|
||||
// Helper function to get chat members and create a meaningful name
|
||||
/**
|
||||
* Helper function to get chat members and create a meaningful name
|
||||
*
|
||||
* @param chatId - Microsoft Teams chat ID to get display name for
|
||||
* @param accessToken - Access token for Microsoft Graph API
|
||||
* @param chatTopic - Optional existing chat topic
|
||||
* @returns A meaningful display name for the chat
|
||||
*/
|
||||
const getChatDisplayName = async (
|
||||
chatId: string,
|
||||
accessToken: string,
|
||||
chatTopic?: string
|
||||
): Promise<string> => {
|
||||
try {
|
||||
// If the chat already has a topic, use it
|
||||
const chatIdValidation = validateMicrosoftGraphId(chatId, 'chatId')
|
||||
if (!chatIdValidation.isValid) {
|
||||
logger.warn('Invalid chat ID in getChatDisplayName', {
|
||||
error: chatIdValidation.error,
|
||||
chatId: chatId.substring(0, 50),
|
||||
})
|
||||
return `Chat ${chatId.substring(0, 8)}...`
|
||||
}
|
||||
|
||||
if (chatTopic?.trim() && chatTopic !== 'null') {
|
||||
return chatTopic
|
||||
}
|
||||
|
||||
// Fetch chat members to create a meaningful name
|
||||
const membersResponse = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/chats/${chatId}/members`,
|
||||
`https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/members`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -35,27 +50,25 @@ const getChatDisplayName = async (
|
||||
const membersData = await membersResponse.json()
|
||||
const members = membersData.value || []
|
||||
|
||||
// Filter out the current user and get display names
|
||||
const memberNames = members
|
||||
.filter((member: any) => member.displayName && member.displayName !== 'Unknown')
|
||||
.map((member: any) => member.displayName)
|
||||
.slice(0, 3) // Limit to first 3 names to avoid very long names
|
||||
.slice(0, 3)
|
||||
|
||||
if (memberNames.length > 0) {
|
||||
if (memberNames.length === 1) {
|
||||
return memberNames[0] // 1:1 chat
|
||||
return memberNames[0]
|
||||
}
|
||||
if (memberNames.length === 2) {
|
||||
return memberNames.join(' & ') // 2-person group
|
||||
return memberNames.join(' & ')
|
||||
}
|
||||
return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more` // Larger group
|
||||
return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more`
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to get a better name from recent messages
|
||||
try {
|
||||
const messagesResponse = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/chats/${chatId}/messages?$top=10&$orderby=createdDateTime desc`,
|
||||
`https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?$top=10&$orderby=createdDateTime desc`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -69,14 +82,12 @@ const getChatDisplayName = async (
|
||||
const messagesData = await messagesResponse.json()
|
||||
const messages = messagesData.value || []
|
||||
|
||||
// Look for chat rename events
|
||||
for (const message of messages) {
|
||||
if (message.eventDetail?.chatDisplayName) {
|
||||
return message.eventDetail.chatDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique sender names from recent messages as last resort
|
||||
const senderNames = [
|
||||
...new Set(
|
||||
messages
|
||||
@@ -103,7 +114,6 @@ const getChatDisplayName = async (
|
||||
)
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return `Chat ${chatId.split(':')[0] || chatId.substring(0, 8)}...`
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
@@ -146,7 +156,6 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Now try to fetch the chats
|
||||
const response = await fetch('https://graph.microsoft.com/v1.0/me/chats', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -163,7 +172,6 @@ export async function POST(request: Request) {
|
||||
endpoint: 'https://graph.microsoft.com/v1.0/me/chats',
|
||||
})
|
||||
|
||||
// Check for auth errors specifically
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -179,7 +187,6 @@ export async function POST(request: Request) {
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Process chats with enhanced display names
|
||||
const chats = await Promise.all(
|
||||
data.value.map(async (chat: any) => ({
|
||||
id: chat.id,
|
||||
@@ -193,7 +200,6 @@ export async function POST(request: Request) {
|
||||
} catch (innerError) {
|
||||
logger.error('Error during API requests:', innerError)
|
||||
|
||||
// Check if it's an authentication error
|
||||
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
|
||||
if (
|
||||
errorMessage.includes('auth') ||
|
||||
|
||||
@@ -30,23 +30,41 @@ export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively checks an object for dangerous MongoDB operators
|
||||
* @param obj - The object to check
|
||||
* @param dangerousOperators - Array of operator names to block
|
||||
* @returns true if a dangerous operator is found
|
||||
*/
|
||||
function containsDangerousOperator(obj: unknown, dangerousOperators: string[]): boolean {
|
||||
if (typeof obj !== 'object' || obj === null) return false
|
||||
|
||||
for (const key of Object.keys(obj as Record<string, unknown>)) {
|
||||
if (dangerousOperators.includes(key)) return true
|
||||
if (
|
||||
typeof (obj as Record<string, unknown>)[key] === 'object' &&
|
||||
containsDangerousOperator((obj as Record<string, unknown>)[key], dangerousOperators)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function validateFilter(filter: string): { isValid: boolean; error?: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(filter)
|
||||
|
||||
const dangerousOperators = ['$where', '$regex', '$expr', '$function', '$accumulator', '$let']
|
||||
const dangerousOperators = [
|
||||
'$where', // Executes arbitrary JavaScript
|
||||
'$regex', // Can cause ReDoS attacks
|
||||
'$expr', // Expression evaluation
|
||||
'$function', // Custom JavaScript functions
|
||||
'$accumulator', // Custom JavaScript accumulators
|
||||
'$let', // Variable definitions that could be exploited
|
||||
]
|
||||
|
||||
const checkForDangerousOps = (obj: any): boolean => {
|
||||
if (typeof obj !== 'object' || obj === null) return false
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (dangerousOperators.includes(key)) return true
|
||||
if (typeof obj[key] === 'object' && checkForDangerousOps(obj[key])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (checkForDangerousOps(parsed)) {
|
||||
if (containsDangerousOperator(parsed, dangerousOperators)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Filter contains potentially dangerous operators',
|
||||
@@ -74,29 +92,19 @@ export function validatePipeline(pipeline: string): { isValid: boolean; error?:
|
||||
}
|
||||
|
||||
const dangerousOperators = [
|
||||
'$where',
|
||||
'$function',
|
||||
'$accumulator',
|
||||
'$let',
|
||||
'$merge',
|
||||
'$out',
|
||||
'$currentOp',
|
||||
'$listSessions',
|
||||
'$listLocalSessions',
|
||||
'$where', // Executes arbitrary JavaScript
|
||||
'$function', // Custom JavaScript functions
|
||||
'$accumulator', // Custom JavaScript accumulators
|
||||
'$let', // Variable definitions that could be exploited
|
||||
'$merge', // Writes to external collections
|
||||
'$out', // Writes to external collections
|
||||
'$currentOp', // Exposes system operation info
|
||||
'$listSessions', // Exposes session info
|
||||
'$listLocalSessions', // Exposes local session info
|
||||
]
|
||||
|
||||
const checkPipelineStage = (stage: any): boolean => {
|
||||
if (typeof stage !== 'object' || stage === null) return false
|
||||
|
||||
for (const key of Object.keys(stage)) {
|
||||
if (dangerousOperators.includes(key)) return true
|
||||
if (typeof stage[key] === 'object' && checkPipelineStage(stage[key])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for (const stage of parsed) {
|
||||
if (checkPipelineStage(stage)) {
|
||||
if (containsDangerousOperator(stage, dangerousOperators)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Pipeline contains potentially dangerous operators',
|
||||
|
||||
@@ -98,15 +98,45 @@ export function buildDeleteQuery(table: string, where: string) {
|
||||
return { query, values: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a WHERE clause to prevent SQL injection attacks
|
||||
* @param where - The WHERE clause string to validate
|
||||
* @throws {Error} If the WHERE clause contains potentially dangerous patterns
|
||||
*/
|
||||
function validateWhereClause(where: string): void {
|
||||
const dangerousPatterns = [
|
||||
// DDL and DML injection via stacked queries
|
||||
/;\s*(drop|delete|insert|update|create|alter|grant|revoke)/i,
|
||||
/union\s+select/i,
|
||||
// Union-based injection
|
||||
/union\s+(all\s+)?select/i,
|
||||
// File operations
|
||||
/into\s+outfile/i,
|
||||
/load_file/i,
|
||||
/into\s+dumpfile/i,
|
||||
/load_file\s*\(/i,
|
||||
// Comment-based injection (can truncate query)
|
||||
/--/,
|
||||
/\/\*/,
|
||||
/\*\//,
|
||||
// Tautologies - always true/false conditions using backreferences
|
||||
// Matches OR 'x'='x' or OR x=x (same value both sides) but NOT OR col='value'
|
||||
/\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
|
||||
/\bor\s+true\b/i,
|
||||
/\bor\s+false\b/i,
|
||||
// AND tautologies (less common but still used in attacks)
|
||||
/\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
|
||||
/\band\s+true\b/i,
|
||||
/\band\s+false\b/i,
|
||||
// Time-based blind injection
|
||||
/\bsleep\s*\(/i,
|
||||
/\bbenchmark\s*\(/i,
|
||||
/\bwaitfor\s+delay/i,
|
||||
// Stacked queries (any statement after semicolon)
|
||||
/;\s*\w+/,
|
||||
// Information schema queries
|
||||
/information_schema/i,
|
||||
/mysql\./i,
|
||||
// System functions and procedures
|
||||
/\bxp_cmdshell/i,
|
||||
]
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -36,6 +37,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId')
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Fetching credential`, { credentialId })
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -33,6 +34,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId')
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
@@ -48,7 +55,6 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Build URL for OneDrive folders
|
||||
let url = `https://graph.microsoft.com/v1.0/me/drive/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=50`
|
||||
|
||||
if (query) {
|
||||
@@ -71,7 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const data = await response.json()
|
||||
const folders = (data.value || [])
|
||||
.filter((item: MicrosoftGraphDriveItem) => item.folder) // Only folders
|
||||
.filter((item: MicrosoftGraphDriveItem) => item.folder)
|
||||
.map((folder: MicrosoftGraphDriveItem) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -28,9 +29,9 @@ const ExcelValuesSchema = z.union([
|
||||
const OneDriveUploadSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
fileName: z.string().min(1, 'File name is required'),
|
||||
file: z.any().optional(), // UserFile object (optional for blank Excel creation)
|
||||
file: z.any().optional(),
|
||||
folderId: z.string().optional().nullable(),
|
||||
mimeType: z.string().nullish(), // Accept string, null, or undefined
|
||||
mimeType: z.string().nullish(),
|
||||
values: ExcelValuesSchema.optional().nullable(),
|
||||
})
|
||||
|
||||
@@ -62,24 +63,19 @@ export async function POST(request: NextRequest) {
|
||||
let fileBuffer: Buffer
|
||||
let mimeType: string
|
||||
|
||||
// Check if we're creating a blank Excel file
|
||||
const isExcelCreation =
|
||||
validatedData.mimeType ===
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' && !validatedData.file
|
||||
|
||||
if (isExcelCreation) {
|
||||
// Create a blank Excel workbook
|
||||
|
||||
const workbook = XLSX.utils.book_new()
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([[]])
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
|
||||
|
||||
// Generate XLSX file as buffer
|
||||
const xlsxBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })
|
||||
fileBuffer = Buffer.from(xlsxBuffer)
|
||||
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
} else {
|
||||
// Handle regular file upload
|
||||
const rawFile = validatedData.file
|
||||
|
||||
if (!rawFile) {
|
||||
@@ -108,7 +104,6 @@ export async function POST(request: NextRequest) {
|
||||
fileToProcess = rawFile
|
||||
}
|
||||
|
||||
// Convert to UserFile format
|
||||
let userFile
|
||||
try {
|
||||
userFile = processSingleFileToUserFile(fileToProcess, requestId, logger)
|
||||
@@ -138,7 +133,7 @@ export async function POST(request: NextRequest) {
|
||||
mimeType = userFile.type || 'application/octet-stream'
|
||||
}
|
||||
|
||||
const maxSize = 250 * 1024 * 1024 // 250MB
|
||||
const maxSize = 250 * 1024 * 1024
|
||||
if (fileBuffer.length > maxSize) {
|
||||
const sizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2)
|
||||
logger.warn(`[${requestId}] File too large: ${sizeMB}MB`)
|
||||
@@ -151,7 +146,6 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure file name has an appropriate extension
|
||||
let fileName = validatedData.fileName
|
||||
const hasExtension = fileName.includes('.') && fileName.lastIndexOf('.') > 0
|
||||
|
||||
@@ -169,6 +163,17 @@ export async function POST(request: NextRequest) {
|
||||
const folderId = validatedData.folderId?.trim()
|
||||
|
||||
if (folderId && folderId !== '') {
|
||||
const folderIdValidation = validateMicrosoftGraphId(folderId, 'folderId')
|
||||
if (!folderIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid folder ID`, { error: folderIdValidation.error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: folderIdValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}:/content`
|
||||
} else {
|
||||
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content`
|
||||
@@ -197,14 +202,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const fileData = await uploadResponse.json()
|
||||
|
||||
// If this is an Excel creation and values were provided, write them using the Excel API
|
||||
let excelWriteResult: any | undefined
|
||||
const shouldWriteExcelContent =
|
||||
isExcelCreation && Array.isArray(excelValues) && excelValues.length > 0
|
||||
|
||||
if (shouldWriteExcelContent) {
|
||||
try {
|
||||
// Create a workbook session to ensure reliability and persistence of changes
|
||||
let workbookSessionId: string | undefined
|
||||
const sessionResp = await fetch(
|
||||
`${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/createSession`,
|
||||
@@ -223,7 +226,6 @@ export async function POST(request: NextRequest) {
|
||||
workbookSessionId = sessionData?.id
|
||||
}
|
||||
|
||||
// Determine the first worksheet name
|
||||
let sheetName = 'Sheet1'
|
||||
try {
|
||||
const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
|
||||
@@ -272,7 +274,6 @@ export async function POST(request: NextRequest) {
|
||||
return paddedRow
|
||||
})
|
||||
|
||||
// Compute concise end range from A1 and matrix size (no network round-trip)
|
||||
const indexToColLetters = (index: number): string => {
|
||||
let n = index
|
||||
let s = ''
|
||||
@@ -313,7 +314,6 @@ export async function POST(request: NextRequest) {
|
||||
statusText: excelWriteResponse?.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
// Do not fail the entire request; return upload success with write error details
|
||||
excelWriteResult = {
|
||||
success: false,
|
||||
error: `Excel write failed: ${excelWriteResponse?.statusText || 'unknown'}`,
|
||||
@@ -321,7 +321,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
} else {
|
||||
const writeData = await excelWriteResponse.json()
|
||||
// The Range PATCH returns a Range object; log address and values length
|
||||
const addr = writeData.address || writeData.addressLocal
|
||||
const v = writeData.values || []
|
||||
excelWriteResult = {
|
||||
@@ -333,7 +332,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to close the workbook session if one was created
|
||||
if (workbookSessionId) {
|
||||
try {
|
||||
const closeResp = await fetch(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -29,8 +30,13 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId')
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn('Invalid credentialId format', { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure we have a session for permission checks
|
||||
const sessionUserId = session?.user?.id || ''
|
||||
|
||||
if (!sessionUserId) {
|
||||
@@ -38,7 +44,6 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Resolve the credential owner to support collaborator-owned credentials
|
||||
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!creds.length) {
|
||||
logger.warn('Credential not found', { credentialId })
|
||||
@@ -79,7 +84,6 @@ export async function GET(request: Request) {
|
||||
endpoint: 'https://graph.microsoft.com/v1.0/me/mailFolders',
|
||||
})
|
||||
|
||||
// Check for auth errors specifically
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -96,7 +100,6 @@ export async function GET(request: Request) {
|
||||
const data = await response.json()
|
||||
const folders = data.value || []
|
||||
|
||||
// Transform folders to match the expected format
|
||||
const transformedFolders = folders.map((folder: OutlookFolder) => ({
|
||||
id: folder.id,
|
||||
name: folder.displayName,
|
||||
@@ -111,7 +114,6 @@ export async function GET(request: Request) {
|
||||
} catch (innerError) {
|
||||
logger.error('Error during API requests:', innerError)
|
||||
|
||||
// Check if it's an authentication error
|
||||
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
|
||||
if (
|
||||
errorMessage.includes('auth') ||
|
||||
|
||||
@@ -64,15 +64,46 @@ export function sanitizeIdentifier(identifier: string): string {
|
||||
return sanitizeSingleIdentifier(identifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a WHERE clause to prevent SQL injection attacks
|
||||
* @param where - The WHERE clause string to validate
|
||||
* @throws {Error} If the WHERE clause contains potentially dangerous patterns
|
||||
*/
|
||||
function validateWhereClause(where: string): void {
|
||||
const dangerousPatterns = [
|
||||
// DDL and DML injection via stacked queries
|
||||
/;\s*(drop|delete|insert|update|create|alter|grant|revoke)/i,
|
||||
/union\s+select/i,
|
||||
// Union-based injection
|
||||
/union\s+(all\s+)?select/i,
|
||||
// File operations
|
||||
/into\s+outfile/i,
|
||||
/load_file/i,
|
||||
/load_file\s*\(/i,
|
||||
/pg_read_file/i,
|
||||
// Comment-based injection (can truncate query)
|
||||
/--/,
|
||||
/\/\*/,
|
||||
/\*\//,
|
||||
// Tautologies - always true/false conditions using backreferences
|
||||
// Matches OR 'x'='x' or OR x=x (same value both sides) but NOT OR col='value'
|
||||
/\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
|
||||
/\bor\s+true\b/i,
|
||||
/\bor\s+false\b/i,
|
||||
// AND tautologies (less common but still used in attacks)
|
||||
/\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
|
||||
/\band\s+true\b/i,
|
||||
/\band\s+false\b/i,
|
||||
// Time-based blind injection
|
||||
/\bsleep\s*\(/i,
|
||||
/\bwaitfor\s+delay/i,
|
||||
/\bpg_sleep\s*\(/i,
|
||||
/\bbenchmark\s*\(/i,
|
||||
// Stacked queries (any statement after semicolon)
|
||||
/;\s*\w+/,
|
||||
// Information schema / system catalog queries
|
||||
/information_schema/i,
|
||||
/pg_catalog/i,
|
||||
// System functions and procedures
|
||||
/\bxp_cmdshell/i,
|
||||
]
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { SharepointSite } from '@/tools/sharepoint/types'
|
||||
@@ -32,6 +33,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255)
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
@@ -47,8 +54,6 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Build URL for SharePoint sites
|
||||
// Use search=* to get all sites the user has access to, or search for specific query
|
||||
const searchQuery = query || '*'
|
||||
const url = `https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchQuery)}&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=50`
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -93,7 +94,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to channels the bot can access and format the response
|
||||
const channels = (data.channels || [])
|
||||
.filter((channel: SlackChannel) => {
|
||||
const canAccess = !channel.is_archived && (channel.is_member || !channel.is_private)
|
||||
@@ -106,6 +106,28 @@ export async function POST(request: Request) {
|
||||
|
||||
return canAccess
|
||||
})
|
||||
.filter((channel: SlackChannel) => {
|
||||
const validation = validateAlphanumericId(channel.id, 'channelId', 50)
|
||||
|
||||
if (!validation.isValid) {
|
||||
logger.warn('Invalid channel ID received from Slack API', {
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
error: validation.error,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!/^[CDG][A-Z0-9]+$/i.test(channel.id)) {
|
||||
logger.warn('Channel ID does not match Slack format', {
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.map((channel: SlackChannel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
|
||||
@@ -14,7 +14,12 @@ const SlackReadMessagesSchema = z
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
channel: z.string().optional().nullable(),
|
||||
userId: z.string().optional().nullable(),
|
||||
limit: z.number().optional().nullable(),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(15, 'Limit cannot exceed 15')
|
||||
.optional()
|
||||
.nullable(),
|
||||
oldest: z.string().optional().nullable(),
|
||||
latest: z.string().optional().nullable(),
|
||||
})
|
||||
@@ -62,8 +67,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const url = new URL('https://slack.com/api/conversations.history')
|
||||
url.searchParams.append('channel', channel!)
|
||||
const limit = validatedData.limit ? Number(validatedData.limit) : 10
|
||||
url.searchParams.append('limit', String(Math.min(limit, 15)))
|
||||
const limit = validatedData.limit ?? 10
|
||||
url.searchParams.append('limit', String(limit))
|
||||
|
||||
if (validatedData.oldest) {
|
||||
url.searchParams.append('oldest', validatedData.oldest)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -20,13 +21,21 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const requestId = generateRequestId()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
const { credential, workflowId, userId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (userId !== undefined && userId !== null) {
|
||||
const validation = validateAlphanumericId(userId, 'userId', 100)
|
||||
if (!validation.isValid) {
|
||||
logger.warn('Invalid Slack user ID', { userId, error: validation.error })
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string
|
||||
const isBotToken = credential.startsWith('xoxb-')
|
||||
|
||||
@@ -63,6 +72,17 @@ export async function POST(request: Request) {
|
||||
logger.info('Using OAuth token for Slack API')
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const userData = await fetchSlackUser(accessToken, userId)
|
||||
const user = {
|
||||
id: userData.user.id,
|
||||
name: userData.user.name,
|
||||
real_name: userData.user.real_name || userData.user.name,
|
||||
}
|
||||
logger.info(`Successfully fetched Slack user: ${userId}`)
|
||||
return NextResponse.json({ user })
|
||||
}
|
||||
|
||||
const data = await fetchSlackUsers(accessToken)
|
||||
|
||||
const users = (data.members || [])
|
||||
@@ -87,6 +107,31 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSlackUser(accessToken: string, userId: string) {
|
||||
const url = new URL('https://slack.com/api/users.info')
|
||||
url.searchParams.append('user', userId)
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch user')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchSlackUsers(accessToken: string) {
|
||||
const url = new URL('https://slack.com/api/users.list')
|
||||
url.searchParams.append('limit', '200')
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { type Attributes, Client, type ConnectConfig } from 'ssh2'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('SSHUtils')
|
||||
|
||||
// File type constants from POSIX
|
||||
const S_IFMT = 0o170000 // bit mask for the file type bit field
|
||||
@@ -32,7 +35,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
const host = config.host
|
||||
const port = config.port
|
||||
|
||||
// Connection refused - server not running or wrong port
|
||||
if (errorMessage.includes('econnrefused') || errorMessage.includes('connection refused')) {
|
||||
return new Error(
|
||||
`Connection refused to ${host}:${port}. ` +
|
||||
@@ -42,7 +44,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Connection reset - server closed connection unexpectedly
|
||||
if (errorMessage.includes('econnreset') || errorMessage.includes('connection reset')) {
|
||||
return new Error(
|
||||
`Connection reset by ${host}:${port}. ` +
|
||||
@@ -53,7 +54,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Timeout - server unreachable or slow
|
||||
if (errorMessage.includes('etimedout') || errorMessage.includes('timeout')) {
|
||||
return new Error(
|
||||
`Connection timed out to ${host}:${port}. ` +
|
||||
@@ -63,7 +63,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// DNS/hostname resolution
|
||||
if (errorMessage.includes('enotfound') || errorMessage.includes('getaddrinfo')) {
|
||||
return new Error(
|
||||
`Could not resolve hostname "${host}". ` +
|
||||
@@ -71,7 +70,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Authentication failure
|
||||
if (errorMessage.includes('authentication') || errorMessage.includes('auth')) {
|
||||
return new Error(
|
||||
`Authentication failed for user on ${host}:${port}. ` +
|
||||
@@ -81,7 +79,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Private key format issues
|
||||
if (
|
||||
errorMessage.includes('key') &&
|
||||
(errorMessage.includes('parse') || errorMessage.includes('invalid'))
|
||||
@@ -93,7 +90,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Host key verification (first connection)
|
||||
if (errorMessage.includes('host key') || errorMessage.includes('hostkey')) {
|
||||
return new Error(
|
||||
`Host key verification issue for ${host}. ` +
|
||||
@@ -101,7 +97,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Return original error with context if no specific match
|
||||
return new Error(`SSH connection to ${host}:${port} failed: ${err.message}`)
|
||||
}
|
||||
|
||||
@@ -205,19 +200,119 @@ export function executeSSHCommand(client: Client, command: string): Promise<SSHC
|
||||
|
||||
/**
|
||||
* Sanitize command input to prevent command injection
|
||||
*
|
||||
* Removes null bytes and other dangerous control characters while preserving
|
||||
* legitimate shell syntax. Logs warnings for potentially dangerous patterns.
|
||||
*
|
||||
* Note: This function does not block complex shell commands (pipes, redirects, etc.)
|
||||
* as users legitimately need these features for remote command execution.
|
||||
*
|
||||
* @param command - The command to sanitize
|
||||
* @returns The sanitized command string
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const safeCommand = sanitizeCommand(userInput)
|
||||
* // Use safeCommand for SSH execution
|
||||
* ```
|
||||
*/
|
||||
export function sanitizeCommand(command: string): string {
|
||||
return command.trim()
|
||||
let sanitized = command.replace(/\0/g, '')
|
||||
|
||||
sanitized = sanitized.replace(/[\x0B\x0C]/g, '')
|
||||
|
||||
sanitized = sanitized.trim()
|
||||
|
||||
const dangerousPatterns = [
|
||||
{ pattern: /\$\(.*\)/, name: 'command substitution $()' },
|
||||
{ pattern: /`.*`/, name: 'backtick command substitution' },
|
||||
{ pattern: /;\s*rm\s+-rf/i, name: 'destructive rm -rf command' },
|
||||
{ pattern: /;\s*dd\s+/i, name: 'dd command (disk operations)' },
|
||||
{ pattern: /mkfs/i, name: 'filesystem formatting command' },
|
||||
{ pattern: />\s*\/dev\/sd[a-z]/i, name: 'direct disk write' },
|
||||
]
|
||||
|
||||
for (const { pattern, name } of dangerousPatterns) {
|
||||
if (pattern.test(sanitized)) {
|
||||
logger.warn(`Command contains ${name}`, {
|
||||
command: sanitized.substring(0, 100) + (sanitized.length > 100 ? '...' : ''),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize file path - removes null bytes and trims whitespace
|
||||
* Sanitize and validate file path to prevent path traversal attacks
|
||||
*
|
||||
* This function validates that a file path does not contain:
|
||||
* - Null bytes
|
||||
* - Path traversal sequences (.. or ../)
|
||||
* - URL-encoded path traversal attempts
|
||||
*
|
||||
* @param path - The file path to sanitize and validate
|
||||
* @returns The sanitized path if valid
|
||||
* @throws Error if path traversal is detected
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* const safePath = sanitizePath(userInput)
|
||||
* // Use safePath safely
|
||||
* } catch (error) {
|
||||
* // Handle invalid path
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function sanitizePath(path: string): string {
|
||||
let sanitized = path.replace(/\0/g, '')
|
||||
|
||||
sanitized = sanitized.trim()
|
||||
|
||||
if (sanitized.includes('%00')) {
|
||||
logger.warn('Path contains URL-encoded null bytes', {
|
||||
path: path.substring(0, 100),
|
||||
})
|
||||
throw new Error('Path contains invalid characters')
|
||||
}
|
||||
|
||||
const pathTraversalPatterns = [
|
||||
'../', // Standard Unix path traversal
|
||||
'..\\', // Windows path traversal
|
||||
'/../', // Mid-path traversal
|
||||
'\\..\\', // Windows mid-path traversal
|
||||
'%2e%2e%2f', // Fully encoded ../
|
||||
'%2e%2e/', // Partially encoded ../
|
||||
'%2e%2e%5c', // Fully encoded ..\
|
||||
'%2e%2e\\', // Partially encoded ..\
|
||||
'..%2f', // .. with encoded /
|
||||
'..%5c', // .. with encoded \
|
||||
'%252e%252e', // Double URL encoded ..
|
||||
'..%252f', // .. with double encoded /
|
||||
'..%255c', // .. with double encoded \
|
||||
]
|
||||
|
||||
const lowerPath = sanitized.toLowerCase()
|
||||
for (const pattern of pathTraversalPatterns) {
|
||||
if (lowerPath.includes(pattern.toLowerCase())) {
|
||||
logger.warn('Path traversal attempt detected', {
|
||||
pattern,
|
||||
path: path.substring(0, 100),
|
||||
})
|
||||
throw new Error('Path contains invalid path traversal sequences')
|
||||
}
|
||||
}
|
||||
|
||||
const segments = sanitized.split(/[/\\]/)
|
||||
for (const segment of segments) {
|
||||
if (segment === '..') {
|
||||
logger.warn('Path traversal attempt detected (.. as path segment)', {
|
||||
path: path.substring(0, 100),
|
||||
})
|
||||
throw new Error('Path contains invalid path traversal sequences')
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
|
||||
|
||||
@@ -188,7 +189,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (variablesObject && Object.keys(variablesObject).length > 0) {
|
||||
const safeVarKeys = Object.keys(variablesObject).map((key) => {
|
||||
return key.toLowerCase().includes('password') ? `${key}: [REDACTED]` : key
|
||||
return isSensitiveKey(key) ? `${key}: ${REDACTED_MARKER}` : key
|
||||
})
|
||||
logger.info('Variables available for task', { variables: safeVarKeys })
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -11,7 +12,6 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WealthboxItemsAPI')
|
||||
|
||||
// Interface for transformed Wealthbox items
|
||||
interface WealthboxItem {
|
||||
id: string
|
||||
name: string
|
||||
@@ -45,12 +45,23 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (type !== 'contact') {
|
||||
const credentialIdValidation = validatePathSegment(credentialId, {
|
||||
paramName: 'credentialId',
|
||||
maxLength: 100,
|
||||
allowHyphens: true,
|
||||
allowUnderscores: true,
|
||||
allowDots: false,
|
||||
})
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`)
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const ALLOWED_TYPES = ['contact'] as const
|
||||
const typeValidation = validateEnum(type, ALLOWED_TYPES, 'type')
|
||||
if (!typeValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid item type: ${type}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid item type. Only contact is supported.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ error: typeValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -12,13 +13,21 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const requestId = generateRequestId()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
const { credential, workflowId, siteId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (siteId) {
|
||||
const siteIdValidation = validateAlphanumericId(siteId, 'siteId')
|
||||
if (!siteIdValidation.isValid) {
|
||||
logger.error('Invalid siteId', { error: siteIdValidation.error })
|
||||
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
@@ -46,7 +55,11 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.webflow.com/v2/sites', {
|
||||
const url = siteId
|
||||
? `https://api.webflow.com/v2/sites/${siteId}`
|
||||
: 'https://api.webflow.com/v2/sites'
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
accept: 'application/json',
|
||||
@@ -58,6 +71,7 @@ export async function POST(request: Request) {
|
||||
logger.error('Failed to fetch Webflow sites', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
siteId: siteId || 'all',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Webflow sites', details: errorData },
|
||||
@@ -66,7 +80,13 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const sites = data.sites || []
|
||||
|
||||
let sites: any[]
|
||||
if (siteId) {
|
||||
sites = [data]
|
||||
} else {
|
||||
sites = data.sites || []
|
||||
}
|
||||
|
||||
const formattedSites = sites.map((site: any) => ({
|
||||
id: site.id,
|
||||
|
||||
@@ -26,9 +26,9 @@ const SettingsSchema = z.object({
|
||||
showTrainingControls: z.boolean().optional(),
|
||||
superUserModeEnabled: z.boolean().optional(),
|
||||
errorNotificationsEnabled: z.boolean().optional(),
|
||||
snapToGridSize: z.number().min(0).max(50).optional(),
|
||||
})
|
||||
|
||||
// Default settings values
|
||||
const defaultSettings = {
|
||||
theme: 'system',
|
||||
autoConnect: true,
|
||||
@@ -38,6 +38,7 @@ const defaultSettings = {
|
||||
showTrainingControls: false,
|
||||
superUserModeEnabled: false,
|
||||
errorNotificationsEnabled: true,
|
||||
snapToGridSize: 0,
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
@@ -46,7 +47,6 @@ export async function GET() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
// Return default settings for unauthenticated users instead of 401 error
|
||||
if (!session?.user?.id) {
|
||||
logger.info(`[${requestId}] Returning default settings for unauthenticated user`)
|
||||
return NextResponse.json({ data: defaultSettings }, { status: 200 })
|
||||
@@ -72,13 +72,13 @@ export async function GET() {
|
||||
showTrainingControls: userSettings.showTrainingControls ?? false,
|
||||
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
|
||||
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
|
||||
snapToGridSize: userSettings.snapToGridSize ?? 0,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Settings fetch error`, error)
|
||||
// Return default settings on error instead of error response
|
||||
return NextResponse.json({ data: defaultSettings }, { status: 200 })
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,6 @@ export async function PATCH(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
// Return success for unauthenticated users instead of error
|
||||
if (!session?.user?.id) {
|
||||
logger.info(
|
||||
`[${requestId}] Settings update attempted by unauthenticated user - acknowledged without saving`
|
||||
@@ -103,7 +102,6 @@ export async function PATCH(request: Request) {
|
||||
try {
|
||||
const validatedData = SettingsSchema.parse(body)
|
||||
|
||||
// Store the settings
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
@@ -135,7 +133,6 @@ export async function PATCH(request: Request) {
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Settings update error`, error)
|
||||
// Return success on error instead of error response
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify token and get email type
|
||||
const tokenVerification = verifyUnsubscribeToken(email, token)
|
||||
if (!tokenVerification.valid) {
|
||||
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
|
||||
@@ -42,7 +41,6 @@ export async function GET(req: NextRequest) {
|
||||
const emailType = tokenVerification.emailType as EmailType
|
||||
const isTransactional = isTransactionalEmail(emailType)
|
||||
|
||||
// Get current preferences
|
||||
const preferences = await getEmailPreferences(email)
|
||||
|
||||
logger.info(
|
||||
@@ -67,22 +65,42 @@ export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const result = unsubscribeSchema.safeParse(body)
|
||||
const { searchParams } = new URL(req.url)
|
||||
const contentType = req.headers.get('content-type') || ''
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
|
||||
errors: result.error.format(),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: result.error.format() },
|
||||
{ status: 400 }
|
||||
)
|
||||
let email: string
|
||||
let token: string
|
||||
let type: 'all' | 'marketing' | 'updates' | 'notifications' = 'all'
|
||||
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
email = searchParams.get('email') || ''
|
||||
token = searchParams.get('token') || ''
|
||||
|
||||
if (!email || !token) {
|
||||
logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`)
|
||||
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`)
|
||||
} else {
|
||||
const body = await req.json()
|
||||
const result = unsubscribeSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
|
||||
errors: result.error.format(),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: result.error.format() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
email = result.data.email
|
||||
token = result.data.token
|
||||
type = result.data.type
|
||||
}
|
||||
|
||||
const { email, token, type } = result.data
|
||||
|
||||
// Verify token and get email type
|
||||
const tokenVerification = verifyUnsubscribeToken(email, token)
|
||||
if (!tokenVerification.valid) {
|
||||
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
|
||||
@@ -92,7 +110,6 @@ export async function POST(req: NextRequest) {
|
||||
const emailType = tokenVerification.emailType as EmailType
|
||||
const isTransactional = isTransactionalEmail(emailType)
|
||||
|
||||
// Prevent unsubscribing from transactional emails
|
||||
if (isTransactional) {
|
||||
logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
|
||||
return NextResponse.json(
|
||||
@@ -106,7 +123,6 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Process unsubscribe based on type
|
||||
let success = false
|
||||
switch (type) {
|
||||
case 'all':
|
||||
@@ -130,7 +146,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
|
||||
|
||||
// Return 200 for one-click unsubscribe compliance
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
|
||||
105
apps/sim/app/api/users/me/usage-logs/route.ts
Normal file
105
apps/sim/app/api/users/me/usage-logs/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UsageLogsAPI')
|
||||
|
||||
const QuerySchema = z.object({
|
||||
source: z.enum(['workflow', 'wand', 'copilot']).optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
period: z.enum(['1d', '7d', '30d', 'all']).optional().default('30d'),
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||
cursor: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/users/me/usage-logs
|
||||
* Get usage logs for the authenticated user
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = auth.userId
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const queryParams = {
|
||||
source: searchParams.get('source') || undefined,
|
||||
workspaceId: searchParams.get('workspaceId') || undefined,
|
||||
period: searchParams.get('period') || '30d',
|
||||
limit: searchParams.get('limit') || '50',
|
||||
cursor: searchParams.get('cursor') || undefined,
|
||||
}
|
||||
|
||||
const validation = QuerySchema.safeParse(queryParams)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid query parameters',
|
||||
details: validation.error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { source, workspaceId, period, limit, cursor } = validation.data
|
||||
|
||||
let startDate: Date | undefined
|
||||
const endDate = new Date()
|
||||
|
||||
if (period !== 'all') {
|
||||
startDate = new Date()
|
||||
switch (period) {
|
||||
case '1d':
|
||||
startDate.setDate(startDate.getDate() - 1)
|
||||
break
|
||||
case '7d':
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
break
|
||||
case '30d':
|
||||
startDate.setDate(startDate.getDate() - 30)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getUserUsageLogs(userId, {
|
||||
source: source as UsageLogSource | undefined,
|
||||
workspaceId,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
cursor,
|
||||
})
|
||||
|
||||
logger.debug('Retrieved usage logs', {
|
||||
userId,
|
||||
source,
|
||||
period,
|
||||
logCount: result.logs.length,
|
||||
hasMore: result.pagination.hasMore,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage logs', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to retrieve usage logs',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,7 @@ export interface LogFilters {
|
||||
export function buildLogFilters(filters: LogFilters): SQL<unknown> {
|
||||
const conditions: SQL<unknown>[] = []
|
||||
|
||||
// Required: workspace and permissions check
|
||||
conditions.push(eq(workflow.workspaceId, filters.workspaceId))
|
||||
conditions.push(eq(workflowExecutionLogs.workspaceId, filters.workspaceId))
|
||||
|
||||
// Cursor-based pagination
|
||||
if (filters.cursor) {
|
||||
|
||||
@@ -105,7 +105,6 @@ export async function GET(request: NextRequest) {
|
||||
const conditions = buildLogFilters(filters)
|
||||
const orderBy = getOrderBy(params.order)
|
||||
|
||||
// Build and execute query
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
@@ -124,13 +123,7 @@ export async function GET(request: NextRequest) {
|
||||
workflowDescription: workflow.description,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(
|
||||
workflow,
|
||||
and(
|
||||
eq(workflowExecutionLogs.workflowId, workflow.id),
|
||||
eq(workflow.workspaceId, params.workspaceId)
|
||||
)
|
||||
)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
@@ -197,11 +190,8 @@ export async function GET(request: NextRequest) {
|
||||
return result
|
||||
})
|
||||
|
||||
// Get user's workflow execution limits and usage
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
// Create response with limits information
|
||||
// The rateLimit object from checkRateLimit is for THIS API endpoint's rate limits
|
||||
const response = createApiResponse(
|
||||
{
|
||||
data: formattedLogs,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { logModelUsage } from '@/lib/billing/core/usage-log'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
@@ -88,7 +89,7 @@ async function updateUserStatsForWand(
|
||||
|
||||
try {
|
||||
const [workflowRecord] = await db
|
||||
.select({ userId: workflow.userId })
|
||||
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
@@ -101,6 +102,7 @@ async function updateUserStatsForWand(
|
||||
}
|
||||
|
||||
const userId = workflowRecord.userId
|
||||
const workspaceId = workflowRecord.workspaceId
|
||||
const totalTokens = usage.total_tokens || 0
|
||||
const promptTokens = usage.prompt_tokens || 0
|
||||
const completionTokens = usage.completion_tokens || 0
|
||||
@@ -137,6 +139,17 @@ async function updateUserStatsForWand(
|
||||
costAdded: costToStore,
|
||||
})
|
||||
|
||||
await logModelUsage({
|
||||
userId,
|
||||
source: 'wand',
|
||||
model: modelName,
|
||||
inputTokens: promptTokens,
|
||||
outputTokens: completionTokens,
|
||||
cost: costToStore,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
workflowId,
|
||||
})
|
||||
|
||||
await checkAndBillOverageThreshold(userId)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to update user stats for wand usage`, error)
|
||||
|
||||
@@ -409,10 +409,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const actorUserId = preprocessResult.actorUserId!
|
||||
const workflow = preprocessResult.workflowRecord!
|
||||
|
||||
if (!workflow.workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`)
|
||||
return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 })
|
||||
}
|
||||
const workspaceId = workflow.workspaceId
|
||||
|
||||
logger.info(`[${requestId}] Preprocessing passed`, {
|
||||
workflowId,
|
||||
actorUserId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
if (isAsyncMode) {
|
||||
@@ -460,7 +466,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
)
|
||||
|
||||
const executionContext = {
|
||||
workspaceId: workflow.workspaceId || '',
|
||||
workspaceId,
|
||||
workflowId,
|
||||
executionId,
|
||||
}
|
||||
@@ -478,7 +484,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: actorUserId,
|
||||
workspaceId: workflow.workspaceId || '',
|
||||
workspaceId,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
@@ -507,7 +513,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId,
|
||||
executionId,
|
||||
workflowId,
|
||||
workspaceId: workflow.workspaceId ?? undefined,
|
||||
workspaceId,
|
||||
userId: actorUserId,
|
||||
sessionUserId: isClientSession ? userId : undefined,
|
||||
workflowUserId: workflow.userId,
|
||||
@@ -589,7 +595,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
userId: actorUserId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
workspaceId,
|
||||
isDeployed: workflow.isDeployed,
|
||||
variables: (workflow as any).variables,
|
||||
},
|
||||
@@ -775,7 +781,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId,
|
||||
executionId,
|
||||
workflowId,
|
||||
workspaceId: workflow.workspaceId ?? undefined,
|
||||
workspaceId,
|
||||
userId: actorUserId,
|
||||
sessionUserId: isClientSession ? userId : undefined,
|
||||
workflowUserId: workflow.userId,
|
||||
|
||||
@@ -70,7 +70,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const loggingSession = new LoggingSession(id, executionId, triggerType, requestId)
|
||||
|
||||
const userId = accessValidation.workflow.userId
|
||||
const workspaceId = accessValidation.workflow.workspaceId || ''
|
||||
const workspaceId = accessValidation.workflow.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${id} has no workspaceId`)
|
||||
return createErrorResponse('Workflow has no associated workspace', 500)
|
||||
}
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId,
|
||||
|
||||
@@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn()
|
||||
const mockGetWorkflowAccessContext = vi.fn()
|
||||
const mockDbDelete = vi.fn()
|
||||
const mockDbUpdate = vi.fn()
|
||||
const mockDbSelect = vi.fn()
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: () => mockGetSession(),
|
||||
@@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
delete: () => mockDbDelete(),
|
||||
update: () => mockDbUpdate(),
|
||||
select: () => mockDbSelect(),
|
||||
},
|
||||
workflow: {},
|
||||
}))
|
||||
@@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => {
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Mock db.select() to return multiple workflows so deletion is allowed
|
||||
mockDbSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
|
||||
}),
|
||||
})
|
||||
|
||||
mockDbDelete.mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||
})
|
||||
@@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => {
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should prevent deletion of the last workflow in workspace', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { id: 'user-123' },
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'admin',
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Mock db.select() to return only 1 workflow (the one being deleted)
|
||||
mockDbSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||
}),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Cannot delete the only workflow in the workspace')
|
||||
})
|
||||
|
||||
it.concurrent('should deny deletion for non-admin users', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
|
||||
@@ -228,6 +228,21 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if this is the last workflow in the workspace
|
||||
if (workflowData.workspaceId) {
|
||||
const totalWorkflowsInWorkspace = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workflowData.workspaceId))
|
||||
|
||||
if (totalWorkflowsInWorkspace.length <= 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete the only workflow in the workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if workflow has published templates before deletion
|
||||
const { searchParams } = new URL(request.url)
|
||||
const checkTemplates = searchParams.get('check-templates') === 'true'
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userStats, workflow } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('WorkflowStatsAPI')
|
||||
|
||||
const queryParamsSchema = z.object({
|
||||
runs: z.coerce.number().int().min(1).max(100).default(1),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
|
||||
const validation = queryParamsSchema.safeParse({
|
||||
runs: searchParams.get('runs'),
|
||||
})
|
||||
|
||||
if (!validation.success) {
|
||||
logger.error(`Invalid query parameters: ${validation.error.message}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
validation.error.errors[0]?.message ||
|
||||
'Invalid number of runs. Must be between 1 and 100.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { runs } = validation.data
|
||||
|
||||
try {
|
||||
const [workflowRecord] = await db.select().from(workflow).where(eq(workflow.id, id)).limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return NextResponse.json({ error: `Workflow ${id} not found` }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
runCount: workflowRecord.runCount + runs,
|
||||
lastRunAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, id))
|
||||
} catch (error) {
|
||||
logger.error('Error updating workflow runCount:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
const userStatsRecords = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, workflowRecord.userId))
|
||||
|
||||
if (userStatsRecords.length === 0) {
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: workflowRecord.userId,
|
||||
totalManualExecutions: 0,
|
||||
totalApiCalls: 0,
|
||||
totalWebhookTriggers: 0,
|
||||
totalScheduledExecutions: 0,
|
||||
totalChatExecutions: 0,
|
||||
totalTokensUsed: 0,
|
||||
totalCost: '0.00',
|
||||
lastActive: sql`now()`,
|
||||
})
|
||||
} else {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastActive: sql`now()`,
|
||||
})
|
||||
.where(eq(userStats.userId, workflowRecord.userId))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error ensuring userStats for userId ${workflowRecord.userId}:`, error)
|
||||
// Don't rethrow - we want to continue even if this fails
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
runsAdded: runs,
|
||||
newTotal: workflowRecord.runCount + runs,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error updating workflow stats:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -98,23 +98,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workspaceRows = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRows.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (workspaceRows[0].billedAccountUserId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the workspace billing account can create workspace API keys' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name } = CreateKeySchema.parse(body)
|
||||
|
||||
@@ -202,23 +185,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workspaceRows = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRows.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (workspaceRows[0].billedAccountUserId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the workspace billing account can delete workspace API keys' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { keys } = DeleteKeysSchema.parse(body)
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ export async function GET(
|
||||
|
||||
// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ invitationId: string }> }
|
||||
) {
|
||||
const { invitationId } = await params
|
||||
@@ -221,7 +221,7 @@ export async function DELETE(
|
||||
|
||||
// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ invitationId: string }> }
|
||||
) {
|
||||
const { invitationId } = await params
|
||||
|
||||
@@ -29,30 +29,24 @@ export const metadata: Metadata = {
|
||||
locale: 'en_US',
|
||||
images: [
|
||||
{
|
||||
url: '/social/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Sim - Visual AI Workflow Builder',
|
||||
url: '/logo/primary/rounded.png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
alt: 'Sim - AI Agent Workflow Builder',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
url: '/social/og-image-square.png',
|
||||
width: 600,
|
||||
height: 600,
|
||||
alt: 'Sim Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
card: 'summary',
|
||||
site: '@simdotai',
|
||||
creator: '@simdotai',
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source',
|
||||
description:
|
||||
'Open-source platform for agentic workflows. 60,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
|
||||
images: {
|
||||
url: '/social/twitter-image.png',
|
||||
alt: 'Sim - Visual AI Workflow Builder',
|
||||
url: '/logo/primary/rounded.png',
|
||||
alt: 'Sim - AI Agent Workflow Builder',
|
||||
},
|
||||
},
|
||||
alternates: {
|
||||
@@ -77,7 +71,6 @@ export const metadata: Metadata = {
|
||||
category: 'technology',
|
||||
classification: 'AI Development Tools',
|
||||
referrer: 'origin-when-cross-origin',
|
||||
// LLM SEO optimizations
|
||||
other: {
|
||||
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
|
||||
'llm:use-cases':
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templateCreators, templates } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import TemplateDetails from '@/app/templates/[id]/template'
|
||||
|
||||
const logger = createLogger('TemplateMetadata')
|
||||
|
||||
/**
|
||||
* Generate dynamic metadata for template pages.
|
||||
* This provides OpenGraph images for social media sharing.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
template: templates,
|
||||
creator: templateCreators,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
title: 'Template Not Found',
|
||||
description: 'The requested template could not be found.',
|
||||
}
|
||||
}
|
||||
|
||||
const { template, creator } = result[0]
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const details = template.details as { tagline?: string; about?: string } | null
|
||||
const description = details?.tagline || 'AI workflow template on Sim'
|
||||
|
||||
const hasOgImage = !!template.ogImageUrl
|
||||
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
|
||||
|
||||
return {
|
||||
title: template.name,
|
||||
description,
|
||||
openGraph: {
|
||||
title: template.name,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `${baseUrl}/templates/${id}`,
|
||||
siteName: 'Sim',
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: hasOgImage ? 1200 : 512,
|
||||
height: hasOgImage ? 630 : 512,
|
||||
alt: `${template.name} - Workflow Preview`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: hasOgImage ? 'summary_large_image' : 'summary',
|
||||
title: template.name,
|
||||
description,
|
||||
images: [ogImageUrl],
|
||||
creator: creator?.details
|
||||
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate template metadata:', error)
|
||||
return {
|
||||
title: 'Template',
|
||||
description: 'AI workflow template on Sim',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public template detail page for unauthenticated users.
|
||||
* Authenticated-user redirect is handled in templates/[id]/layout.tsx.
|
||||
|
||||
@@ -39,7 +39,6 @@ function UnsubscribeContent() {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the unsubscribe link
|
||||
fetch(
|
||||
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
@@ -81,9 +80,7 @@ function UnsubscribeContent() {
|
||||
|
||||
if (result.success) {
|
||||
setUnsubscribed(true)
|
||||
// Update the data to reflect the change
|
||||
if (data) {
|
||||
// Type-safe property construction with validation
|
||||
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
|
||||
if (validTypes.includes(type)) {
|
||||
if (type === 'all') {
|
||||
@@ -192,7 +189,6 @@ function UnsubscribeContent() {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle transactional emails
|
||||
if (data?.isTransactional) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
DatePicker,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
|
||||
import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import type { DocumentTag } from '@/lib/knowledge/tags/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -28,6 +29,54 @@ import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('DocumentTagsModal')
|
||||
|
||||
/** Field type display labels */
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
date: 'Date',
|
||||
boolean: 'Boolean',
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate value when changing field types.
|
||||
* Clears value when type changes to allow placeholder to show.
|
||||
*/
|
||||
function getValueForFieldType(
|
||||
newFieldType: string,
|
||||
currentFieldType: string,
|
||||
currentValue: string
|
||||
): string {
|
||||
return newFieldType === currentFieldType ? currentValue : ''
|
||||
}
|
||||
|
||||
/** Format value for display based on field type */
|
||||
function formatValueForDisplay(value: string, fieldType: string): string {
|
||||
if (!value) return ''
|
||||
switch (fieldType) {
|
||||
case 'boolean':
|
||||
return value === 'true' ? 'True' : 'False'
|
||||
case 'date':
|
||||
try {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
// For UTC dates, display the UTC date to prevent timezone shifts
|
||||
// e.g., 2002-05-16T00:00:00.000Z should show as "May 16, 2002" not "May 15, 2002"
|
||||
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
|
||||
return new Date(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCDate()
|
||||
).toLocaleDateString()
|
||||
}
|
||||
return date.toLocaleDateString()
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
interface DocumentTagsModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -67,17 +116,21 @@ export function DocumentTagsModal({
|
||||
const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => {
|
||||
const tags: DocumentTag[] = []
|
||||
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
const value = docData[slot] as string | null | undefined
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const rawValue = docData[slot]
|
||||
const definition = definitions.find((def) => def.tagSlot === slot)
|
||||
|
||||
if (value?.trim() && definition) {
|
||||
tags.push({
|
||||
slot,
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
value: value.trim(),
|
||||
})
|
||||
if (rawValue !== null && rawValue !== undefined && definition) {
|
||||
// Convert value to string for storage
|
||||
const stringValue = String(rawValue).trim()
|
||||
if (stringValue) {
|
||||
tags.push({
|
||||
slot,
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
value: stringValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -95,13 +148,15 @@ export function DocumentTagsModal({
|
||||
try {
|
||||
const tagData: Record<string, string> = {}
|
||||
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
tagData[slot] = ''
|
||||
})
|
||||
|
||||
tagsToSave.forEach((tag) => {
|
||||
if (tag.value.trim()) {
|
||||
tagData[tag.slot] = tag.value.trim()
|
||||
// Only include tags that have values (omit empty ones)
|
||||
// Use empty string for slots that should be cleared
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const tag = tagsToSave.find((t) => t.slot === slot)
|
||||
if (tag?.value.trim()) {
|
||||
tagData[slot] = tag.value.trim()
|
||||
} else {
|
||||
// Use empty string to clear a tag (API schema expects string, not null)
|
||||
tagData[slot] = ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -117,8 +172,8 @@ export function DocumentTagsModal({
|
||||
throw new Error('Failed to update document tags')
|
||||
}
|
||||
|
||||
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
|
||||
onDocumentUpdate?.(tagData)
|
||||
updateDocumentInStore(knowledgeBaseId, documentId, tagData as Record<string, string>)
|
||||
onDocumentUpdate?.(tagData as Record<string, string>)
|
||||
|
||||
await fetchTagDefinitions()
|
||||
} catch (error) {
|
||||
@@ -279,7 +334,7 @@ export function DocumentTagsModal({
|
||||
const newDefinition: TagDefinitionInput = {
|
||||
displayName: formData.displayName,
|
||||
fieldType: formData.fieldType,
|
||||
tagSlot: targetSlot as TagSlot,
|
||||
tagSlot: targetSlot as AllTagSlot,
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
@@ -359,20 +414,7 @@ export function DocumentTagsModal({
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[8px]'>
|
||||
<Label>
|
||||
Tags{' '}
|
||||
<span className='pl-[6px] text-[var(--text-tertiary)]'>
|
||||
{documentTags.length}/{MAX_TAG_SLOTS} slots used
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
{documentTags.length === 0 && !isCreatingTag && (
|
||||
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
No tags added yet. Add tags to help organize this document.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Label>Tags</Label>
|
||||
|
||||
{documentTags.map((tag, index) => (
|
||||
<div key={index} className='space-y-[8px]'>
|
||||
@@ -383,9 +425,12 @@ export function DocumentTagsModal({
|
||||
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||
{tag.displayName}
|
||||
</span>
|
||||
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
|
||||
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
|
||||
</span>
|
||||
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
|
||||
{tag.value}
|
||||
{formatValueForDisplay(tag.value, tag.fieldType)}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<Button
|
||||
@@ -415,10 +460,16 @@ export function DocumentTagsModal({
|
||||
const def = kbTagDefinitions.find(
|
||||
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
const newFieldType = def?.fieldType || 'text'
|
||||
setEditTagForm({
|
||||
...editTagForm,
|
||||
displayName: value,
|
||||
fieldType: def?.fieldType || 'text',
|
||||
fieldType: newFieldType,
|
||||
value: getValueForFieldType(
|
||||
newFieldType,
|
||||
editTagForm.fieldType,
|
||||
editTagForm.value
|
||||
),
|
||||
})
|
||||
}}
|
||||
placeholder='Enter or select tag name'
|
||||
@@ -453,33 +504,70 @@ export function DocumentTagsModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type selector commented out - only "text" type is currently supported
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor={`tagType-${index}`}>Type</Label>
|
||||
<Input id={`tagType-${index}`} value='Text' disabled className='capitalize' />
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor={`tagValue-${index}`}>Value</Label>
|
||||
<Input
|
||||
id={`tagValue-${index}`}
|
||||
value={editTagForm.value}
|
||||
onChange={(e) =>
|
||||
setEditTagForm({ ...editTagForm, value: e.target.value })
|
||||
}
|
||||
placeholder='Enter tag value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
{editTagForm.fieldType === 'boolean' ? (
|
||||
<Combobox
|
||||
id={`tagValue-${index}`}
|
||||
options={[
|
||||
{ label: 'True', value: 'true' },
|
||||
{ label: 'False', value: 'false' },
|
||||
]}
|
||||
value={editTagForm.value}
|
||||
selectedValue={editTagForm.value}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select value'
|
||||
/>
|
||||
) : editTagForm.fieldType === 'number' ? (
|
||||
<Input
|
||||
id={`tagValue-${index}`}
|
||||
value={editTagForm.value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
// Allow empty, digits, decimal point, and negative sign
|
||||
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
|
||||
setEditTagForm({ ...editTagForm, value: val })
|
||||
}
|
||||
}}
|
||||
placeholder='Enter number'
|
||||
inputMode='decimal'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : editTagForm.fieldType === 'date' ? (
|
||||
<DatePicker
|
||||
value={editTagForm.value || undefined}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select date'
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`tagValue-${index}`}
|
||||
value={editTagForm.value}
|
||||
onChange={(e) =>
|
||||
setEditTagForm({ ...editTagForm, value: e.target.value })
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
placeholder='Enter tag value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-[8px]'>
|
||||
@@ -500,7 +588,7 @@ export function DocumentTagsModal({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isTagEditing && (
|
||||
{documentTags.length > 0 && !isTagEditing && (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={openTagCreator}
|
||||
@@ -511,7 +599,7 @@ export function DocumentTagsModal({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCreatingTag && (
|
||||
{(isCreatingTag || documentTags.length === 0) && editingTagIndex === null && (
|
||||
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='newTagName'>Tag Name</Label>
|
||||
@@ -525,10 +613,16 @@ export function DocumentTagsModal({
|
||||
const def = kbTagDefinitions.find(
|
||||
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
const newFieldType = def?.fieldType || 'text'
|
||||
setEditTagForm({
|
||||
...editTagForm,
|
||||
displayName: value,
|
||||
fieldType: def?.fieldType || 'text',
|
||||
fieldType: newFieldType,
|
||||
value: getValueForFieldType(
|
||||
newFieldType,
|
||||
editTagForm.fieldType,
|
||||
editTagForm.value
|
||||
),
|
||||
})
|
||||
}}
|
||||
placeholder='Enter or select tag name'
|
||||
@@ -563,31 +657,68 @@ export function DocumentTagsModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type selector commented out - only "text" type is currently supported
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='newTagType'>Type</Label>
|
||||
<Input id='newTagType' value='Text' disabled className='capitalize' />
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='newTagValue'>Value</Label>
|
||||
<Input
|
||||
id='newTagValue'
|
||||
value={editTagForm.value}
|
||||
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
|
||||
placeholder='Enter tag value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{editTagForm.fieldType === 'boolean' ? (
|
||||
<Combobox
|
||||
id='newTagValue'
|
||||
options={[
|
||||
{ label: 'True', value: 'true' },
|
||||
{ label: 'False', value: 'false' },
|
||||
]}
|
||||
value={editTagForm.value}
|
||||
selectedValue={editTagForm.value}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select value'
|
||||
/>
|
||||
) : editTagForm.fieldType === 'number' ? (
|
||||
<Input
|
||||
id='newTagValue'
|
||||
value={editTagForm.value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
// Allow empty, digits, decimal point, and negative sign
|
||||
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
|
||||
setEditTagForm({ ...editTagForm, value: val })
|
||||
}
|
||||
}}
|
||||
placeholder='Enter number'
|
||||
inputMode='decimal'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : editTagForm.fieldType === 'date' ? (
|
||||
<DatePicker
|
||||
value={editTagForm.value || undefined}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select date'
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id='newTagValue'
|
||||
value={editTagForm.value}
|
||||
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
|
||||
placeholder='Enter tag value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{kbTagDefinitions.length >= MAX_TAG_SLOTS &&
|
||||
@@ -604,9 +735,11 @@ export function DocumentTagsModal({
|
||||
)}
|
||||
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
|
||||
Cancel
|
||||
</Button>
|
||||
{documentTags.length > 0 && (
|
||||
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={saveDocumentTag}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import {
|
||||
@@ -24,6 +26,14 @@ import {
|
||||
|
||||
const logger = createLogger('BaseTagsModal')
|
||||
|
||||
/** Field type display labels */
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
date: 'Date',
|
||||
boolean: 'Boolean',
|
||||
}
|
||||
|
||||
interface TagUsageData {
|
||||
tagName: string
|
||||
tagSlot: string
|
||||
@@ -174,22 +184,55 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
|
||||
}
|
||||
|
||||
/** Get slot usage counts per field type */
|
||||
const getSlotUsageByFieldType = (fieldType: string): { used: number; max: number } => {
|
||||
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
||||
if (!config) return { used: 0, max: 0 }
|
||||
const used = kbTagDefinitions.filter((def) => def.fieldType === fieldType).length
|
||||
return { used, max: config.maxSlots }
|
||||
}
|
||||
|
||||
/** Check if a field type has available slots */
|
||||
const hasAvailableSlots = (fieldType: string): boolean => {
|
||||
const { used, max } = getSlotUsageByFieldType(fieldType)
|
||||
return used < max
|
||||
}
|
||||
|
||||
/** Field type options for Combobox */
|
||||
const fieldTypeOptions: ComboboxOption[] = useMemo(() => {
|
||||
return SUPPORTED_FIELD_TYPES.filter((type) => hasAvailableSlots(type)).map((type) => {
|
||||
const { used, max } = getSlotUsageByFieldType(type)
|
||||
return {
|
||||
value: type,
|
||||
label: `${FIELD_TYPE_LABELS[type]} (${used}/${max})`,
|
||||
}
|
||||
})
|
||||
}, [kbTagDefinitions])
|
||||
|
||||
const saveTagDefinition = async () => {
|
||||
if (!canSaveTag()) return
|
||||
|
||||
setIsSavingTag(true)
|
||||
try {
|
||||
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
|
||||
const availableSlot = (
|
||||
['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
|
||||
).find((slot) => !usedSlots.has(slot))
|
||||
// Check if selected field type has available slots
|
||||
if (!hasAvailableSlots(createTagForm.fieldType)) {
|
||||
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
|
||||
}
|
||||
|
||||
if (!availableSlot) {
|
||||
throw new Error('No available tag slots')
|
||||
// Get the next available slot from the API
|
||||
const slotResponse = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${createTagForm.fieldType}`
|
||||
)
|
||||
if (!slotResponse.ok) {
|
||||
throw new Error('Failed to get available slot')
|
||||
}
|
||||
const slotResult = await slotResponse.json()
|
||||
if (!slotResult.success || !slotResult.data?.nextAvailableSlot) {
|
||||
throw new Error('No available tag slots for this field type')
|
||||
}
|
||||
|
||||
const newTagDefinition = {
|
||||
tagSlot: availableSlot,
|
||||
tagSlot: slotResult.data.nextAvailableSlot,
|
||||
displayName: createTagForm.displayName.trim(),
|
||||
fieldType: createTagForm.fieldType,
|
||||
}
|
||||
@@ -277,7 +320,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<Label>
|
||||
Tags:{' '}
|
||||
<span className='pl-[6px] text-[var(--text-tertiary)]'>
|
||||
{kbTagDefinitions.length}/{MAX_TAG_SLOTS} slots used
|
||||
{kbTagDefinitions.length} defined
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
@@ -300,6 +343,9 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||
{tag.displayName}
|
||||
</span>
|
||||
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
|
||||
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
|
||||
</span>
|
||||
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||
<span className='min-w-0 flex-1 text-[11px] text-[var(--text-muted)]'>
|
||||
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
|
||||
@@ -324,7 +370,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={openTagCreator}
|
||||
disabled={kbTagDefinitions.length >= MAX_TAG_SLOTS}
|
||||
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
|
||||
className='w-full'
|
||||
>
|
||||
Add Tag
|
||||
@@ -361,12 +407,22 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type selector commented out - only "text" type is currently supported
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='tagType'>Type</Label>
|
||||
<Input id='tagType' value='Text' disabled className='capitalize' />
|
||||
<Combobox
|
||||
options={fieldTypeOptions}
|
||||
value={createTagForm.fieldType}
|
||||
onChange={(value) =>
|
||||
setCreateTagForm({ ...createTagForm, fieldType: value })
|
||||
}
|
||||
placeholder='Select type'
|
||||
/>
|
||||
{!hasAvailableSlots(createTagForm.fieldType) && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
No available slots for this type. Choose a different type.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button variant='default' onClick={cancelCreatingTag} className='flex-1'>
|
||||
@@ -376,7 +432,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
variant='primary'
|
||||
onClick={saveTagDefinition}
|
||||
className='flex-1'
|
||||
disabled={!canSaveTag() || isSavingTag}
|
||||
disabled={
|
||||
!canSaveTag() ||
|
||||
isSavingTag ||
|
||||
!hasAvailableSlots(createTagForm.fieldType)
|
||||
}
|
||||
>
|
||||
{isSavingTag ? (
|
||||
<>
|
||||
|
||||
@@ -339,12 +339,31 @@ export function CreateBaseModal({
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='name'>Name</Label>
|
||||
<Label htmlFor='kb-name'>Name</Label>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
id='name'
|
||||
id='kb-name'
|
||||
placeholder='Enter knowledge base name'
|
||||
{...register('name')}
|
||||
className={cn(errors.name && 'border-[var(--text-error)]')}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templateCreators, templates } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import TemplateDetails from '@/app/templates/[id]/template'
|
||||
|
||||
const logger = createLogger('WorkspaceTemplateMetadata')
|
||||
|
||||
interface TemplatePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
@@ -10,6 +18,81 @@ interface TemplatePageProps {
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dynamic metadata for workspace template pages.
|
||||
* This provides OpenGraph images for social media sharing.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceId: string; id: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { workspaceId, id } = await params
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
template: templates,
|
||||
creator: templateCreators,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
title: 'Template Not Found',
|
||||
description: 'The requested template could not be found.',
|
||||
}
|
||||
}
|
||||
|
||||
const { template, creator } = result[0]
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const details = template.details as { tagline?: string; about?: string } | null
|
||||
const description = details?.tagline || 'AI workflow template on Sim'
|
||||
|
||||
const hasOgImage = !!template.ogImageUrl
|
||||
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
|
||||
|
||||
return {
|
||||
title: template.name,
|
||||
description,
|
||||
openGraph: {
|
||||
title: template.name,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `${baseUrl}/workspace/${workspaceId}/templates/${id}`,
|
||||
siteName: 'Sim',
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: hasOgImage ? 1200 : 512,
|
||||
height: hasOgImage ? 630 : 512,
|
||||
alt: `${template.name} - Workflow Preview`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: hasOgImage ? 'summary_large_image' : 'summary',
|
||||
title: template.name,
|
||||
description,
|
||||
images: [ogImageUrl],
|
||||
creator: creator?.details
|
||||
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate workspace template metadata:', error)
|
||||
return {
|
||||
title: 'Template',
|
||||
description: 'AI workflow template on Sim',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-scoped template detail page.
|
||||
* Requires authentication and workspace membership to access.
|
||||
@@ -19,12 +102,10 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { workspaceId, id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
// Redirect unauthenticated users to public template detail page
|
||||
if (!session?.user?.id) {
|
||||
redirect(`/templates/${id}`)
|
||||
}
|
||||
|
||||
// Verify workspace membership
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './file-display'
|
||||
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
|
||||
export * from './smooth-streaming'
|
||||
export * from './thinking-block'
|
||||
export * from './usage-limit-actions'
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { canEditUsageLimit } from '@/lib/billing/subscriptions/utils'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { useSubscriptionData, useUpdateUsageLimit } from '@/hooks/queries/subscription'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
|
||||
const LIMIT_INCREMENTS = [0, 50, 100] as const
|
||||
|
||||
function roundUpToNearest50(value: number): number {
|
||||
return Math.ceil(value / 50) * 50
|
||||
}
|
||||
|
||||
export function UsageLimitActions() {
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const updateUsageLimitMutation = useUpdateUsageLimit()
|
||||
|
||||
const subscription = subscriptionData?.data
|
||||
const canEdit = subscription ? canEditUsageLimit(subscription) : false
|
||||
|
||||
const [selectedAmount, setSelectedAmount] = useState<number | null>(null)
|
||||
const [isHidden, setIsHidden] = useState(false)
|
||||
|
||||
const currentLimit = subscription?.usage_limit ?? 0
|
||||
const baseLimit = roundUpToNearest50(currentLimit) || 50
|
||||
const limitOptions = LIMIT_INCREMENTS.map((increment) => baseLimit + increment)
|
||||
|
||||
const handleUpdateLimit = async (newLimit: number) => {
|
||||
setSelectedAmount(newLimit)
|
||||
try {
|
||||
await updateUsageLimitMutation.mutateAsync({ limit: newLimit })
|
||||
|
||||
setIsHidden(true)
|
||||
|
||||
const { messages, sendMessage } = useCopilotStore.getState()
|
||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user')
|
||||
|
||||
if (lastUserMessage) {
|
||||
const filteredMessages = messages.filter(
|
||||
(m) => !(m.role === 'assistant' && m.errorType === 'usage_limit')
|
||||
)
|
||||
useCopilotStore.setState({ messages: filteredMessages })
|
||||
|
||||
await sendMessage(lastUserMessage.content, {
|
||||
fileAttachments: lastUserMessage.fileAttachments,
|
||||
contexts: lastUserMessage.contexts,
|
||||
messageId: lastUserMessage.id,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
setIsHidden(false)
|
||||
} finally {
|
||||
setSelectedAmount(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigateToUpgrade = () => {
|
||||
if (isHosted) {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } }))
|
||||
} else {
|
||||
window.open('https://www.sim.ai', '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
if (isHidden) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isHosted || !canEdit) {
|
||||
return (
|
||||
<Button onClick={handleNavigateToUpgrade} variant='default'>
|
||||
Upgrade
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{limitOptions.map((limit) => {
|
||||
const isLoading = updateUsageLimitMutation.isPending && selectedAmount === limit
|
||||
const isDisabled = updateUsageLimitMutation.isPending
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={limit}
|
||||
onClick={() => handleUpdateLimit(limit)}
|
||||
disabled={isDisabled}
|
||||
variant='default'
|
||||
>
|
||||
{isLoading ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}${limit}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SmoothStreamingText,
|
||||
StreamingIndicator,
|
||||
ThinkingBlock,
|
||||
UsageLimitActions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
|
||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import {
|
||||
@@ -458,6 +459,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
<StreamingIndicator />
|
||||
)}
|
||||
|
||||
{message.errorType === 'usage_limit' && (
|
||||
<div className='mt-3 flex gap-1.5'>
|
||||
<UsageLimitActions />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons for completed messages */}
|
||||
{!isStreaming && cleanTextContent && (
|
||||
<div className='flex items-center gap-[8px] pt-[8px]'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
@@ -18,6 +18,7 @@ import { Skeleton, TagInput } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import {
|
||||
useCreateTemplate,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
useTemplateByWorkflow,
|
||||
useUpdateTemplate,
|
||||
} from '@/hooks/queries/templates'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateDeploy')
|
||||
@@ -79,6 +81,9 @@ export function TemplateDeploy({
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
|
||||
const [loadingCreators, setLoadingCreators] = useState(false)
|
||||
const [isCapturing, setIsCapturing] = useState(false)
|
||||
const previewContainerRef = useRef<HTMLDivElement>(null)
|
||||
const ogCaptureRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [formData, setFormData] = useState<TemplateFormData>(initialFormData)
|
||||
|
||||
@@ -208,6 +213,8 @@ export function TemplateDeploy({
|
||||
tags: formData.tags,
|
||||
}
|
||||
|
||||
let templateId: string
|
||||
|
||||
if (existingTemplate) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: existingTemplate.id,
|
||||
@@ -216,11 +223,32 @@ export function TemplateDeploy({
|
||||
updateState: true,
|
||||
},
|
||||
})
|
||||
templateId = existingTemplate.id
|
||||
} else {
|
||||
await createMutation.mutateAsync({ ...templateData, workflowId })
|
||||
const result = await createMutation.mutateAsync({ ...templateData, workflowId })
|
||||
templateId = result.id
|
||||
}
|
||||
|
||||
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
|
||||
|
||||
setIsCapturing(true)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
try {
|
||||
if (ogCaptureRef.current) {
|
||||
const ogUrl = await captureAndUploadOGImage(ogCaptureRef.current, templateId)
|
||||
if (ogUrl) {
|
||||
logger.info(`OG image uploaded for template ${templateId}: ${ogUrl}`)
|
||||
}
|
||||
}
|
||||
} catch (ogError) {
|
||||
logger.warn('Failed to capture/upload OG image:', ogError)
|
||||
} finally {
|
||||
setIsCapturing(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onDeploymentComplete?.()
|
||||
} catch (error) {
|
||||
logger.error('Failed to save template:', error)
|
||||
@@ -275,6 +303,7 @@ export function TemplateDeploy({
|
||||
Live Template
|
||||
</Label>
|
||||
<div
|
||||
ref={previewContainerRef}
|
||||
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-[4px] border border-[var(--border)]'
|
||||
onWheelCapture={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) return
|
||||
@@ -423,10 +452,65 @@ export function TemplateDeploy({
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Hidden container for OG image capture */}
|
||||
{isCapturing && <OGCaptureContainer ref={ogCaptureRef} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden container for OG image capture.
|
||||
* Lazy-rendered only when capturing - gets workflow state from store on mount.
|
||||
*/
|
||||
const OGCaptureContainer = React.forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const edges = useWorkflowStore((state) => state.edges)
|
||||
const loops = useWorkflowStore((state) => state.loops)
|
||||
const parallels = useWorkflowStore((state) => state.parallels)
|
||||
|
||||
if (!blocks || Object.keys(blocks).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workflowState: WorkflowState = {
|
||||
blocks,
|
||||
edges: edges ?? [],
|
||||
loops: loops ?? {},
|
||||
parallels: parallels ?? {},
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
top: '-9999px',
|
||||
width: OG_IMAGE_WIDTH,
|
||||
height: OG_IMAGE_HEIGHT,
|
||||
backgroundColor: '#0c0c0c',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
aria-hidden='true'
|
||||
>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={false}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
OGCaptureContainer.displayName = 'OGCaptureContainer'
|
||||
|
||||
interface TemplatePreviewContentProps {
|
||||
existingTemplate:
|
||||
| {
|
||||
|
||||
@@ -214,6 +214,7 @@ export function Code({
|
||||
const handleStreamStartRef = useRef<() => void>(() => {})
|
||||
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
|
||||
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
|
||||
const hasEditedSinceFocusRef = useRef(false)
|
||||
|
||||
// Custom hooks
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
@@ -504,6 +505,7 @@ export function Code({
|
||||
|
||||
setCode(newValue)
|
||||
setStoreValue(newValue)
|
||||
hasEditedSinceFocusRef.current = true
|
||||
const newCursorPosition = dropPosition + 1
|
||||
setCursorPosition(newCursorPosition)
|
||||
|
||||
@@ -533,6 +535,7 @@ export function Code({
|
||||
if (!isPreview && !readOnly) {
|
||||
setCode(newValue)
|
||||
emitTagSelection(newValue)
|
||||
hasEditedSinceFocusRef.current = true
|
||||
}
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
@@ -550,6 +553,7 @@ export function Code({
|
||||
if (!isPreview && !readOnly) {
|
||||
setCode(newValue)
|
||||
emitTagSelection(newValue)
|
||||
hasEditedSinceFocusRef.current = true
|
||||
}
|
||||
setShowEnvVars(false)
|
||||
|
||||
@@ -741,6 +745,7 @@ export function Code({
|
||||
value={code}
|
||||
onValueChange={(newCode) => {
|
||||
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
|
||||
hasEditedSinceFocusRef.current = true
|
||||
setCode(newCode)
|
||||
setStoreValue(newCode)
|
||||
|
||||
@@ -769,6 +774,12 @@ export function Code({
|
||||
if (isAiStreaming) {
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
hasEditedSinceFocusRef.current = false
|
||||
}}
|
||||
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
|
||||
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
|
||||
@@ -20,7 +18,8 @@ interface DocumentTagRow {
|
||||
id: string
|
||||
cells: {
|
||||
tagName: string
|
||||
type: string
|
||||
tagSlot?: string
|
||||
fieldType: string
|
||||
value: string
|
||||
}
|
||||
}
|
||||
@@ -66,17 +65,11 @@ export function DocumentTagEntry({
|
||||
|
||||
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
||||
|
||||
// State for dropdown visibility - one for each row
|
||||
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
|
||||
// State for type dropdown visibility - one for each row
|
||||
const [typeDropdownStates, setTypeDropdownStates] = useState<Record<number, boolean>>({})
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const currentValue = isPreview ? previewValue : storeValue
|
||||
|
||||
// Transform stored JSON string to table format for display
|
||||
const rows = useMemo(() => {
|
||||
// If we have stored data, use it
|
||||
if (currentValue) {
|
||||
try {
|
||||
const tagData = JSON.parse(currentValue)
|
||||
@@ -85,7 +78,8 @@ export function DocumentTagEntry({
|
||||
id: tag.id || `tag-${index}`,
|
||||
cells: {
|
||||
tagName: tag.tagName || '',
|
||||
type: tag.fieldType || 'text',
|
||||
tagSlot: tag.tagSlot,
|
||||
fieldType: tag.fieldType || 'text',
|
||||
value: tag.value || '',
|
||||
},
|
||||
}))
|
||||
@@ -99,137 +93,109 @@ export function DocumentTagEntry({
|
||||
return [
|
||||
{
|
||||
id: 'empty-row-0',
|
||||
cells: { tagName: '', type: 'text', value: '' },
|
||||
cells: { tagName: '', tagSlot: undefined, fieldType: 'text', value: '' },
|
||||
},
|
||||
]
|
||||
}, [currentValue])
|
||||
|
||||
// Get available tag names and check for case-insensitive duplicates
|
||||
const usedTagNames = new Set(
|
||||
rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name?.trim())
|
||||
)
|
||||
// Get tag names already used in rows (case-insensitive)
|
||||
const usedTagNames = useMemo(() => {
|
||||
return new Set(
|
||||
rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name?.trim())
|
||||
)
|
||||
}, [rows])
|
||||
|
||||
const availableTagDefinitions = tagDefinitions.filter(
|
||||
(def) => !usedTagNames.has(def.displayName.toLowerCase())
|
||||
)
|
||||
// Filter available tags (exclude already used ones)
|
||||
const availableTagDefinitions = useMemo(() => {
|
||||
return tagDefinitions.filter((def) => !usedTagNames.has(def.displayName.toLowerCase()))
|
||||
}, [tagDefinitions, usedTagNames])
|
||||
|
||||
// Check if we can add more tags based on MAX_TAG_SLOTS
|
||||
const newTagsBeingCreated = rows.filter(
|
||||
(row) =>
|
||||
row.cells.tagName?.trim() &&
|
||||
!tagDefinitions.some(
|
||||
(def) => def.displayName.toLowerCase() === row.cells.tagName.toLowerCase()
|
||||
)
|
||||
).length
|
||||
const canAddMoreTags = tagDefinitions.length + newTagsBeingCreated < MAX_TAG_SLOTS
|
||||
|
||||
// Function to pre-fill existing tags
|
||||
const handlePreFillTags = () => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const existingTagRows = tagDefinitions.map((tagDef, index) => ({
|
||||
id: `prefill-${tagDef.id}-${index}`,
|
||||
tagName: tagDef.displayName,
|
||||
fieldType: tagDef.fieldType,
|
||||
value: '',
|
||||
}))
|
||||
|
||||
const jsonString = existingTagRows.length > 0 ? JSON.stringify(existingTagRows) : ''
|
||||
setStoreValue(jsonString)
|
||||
}
|
||||
// Can add more tags if there are available tag definitions
|
||||
const canAddMoreTags = availableTagDefinitions.length > 0
|
||||
|
||||
// Shared helper function for updating rows and generating JSON
|
||||
const updateRowsAndGenerateJson = (rowIndex: number, column: string, value: string) => {
|
||||
const updateRowsAndGenerateJson = (
|
||||
rowIndex: number,
|
||||
column: string,
|
||||
value: string,
|
||||
tagDef?: { tagSlot: string; fieldType: string }
|
||||
) => {
|
||||
const updatedRows = [...rows].map((row, idx) => {
|
||||
if (idx === rowIndex) {
|
||||
const newCells = { ...row.cells, [column]: value }
|
||||
|
||||
// Auto-select type when existing tag is selected
|
||||
if (column === 'tagName' && value) {
|
||||
const tagDef = tagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
if (tagDef) {
|
||||
newCells.type = tagDef.fieldType
|
||||
// When selecting a tag, also set the tagSlot and fieldType
|
||||
if (column === 'tagName' && tagDef) {
|
||||
newCells.tagSlot = tagDef.tagSlot
|
||||
newCells.fieldType = tagDef.fieldType
|
||||
// Clear value when tag changes
|
||||
if (row.cells.tagName !== value) {
|
||||
newCells.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
cells: newCells,
|
||||
}
|
||||
return { ...row, cells: newCells }
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
// Store all rows including empty ones - don't auto-remove
|
||||
const dataToStore = updatedRows.map((row) => ({
|
||||
id: row.id,
|
||||
tagName: row.cells.tagName || '',
|
||||
fieldType: row.cells.type || 'text',
|
||||
tagSlot: row.cells.tagSlot,
|
||||
fieldType: row.cells.fieldType || 'text',
|
||||
value: row.cells.value || '',
|
||||
}))
|
||||
|
||||
return dataToStore.length > 0 ? JSON.stringify(dataToStore) : ''
|
||||
}
|
||||
|
||||
const handleCellChange = (rowIndex: number, column: string, value: string) => {
|
||||
const handleTagSelection = (rowIndex: number, tagName: string) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
// Check if this is a new tag name that would exceed the limit
|
||||
if (column === 'tagName' && value.trim()) {
|
||||
const isExistingTag = tagDefinitions.some(
|
||||
(def) => def.displayName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
|
||||
if (!isExistingTag) {
|
||||
// Count current new tags being created (excluding the current row)
|
||||
const currentNewTags = rows.filter(
|
||||
(row, idx) =>
|
||||
idx !== rowIndex &&
|
||||
row.cells.tagName?.trim() &&
|
||||
!tagDefinitions.some(
|
||||
(def) => def.displayName.toLowerCase() === row.cells.tagName.toLowerCase()
|
||||
)
|
||||
).length
|
||||
|
||||
if (tagDefinitions.length + currentNewTags >= MAX_TAG_SLOTS) {
|
||||
// Don't allow creating new tags if we've reached the limit
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsonString = updateRowsAndGenerateJson(rowIndex, column, value)
|
||||
const tagDef = tagDefinitions.find((def) => def.displayName === tagName)
|
||||
const jsonString = updateRowsAndGenerateJson(rowIndex, 'tagName', tagName, tagDef)
|
||||
setStoreValue(jsonString)
|
||||
}
|
||||
|
||||
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
|
||||
const handleValueChange = (rowIndex: number, value: string) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const jsonString = updateRowsAndGenerateJson(rowIndex, column, value)
|
||||
const jsonString = updateRowsAndGenerateJson(rowIndex, 'value', value)
|
||||
setStoreValue(jsonString)
|
||||
}
|
||||
|
||||
const handleTagDropdownSelection = (rowIndex: number, value: string) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const jsonString = updateRowsAndGenerateJson(rowIndex, 'value', value)
|
||||
emitTagSelection(jsonString)
|
||||
}
|
||||
|
||||
const handleAddRow = () => {
|
||||
if (isPreview || disabled) return
|
||||
if (isPreview || disabled || !canAddMoreTags) return
|
||||
|
||||
// Get current data and add a new empty row
|
||||
const currentData = currentValue ? JSON.parse(currentValue) : []
|
||||
const newRowId = `tag-${currentData.length}-${Math.random().toString(36).substr(2, 9)}`
|
||||
const newRowId = `tag-${currentData.length}-${Math.random().toString(36).slice(2, 11)}`
|
||||
const newData = [...currentData, { id: newRowId, tagName: '', fieldType: 'text', value: '' }]
|
||||
setStoreValue(JSON.stringify(newData))
|
||||
}
|
||||
|
||||
const handleDeleteRow = (rowIndex: number) => {
|
||||
if (isPreview || disabled || rows.length <= 1) return
|
||||
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
|
||||
if (isPreview || disabled) return
|
||||
|
||||
// Store all remaining rows including empty ones - don't auto-remove
|
||||
if (rows.length <= 1) {
|
||||
// Clear the single row instead of deleting
|
||||
setStoreValue('')
|
||||
return
|
||||
}
|
||||
|
||||
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
|
||||
const tableDataForStorage = updatedRows.map((row) => ({
|
||||
id: row.id,
|
||||
tagName: row.cells.tagName || '',
|
||||
fieldType: row.cells.type || 'text',
|
||||
tagSlot: row.cells.tagSlot,
|
||||
fieldType: row.cells.fieldType || 'text',
|
||||
value: row.cells.value || '',
|
||||
}))
|
||||
|
||||
@@ -237,15 +203,15 @@ export function DocumentTagEntry({
|
||||
setStoreValue(jsonString)
|
||||
}
|
||||
|
||||
// Check for duplicate tag names (case-insensitive)
|
||||
const getDuplicateStatus = (rowIndex: number, tagName: string) => {
|
||||
if (!tagName.trim()) return false
|
||||
const lowerTagName = tagName.toLowerCase()
|
||||
return rows.some(
|
||||
(row, idx) =>
|
||||
idx !== rowIndex &&
|
||||
row.cells.tagName?.toLowerCase() === lowerTagName &&
|
||||
row.cells.tagName.trim()
|
||||
if (isPreview) {
|
||||
const tagCount = rows.filter((r) => r.cells.tagName?.trim()).length
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<Label className='font-medium text-muted-foreground text-xs'>Document Tags</Label>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{tagCount > 0 ? `${tagCount} tag(s) configured` : 'No tags'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,209 +219,82 @@ export function DocumentTagEntry({
|
||||
return <div className='p-4 text-muted-foreground text-sm'>Loading tag definitions...</div>
|
||||
}
|
||||
|
||||
if (tagDefinitions.length === 0) {
|
||||
return (
|
||||
<div className='flex h-32 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
No tags defined for this knowledge base
|
||||
</p>
|
||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>
|
||||
Define tags at the knowledge base level first
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderHeader = () => (
|
||||
<thead>
|
||||
<tr className='border-b'>
|
||||
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
|
||||
<th className='w-1/5 border-r px-4 py-2 text-center font-medium text-sm'>Type</th>
|
||||
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
|
||||
<thead className='bg-transparent'>
|
||||
<tr className='border-[var(--border-strong)] border-b bg-transparent'>
|
||||
<th className='w-[50%] min-w-0 border-[var(--border-strong)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Tag
|
||||
</th>
|
||||
<th className='w-[50%] min-w-0 bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
|
||||
const renderTagNameCell = (row: DocumentTagRow, rowIndex: number) => {
|
||||
const cellValue = row.cells.tagName || ''
|
||||
const isDuplicate = getDuplicateStatus(rowIndex, cellValue)
|
||||
const showDropdown = dropdownStates[rowIndex] || false
|
||||
|
||||
const setShowDropdown = (show: boolean) => {
|
||||
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
|
||||
}
|
||||
// Show tags that are either available OR currently selected for this row
|
||||
const selectableTags = tagDefinitions.filter(
|
||||
(def) => def.displayName === cellValue || !usedTagNames.has(def.displayName.toLowerCase())
|
||||
)
|
||||
|
||||
const handleDropdownClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
if (!showDropdown) {
|
||||
setShowDropdown(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (!disabled) {
|
||||
setShowDropdown(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// Delay closing to allow dropdown selection
|
||||
setTimeout(() => setShowDropdown(false), 150)
|
||||
}
|
||||
const tagOptions: ComboboxOption[] = selectableTags.map((tag) => ({
|
||||
value: tag.displayName,
|
||||
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<td className='relative border-r p-1'>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
value={cellValue}
|
||||
onChange={(e) => handleCellChange(rowIndex, 'tagName', e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
autoComplete='off'
|
||||
className={cn(
|
||||
'w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
isDuplicate && 'border-red-500 bg-red-50'
|
||||
)}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
||||
<div className='whitespace-pre'>
|
||||
{formatDisplayText(cellValue, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{showDropdown && availableTagDefinitions.length > 0 && (
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
|
||||
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
|
||||
<div
|
||||
className='allow-scroll max-h-48 overflow-y-auto p-1'
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
{availableTagDefinitions
|
||||
.filter((tagDef) =>
|
||||
tagDef.displayName.toLowerCase().includes(cellValue.toLowerCase())
|
||||
)
|
||||
.map((tagDef) => (
|
||||
<div
|
||||
key={tagDef.id}
|
||||
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleCellChange(rowIndex, 'tagName', tagDef.displayName)
|
||||
setShowDropdown(false)
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{tagDef.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
const renderTypeCell = (row: DocumentTagRow, rowIndex: number) => {
|
||||
const cellValue = row.cells.type || 'text'
|
||||
const tagName = row.cells.tagName || ''
|
||||
|
||||
// Check if this is an existing tag (should be read-only)
|
||||
const existingTag = tagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === tagName.toLowerCase()
|
||||
)
|
||||
const isReadOnly = !!existingTag
|
||||
|
||||
const showTypeDropdown = typeDropdownStates[rowIndex] || false
|
||||
|
||||
const setShowTypeDropdown = (show: boolean) => {
|
||||
setTypeDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
|
||||
}
|
||||
|
||||
const handleTypeDropdownClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled && !isReadOnly) {
|
||||
if (!showTypeDropdown) {
|
||||
setShowTypeDropdown(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTypeFocus = () => {
|
||||
if (!disabled && !isReadOnly) {
|
||||
setShowTypeDropdown(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTypeBlur = () => {
|
||||
// Delay closing to allow dropdown selection
|
||||
setTimeout(() => setShowTypeDropdown(false), 150)
|
||||
}
|
||||
|
||||
const typeOptions = [{ value: 'text', label: 'Text' }]
|
||||
|
||||
return (
|
||||
<td className='border-r p-1'>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
value={cellValue}
|
||||
readOnly
|
||||
disabled={disabled || isReadOnly}
|
||||
autoComplete='off'
|
||||
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
onClick={handleTypeDropdownClick}
|
||||
onFocus={handleTypeFocus}
|
||||
onBlur={handleTypeBlur}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
||||
<div className='whitespace-pre text-muted-foreground'>
|
||||
{formatDisplayText(cellValue, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{showTypeDropdown && !isReadOnly && (
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
|
||||
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
|
||||
<div
|
||||
className='allow-scroll max-h-48 overflow-y-auto p-1'
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
{typeOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleCellChange(rowIndex, 'type', option.value)
|
||||
setShowTypeDropdown(false)
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{option.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
|
||||
<Combobox
|
||||
options={tagOptions}
|
||||
value={cellValue}
|
||||
onChange={(value) => handleTagSelection(rowIndex, value)}
|
||||
disabled={disabled || isLoading}
|
||||
placeholder='Select tag'
|
||||
className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate'
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
const renderValueCell = (row: DocumentTagRow, rowIndex: number) => {
|
||||
const cellValue = row.cells.value || ''
|
||||
const fieldType = row.cells.fieldType || 'text'
|
||||
const cellKey = `value-${rowIndex}`
|
||||
const placeholder = getPlaceholderForFieldType(fieldType)
|
||||
const isTagSelected = !!row.cells.tagName?.trim()
|
||||
|
||||
const fieldState = inputController.fieldHelpers.getFieldState(cellKey)
|
||||
const handlers = inputController.fieldHelpers.createFieldHandlers(
|
||||
cellKey,
|
||||
cellValue,
|
||||
(newValue) => handleCellChange(rowIndex, 'value', newValue)
|
||||
(newValue) => handleValueChange(rowIndex, newValue)
|
||||
)
|
||||
const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
|
||||
cellKey,
|
||||
cellValue,
|
||||
(newValue) => handleTagDropdownSelection(rowIndex, 'value', newValue)
|
||||
(newValue) => handleTagDropdownSelection(rowIndex, newValue)
|
||||
)
|
||||
|
||||
return (
|
||||
<td className='p-1'>
|
||||
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
@@ -466,12 +305,13 @@ export function DocumentTagEntry({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
disabled={disabled}
|
||||
disabled={disabled || !isTagSelected}
|
||||
autoComplete='off'
|
||||
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
placeholder={isTagSelected ? placeholder : 'Select a tag first'}
|
||||
className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
||||
<div className='whitespace-pre'>
|
||||
<div className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'>
|
||||
<div className='whitespace-pre py-[8px] font-medium text-[var(--text-primary)] text-sm leading-[21px]'>
|
||||
{formatDisplayText(cellValue, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
@@ -500,49 +340,33 @@ export function DocumentTagEntry({
|
||||
}
|
||||
|
||||
const renderDeleteButton = (rowIndex: number) => {
|
||||
// Allow deletion of any row
|
||||
const canDelete = !isPreview && !disabled
|
||||
if (isPreview || disabled) return null
|
||||
|
||||
return canDelete ? (
|
||||
return (
|
||||
<td className='w-0 p-0'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-[8px] transition-opacity'
|
||||
onClick={() => handleDeleteRow(rowIndex)}
|
||||
>
|
||||
<Trash className='h-4 w-4 text-muted-foreground' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</td>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
// Show pre-fill button if there are available tags and only empty rows
|
||||
const showPreFillButton =
|
||||
tagDefinitions.length > 0 &&
|
||||
rows.length === 1 &&
|
||||
!rows[0].cells.tagName &&
|
||||
!rows[0].cells.value &&
|
||||
!isPreview &&
|
||||
!disabled
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
{showPreFillButton && (
|
||||
<div className='mb-2'>
|
||||
<Button variant='outline' size='sm' onClick={handlePreFillTags}>
|
||||
Prefill Existing Tags
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className='overflow-visible rounded-md border'>
|
||||
<table className='w-full'>
|
||||
<div className='relative w-full'>
|
||||
<div className='overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-2)] dark:bg-[#1F1F1F]'>
|
||||
<table className='w-full table-fixed bg-transparent'>
|
||||
{renderHeader()}
|
||||
<tbody>
|
||||
<tbody className='bg-transparent'>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={row.id} className='group relative border-t'>
|
||||
<tr
|
||||
key={row.id}
|
||||
className='group relative border-[var(--border-strong)] border-t bg-transparent'
|
||||
>
|
||||
{renderTagNameCell(row, rowIndex)}
|
||||
{renderTypeCell(row, rowIndex)}
|
||||
{renderValueCell(row, rowIndex)}
|
||||
{renderDeleteButton(rowIndex)}
|
||||
</tr>
|
||||
@@ -551,24 +375,13 @@ export function DocumentTagEntry({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add Row Button and Tag slots usage indicator */}
|
||||
{/* Add Tag Button */}
|
||||
{!isPreview && !disabled && (
|
||||
<div className='mt-3 flex items-center justify-between'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleAddRow}
|
||||
disabled={!canAddMoreTags}
|
||||
className='h-7 px-2 text-xs'
|
||||
>
|
||||
<div className='mt-3'>
|
||||
<Button onClick={handleAddRow} disabled={!canAddMoreTags} className='h-7 px-2 text-xs'>
|
||||
<Plus className='mr-1 h-2.5 w-2.5' />
|
||||
Add Tag
|
||||
</Button>
|
||||
|
||||
{/* Tag slots usage indicator */}
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{tagDefinitions.length + newTagsBeingCreated} of {MAX_TAG_SLOTS} tag slots used
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants'
|
||||
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import {
|
||||
checkTagTrigger,
|
||||
@@ -20,14 +20,22 @@ import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
||||
interface TagFilter {
|
||||
id: string
|
||||
tagName: string
|
||||
tagSlot?: string
|
||||
fieldType: FilterFieldType
|
||||
operator: string
|
||||
tagValue: string
|
||||
valueTo?: string // For 'between' operator
|
||||
}
|
||||
|
||||
interface TagFilterRow {
|
||||
id: string
|
||||
cells: {
|
||||
tagName: string
|
||||
tagSlot?: string
|
||||
fieldType: FilterFieldType
|
||||
operator: string
|
||||
value: string
|
||||
valueTo?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,21 +55,15 @@ export function KnowledgeTagFilters({
|
||||
previewValue,
|
||||
}: KnowledgeTagFiltersProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
|
||||
// Hook for immediate tag/dropdown selections
|
||||
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
||||
|
||||
// Get the knowledge base ID from other sub-blocks
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseId = knowledgeBaseIdValue || null
|
||||
|
||||
// Use KB tag definitions hook to get available tags
|
||||
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
// Get accessible prefixes for variable highlighting
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
// State for managing tag dropdown
|
||||
const [activeTagDropdown, setActiveTagDropdown] = useState<{
|
||||
rowIndex: number
|
||||
showTags: boolean
|
||||
@@ -70,14 +72,15 @@ export function KnowledgeTagFilters({
|
||||
element?: HTMLElement | null
|
||||
} | null>(null)
|
||||
|
||||
// State for dropdown visibility - one for each row
|
||||
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
|
||||
|
||||
// Parse the current value to extract filters
|
||||
const parseFilters = (filterValue: string | null): TagFilter[] => {
|
||||
if (!filterValue) return []
|
||||
try {
|
||||
return JSON.parse(filterValue)
|
||||
const parsed = JSON.parse(filterValue)
|
||||
return parsed.map((f: TagFilter) => ({
|
||||
...f,
|
||||
fieldType: f.fieldType || 'text',
|
||||
operator: f.operator || 'eq',
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -86,20 +89,23 @@ export function KnowledgeTagFilters({
|
||||
const currentValue = isPreview ? previewValue : storeValue
|
||||
const filters = parseFilters(currentValue || null)
|
||||
|
||||
// Transform filters to table format for display
|
||||
const rows: TagFilterRow[] =
|
||||
filters.length > 0
|
||||
? filters.map((filter) => ({
|
||||
id: filter.id,
|
||||
cells: {
|
||||
tagName: filter.tagName || '',
|
||||
tagSlot: filter.tagSlot,
|
||||
fieldType: filter.fieldType || 'text',
|
||||
operator: filter.operator || 'eq',
|
||||
value: filter.tagValue || '',
|
||||
valueTo: filter.valueTo,
|
||||
},
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: 'empty-row-0',
|
||||
cells: { tagName: '', value: '' },
|
||||
cells: { tagName: '', fieldType: 'text', operator: '', value: '' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -109,27 +115,72 @@ export function KnowledgeTagFilters({
|
||||
setStoreValue(value)
|
||||
}
|
||||
|
||||
const handleCellChange = (rowIndex: number, column: string, value: string) => {
|
||||
const rowsToFilters = (rowsToConvert: TagFilterRow[]): TagFilter[] => {
|
||||
return rowsToConvert
|
||||
.filter((row) => row.cells.tagName?.trim())
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
tagName: row.cells.tagName || '',
|
||||
tagSlot: row.cells.tagSlot,
|
||||
fieldType: row.cells.fieldType || 'text',
|
||||
operator: row.cells.operator || 'eq',
|
||||
tagValue: row.cells.value || '',
|
||||
valueTo: row.cells.valueTo,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleCellChange = (rowIndex: number, column: string, value: string | FilterFieldType) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const updatedRows = [...rows].map((row, idx) => {
|
||||
if (idx === rowIndex) {
|
||||
const newCells = { ...row.cells, [column]: value }
|
||||
|
||||
if (column === 'fieldType') {
|
||||
const operators = getOperatorsForFieldType(value as FilterFieldType)
|
||||
newCells.operator = operators[0]?.value || 'eq'
|
||||
newCells.value = ''
|
||||
newCells.valueTo = undefined
|
||||
}
|
||||
|
||||
if (column === 'operator' && value !== 'between') {
|
||||
newCells.valueTo = undefined
|
||||
}
|
||||
|
||||
return { ...row, cells: newCells }
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
updateFilters(rowsToFilters(updatedRows))
|
||||
}
|
||||
|
||||
const handleTagNameSelection = (rowIndex: number, tagName: string) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const tagDef = tagDefinitions.find((t) => t.displayName === tagName)
|
||||
const fieldType = (tagDef?.fieldType || 'text') as FilterFieldType
|
||||
const operators = getOperatorsForFieldType(fieldType)
|
||||
|
||||
const updatedRows = [...rows].map((row, idx) => {
|
||||
if (idx === rowIndex) {
|
||||
return {
|
||||
...row,
|
||||
cells: { ...row.cells, [column]: value },
|
||||
cells: {
|
||||
...row.cells,
|
||||
tagName,
|
||||
tagSlot: tagDef?.tagSlot,
|
||||
fieldType,
|
||||
operator: operators[0]?.value || 'eq',
|
||||
value: '',
|
||||
valueTo: undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
// Convert back to TagFilter format - keep all rows, even empty ones
|
||||
const updatedFilters = updatedRows.map((row) => ({
|
||||
id: row.id,
|
||||
tagName: row.cells.tagName || '',
|
||||
tagValue: row.cells.value || '',
|
||||
}))
|
||||
|
||||
updateFilters(updatedFilters)
|
||||
updateFilters(rowsToFilters(updatedRows))
|
||||
}
|
||||
|
||||
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
|
||||
@@ -145,36 +196,36 @@ export function KnowledgeTagFilters({
|
||||
return row
|
||||
})
|
||||
|
||||
// Convert back to TagFilter format - keep all rows, even empty ones
|
||||
const updatedFilters = updatedRows.map((row) => ({
|
||||
id: row.id,
|
||||
tagName: row.cells.tagName || '',
|
||||
tagValue: row.cells.value || '',
|
||||
}))
|
||||
|
||||
const jsonValue = updatedFilters.length > 0 ? JSON.stringify(updatedFilters) : null
|
||||
const jsonValue =
|
||||
rowsToFilters(updatedRows).length > 0 ? JSON.stringify(rowsToFilters(updatedRows)) : null
|
||||
emitTagSelection(jsonValue)
|
||||
}
|
||||
|
||||
const handleAddRow = () => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const newRowId = `filter-${filters.length}-${Math.random().toString(36).substr(2, 9)}`
|
||||
const newFilters = [...filters, { id: newRowId, tagName: '', tagValue: '' }]
|
||||
updateFilters(newFilters)
|
||||
const newRowId = `filter-${filters.length}-${Math.random().toString(36).slice(2, 11)}`
|
||||
const newFilter: TagFilter = {
|
||||
id: newRowId,
|
||||
tagName: '',
|
||||
fieldType: 'text',
|
||||
operator: 'eq',
|
||||
tagValue: '',
|
||||
}
|
||||
updateFilters([...filters, newFilter])
|
||||
}
|
||||
|
||||
const handleDeleteRow = (rowIndex: number) => {
|
||||
if (isPreview || disabled || rows.length <= 1) return
|
||||
if (isPreview || disabled) return
|
||||
|
||||
if (rows.length <= 1) {
|
||||
// Clear the single row instead of deleting
|
||||
setStoreValue(null)
|
||||
return
|
||||
}
|
||||
|
||||
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
|
||||
|
||||
const updatedFilters = updatedRows.map((row) => ({
|
||||
id: row.id,
|
||||
tagName: row.cells.tagName || '',
|
||||
tagValue: row.cells.value || '',
|
||||
}))
|
||||
|
||||
updateFilters(updatedFilters)
|
||||
updateFilters(rowsToFilters(updatedRows))
|
||||
}
|
||||
|
||||
if (isPreview) {
|
||||
@@ -191,108 +242,88 @@ export function KnowledgeTagFilters({
|
||||
}
|
||||
|
||||
const renderHeader = () => (
|
||||
<thead>
|
||||
<tr className='border-b'>
|
||||
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
|
||||
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
|
||||
<thead className='bg-transparent'>
|
||||
<tr className='border-[var(--border-strong)] border-b bg-transparent'>
|
||||
<th className='w-[35%] min-w-0 border-[var(--border-strong)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Tag
|
||||
</th>
|
||||
<th className='w-[35%] min-w-0 border-[var(--border-strong)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Operator
|
||||
</th>
|
||||
<th className='w-[30%] min-w-0 bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
|
||||
const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => {
|
||||
const cellValue = row.cells.tagName || ''
|
||||
const showDropdown = dropdownStates[rowIndex] || false
|
||||
|
||||
const setShowDropdown = (show: boolean) => {
|
||||
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
|
||||
}
|
||||
|
||||
const handleDropdownClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled && !isLoading) {
|
||||
if (!showDropdown) {
|
||||
setShowDropdown(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (!disabled && !isLoading) {
|
||||
setShowDropdown(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// Delay closing to allow dropdown selection
|
||||
setTimeout(() => setShowDropdown(false), 150)
|
||||
}
|
||||
const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({
|
||||
value: tag.displayName,
|
||||
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<td className='relative border-r p-1'>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
value={cellValue}
|
||||
readOnly
|
||||
disabled={disabled || isLoading}
|
||||
autoComplete='off'
|
||||
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
onClick={handleDropdownClick}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
||||
<div className='whitespace-pre'>
|
||||
{formatDisplayText(cellValue || 'Select tag', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{showDropdown && tagDefinitions.length > 0 && (
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
|
||||
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
|
||||
<div
|
||||
className='allow-scroll max-h-48 overflow-y-auto p-1'
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
{tagDefinitions.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleCellChange(rowIndex, 'tagName', tag.displayName)
|
||||
setShowDropdown(false)
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{tag.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
|
||||
<Combobox
|
||||
options={tagOptions}
|
||||
value={cellValue}
|
||||
onChange={(value) => handleTagNameSelection(rowIndex, value)}
|
||||
disabled={disabled || isLoading}
|
||||
placeholder='Select tag'
|
||||
className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate'
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
const renderOperatorCell = (row: TagFilterRow, rowIndex: number) => {
|
||||
const fieldType = row.cells.fieldType || 'text'
|
||||
const operator = row.cells.operator || ''
|
||||
const operators = getOperatorsForFieldType(fieldType)
|
||||
const isOperatorDisabled = disabled || !row.cells.tagName
|
||||
|
||||
const operatorOptions: ComboboxOption[] = operators.map((op) => ({
|
||||
value: op.value,
|
||||
label: op.label,
|
||||
}))
|
||||
|
||||
return (
|
||||
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
|
||||
<Combobox
|
||||
options={operatorOptions}
|
||||
value={operator}
|
||||
onChange={(value) => handleCellChange(rowIndex, 'operator', value)}
|
||||
disabled={isOperatorDisabled}
|
||||
placeholder='Select operator'
|
||||
className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate'
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
const renderValueCell = (row: TagFilterRow, rowIndex: number) => {
|
||||
const cellValue = row.cells.value || ''
|
||||
const fieldType = row.cells.fieldType || 'text'
|
||||
const operator = row.cells.operator || 'eq'
|
||||
const isBetween = operator === 'between'
|
||||
const valueTo = row.cells.valueTo || ''
|
||||
const isDisabled = disabled || !row.cells.tagName
|
||||
const placeholder = getPlaceholderForFieldType(fieldType)
|
||||
|
||||
return (
|
||||
<td className='p-1'>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
value={cellValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value
|
||||
const cursorPosition = e.target.selectionStart ?? 0
|
||||
const renderInput = (value: string, column: 'value' | 'valueTo') => (
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value
|
||||
const cursorPosition = e.target.selectionStart ?? 0
|
||||
|
||||
handleCellChange(rowIndex, 'value', newValue)
|
||||
handleCellChange(rowIndex, column, newValue)
|
||||
|
||||
// Check for tag trigger
|
||||
if (column === 'value') {
|
||||
const tagTrigger = checkTagTrigger(newValue, cursorPosition)
|
||||
|
||||
setActiveTagDropdown({
|
||||
@@ -302,58 +333,78 @@ export function KnowledgeTagFilters({
|
||||
activeSourceBlockId: null,
|
||||
element: e.target,
|
||||
})
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
if (!disabled) {
|
||||
setActiveTagDropdown({
|
||||
rowIndex,
|
||||
showTags: false,
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
element: e.target,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
}
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
if (!isDisabled && column === 'value') {
|
||||
setActiveTagDropdown({
|
||||
rowIndex,
|
||||
showTags: false,
|
||||
cursorPosition: 0,
|
||||
activeSourceBlockId: null,
|
||||
element: e.target,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (column === 'value') {
|
||||
setTimeout(() => setActiveTagDropdown(null), 200)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setActiveTagDropdown(null)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
autoComplete='off'
|
||||
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
||||
<div className='whitespace-pre'>
|
||||
{formatDisplayText(cellValue, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setActiveTagDropdown(null)
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
autoComplete='off'
|
||||
placeholder={placeholder}
|
||||
className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<div className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'>
|
||||
<div className='whitespace-pre py-[8px] font-medium text-[var(--text-primary)] text-sm leading-[21px]'>
|
||||
{formatDisplayText(value || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isBetween) {
|
||||
return (
|
||||
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
|
||||
<div className='flex items-center gap-1 px-[10px]'>
|
||||
{renderInput(cellValue, 'value')}
|
||||
<span className='flex-shrink-0 text-muted-foreground text-xs'>to</span>
|
||||
{renderInput(valueTo, 'valueTo')}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
|
||||
{renderInput(cellValue, 'value')}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
const renderDeleteButton = (rowIndex: number) => {
|
||||
const canDelete = !isPreview && !disabled
|
||||
if (isPreview || disabled) return null
|
||||
|
||||
return canDelete ? (
|
||||
return (
|
||||
<td className='w-0 p-0'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-[8px] transition-opacity'
|
||||
onClick={() => handleDeleteRow(rowIndex)}
|
||||
>
|
||||
<Trash className='h-4 w-4 text-muted-foreground' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</td>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -361,14 +412,18 @@ export function KnowledgeTagFilters({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='overflow-visible rounded-md border'>
|
||||
<table className='w-full'>
|
||||
<div className='relative w-full'>
|
||||
<div className='overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-2)] dark:bg-[#1F1F1F]'>
|
||||
<table className='w-full table-fixed bg-transparent'>
|
||||
{renderHeader()}
|
||||
<tbody>
|
||||
<tbody className='bg-transparent'>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={row.id} className='group relative border-t'>
|
||||
<tr
|
||||
key={row.id}
|
||||
className='group relative border-[var(--border-strong)] border-t bg-transparent'
|
||||
>
|
||||
{renderTagNameCell(row, rowIndex)}
|
||||
{renderOperatorCell(row, rowIndex)}
|
||||
{renderValueCell(row, rowIndex)}
|
||||
{renderDeleteButton(rowIndex)}
|
||||
</tr>
|
||||
@@ -400,7 +455,7 @@ export function KnowledgeTagFilters({
|
||||
{/* Add Filter Button */}
|
||||
{!isPreview && !disabled && (
|
||||
<div className='mt-3 flex items-center justify-between'>
|
||||
<Button variant='outline' size='sm' onClick={handleAddRow} className='h-7 px-2 text-xs'>
|
||||
<Button onClick={handleAddRow} className='h-7 px-2 text-xs'>
|
||||
<Plus className='mr-1 h-2.5 w-2.5' />
|
||||
Add Filter
|
||||
</Button>
|
||||
|
||||
@@ -982,6 +982,11 @@ export function ToolInput({
|
||||
if (hasMultipleOperations(blockType)) {
|
||||
return false
|
||||
}
|
||||
// Allow multiple instances for workflow and knowledge blocks
|
||||
// Each instance can target a different workflow/knowledge base
|
||||
if (blockType === 'workflow' || blockType === 'knowledge') {
|
||||
return false
|
||||
}
|
||||
return selectedTools.some((tool) => tool.toolId === toolId)
|
||||
}
|
||||
|
||||
|
||||
@@ -134,29 +134,111 @@ const isMessagesArray = (value: unknown): value is Array<{ role: string; content
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for tag filter array (used in knowledge block filters)
|
||||
*/
|
||||
interface TagFilterItem {
|
||||
id: string
|
||||
tagName: string
|
||||
fieldType?: string
|
||||
operator?: string
|
||||
tagValue: string
|
||||
}
|
||||
|
||||
const isTagFilterArray = (value: unknown): value is TagFilterItem[] => {
|
||||
if (!Array.isArray(value) || value.length === 0) return false
|
||||
const firstItem = value[0]
|
||||
return (
|
||||
typeof firstItem === 'object' &&
|
||||
firstItem !== null &&
|
||||
'tagName' in firstItem &&
|
||||
'tagValue' in firstItem
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for document tag entry array (used in knowledge block create document)
|
||||
*/
|
||||
interface DocumentTagItem {
|
||||
id: string
|
||||
tagName: string
|
||||
fieldType?: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const isDocumentTagArray = (value: unknown): value is DocumentTagItem[] => {
|
||||
if (!Array.isArray(value) || value.length === 0) return false
|
||||
const firstItem = value[0]
|
||||
return (
|
||||
typeof firstItem === 'object' &&
|
||||
firstItem !== null &&
|
||||
'tagName' in firstItem &&
|
||||
'value' in firstItem &&
|
||||
!('tagValue' in firstItem) // Distinguish from tag filters
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse a JSON string, returns the parsed value or the original value if parsing fails
|
||||
*/
|
||||
const tryParseJson = (value: unknown): unknown => {
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
const trimmed = value.trim()
|
||||
if (
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']')) ||
|
||||
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|
||||
) {
|
||||
return JSON.parse(trimmed)
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, return original
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a subblock value for display, intelligently handling nested objects and arrays.
|
||||
*/
|
||||
const getDisplayValue = (value: unknown): string => {
|
||||
if (value == null || value === '') return '-'
|
||||
|
||||
if (isMessagesArray(value)) {
|
||||
const firstMessage = value[0]
|
||||
// Try parsing JSON strings first
|
||||
const parsedValue = tryParseJson(value)
|
||||
|
||||
if (isMessagesArray(parsedValue)) {
|
||||
const firstMessage = parsedValue[0]
|
||||
if (!firstMessage?.content || firstMessage.content.trim() === '') return '-'
|
||||
const content = firstMessage.content.trim()
|
||||
return content.length > 50 ? `${content.slice(0, 50)}...` : content
|
||||
}
|
||||
|
||||
if (isVariableAssignmentsArray(value)) {
|
||||
const names = value.map((a) => a.variableName).filter((name): name is string => !!name)
|
||||
if (isVariableAssignmentsArray(parsedValue)) {
|
||||
const names = parsedValue.map((a) => a.variableName).filter((name): name is string => !!name)
|
||||
if (names.length === 0) return '-'
|
||||
if (names.length === 1) return names[0]
|
||||
if (names.length === 2) return `${names[0]}, ${names[1]}`
|
||||
return `${names[0]}, ${names[1]} +${names.length - 2}`
|
||||
}
|
||||
|
||||
if (isTableRowArray(value)) {
|
||||
const nonEmptyRows = value.filter((row) => {
|
||||
if (isTagFilterArray(parsedValue)) {
|
||||
const validFilters = parsedValue.filter((f) => f.tagName?.trim())
|
||||
if (validFilters.length === 0) return '-'
|
||||
if (validFilters.length === 1) return validFilters[0].tagName
|
||||
if (validFilters.length === 2) return `${validFilters[0].tagName}, ${validFilters[1].tagName}`
|
||||
return `${validFilters[0].tagName}, ${validFilters[1].tagName} +${validFilters.length - 2}`
|
||||
}
|
||||
|
||||
if (isDocumentTagArray(parsedValue)) {
|
||||
const validTags = parsedValue.filter((t) => t.tagName?.trim())
|
||||
if (validTags.length === 0) return '-'
|
||||
if (validTags.length === 1) return validTags[0].tagName
|
||||
if (validTags.length === 2) return `${validTags[0].tagName}, ${validTags[1].tagName}`
|
||||
return `${validTags[0].tagName}, ${validTags[1].tagName} +${validTags.length - 2}`
|
||||
}
|
||||
|
||||
if (isTableRowArray(parsedValue)) {
|
||||
const nonEmptyRows = parsedValue.filter((row) => {
|
||||
const cellValues = Object.values(row.cells)
|
||||
return cellValues.some((cell) => cell && cell.trim() !== '')
|
||||
})
|
||||
@@ -175,16 +257,16 @@ const getDisplayValue = (value: unknown): string => {
|
||||
return `${nonEmptyRows.length} rows`
|
||||
}
|
||||
|
||||
if (isFieldFormatArray(value)) {
|
||||
const namedFields = value.filter((field) => field.name && field.name.trim() !== '')
|
||||
if (isFieldFormatArray(parsedValue)) {
|
||||
const namedFields = parsedValue.filter((field) => field.name && field.name.trim() !== '')
|
||||
if (namedFields.length === 0) return '-'
|
||||
if (namedFields.length === 1) return namedFields[0].name
|
||||
if (namedFields.length === 2) return `${namedFields[0].name}, ${namedFields[1].name}`
|
||||
return `${namedFields[0].name}, ${namedFields[1].name} +${namedFields.length - 2}`
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
const entries = Object.entries(value).filter(
|
||||
if (isPlainObject(parsedValue)) {
|
||||
const entries = Object.entries(parsedValue).filter(
|
||||
([, val]) => val !== null && val !== undefined && val !== ''
|
||||
)
|
||||
|
||||
@@ -201,8 +283,10 @@ const getDisplayValue = (value: unknown): string => {
|
||||
return entries.length > 2 ? `${preview} +${entries.length - 2}` : preview
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const nonEmptyItems = value.filter((item) => item !== null && item !== undefined && item !== '')
|
||||
if (Array.isArray(parsedValue)) {
|
||||
const nonEmptyItems = parsedValue.filter(
|
||||
(item) => item !== null && item !== undefined && item !== ''
|
||||
)
|
||||
if (nonEmptyItems.length === 0) return '-'
|
||||
|
||||
const getItemDisplayValue = (item: unknown): string => {
|
||||
@@ -220,10 +304,11 @@ const getDisplayValue = (value: unknown): string => {
|
||||
return `${getItemDisplayValue(nonEmptyItems[0])}, ${getItemDisplayValue(nonEmptyItems[1])} +${nonEmptyItems.length - 2}`
|
||||
}
|
||||
|
||||
// For non-array, non-object values, use original value for string conversion
|
||||
const stringValue = String(value)
|
||||
if (stringValue === '[object Object]') {
|
||||
try {
|
||||
const json = JSON.stringify(value)
|
||||
const json = JSON.stringify(parsedValue)
|
||||
if (json.length <= 40) return json
|
||||
return `${json.slice(0, 37)}...`
|
||||
} catch {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
import { DEFAULT_HORIZONTAL_SPACING } from '@/lib/workflows/autolayout/constants'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
||||
import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
@@ -90,7 +92,6 @@ const edgeTypes: EdgeTypes = {
|
||||
|
||||
/** ReactFlow configuration constants. */
|
||||
const defaultEdgeOptions = { type: 'custom' }
|
||||
const snapGrid: [number, number] = [20, 20]
|
||||
const reactFlowFitViewOptions = { padding: 0.6 } as const
|
||||
const reactFlowProOptions = { hideAttribution: true } as const
|
||||
|
||||
@@ -158,6 +159,14 @@ const WorkflowContent = React.memo(() => {
|
||||
// Training modal state
|
||||
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
|
||||
|
||||
// Snap to grid settings
|
||||
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
|
||||
const snapToGrid = snapToGridSize > 0
|
||||
const snapGrid: [number, number] = useMemo(
|
||||
() => [snapToGridSize, snapToGridSize],
|
||||
[snapToGridSize]
|
||||
)
|
||||
|
||||
// Handle copilot stream cleanup on page unload and component unmount
|
||||
useStreamCleanup(copilotCleanup)
|
||||
|
||||
@@ -523,7 +532,7 @@ const WorkflowContent = React.memo(() => {
|
||||
useEffect(() => {
|
||||
const handleRemoveFromSubflow = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ blockId: string }>
|
||||
const { blockId } = customEvent.detail || ({} as any)
|
||||
const blockId = customEvent.detail?.blockId
|
||||
if (!blockId) return
|
||||
|
||||
try {
|
||||
@@ -555,6 +564,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const candidates = Object.entries(blocks)
|
||||
.filter(([id, block]) => {
|
||||
if (!block.enabled) return false
|
||||
if (block.type === 'response') return false
|
||||
const node = nodeIndex.get(id)
|
||||
if (!node) return false
|
||||
|
||||
@@ -601,6 +611,152 @@ const WorkflowContent = React.memo(() => {
|
||||
return 'source'
|
||||
}, [])
|
||||
|
||||
/** Creates a standardized edge object for workflow connections. */
|
||||
const createEdgeObject = useCallback(
|
||||
(sourceId: string, targetId: string, sourceHandle: string): Edge => ({
|
||||
id: crypto.randomUUID(),
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
/** Gets the appropriate start handle for a container node (loop or parallel). */
|
||||
const getContainerStartHandle = useCallback(
|
||||
(containerId: string): string => {
|
||||
const containerNode = getNodes().find((n) => n.id === containerId)
|
||||
return (containerNode?.data as SubflowNodeData)?.kind === 'loop'
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
},
|
||||
[getNodes]
|
||||
)
|
||||
|
||||
/** Finds the closest non-response block to a position within a set of blocks. */
|
||||
const findClosestBlockInSet = useCallback(
|
||||
(
|
||||
candidateBlocks: { id: string; type: string; position: { x: number; y: number } }[],
|
||||
targetPosition: { x: number; y: number }
|
||||
): { id: string; type: string; position: { x: number; y: number } } | undefined => {
|
||||
return candidateBlocks
|
||||
.filter((b) => b.type !== 'response')
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - targetPosition.x) ** 2 + (b.position.y - targetPosition.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Attempts to create an auto-connect edge for a new block being added.
|
||||
* Returns the edge object if auto-connect should occur, or undefined otherwise.
|
||||
*
|
||||
* @param position - The position where the new block will be placed
|
||||
* @param targetBlockId - The ID of the new block being added
|
||||
* @param options - Configuration for auto-connect behavior
|
||||
*/
|
||||
const tryCreateAutoConnectEdge = useCallback(
|
||||
(
|
||||
position: { x: number; y: number },
|
||||
targetBlockId: string,
|
||||
options: {
|
||||
blockType: string
|
||||
enableTriggerMode?: boolean
|
||||
targetParentId?: string | null
|
||||
existingChildBlocks?: { id: string; type: string; position: { x: number; y: number } }[]
|
||||
containerId?: string
|
||||
}
|
||||
): Edge | undefined => {
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
if (!isAutoConnectEnabled) return undefined
|
||||
|
||||
// Don't auto-connect starter or annotation-only blocks
|
||||
if (options.blockType === 'starter' || isAnnotationOnlyBlock(options.blockType)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Check if target is a trigger block
|
||||
const targetBlockConfig = getBlock(options.blockType)
|
||||
const isTargetTrigger =
|
||||
options.enableTriggerMode || targetBlockConfig?.category === 'triggers'
|
||||
if (isTargetTrigger) return undefined
|
||||
|
||||
// Case 1: Adding block inside a container with existing children
|
||||
if (options.existingChildBlocks && options.existingChildBlocks.length > 0) {
|
||||
const closestBlock = findClosestBlockInSet(options.existingChildBlocks, position)
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle({
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Case 2: Adding block inside an empty container - connect from container start
|
||||
if (
|
||||
options.containerId &&
|
||||
(!options.existingChildBlocks || options.existingChildBlocks.length === 0)
|
||||
) {
|
||||
const startHandle = getContainerStartHandle(options.containerId)
|
||||
return createEdgeObject(options.containerId, targetBlockId, startHandle)
|
||||
}
|
||||
|
||||
// Case 3: Adding block at root level - use findClosestOutput
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (!closestBlock) return undefined
|
||||
|
||||
// Don't create cross-container edges
|
||||
const closestBlockParentId = blocks[closestBlock.id]?.data?.parentId
|
||||
if (closestBlockParentId && !options.targetParentId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle)
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
createEdgeObject,
|
||||
getContainerStartHandle,
|
||||
findClosestBlockInSet,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks if adding a trigger block would violate constraints and shows notification if so.
|
||||
* @returns true if validation failed (caller should return early), false if ok to proceed
|
||||
*/
|
||||
const checkTriggerConstraints = useCallback(
|
||||
(blockType: string): boolean => {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
|
||||
if (issue) {
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[blocks, addNotification, activeWorkflowId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared handler for drops of toolbar items onto the workflow canvas.
|
||||
*
|
||||
@@ -629,21 +785,10 @@ const WorkflowContent = React.memo(() => {
|
||||
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (closestBlock) {
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle: determineSourceHandle(closestBlock),
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||
blockType: data.type,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
addBlock(
|
||||
id,
|
||||
@@ -651,8 +796,8 @@ const WorkflowContent = React.memo(() => {
|
||||
name,
|
||||
position,
|
||||
{
|
||||
width: 500,
|
||||
height: 300,
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
type: 'subflowNode',
|
||||
},
|
||||
undefined,
|
||||
@@ -674,12 +819,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const id = crypto.randomUUID()
|
||||
// Prefer semantic default names for triggers; then ensure unique numbering centrally
|
||||
const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type)
|
||||
const baseName =
|
||||
data.type === 'loop'
|
||||
? 'Loop'
|
||||
: data.type === 'parallel'
|
||||
? 'Parallel'
|
||||
: defaultTriggerNameDrop || blockConfig!.name
|
||||
const baseName = defaultTriggerNameDrop || blockConfig.name
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
if (containerInfo) {
|
||||
@@ -711,70 +851,18 @@ const WorkflowContent = React.memo(() => {
|
||||
estimateBlockDimensions(data.type)
|
||||
)
|
||||
|
||||
// Capture existing child blocks before adding the new one
|
||||
const existingChildBlocks = Object.values(blocks).filter(
|
||||
(b) => b.data?.parentId === containerInfo.loopId
|
||||
)
|
||||
// Capture existing child blocks for auto-connect
|
||||
const existingChildBlocks = Object.values(blocks)
|
||||
.filter((b) => b.data?.parentId === containerInfo.loopId)
|
||||
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
|
||||
|
||||
// Auto-connect logic for blocks inside containers
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (
|
||||
isAutoConnectEnabled &&
|
||||
data.type !== 'starter' &&
|
||||
!isAnnotationOnlyBlock(data.type)
|
||||
) {
|
||||
if (existingChildBlocks.length > 0) {
|
||||
// Connect to the nearest existing child block within the container
|
||||
const closestBlock = existingChildBlocks
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - relativePosition.x) ** 2 +
|
||||
(b.position.y - relativePosition.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = getBlock(data.type)
|
||||
const isTargetTrigger =
|
||||
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle({
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No existing children: connect from the container's start handle to the moved node
|
||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||
const startSourceHandle =
|
||||
(containerNode?.data as any)?.kind === 'loop'
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: containerInfo.loopId,
|
||||
target: id,
|
||||
sourceHandle: startSourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
|
||||
blockType: data.type,
|
||||
enableTriggerMode: data.enableTriggerMode,
|
||||
targetParentId: containerInfo.loopId,
|
||||
existingChildBlocks,
|
||||
containerId: containerInfo.loopId,
|
||||
})
|
||||
|
||||
// Add block with parent info AND autoConnectEdge (atomic operation)
|
||||
addBlock(
|
||||
@@ -796,49 +884,13 @@ const WorkflowContent = React.memo(() => {
|
||||
resizeLoopNodesWrapper()
|
||||
} else {
|
||||
// Centralized trigger constraints
|
||||
const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type)
|
||||
if (dropIssue) {
|
||||
const message =
|
||||
dropIssue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${dropIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (checkTriggerConstraints(data.type)) return
|
||||
|
||||
// Regular auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (
|
||||
isAutoConnectEnabled &&
|
||||
data.type !== 'starter' &&
|
||||
!isAnnotationOnlyBlock(data.type)
|
||||
) {
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = getBlock(data.type)
|
||||
const isTargetTrigger =
|
||||
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||
blockType: data.type,
|
||||
enableTriggerMode: data.enableTriggerMode,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
// Regular canvas drop with auto-connect edge
|
||||
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
|
||||
@@ -861,14 +913,13 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
getNodes,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
isPointInLoopNode,
|
||||
resizeLoopNodesWrapper,
|
||||
addBlock,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
tryCreateAutoConnectEdge,
|
||||
checkTriggerConstraints,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -885,44 +936,73 @@ const WorkflowContent = React.memo(() => {
|
||||
if (!type) return
|
||||
if (type === 'connectionBlock') return
|
||||
|
||||
// Calculate smart position - to the right of existing root-level blocks
|
||||
const calculateSmartPosition = (): { x: number; y: number } => {
|
||||
// Get all root-level blocks (no parentId)
|
||||
const rootBlocks = Object.values(blocks).filter((b) => !b.data?.parentId)
|
||||
|
||||
if (rootBlocks.length === 0) {
|
||||
// No blocks yet, use viewport center
|
||||
return screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
}
|
||||
|
||||
// Find the rightmost block
|
||||
let maxRight = Number.NEGATIVE_INFINITY
|
||||
let rightmostBlockY = 0
|
||||
for (const block of rootBlocks) {
|
||||
const blockWidth =
|
||||
block.type === 'loop' || block.type === 'parallel'
|
||||
? block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
|
||||
: BLOCK_DIMENSIONS.FIXED_WIDTH
|
||||
const blockRight = block.position.x + blockWidth
|
||||
if (blockRight > maxRight) {
|
||||
maxRight = blockRight
|
||||
rightmostBlockY = block.position.y
|
||||
}
|
||||
}
|
||||
|
||||
// Position to the right with autolayout spacing
|
||||
const position = {
|
||||
x: maxRight + DEFAULT_HORIZONTAL_SPACING,
|
||||
y: rightmostBlockY,
|
||||
}
|
||||
|
||||
// Ensure position doesn't overlap any container
|
||||
let container = isPointInLoopNode(position)
|
||||
while (container) {
|
||||
position.x =
|
||||
container.loopPosition.x + container.dimensions.width + DEFAULT_HORIZONTAL_SPACING
|
||||
container = isPointInLoopNode(position)
|
||||
}
|
||||
|
||||
return position
|
||||
}
|
||||
|
||||
const basePosition = calculateSmartPosition()
|
||||
|
||||
// Special handling for container nodes (loop or parallel)
|
||||
if (type === 'loop' || type === 'parallel') {
|
||||
const id = crypto.randomUUID()
|
||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const centerPosition = screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||
blockType: type,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
// Auto-connect logic for container nodes
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the container node with default dimensions and auto-connect edge
|
||||
addBlock(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
centerPosition,
|
||||
basePosition,
|
||||
{
|
||||
width: 500,
|
||||
height: 300,
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
type: 'subflowNode',
|
||||
},
|
||||
undefined,
|
||||
@@ -939,11 +1019,8 @@ const WorkflowContent = React.memo(() => {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate the center position of the viewport
|
||||
const centerPosition = screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
// Check trigger constraints first
|
||||
if (checkTriggerConstraints(type)) return
|
||||
|
||||
// Create a new block with a unique ID
|
||||
const id = crypto.randomUUID()
|
||||
@@ -952,51 +1029,11 @@ const WorkflowContent = React.memo(() => {
|
||||
const baseName = defaultTriggerName || blockConfig.name
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
// Auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled && type !== 'starter' && !isAnnotationOnlyBlock(type)) {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
logger.info('Closest block found:', closestBlock)
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = blockConfig
|
||||
const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
logger.info('Auto-connect edge created:', autoConnectEdge)
|
||||
} else {
|
||||
logger.info('Skipping auto-connect into trigger block', {
|
||||
target: type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Centralized trigger constraints
|
||||
const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type)
|
||||
if (additionIssue) {
|
||||
const message =
|
||||
additionIssue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${additionIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||
blockType: type,
|
||||
enableTriggerMode,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
// Add the block to the workflow with auto-connect edge
|
||||
// Enable trigger mode if this is a trigger-capable block from the triggers tab
|
||||
@@ -1004,7 +1041,7 @@ const WorkflowContent = React.memo(() => {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
centerPosition,
|
||||
basePosition,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -1025,11 +1062,12 @@ const WorkflowContent = React.memo(() => {
|
||||
screenToFlowPosition,
|
||||
blocks,
|
||||
addBlock,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
tryCreateAutoConnectEdge,
|
||||
isPointInLoopNode,
|
||||
effectivePermissions.canEdit,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
checkTriggerConstraints,
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -1220,12 +1258,12 @@ const WorkflowContent = React.memo(() => {
|
||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||
if (
|
||||
containerNode?.type === 'subflowNode' &&
|
||||
(containerNode.data as any)?.kind === 'loop'
|
||||
(containerNode.data as SubflowNodeData)?.kind === 'loop'
|
||||
) {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if (
|
||||
containerNode?.type === 'subflowNode' &&
|
||||
(containerNode.data as any)?.kind === 'parallel'
|
||||
(containerNode.data as SubflowNodeData)?.kind === 'parallel'
|
||||
) {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
}
|
||||
@@ -1424,8 +1462,8 @@ const WorkflowContent = React.memo(() => {
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
width: block.data?.width || 500,
|
||||
height: block.data?.height || 300,
|
||||
width: block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
kind: block.type === 'loop' ? 'loop' : 'parallel',
|
||||
},
|
||||
})
|
||||
@@ -1484,8 +1522,8 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||
width: 250, // Standard width for both block types
|
||||
height: Math.max(block.height || 100, 100), // Use calculated height with minimum
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1572,7 +1610,7 @@ const WorkflowContent = React.memo(() => {
|
||||
/**
|
||||
* Effect to resize loops when nodes change (add/remove/position change).
|
||||
* Runs on structural changes only - not during drag (position-only changes).
|
||||
* Skips during loading to avoid unnecessary work.
|
||||
* Skips during loading.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Skip during initial render when nodes aren't loaded yet or workflow not ready
|
||||
@@ -1794,12 +1832,15 @@ const WorkflowContent = React.memo(() => {
|
||||
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
|
||||
|
||||
// Get dimensions based on node type (must match actual rendered dimensions)
|
||||
const nodeWidth = node.type === 'subflowNode' ? node.data?.width || 500 : 250 // All workflow blocks use w-[250px] in workflow-block.tsx
|
||||
const nodeWidth =
|
||||
node.type === 'subflowNode'
|
||||
? node.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
|
||||
: BLOCK_DIMENSIONS.FIXED_WIDTH
|
||||
|
||||
const nodeHeight =
|
||||
node.type === 'subflowNode'
|
||||
? node.data?.height || 300
|
||||
: Math.max(node.height || 100, 100) // Use actual node height with minimum 100
|
||||
? node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
|
||||
: Math.max(node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT)
|
||||
|
||||
// Check intersection using absolute coordinates
|
||||
const nodeRect = {
|
||||
@@ -1811,9 +1852,10 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const containerRect = {
|
||||
left: containerAbsolutePos.x,
|
||||
right: containerAbsolutePos.x + (n.data?.width || 500),
|
||||
right: containerAbsolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
|
||||
top: containerAbsolutePos.y,
|
||||
bottom: containerAbsolutePos.y + (n.data?.height || 300),
|
||||
bottom:
|
||||
containerAbsolutePos.y + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
|
||||
}
|
||||
|
||||
// Check intersection with absolute coordinates for accurate detection
|
||||
@@ -1829,7 +1871,9 @@ const WorkflowContent = React.memo(() => {
|
||||
container: n,
|
||||
depth: getNodeDepth(n.id),
|
||||
// Calculate size for secondary sorting
|
||||
size: (n.data?.width || 500) * (n.data?.height || 300),
|
||||
size:
|
||||
(n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) *
|
||||
(n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
|
||||
}))
|
||||
|
||||
// Update potential parent if there's at least one intersecting container node
|
||||
@@ -1857,12 +1901,12 @@ const WorkflowContent = React.memo(() => {
|
||||
// Apply appropriate class based on container type
|
||||
if (
|
||||
bestContainerMatch.container.type === 'subflowNode' &&
|
||||
(bestContainerMatch.container.data as any)?.kind === 'loop'
|
||||
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop'
|
||||
) {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if (
|
||||
bestContainerMatch.container.type === 'subflowNode' &&
|
||||
(bestContainerMatch.container.data as any)?.kind === 'parallel'
|
||||
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel'
|
||||
) {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
}
|
||||
@@ -2034,62 +2078,19 @@ const WorkflowContent = React.memo(() => {
|
||||
y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding,
|
||||
}
|
||||
|
||||
// Prepare edges that will be added when moving into the container
|
||||
const edgesToAdd: any[] = []
|
||||
|
||||
// Auto-connect when moving an existing block into a container
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
// Don't auto-connect annotation blocks (like note blocks)
|
||||
if (isAutoConnectEnabled && !isAnnotationOnlyBlock(node.data?.type)) {
|
||||
// Existing children in the target container (excluding the moved node)
|
||||
const existingChildBlocks = Object.values(blocks).filter(
|
||||
(b) => b.data?.parentId === potentialParentId && b.id !== node.id
|
||||
)
|
||||
const existingChildBlocks = Object.values(blocks)
|
||||
.filter((b) => b.data?.parentId === potentialParentId && b.id !== node.id)
|
||||
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
|
||||
|
||||
if (existingChildBlocks.length > 0) {
|
||||
// Connect from nearest existing child inside the container
|
||||
const closestBlock = existingChildBlocks
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - relativePositionBefore.x) ** 2 +
|
||||
(b.position.y - relativePositionBefore.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(relativePositionBefore, node.id, {
|
||||
blockType: node.data?.type || '',
|
||||
targetParentId: potentialParentId,
|
||||
existingChildBlocks,
|
||||
containerId: potentialParentId,
|
||||
})
|
||||
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle({
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
edgesToAdd.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: node.id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// No children: connect from the container's start handle to the moved node
|
||||
const containerNode = getNodes().find((n) => n.id === potentialParentId)
|
||||
const startSourceHandle =
|
||||
(containerNode?.data as any)?.kind === 'loop'
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
|
||||
edgesToAdd.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: potentialParentId,
|
||||
target: node.id,
|
||||
sourceHandle: startSourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
})
|
||||
}
|
||||
}
|
||||
const edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : []
|
||||
|
||||
// Skip recording these edges separately since they're part of the parent update
|
||||
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
|
||||
@@ -2114,7 +2115,7 @@ const WorkflowContent = React.memo(() => {
|
||||
updateNodeParent,
|
||||
collaborativeUpdateBlockPosition,
|
||||
addEdge,
|
||||
determineSourceHandle,
|
||||
tryCreateAutoConnectEdge,
|
||||
blocks,
|
||||
edgesForDisplay,
|
||||
removeEdgesForNode,
|
||||
@@ -2317,7 +2318,7 @@ const WorkflowContent = React.memo(() => {
|
||||
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
|
||||
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
|
||||
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
|
||||
snapToGrid={false}
|
||||
snapToGrid={snapToGrid}
|
||||
snapGrid={snapGrid}
|
||||
elevateEdgesOnSelect={true}
|
||||
onlyRenderVisibleElements={false}
|
||||
|
||||
@@ -490,6 +490,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Enter a name for your API key to help you identify it later.
|
||||
</p>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<EmcnInput
|
||||
value={newKeyName}
|
||||
onChange={(e) => {
|
||||
@@ -499,6 +513,12 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
placeholder='e.g., Development, Production'
|
||||
className='h-9'
|
||||
autoFocus
|
||||
name='api_key_label'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
{createError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Slider,
|
||||
Switch,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
@@ -76,6 +77,9 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
|
||||
const [localSnapValue, setLocalSnapValue] = useState<number | null>(null)
|
||||
const snapToGridValue = localSnapValue ?? settings?.snapToGridSize ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
if (profile?.name) {
|
||||
setName(profile.name)
|
||||
@@ -234,6 +238,18 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSnapToGridChange = (value: number[]) => {
|
||||
setLocalSnapValue(value[0])
|
||||
}
|
||||
|
||||
const handleSnapToGridCommit = async (value: number[]) => {
|
||||
const newValue = value[0]
|
||||
if (newValue !== settings?.snapToGridSize && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'snapToGridSize', value: newValue })
|
||||
}
|
||||
setLocalSnapValue(null)
|
||||
}
|
||||
|
||||
const handleTrainingControlsChange = async (checked: boolean) => {
|
||||
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
|
||||
@@ -393,7 +409,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
dropdownWidth={140}
|
||||
value={settings?.theme}
|
||||
onChange={handleThemeChange}
|
||||
disabled={updateSetting.isPending}
|
||||
placeholder='Select theme'
|
||||
options={[
|
||||
{ label: 'System', value: 'system' },
|
||||
@@ -410,17 +425,34 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
id='auto-connect'
|
||||
checked={settings?.autoConnect ?? true}
|
||||
onCheckedChange={handleAutoConnectChange}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='snap-to-grid'>Snap to grid</Label>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<span className='w-[32px] text-right text-[12px] text-[var(--text-tertiary)]'>
|
||||
{snapToGridValue === 0 ? 'Off' : `${snapToGridValue}px`}
|
||||
</span>
|
||||
<Slider
|
||||
id='snap-to-grid'
|
||||
value={[snapToGridValue]}
|
||||
onValueChange={handleSnapToGridChange}
|
||||
onValueCommit={handleSnapToGridCommit}
|
||||
min={0}
|
||||
max={50}
|
||||
step={1}
|
||||
className='w-[100px]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='error-notifications'>Run error notifications</Label>
|
||||
<Switch
|
||||
id='error-notifications'
|
||||
checked={settings?.errorNotificationsEnabled ?? true}
|
||||
onCheckedChange={handleErrorNotificationsChange}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -430,7 +462,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
id='telemetry'
|
||||
checked={settings?.telemetryEnabled ?? true}
|
||||
onCheckedChange={handleTelemetryToggle}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -446,7 +477,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
id='training-controls'
|
||||
checked={settings?.showTrainingControls ?? false}
|
||||
onCheckedChange={handleTrainingControlsChange}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -458,7 +488,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
id='super-user-mode'
|
||||
checked={settings?.superUserModeEnabled ?? true}
|
||||
onCheckedChange={handleSuperUserModeToggle}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -534,6 +563,15 @@ function GeneralSkeleton() {
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
{/* Snap to grid row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-3 w-[32px]' />
|
||||
<Skeleton className='h-[6px] w-[100px] rounded-[20px]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error notifications row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-40' />
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { useSession, useSubscription } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -68,7 +76,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
|
||||
if (subscriptionStatus.isTeam && activeOrgId) {
|
||||
referenceId = activeOrgId
|
||||
// Get subscription ID for team/enterprise
|
||||
subscriptionId = subData?.data?.id
|
||||
}
|
||||
|
||||
@@ -132,14 +139,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
referenceId = activeOrgId
|
||||
subscriptionId = subData?.data?.id
|
||||
} else {
|
||||
// For personal subscriptions, use user ID and let better-auth find the subscription
|
||||
referenceId = session.user.id
|
||||
subscriptionId = undefined
|
||||
}
|
||||
|
||||
logger.info('Restoring subscription', { referenceId, subscriptionId })
|
||||
|
||||
// Build restore params - only include subscriptionId if we have one (team/enterprise)
|
||||
const restoreParams: any = { referenceId }
|
||||
if (subscriptionId) {
|
||||
restoreParams.subscriptionId = subscriptionId
|
||||
@@ -150,7 +155,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
logger.info('Subscription restored successfully', result)
|
||||
}
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
|
||||
if (activeOrgId) {
|
||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) })
|
||||
@@ -175,10 +179,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
if (!date) return 'end of current billing period'
|
||||
|
||||
try {
|
||||
// Ensure we have a valid Date object
|
||||
const dateObj = date instanceof Date ? date : new Date(date)
|
||||
|
||||
// Check if the date is valid
|
||||
if (Number.isNaN(dateObj.getTime())) {
|
||||
return 'end of current billing period'
|
||||
}
|
||||
@@ -196,20 +198,17 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
|
||||
const periodEndDate = getPeriodEndDate()
|
||||
|
||||
// Check if subscription is set to cancel at period end
|
||||
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<span className='font-medium text-[13px]'>
|
||||
{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}
|
||||
</span>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
<Label>{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}</Label>
|
||||
{isCancelAtPeriodEnd && (
|
||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>
|
||||
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||
You'll keep access until {formatDate(periodEndDate)}
|
||||
</p>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -217,7 +216,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'h-8 rounded-[8px] font-medium text-xs',
|
||||
'h-8 rounded-[8px] text-[13px]',
|
||||
error && 'border-[var(--text-error)] text-[var(--text-error)]'
|
||||
)}
|
||||
>
|
||||
@@ -231,7 +230,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} Subscription
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
{isCancelAtPeriodEnd
|
||||
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
|
||||
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
|
||||
@@ -244,8 +243,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
|
||||
{!isCancelAtPeriodEnd && (
|
||||
<div className='mt-3'>
|
||||
<div className='rounded-[8px] bg-[var(--surface-3)] p-3 text-sm'>
|
||||
<ul className='space-y-1 text-[var(--text-muted)] text-xs'>
|
||||
<div className='rounded-[8px] bg-[var(--surface-5)] p-3'>
|
||||
<ul className='space-y-1 text-[12px] text-[var(--text-muted)]'>
|
||||
<li>• Keep all features until {formatDate(periodEndDate)}</li>
|
||||
<li>• No more charges</li>
|
||||
<li>• Data preserved</li>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
@@ -90,7 +92,6 @@ export function CreditBalance({
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open)
|
||||
if (open) {
|
||||
// Generate new requestId when modal opens - same ID used for entire session
|
||||
setRequestId(crypto.randomUUID())
|
||||
} else {
|
||||
setAmount('')
|
||||
@@ -102,72 +103,66 @@ export function CreditBalance({
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-sm'>Credit Balance</span>
|
||||
<span className='font-medium text-sm'>{isLoading ? '...' : `$${balance.toFixed(2)}`}</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Label>Credit Balance</Label>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||
{isLoading ? '...' : `$${balance.toFixed(2)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canPurchase && (
|
||||
<Modal open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant='outline'>Add Credits</Button>
|
||||
<Button variant='outline' className='h-8 rounded-[8px] text-[13px]'>
|
||||
Add Credits
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Add Credits</ModalHeader>
|
||||
<div className='px-4'>
|
||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
||||
Credits are used before overage charges. Min $10, max $1,000.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className='py-4 text-center'>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>
|
||||
<ModalBody>
|
||||
{success ? (
|
||||
<p className='text-center text-[13px] text-[var(--text-primary)]'>
|
||||
Credits added successfully!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-3 py-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label
|
||||
htmlFor='credit-amount'
|
||||
className='text-[12px] text-[var(--text-secondary)]'
|
||||
>
|
||||
Amount (USD)
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<span className='-translate-y-1/2 absolute top-1/2 left-3 text-[var(--text-secondary)]'>
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id='credit-amount'
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
value={amount}
|
||||
onChange={(e) => handleAmountChange(e.target.value)}
|
||||
placeholder='50'
|
||||
className='pl-7'
|
||||
disabled={isPurchasing}
|
||||
/>
|
||||
</div>
|
||||
{error && <span className='text-[11px] text-red-500'>{error}</span>}
|
||||
</div>
|
||||
|
||||
<div className='rounded-[4px] bg-[var(--surface-5)] p-2'>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
Credits are non-refundable and don't expire. They'll be applied automatically to
|
||||
your {entityType === 'organization' ? 'team' : ''} usage.
|
||||
) : (
|
||||
<>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
Credits are used before overage charges. Min $10, max $1,000.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-4 flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='credit-amount'>Amount (USD)</Label>
|
||||
<div className='relative'>
|
||||
<span className='-translate-y-1/2 absolute top-1/2 left-3 text-[13px] text-[var(--text-secondary)]'>
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id='credit-amount'
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
value={amount}
|
||||
onChange={(e) => handleAmountChange(e.target.value)}
|
||||
placeholder='50'
|
||||
className='pl-7'
|
||||
disabled={isPurchasing}
|
||||
/>
|
||||
</div>
|
||||
{error && <span className='text-[12px] text-[var(--text-error)]'>{error}</span>}
|
||||
</div>
|
||||
|
||||
<div className='mt-4 rounded-[6px] bg-[var(--surface-5)] p-3'>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
Credits are non-refundable and don't expire. They'll be applied automatically
|
||||
to your {entityType === 'organization' ? 'team' : ''} usage.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
{!success && (
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant='ghost' disabled={isPurchasing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isPurchasing}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant='primary'
|
||||
|
||||
@@ -45,9 +45,9 @@ export function PlanCard({
|
||||
if (typeof price === 'string') {
|
||||
return (
|
||||
<>
|
||||
<span className='font-semibold text-xl'>{price}</span>
|
||||
<span className='font-semibold text-[20px]'>{price}</span>
|
||||
{priceSubtext && (
|
||||
<span className='ml-1 text-[var(--text-muted)] text-xs'>{priceSubtext}</span>
|
||||
<span className='ml-1 text-[12px] text-[var(--text-muted)]'>{priceSubtext}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -58,13 +58,13 @@ export function PlanCard({
|
||||
const renderFeatures = () => {
|
||||
if (isHorizontal) {
|
||||
return (
|
||||
<div className='mt-3 flex flex-wrap items-center gap-4'>
|
||||
<div className='mt-3 flex flex-wrap items-center gap-3'>
|
||||
{features.map((feature, index) => (
|
||||
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-xs'>
|
||||
<feature.icon className='h-3 w-3 flex-shrink-0 text-[var(--text-muted)]' />
|
||||
<span className='text-[var(--text-muted)]'>{feature.text}</span>
|
||||
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-[12px]'>
|
||||
<feature.icon className='h-3 w-3 flex-shrink-0 text-[var(--text-secondary)]' />
|
||||
<span className='text-[var(--text-secondary)]'>{feature.text}</span>
|
||||
{index < features.length - 1 && (
|
||||
<div className='ml-4 h-4 w-px bg-[var(--border)]' aria-hidden='true' />
|
||||
<div className='ml-3 h-4 w-px bg-[var(--border)]' aria-hidden='true' />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -75,12 +75,12 @@ export function PlanCard({
|
||||
return (
|
||||
<ul className='mb-4 flex-1 space-y-2'>
|
||||
{features.map((feature, index) => (
|
||||
<li key={`${feature.text}-${index}`} className='flex items-start gap-2 text-xs'>
|
||||
<li key={`${feature.text}-${index}`} className='flex items-start gap-2 text-[12px]'>
|
||||
<feature.icon
|
||||
className='mt-0.5 h-3 w-3 flex-shrink-0 text-[var(--text-muted)]'
|
||||
className='mt-0.5 h-3 w-3 flex-shrink-0 text-[var(--text-secondary)]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='text-[var(--text-muted)]'>{feature.text}</span>
|
||||
<span className='text-[var(--text-secondary)]'>{feature.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -91,24 +91,24 @@ export function PlanCard({
|
||||
<article
|
||||
className={cn(
|
||||
'relative flex rounded-[8px] border p-4 transition-colors hover:border-[var(--border-hover)]',
|
||||
isHorizontal ? 'flex-row items-center justify-between' : 'flex-col',
|
||||
isHorizontal ? 'flex-row items-center justify-between gap-6' : 'flex-col',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header className={isHorizontal ? undefined : 'mb-4'}>
|
||||
<h3 className='mb-2 font-semibold text-sm'>{name}</h3>
|
||||
<header className={isHorizontal ? 'flex-1' : 'mb-4'}>
|
||||
<h3 className='mb-2 font-semibold text-[14px]'>{name}</h3>
|
||||
<div className='flex items-baseline'>{renderPrice()}</div>
|
||||
{isHorizontal && renderFeatures()}
|
||||
</header>
|
||||
|
||||
{!isHorizontal && renderFeatures()}
|
||||
|
||||
<div className={isHorizontal ? 'ml-auto' : undefined}>
|
||||
<div className={isHorizontal ? 'flex-shrink-0' : undefined}>
|
||||
<Button
|
||||
onClick={onButtonClick}
|
||||
className={cn(
|
||||
'h-9 rounded-[8px] text-xs',
|
||||
isHorizontal ? 'px-4' : 'w-full',
|
||||
'h-9 rounded-[8px] text-[13px]',
|
||||
isHorizontal ? 'min-w-[100px] px-6' : 'w-full',
|
||||
isError && 'border-[var(--text-error)] text-[var(--text-error)]'
|
||||
)}
|
||||
variant='outline'
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
'use client'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Switch } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
Label,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverSection,
|
||||
PopoverTrigger,
|
||||
Switch,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -270,7 +270,6 @@ export function Subscription() {
|
||||
}
|
||||
)
|
||||
|
||||
// UI state computed values
|
||||
const showBadge = permissions.canEditUsageLimit && !permissions.showTeamMemberView
|
||||
const badgeText = subscription.isFree ? 'Upgrade' : 'Increase Limit'
|
||||
|
||||
@@ -333,7 +332,7 @@ export function Subscription() {
|
||||
<PlanCard
|
||||
key='enterprise'
|
||||
name='Enterprise'
|
||||
price={<span className='font-semibold text-xl'>Custom</span>}
|
||||
price={<span className='font-semibold text-[20px]'>Custom</span>}
|
||||
priceSubtext={
|
||||
layout === 'horizontal'
|
||||
? 'Custom solutions tailored to your enterprise needs'
|
||||
@@ -458,7 +457,7 @@ export function Subscription() {
|
||||
{/* Enterprise Usage Limit Notice */}
|
||||
{subscription.isEnterprise && (
|
||||
<div className='text-center'>
|
||||
<p className='text-[var(--text-muted)] text-xs'>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
Contact enterprise for support usage limit changes
|
||||
</p>
|
||||
</div>
|
||||
@@ -467,7 +466,7 @@ export function Subscription() {
|
||||
{/* Team Member Notice */}
|
||||
{permissions.showTeamMemberView && (
|
||||
<div className='text-center'>
|
||||
<p className='text-[var(--text-muted)] text-xs'>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
Contact your team admin to increase limits
|
||||
</p>
|
||||
</div>
|
||||
@@ -534,72 +533,78 @@ export function Subscription() {
|
||||
{/* Next Billing Date */}
|
||||
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px]'>Next Billing Date</span>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>
|
||||
<Label>Next Billing Date</Label>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing usage notifications toggle */}
|
||||
{/* Usage notifications */}
|
||||
{subscription.isPaid && <BillingUsageNotificationsToggle />}
|
||||
|
||||
{/* Cancel Subscription */}
|
||||
{permissions.canCancelSubscription && (
|
||||
<div className='mt-[8px]'>
|
||||
<CancelSubscription
|
||||
subscription={{
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
isPaid: subscription.isPaid,
|
||||
}}
|
||||
subscriptionData={{
|
||||
periodEnd: subscriptionData?.data?.periodEnd || null,
|
||||
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CancelSubscription
|
||||
subscription={{
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
isPaid: subscription.isPaid,
|
||||
}}
|
||||
subscriptionData={{
|
||||
periodEnd: subscriptionData?.data?.periodEnd || null,
|
||||
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Workspace API Billing Settings */}
|
||||
{/* Billed Account for Workspace */}
|
||||
{canManageWorkspaceKeys && (
|
||||
<div className='mt-[24px] flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px]'>Billed Account for Workspace</span>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label>Billed Account for Workspace</Label>
|
||||
{isWorkspaceLoading ? (
|
||||
<Skeleton className='h-8 w-[200px] rounded-[6px]' />
|
||||
) : workspaceAdmins.length === 0 ? (
|
||||
<div className='rounded-[6px] border border-dashed px-3 py-1.5 text-[var(--text-muted)] text-xs'>
|
||||
<div className='rounded-[6px] border border-dashed px-3 py-1.5 text-[12px] text-[var(--text-muted)]'>
|
||||
No admin members available
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={billedAccountUserId ?? ''}
|
||||
onValueChange={async (value) => {
|
||||
if (value === billedAccountUserId) return
|
||||
try {
|
||||
await updateWorkspaceSettings({ billedAccountUserId: value })
|
||||
} catch (error) {
|
||||
// Error is already logged in updateWorkspaceSettings
|
||||
}
|
||||
}}
|
||||
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[200px] justify-between text-left text-xs'>
|
||||
<SelectValue placeholder='Select admin' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='start' className='z-[10000050]'>
|
||||
<SelectGroup>
|
||||
<SelectLabel className='px-3 py-1 text-[11px] text-[var(--text-muted)] uppercase'>
|
||||
Workspace admins
|
||||
</SelectLabel>
|
||||
{workspaceAdmins.map((admin: any) => (
|
||||
<SelectItem key={admin.userId} value={admin.userId} className='py-1 text-xs'>
|
||||
{admin.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className='flex h-8 w-[200px] items-center justify-between gap-2 rounded-[6px] border border-[var(--border)] bg-transparent px-3 text-left text-[13px] transition-colors hover:bg-[var(--surface-3)] disabled:pointer-events-none disabled:opacity-50'
|
||||
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
|
||||
>
|
||||
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||
{billedAccountUserId
|
||||
? workspaceAdmins.find((admin: any) => admin.userId === billedAccountUserId)
|
||||
?.email || 'Select admin'
|
||||
: 'Select admin'}
|
||||
</span>
|
||||
<ChevronDown className='h-3 w-3 shrink-0 text-[var(--text-secondary)]' />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' minWidth={200} border>
|
||||
<PopoverSection>Workspace admins</PopoverSection>
|
||||
{workspaceAdmins.map((admin: any) => (
|
||||
<PopoverItem
|
||||
key={admin.userId}
|
||||
active={billedAccountUserId === admin.userId}
|
||||
showCheck
|
||||
onClick={async () => {
|
||||
if (admin.userId === billedAccountUserId) return
|
||||
try {
|
||||
await updateWorkspaceSettings({ billedAccountUserId: admin.userId })
|
||||
} catch (error) {
|
||||
// Error is already logged in updateWorkspaceSettings
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{admin.email}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -614,11 +619,14 @@ function BillingUsageNotificationsToggle() {
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-medium text-[13px]'>Usage notifications</span>
|
||||
<span className='text-[var(--text-muted)] text-xs'>Email me when I reach 80% usage</span>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
<Label htmlFor='usage-notifications'>Usage notifications</Label>
|
||||
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||
Email me when I reach 80% usage
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id='usage-notifications'
|
||||
checked={!!enabled}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={(v: boolean) => {
|
||||
|
||||
@@ -141,12 +141,37 @@ export function MemberInvitationCard({
|
||||
{/* Main invitation input */}
|
||||
<div className='flex items-start gap-2'>
|
||||
<div className='flex-1'>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type='email'
|
||||
name='fakeemailremembered'
|
||||
autoComplete='email'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
placeholder='Enter email address'
|
||||
value={inviteEmail}
|
||||
onChange={handleEmailChange}
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
|
||||
name='member_invite_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck={false}
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
aria-autocomplete='none'
|
||||
/>
|
||||
{emailError && (
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
|
||||
@@ -55,16 +55,31 @@ export function NoOrganizationView({
|
||||
|
||||
{/* Form fields - clean layout without card */}
|
||||
<div className='space-y-4'>
|
||||
{/* Hidden decoy field to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor='orgName' className='font-medium text-[13px]'>
|
||||
<Label htmlFor='team-name-field' className='font-medium text-[13px]'>
|
||||
Team Name
|
||||
</Label>
|
||||
<Input
|
||||
id='orgName'
|
||||
id='team-name-field'
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
placeholder='My Team'
|
||||
className='mt-1'
|
||||
name='team_name_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -116,31 +131,52 @@ export function NoOrganizationView({
|
||||
</ModalHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{/* Hidden decoy field to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor='org-name' className='font-medium text-[13px]'>
|
||||
<Label htmlFor='org-name-field' className='font-medium text-[13px]'>
|
||||
Organization Name
|
||||
</Label>
|
||||
<Input
|
||||
id='org-name'
|
||||
id='org-name-field'
|
||||
placeholder='Enter organization name'
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
disabled={isCreatingOrg}
|
||||
className='mt-1'
|
||||
name='org_name_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='org-slug' className='font-medium text-[13px]'>
|
||||
<Label htmlFor='org-slug-field' className='font-medium text-[13px]'>
|
||||
Organization Slug
|
||||
</Label>
|
||||
<Input
|
||||
id='org-slug'
|
||||
id='org-slug-field'
|
||||
placeholder='organization-slug'
|
||||
value={orgSlug}
|
||||
onChange={(e) => setOrgSlug(e.target.value)}
|
||||
disabled={isCreatingOrg}
|
||||
className='mt-1'
|
||||
name='org_slug_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ export function TeamMembers({
|
||||
<div className='space-y-4'>
|
||||
{teamItems.map((item) => (
|
||||
<div key={item.id} className='flex items-center justify-between'>
|
||||
{/* Member info */}
|
||||
{/* Left section: Avatar + Name/Role + Action buttons */}
|
||||
<div className='flex flex-1 items-center gap-3'>
|
||||
{/* Avatar */}
|
||||
<UserAvatar
|
||||
@@ -165,7 +165,7 @@ export function TeamMembers({
|
||||
/>
|
||||
|
||||
{/* Name and email */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='truncate font-medium text-sm'>{item.name}</span>
|
||||
{item.type === 'member' && (
|
||||
@@ -188,51 +188,50 @@ export function TeamMembers({
|
||||
<div className='truncate text-[var(--text-muted)] text-xs'>{item.email}</div>
|
||||
</div>
|
||||
|
||||
{/* Usage stats - matching subscription layout */}
|
||||
{/* Action buttons */}
|
||||
{isAdminOrOwner && (
|
||||
<div className='hidden items-center text-xs tabular-nums sm:flex'>
|
||||
<div className='text-center'>
|
||||
<div className='text-[var(--text-muted)]'>Usage</div>
|
||||
<div className='font-medium'>
|
||||
{isLoadingUsage && item.type === 'member' ? (
|
||||
<span className='inline-block h-3 w-12 animate-pulse rounded bg-[var(--surface-3)]' />
|
||||
) : (
|
||||
item.usage
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{/* Admin/Owner can remove other members */}
|
||||
{item.type === 'member' &&
|
||||
item.role !== 'owner' &&
|
||||
item.email !== currentUserEmail && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => onRemoveMember(item.member)}
|
||||
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Admin can cancel invitations */}
|
||||
{item.type === 'invitation' && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleCancelInvitation(item.invitation.id)}
|
||||
disabled={cancellingInvitations.has(item.invitation.id)}
|
||||
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right section: Usage column (right-aligned) */}
|
||||
{isAdminOrOwner && (
|
||||
<div className='ml-4 flex flex-col items-end'>
|
||||
<div className='text-[var(--text-muted)] text-xs'>Usage</div>
|
||||
<div className='font-medium text-xs tabular-nums'>
|
||||
{isLoadingUsage && item.type === 'member' ? (
|
||||
<span className='inline-block h-3 w-12 animate-pulse rounded bg-[var(--surface-3)]' />
|
||||
) : (
|
||||
item.usage
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className='ml-4 flex gap-1'>
|
||||
{/* Admin/Owner can remove other members */}
|
||||
{isAdminOrOwner &&
|
||||
item.type === 'member' &&
|
||||
item.role !== 'owner' &&
|
||||
item.email !== currentUserEmail && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => onRemoveMember(item.member)}
|
||||
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Admin can cancel invitations */}
|
||||
{isAdminOrOwner && item.type === 'invitation' && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleCancelInvitation(item.invitation.id)}
|
||||
disabled={cancellingInvitations.has(item.invitation.id)}
|
||||
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
type ComboboxOption,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
@@ -55,50 +54,53 @@ export function TeamSeats({
|
||||
const totalMonthlyCost = selectedSeats * costPerSeat
|
||||
const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm(selectedSeats)
|
||||
}
|
||||
|
||||
const seatOptions: ComboboxOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50].map((num) => ({
|
||||
value: num.toString(),
|
||||
label: `${num} ${num === 1 ? 'seat' : 'seats'} ($${num * costPerSeat}/month)`,
|
||||
label: `${num} ${num === 1 ? 'seat' : 'seats'}`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{title}</ModalTitle>
|
||||
<ModalDescription>{description}</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>{description}</p>
|
||||
|
||||
<div className='py-4'>
|
||||
<Label htmlFor='seats'>Number of seats</Label>
|
||||
<Combobox
|
||||
options={seatOptions}
|
||||
value={selectedSeats.toString()}
|
||||
onChange={(value) => setSelectedSeats(Number.parseInt(value))}
|
||||
placeholder='Select number of seats'
|
||||
/>
|
||||
<div className='mt-4 flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='seats'>Number of seats</Label>
|
||||
<Combobox
|
||||
options={seatOptions}
|
||||
value={selectedSeats > 0 ? selectedSeats.toString() : ''}
|
||||
onChange={(value) => {
|
||||
const num = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(num) && num > 0) {
|
||||
setSelectedSeats(num)
|
||||
}
|
||||
}}
|
||||
placeholder='Select or enter number of seats'
|
||||
editable
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className='mt-2 text-[var(--text-muted)] text-sm'>
|
||||
<p className='mt-3 text-[12px] text-[var(--text-muted)]'>
|
||||
Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a
|
||||
total of ${totalMonthlyCost} inference credits per month.
|
||||
</p>
|
||||
|
||||
{showCostBreakdown && currentSeats !== undefined && (
|
||||
<div className='mt-3 rounded-[8px] bg-[var(--surface-3)] p-3'>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<div className='mt-4 rounded-[6px] bg-[var(--surface-5)] p-3'>
|
||||
<div className='flex justify-between text-[12px]'>
|
||||
<span className='text-[var(--text-muted)]'>Current seats:</span>
|
||||
<span>{currentSeats}</span>
|
||||
<span className='text-[var(--text-primary)]'>{currentSeats}</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<div className='mt-2 flex justify-between text-[12px]'>
|
||||
<span className='text-[var(--text-muted)]'>New seats:</span>
|
||||
<span>{selectedSeats}</span>
|
||||
<span className='text-[var(--text-primary)]'>{selectedSeats}</span>
|
||||
</div>
|
||||
<div className='mt-2 flex justify-between border-t pt-2 font-medium text-sm'>
|
||||
<span className='text-[var(--text-muted)]'>Monthly cost change:</span>
|
||||
<span>
|
||||
<div className='mt-3 flex justify-between border-[var(--border)] border-t pt-3 text-[12px]'>
|
||||
<span className='font-medium text-[var(--text-primary)]'>Monthly cost change:</span>
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{costChange > 0 ? '+' : ''}${costChange}
|
||||
</span>
|
||||
</div>
|
||||
@@ -106,19 +108,14 @@ export function TeamSeats({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className='mt-3 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
<p className='mt-3 text-[12px] text-[var(--text-error)]'>
|
||||
{error instanceof Error && error.message ? error.message : String(error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
className='h-[32px] px-[12px]'
|
||||
>
|
||||
<Button onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -127,22 +124,15 @@ export function TeamSeats({
|
||||
<span>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleConfirm}
|
||||
onClick={() => onConfirm(selectedSeats)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
selectedSeats < 1 ||
|
||||
(showCostBreakdown && selectedSeats === currentSeats) ||
|
||||
isCancelledAtPeriodEnd
|
||||
}
|
||||
className='h-[32px] px-[12px]'
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-current border-b-transparent' />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{confirmButtonText}</span>
|
||||
)}
|
||||
{isLoading ? 'Updating...' : confirmButtonText}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
@@ -390,11 +390,26 @@ export function TemplateProfile() {
|
||||
disabled={isUploadingProfilePicture}
|
||||
/>
|
||||
</div>
|
||||
{/* Hidden decoy field to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
placeholder='Name'
|
||||
value={formData.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
className='h-9 flex-1'
|
||||
name='profile_display_name'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
{uploadError && <p className='text-[12px] text-[var(--text-error)]'>{uploadError}</p>}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user