Compare commits

...

30 Commits

Author SHA1 Message Date
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
214632604d feat(settings): added snap to grid slider to settings (#2504)
* feat(settings): added snap to grid slider to settings

* ack PR comments

* ack PR comment
2025-12-20 16:54:40 -08:00
Vikhyath Mondreti
1ddbac1d2e fix(code): cmd-z after refocus should not clear subblock (#2503) 2025-12-20 16:26:30 -08:00
Waleed
35a57bfad4 feat(audit): added audit log for billing line items (#2500)
* feat(audit): added audit log for billing line items

* remove migration

* reran migrations after resolving merge conflict

* ack PR comment
2025-12-20 14:10:01 -08:00
Waleed
f8678b179a fix(migrations): remove duplicate indexes (#2501) 2025-12-20 13:55:26 -08:00
Siddharth Ganesan
0ebb45b2db feat(copilot): show inline prompt to increase usage limit or upgrade plan (#2465)
* Add limit v1

* fix ui for copilot upgrade limit inline

* open settings modal

* Upgrade plan button

* Remove comments

* Ishosted check

* Fix hardcoded bumps

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-12-20 13:46:06 -08:00
Waleed
6247f421bc improvement(queries): add workspaceId to execution logs, added missing indexes based on query insights (#2471)
* improvement(queries): added missing indexes

* add workspaceId to execution logs

* remove migration to prep merge

* regen migration

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-12-20 13:33:10 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Waleed
6385d82b85 improvement(ui): updated kb tag component to match existing table (#2498)
* improvement(ui): updated kb tag component to match existing table

* fix selection

* fix more ui

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-12-19 22:26:24 -08:00
Waleed
f91beb324e fix(condition): fixed deactivated edges when if and else if conditions connected to same destination block, added 100+ unit tests (#2497) 2025-12-19 21:12:49 -08:00
Priyanshu Solanki
4f69b171f2 feat(kb): Adding support for more tags to the KB (#2433)
* creating boolean, number and date tags with different equality matchings

* feat: add UI for tag field types with filter operators

- Update base-tags-modal with field type selector dropdown
- Update document-tags-modal with different input types per fieldType
- Update knowledge-tag-filters with operator dropdown and type-specific inputs
- Update search routes to support all tag slot types
- Update hook to use AllTagSlot type

* feat: add field type support to document-tag-entry component

- Add dropdown with all field types (Text, Number, Date, Boolean)
- Render different value inputs based on field type
- Update slot counting to include all field types (28 total)

* fix: resolve MAX_TAG_SLOTS error and z-index dropdown issue

- Replace MAX_TAG_SLOTS with totalSlots in document-tag-entry
- Add z-index to SelectContent in base-tags-modal for proper layering

* fix: handle non-text columns in getTagUsage query

- Only apply empty string check for text columns (tag1-tag7)
- Numeric/date/boolean columns only check IS NOT NULL
- Cast values to text for consistent output

* refactor: use EMCN components for KB UI

- Replace @/components/ui imports with @/components/emcn
- Use Combobox instead of Select for dropdowns
- Use EMCN Switch, Button, Input, Label components
- Remove unsupported 'size' prop from EMCN Button

* fix: layout for delete button next to date picker

- Change delete button from absolute to inline positioning
- Add proper column width (w-10) for delete button
- Add empty header cell for delete column
- Apply fix to both document-tag-entry and knowledge-tag-filters

* fix: clear value when switching tag field type

- Reset value to empty when changing type (e.g., boolean to text)
- Reset value when tag name changes and type differs
- Prevents 'true'/'false' from sticking in text inputs

* feat: add full support for number/date/boolean tag filtering in KB search

- Copy all tag types (number, date, boolean) from document to embedding records
- Update processDocumentTags to handle all field types with proper type conversion
- Add number/date/boolean columns to document queries in checkDocumentWriteAccess
- Update chunk creation to inherit all tag types from parent document
- Add getSearchResultFields helper for consistent query result selection
- Support structured filters with operators (eq, gt, lt, between, etc.)
- Fix search queries to include all 28 tag fields in results

* fixing tags import issue

* fix rm file

* reduced number to 3 and date to 2

* fixing lint

* fixed the prop size issue

* increased number from 3 to 5 and boolean from 7 to 2

* fixed number the sql stuff

* progress

* fix document tag and kb tag modals

* update datepicker emcn component

* fix ui

* progress on KB block tags UI

* fix issues with date filters

* fix execution parsing of types for KB tags

* remove migration before merge

* regen migrations

* fix tests and types

* address greptile comments

* fix more greptile comments

* fix filtering logic for multiple of same row

* fix tests

---------

Co-authored-by: priyanshu.solanki <priyanshu.solanki@saviynt.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-12-19 21:00:35 -08:00
Waleed
a1a189f328 fix(condition): remove dead code from condition handler, defer resolution to function execute tool like the function block (#2496) 2025-12-19 20:18:42 -08:00
Waleed
7dc48510dc fix(tool-input): allow multiple instances of workflow block or kb tools as agent tools (#2495)
* fix(tool-input): allow multiple instances of workflow block or kb tools as agent tools

* ack PR comments
2025-12-19 19:19:42 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
93fe68785e fix(subflow): prevent auto-connect across subflow edges with keyboard shortcut block additions, make positioning for auto-drop smarter (#2489)
* fix(subflow): prevent auto-connect across subflow edges with keyboard shortcut block additions, make positioning for auto-drop smarter

* stronger typing
2025-12-19 18:31:29 -08:00
Vikhyath Mondreti
50c1c6775b fix(logs): always capture cost, logging size failures (#2487)
* fix(logs): truncate strings in tracespans crashing insertion

* add depth check to not crash

* custom serialization to not break tracepsans

* log costs even in log creation failure

* code cleanup?

* fix typing

* remove null bytes

* increase char limit

* reduce char limit
2025-12-19 17:39:18 -08:00
Waleed
df5f823d1c fix(autofill): add dummy inputs to prevent browser autofill for various fields, prevent having 0 workflows in workspace (#2482)
* fix(autofill): add dummy inputs to prevent browser autofill for various fields, prevent having 0 workflows in workspace

* cleanup

* ack PR comments

* fix failing test
2025-12-19 15:29:01 -08:00
Waleed
094f87fa1f fix(edges): prevent autoconnect outgoing edges from response block (#2479) 2025-12-19 13:19:53 -08:00
Waleed
65efa039da fix(redaction): consolidate redaction utils, apply them to inputs and outputs before persisting logs (#2478)
* fix(redaction): consolidate redaction utils, apply them to inputs and outputs before persisting logs

* added testing utils
2025-12-19 13:17:51 -08:00
Waleed
6b15a50311 improvement(ui): updated subscription and team settings modals to emcn (#2477) 2025-12-19 11:41:47 -08:00
Waleed
65787d7cc3 fix(api-keys): remove billed account check during api key generation (#2476) 2025-12-19 11:33:00 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Waleed
656a6b8abd fix(sanitization): added more input sanitization to tool routes (#2475)
* fix(sanitization): added more input sanitization to tool routes

* ack PR comments
2025-12-19 01:27:20 -08:00
Waleed
889b44c90a improvement(db): added missing indexes for common access patterns (#2473) 2025-12-19 00:46:10 -08:00
Waleed
3a33ec929f fix(authentication): added auth checks for various routes, mysql and postgres query validation, csp improvements (#2472) 2025-12-19 00:44:52 -08:00
Waleed
24356d99ec fix(unsubscribe): add one-click unsubscribe (#2467)
* fix(unsubscribe): add one-click unsubscribe

* ack Pr comments
2025-12-18 21:16:24 -08:00
Waleed
6de1c04517 feat(i18n): update translations (#2470) 2025-12-18 21:01:51 -08:00
Adam Gough
38be2b76c4 fix(slack): respect message limit, remove duplicate canonical representations (#2469)
* fix(slack): respect message limit, remove duplicate canonical representations

* removed comment

* updated docs script

---------

Co-authored-by: aadamgough <adam@sim.ai>
2025-12-18 20:37:14 -08:00
Waleed
a2f14cab54 feat(og): add opengraph images for templates, blogs, and updated existing opengraph image for all other pages (#2466)
* feat(og): add opengraph images for templates, blogs, and updated existing opengraph image for all other pages

* added to workspace templates page as well

* ack PR comments
2025-12-18 19:15:06 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
195 changed files with 59055 additions and 2910 deletions

View File

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

View File

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

View File

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

View File

@@ -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\) |

View File

@@ -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) |

View File

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

View File

@@ -110,8 +110,8 @@ Slackチャンネルから最新のメッセージを読み取ります。フィ
| `authMethod` | string | いいえ | 認証方法oauthまたはbot_token |
| `botToken` | string | いいえ | カスタムボット用のボットトークン |
| `channel` | string | いいえ | メッセージを読み取るSlackチャンネル#general |
| `userId` | string | いいえ | DM会話用のユーザーIDU1234567890 |
| `limit` | number | いいえ | 取得するメッセージ数デフォルト10、最大100 |
| `userId` | string | いいえ | DM会話用のユーザーIDU1234567890 |
| `limit` | number | いいえ | 取得するメッセージ数デフォルト10、最大15 |
| `oldest` | string | いいえ | 時間範囲の開始(タイムスタンプ) |
| `latest` | string | いいえ | 時間範囲の終了(タイムスタンプ) |

View File

@@ -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 | 否 | 时间范围结束(时间戳) |
#### 输出

View File

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

View File

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

View File

@@ -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)

View File

@@ -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[] = []

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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() { ... }

View File

@@ -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(

View File

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

View File

@@ -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(

View File

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

View File

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

View File

@@ -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,
})

View File

@@ -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}"`

View File

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

View File

@@ -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`)

View File

@@ -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)))

View File

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

View File

@@ -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)
)
)

View File

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

View File

@@ -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')`
)

View File

@@ -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)

View File

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

View File

@@ -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() })

View 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 })
}
}

View File

@@ -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() })

View File

@@ -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))

View File

@@ -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()

View File

@@ -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`)

View File

@@ -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`,
{

View File

@@ -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)

View File

@@ -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`,
{

View File

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

View File

@@ -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') ||

View File

@@ -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') ||

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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,

View File

@@ -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(

View File

@@ -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') ||

View File

@@ -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) {

View File

@@ -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`

View File

@@ -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,

View File

@@ -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)

View File

@@ -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')

View File

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

View File

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

View File

@@ -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)

View File

@@ -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,

View File

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

View File

@@ -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,

View 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 }
)
}
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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',

View File

@@ -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'

View File

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

View File

@@ -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)

View File

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

View File

@@ -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':

View File

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

View File

@@ -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'>

View File

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

View File

@@ -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 ? (
<>

View File

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

View File

@@ -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('/')

View File

@@ -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'

View File

@@ -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>
)
})}
</>
)
}

View File

@@ -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]'>

View File

@@ -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:
| {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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'>

View File

@@ -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' />

View File

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

View File

@@ -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'

View File

@@ -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'

View File

@@ -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) => {

View File

@@ -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]'>

View File

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

View File

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

View File

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

View File

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