mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
88 Commits
improvemen
...
v0.5.39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f4ec962ad | ||
|
|
f21eaf1f10 | ||
|
|
942da8815d | ||
|
|
4827866f9a | ||
|
|
214632604d | ||
|
|
1ddbac1d2e | ||
|
|
35a57bfad4 | ||
|
|
f8678b179a | ||
|
|
0ebb45b2db | ||
|
|
6247f421bc | ||
|
|
3e697d9ed9 | ||
|
|
6385d82b85 | ||
|
|
f91beb324e | ||
|
|
4f69b171f2 | ||
|
|
a1a189f328 | ||
|
|
7dc48510dc | ||
|
|
4431a1a484 | ||
|
|
93fe68785e | ||
|
|
50c1c6775b | ||
|
|
df5f823d1c | ||
|
|
094f87fa1f | ||
|
|
65efa039da | ||
|
|
6b15a50311 | ||
|
|
65787d7cc3 | ||
|
|
4d1a9a3f22 | ||
|
|
656a6b8abd | ||
|
|
889b44c90a | ||
|
|
3a33ec929f | ||
|
|
24356d99ec | ||
|
|
6de1c04517 | ||
|
|
38be2b76c4 | ||
|
|
a2f14cab54 | ||
|
|
474762d6fb | ||
|
|
0005c3e465 | ||
|
|
fc40b4f7af | ||
|
|
eb07a080fb | ||
|
|
2a7f51a2f6 | ||
|
|
90c3c43607 | ||
|
|
83d813a7cc | ||
|
|
811c736705 | ||
|
|
c6757311af | ||
|
|
b5b12ba2d1 | ||
|
|
0d30676e34 | ||
|
|
36bdccb449 | ||
|
|
f45730a89e | ||
|
|
04cd837e9c | ||
|
|
c23130a26e | ||
|
|
7575cd6f27 | ||
|
|
fbde64f0b0 | ||
|
|
25f7ed20f6 | ||
|
|
261aa3d72d | ||
|
|
9da19e84b7 | ||
|
|
e83afc0a62 | ||
|
|
1720fa8749 | ||
|
|
f3ad7750af | ||
|
|
78b7643e65 | ||
|
|
67cfb21d08 | ||
|
|
1d6975db49 | ||
|
|
837aabca5e | ||
|
|
f9cfca92bf | ||
|
|
25afacb25e | ||
|
|
fcf52ac4d5 | ||
|
|
842200bcf2 | ||
|
|
a0fb889644 | ||
|
|
f526c36fc0 | ||
|
|
e24f31cbce | ||
|
|
3fbd57caf1 | ||
|
|
b5da61377c | ||
|
|
18b7032494 | ||
|
|
b7bbef8620 | ||
|
|
52edbea659 | ||
|
|
d480057fd3 | ||
|
|
c27c233da0 | ||
|
|
ebef5f3a27 | ||
|
|
12c4c2d44f | ||
|
|
929a352edb | ||
|
|
6cd078b0fe | ||
|
|
31874939ee | ||
|
|
e157ce5fbc | ||
|
|
774e5d585c | ||
|
|
54cc93743f | ||
|
|
8c32ad4c0d | ||
|
|
1d08796853 | ||
|
|
ebcd243942 | ||
|
|
b7e814b721 | ||
|
|
842ef27ed9 | ||
|
|
31c34b2ea3 | ||
|
|
8f0ef58056 |
13
.github/workflows/test-build.yml
vendored
13
.github/workflows/test-build.yml
vendored
@@ -48,6 +48,19 @@ jobs:
|
||||
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
|
||||
run: bun run test
|
||||
|
||||
- name: Check schema and migrations are in sync
|
||||
working-directory: packages/db
|
||||
run: |
|
||||
bunx drizzle-kit generate --config=./drizzle.config.ts
|
||||
if [ -n "$(git status --porcelain ./migrations)" ]; then
|
||||
echo "❌ Schema and migrations are out of sync!"
|
||||
echo "Run 'cd packages/db && bunx drizzle-kit generate' and commit the new migrations."
|
||||
git status --porcelain ./migrations
|
||||
git diff ./migrations
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Schema and migrations are in sync"
|
||||
|
||||
- name: Build application
|
||||
env:
|
||||
NODE_OPTIONS: '--no-warnings'
|
||||
|
||||
@@ -188,6 +188,7 @@ DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
|
||||
|
||||
Then run the migrations:
|
||||
```bash
|
||||
cd packages/db # Required so drizzle picks correct .env file
|
||||
bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
```
|
||||
|
||||
|
||||
23
apps/docs/app/[lang]/not-found.tsx
Normal file
23
apps/docs/app/[lang]/not-found.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { DocsBody, DocsPage } from 'fumadocs-ui/page'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Page Not Found',
|
||||
}
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<DocsPage>
|
||||
<DocsBody>
|
||||
<div className='flex min-h-[60vh] flex-col items-center justify-center text-center'>
|
||||
<h1 className='mb-4 bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] bg-clip-text font-bold text-8xl text-transparent'>
|
||||
404
|
||||
</h1>
|
||||
<h2 className='mb-2 font-semibold text-2xl text-foreground'>Page Not Found</h2>
|
||||
<p className='text-muted-foreground'>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</div>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import { source } from '@/lib/source'
|
||||
|
||||
export const revalidate = false
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug?: string[] }> }) {
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug?: string[] }> }
|
||||
) {
|
||||
const { slug } = await params
|
||||
|
||||
let lang: (typeof i18n.languages)[number] = i18n.defaultLanguage
|
||||
|
||||
@@ -120,117 +120,117 @@ import {
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
zoom: ZoomIcon,
|
||||
zep: ZepIcon,
|
||||
zendesk: ZendeskIcon,
|
||||
youtube: YouTubeIcon,
|
||||
x: xIcon,
|
||||
wordpress: WordpressIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
webflow: WebflowIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
vision: EyeIcon,
|
||||
video_generator: VideoIcon,
|
||||
typeform: TypeformIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
tts: TTSIcon,
|
||||
trello: TrelloIcon,
|
||||
translate: TranslateIcon,
|
||||
thinking: BrainIcon,
|
||||
telegram: TelegramIcon,
|
||||
tavily: TavilyIcon,
|
||||
supabase: SupabaseIcon,
|
||||
stt: STTIcon,
|
||||
stripe: StripeIcon,
|
||||
stagehand: StagehandIcon,
|
||||
ssh: SshIcon,
|
||||
sqs: SQSIcon,
|
||||
spotify: SpotifyIcon,
|
||||
smtp: SmtpIcon,
|
||||
slack: SlackIcon,
|
||||
shopify: ShopifyIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
sftp: SftpIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
serper: SerperIcon,
|
||||
sentry: SentryIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
search: SearchIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
s3: S3Icon,
|
||||
resend: ResendIcon,
|
||||
reddit: RedditIcon,
|
||||
rds: RDSIcon,
|
||||
qdrant: QdrantIcon,
|
||||
posthog: PosthogIcon,
|
||||
postgresql: PostgresIcon,
|
||||
polymarket: PolymarketIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
pinecone: PineconeIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
outlook: OutlookIcon,
|
||||
openai: OpenAIIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
notion: NotionIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
mysql: MySQLIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
memory: BrainIcon,
|
||||
mem0: Mem0Icon,
|
||||
mailgun: MailgunIcon,
|
||||
mailchimp: MailchimpIcon,
|
||||
linkup: LinkupIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
linear: LinearIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
kalshi: KalshiIcon,
|
||||
jira: JiraIcon,
|
||||
jina: JinaAIIcon,
|
||||
intercom: IntercomIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
image_generator: ImageIcon,
|
||||
hunter: HunterIOIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hubspot: HubspotIcon,
|
||||
grafana: GrafanaIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
google_slides: GoogleSlidesIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
google_groups: GoogleGroupsIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
google_search: GoogleIcon,
|
||||
gmail: GmailIcon,
|
||||
gitlab: GitLabIcon,
|
||||
github: GithubIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
file: DocumentIcon,
|
||||
exa: ExaAIIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
dynamodb: DynamoDBIcon,
|
||||
duckduckgo: DuckDuckGoIcon,
|
||||
dropbox: DropboxIcon,
|
||||
discord: DiscordIcon,
|
||||
datadog: DatadogIcon,
|
||||
cursor: CursorIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
clay: ClayIcon,
|
||||
calendly: CalendlyIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
asana: AsanaIcon,
|
||||
arxiv: ArxivIcon,
|
||||
apollo: ApolloIcon,
|
||||
apify: ApifyIcon,
|
||||
airtable: AirtableIcon,
|
||||
ahrefs: AhrefsIcon,
|
||||
airtable: AirtableIcon,
|
||||
apify: ApifyIcon,
|
||||
apollo: ApolloIcon,
|
||||
arxiv: ArxivIcon,
|
||||
asana: AsanaIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
calendly: CalendlyIcon,
|
||||
clay: ClayIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
cursor: CursorIcon,
|
||||
datadog: DatadogIcon,
|
||||
discord: DiscordIcon,
|
||||
dropbox: DropboxIcon,
|
||||
duckduckgo: DuckDuckGoIcon,
|
||||
dynamodb: DynamoDBIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
exa: ExaAIIcon,
|
||||
file: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
github: GithubIcon,
|
||||
gitlab: GitLabIcon,
|
||||
gmail: GmailIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
google_groups: GoogleGroupsIcon,
|
||||
google_search: GoogleIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
google_slides: GoogleSlidesIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
grafana: GrafanaIcon,
|
||||
hubspot: HubspotIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hunter: HunterIOIcon,
|
||||
image_generator: ImageIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
intercom: IntercomIcon,
|
||||
jina: JinaAIIcon,
|
||||
jira: JiraIcon,
|
||||
kalshi: KalshiIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
linear: LinearIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
linkup: LinkupIcon,
|
||||
mailchimp: MailchimpIcon,
|
||||
mailgun: MailgunIcon,
|
||||
mem0: Mem0Icon,
|
||||
memory: BrainIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mysql: MySQLIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
notion: NotionIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
openai: OpenAIIcon,
|
||||
outlook: OutlookIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
pinecone: PineconeIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
polymarket: PolymarketIcon,
|
||||
postgresql: PostgresIcon,
|
||||
posthog: PosthogIcon,
|
||||
qdrant: QdrantIcon,
|
||||
rds: RDSIcon,
|
||||
reddit: RedditIcon,
|
||||
resend: ResendIcon,
|
||||
s3: S3Icon,
|
||||
salesforce: SalesforceIcon,
|
||||
search: SearchIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
sentry: SentryIcon,
|
||||
serper: SerperIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
sftp: SftpIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
shopify: ShopifyIcon,
|
||||
slack: SlackIcon,
|
||||
smtp: SmtpIcon,
|
||||
spotify: SpotifyIcon,
|
||||
sqs: SQSIcon,
|
||||
ssh: SshIcon,
|
||||
stagehand: StagehandIcon,
|
||||
stripe: StripeIcon,
|
||||
stt: STTIcon,
|
||||
supabase: SupabaseIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
thinking: BrainIcon,
|
||||
translate: TranslateIcon,
|
||||
trello: TrelloIcon,
|
||||
tts: TTSIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
typeform: TypeformIcon,
|
||||
video_generator: VideoIcon,
|
||||
vision: EyeIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
webflow: WebflowIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
wordpress: WordpressIcon,
|
||||
x: xIcon,
|
||||
youtube: YouTubeIcon,
|
||||
zendesk: ZendeskIcon,
|
||||
zep: ZepIcon,
|
||||
zoom: ZoomIcon,
|
||||
}
|
||||
|
||||
@@ -90,14 +90,20 @@ Ein Jira-Issue erstellen
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Ja | Ihre Jira-Domain (z.B. ihrfirma.atlassian.net) |
|
||||
| `domain` | string | Ja | Ihre Jira-Domain \(z.B. ihrfirma.atlassian.net\) |
|
||||
| `projectId` | string | Ja | Projekt-ID für das Issue |
|
||||
| `summary` | string | Ja | Zusammenfassung für das Issue |
|
||||
| `description` | string | Nein | Beschreibung für das Issue |
|
||||
| `priority` | string | Nein | Priorität für das Issue |
|
||||
| `assignee` | string | Nein | Bearbeiter für das Issue |
|
||||
| `cloudId` | string | Nein | Jira Cloud-ID für die Instanz. Wenn nicht angegeben, wird sie anhand der Domain abgerufen. |
|
||||
| `issueType` | string | Ja | Art des zu erstellenden Issues (z.B. Task, Story) |
|
||||
| `priority` | string | Nein | Prioritäts-ID oder -Name für das Issue \(z.B. "10000" oder "High"\) |
|
||||
| `assignee` | string | Nein | Account-ID des Bearbeiters für das Issue |
|
||||
| `cloudId` | string | Nein | Jira Cloud-ID für die Instanz. Wenn nicht angegeben, wird sie über die Domain abgerufen. |
|
||||
| `issueType` | string | Ja | Typ des zu erstellenden Issues \(z.B. Task, Story\) |
|
||||
| `labels` | array | Nein | Labels für das Issue \(Array von Label-Namen\) |
|
||||
| `duedate` | string | Nein | Fälligkeitsdatum für das Issue \(Format: YYYY-MM-DD\) |
|
||||
| `reporter` | string | Nein | Account-ID des Melders für das Issue |
|
||||
| `environment` | string | Nein | Umgebungsinformationen für das Issue |
|
||||
| `customFieldId` | string | Nein | Benutzerdefinierte Feld-ID \(z.B. customfield_10001\) |
|
||||
| `customFieldValue` | string | Nein | Wert für das benutzerdefinierte Feld |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
@@ -107,6 +113,7 @@ Ein Jira-Issue erstellen
|
||||
| `issueKey` | string | Erstellter Issue-Key \(z.B. PROJ-123\) |
|
||||
| `summary` | string | Issue-Zusammenfassung |
|
||||
| `url` | string | URL zum erstellten Issue |
|
||||
| `assigneeId` | string | Account-ID des zugewiesenen Benutzers \(falls zugewiesen\) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -520,6 +527,30 @@ Einen Beobachter von einem Jira-Issue entfernen
|
||||
| `issueKey` | string | Issue-Key |
|
||||
| `watcherAccountId` | string | Account-ID des entfernten Beobachters |
|
||||
|
||||
### `jira_get_users`
|
||||
|
||||
Jira-Benutzer abrufen. Wenn eine Account-ID angegeben wird, wird ein einzelner Benutzer zurückgegeben. Andernfalls wird eine Liste aller Benutzer zurückgegeben.
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Ja | Ihre Jira-Domain \(z.B. ihrfirma.atlassian.net\) |
|
||||
| `accountId` | string | Nein | Optionale Account-ID, um einen bestimmten Benutzer abzurufen. Wenn nicht angegeben, werden alle Benutzer zurückgegeben. |
|
||||
| `startAt` | number | Nein | Der Index des ersten zurückzugebenden Benutzers \(für Paginierung, Standard: 0\) |
|
||||
| `maxResults` | number | Nein | Maximale Anzahl der zurückzugebenden Benutzer \(Standard: 50\) |
|
||||
| `cloudId` | string | Nein | Jira Cloud-ID für die Instanz. Wenn nicht angegeben, wird sie anhand der Domain abgerufen. |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Zeitstempel der Operation |
|
||||
| `users` | json | Array von Benutzern mit accountId, displayName, emailAddress, active-Status und avatarUrls |
|
||||
| `total` | number | Gesamtanzahl der zurückgegebenen Benutzer |
|
||||
| `startAt` | number | Startindex für Paginierung |
|
||||
| `maxResults` | number | Maximale Ergebnisse pro Seite |
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Kategorie: `tools`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -97,10 +97,16 @@ Write a Jira issue
|
||||
| `projectId` | string | Yes | Project ID for the issue |
|
||||
| `summary` | string | Yes | Summary for the issue |
|
||||
| `description` | string | No | Description for the issue |
|
||||
| `priority` | string | No | Priority for the issue |
|
||||
| `assignee` | string | No | Assignee for the issue |
|
||||
| `priority` | string | No | Priority ID or name for the issue \(e.g., "10000" or "High"\) |
|
||||
| `assignee` | string | No | Assignee account ID for the issue |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
| `issueType` | string | Yes | Type of issue to create \(e.g., Task, Story\) |
|
||||
| `labels` | array | No | Labels for the issue \(array of label names\) |
|
||||
| `duedate` | string | No | Due date for the issue \(format: YYYY-MM-DD\) |
|
||||
| `reporter` | string | No | Reporter account ID for the issue |
|
||||
| `environment` | string | No | Environment information for the issue |
|
||||
| `customFieldId` | string | No | Custom field ID \(e.g., customfield_10001\) |
|
||||
| `customFieldValue` | string | No | Value for the custom field |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -110,6 +116,7 @@ Write a Jira issue
|
||||
| `issueKey` | string | Created issue key \(e.g., PROJ-123\) |
|
||||
| `summary` | string | Issue summary |
|
||||
| `url` | string | URL to the created issue |
|
||||
| `assigneeId` | string | Account ID of the assigned user \(if assigned\) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -523,6 +530,30 @@ Remove a watcher from a Jira issue
|
||||
| `issueKey` | string | Issue key |
|
||||
| `watcherAccountId` | string | Removed watcher account ID |
|
||||
|
||||
### `jira_get_users`
|
||||
|
||||
Get Jira users. If an account ID is provided, returns a single user. Otherwise, returns a list of all users.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `accountId` | string | No | Optional account ID to get a specific user. If not provided, returns all users. |
|
||||
| `startAt` | number | No | The index of the first user to return \(for pagination, default: 0\) |
|
||||
| `maxResults` | number | No | Maximum number of users to return \(default: 50\) |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Timestamp of the operation |
|
||||
| `users` | json | Array of users with accountId, displayName, emailAddress, active status, and avatarUrls |
|
||||
| `total` | number | Total number of users returned |
|
||||
| `startAt` | number | Pagination start index |
|
||||
| `maxResults` | number | Maximum results per page |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -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\) |
|
||||
|
||||
|
||||
@@ -89,24 +89,31 @@ Escribir una incidencia de Jira
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `domain` | string | Sí | Tu dominio de Jira \(p. ej., tuempresa.atlassian.net\) |
|
||||
| `projectId` | string | Sí | ID del proyecto para la incidencia |
|
||||
| `summary` | string | Sí | Resumen de la incidencia |
|
||||
| `description` | string | No | Descripción de la incidencia |
|
||||
| `priority` | string | No | Prioridad de la incidencia |
|
||||
| `assignee` | string | No | Asignado para la incidencia |
|
||||
| `cloudId` | string | No | ID de Jira Cloud para la instancia. Si no se proporciona, se obtendrá utilizando el dominio. |
|
||||
| `priority` | string | No | ID o nombre de prioridad para la incidencia \(p. ej., "10000" o "Alta"\) |
|
||||
| `assignee` | string | No | ID de cuenta del asignado para la incidencia |
|
||||
| `cloudId` | string | No | ID de Jira Cloud para la instancia. Si no se proporciona, se obtendrá usando el dominio. |
|
||||
| `issueType` | string | Sí | Tipo de incidencia a crear \(p. ej., Tarea, Historia\) |
|
||||
| `labels` | array | No | Etiquetas para la incidencia \(array de nombres de etiquetas\) |
|
||||
| `duedate` | string | No | Fecha de vencimiento para la incidencia \(formato: AAAA-MM-DD\) |
|
||||
| `reporter` | string | No | ID de cuenta del informador para la incidencia |
|
||||
| `environment` | string | No | Información del entorno para la incidencia |
|
||||
| `customFieldId` | string | No | ID del campo personalizado \(p. ej., customfield_10001\) |
|
||||
| `customFieldValue` | string | No | Valor para el campo personalizado |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Marca de tiempo de la operación |
|
||||
| `issueKey` | string | Clave de la incidencia creada (p. ej., PROJ-123) |
|
||||
| `issueKey` | string | Clave de la incidencia creada \(p. ej., PROJ-123\) |
|
||||
| `summary` | string | Resumen de la incidencia |
|
||||
| `url` | string | URL de la incidencia creada |
|
||||
| `assigneeId` | string | ID de cuenta del usuario asignado \(si está asignado\) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -520,6 +527,30 @@ Eliminar un observador de una incidencia de Jira
|
||||
| `issueKey` | string | Clave de incidencia |
|
||||
| `watcherAccountId` | string | ID de cuenta del observador eliminado |
|
||||
|
||||
### `jira_get_users`
|
||||
|
||||
Obtener usuarios de Jira. Si se proporciona un ID de cuenta, devuelve un solo usuario. De lo contrario, devuelve una lista de todos los usuarios.
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `domain` | string | Sí | Tu dominio de Jira \(p. ej., tuempresa.atlassian.net\) |
|
||||
| `accountId` | string | No | ID de cuenta opcional para obtener un usuario específico. Si no se proporciona, devuelve todos los usuarios. |
|
||||
| `startAt` | number | No | El índice del primer usuario a devolver \(para paginación, predeterminado: 0\) |
|
||||
| `maxResults` | number | No | Número máximo de usuarios a devolver \(predeterminado: 50\) |
|
||||
| `cloudId` | string | No | ID de Jira Cloud para la instancia. Si no se proporciona, se obtendrá usando el dominio. |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Marca de tiempo de la operación |
|
||||
| `users` | json | Array de usuarios con accountId, displayName, emailAddress, estado activo y avatarUrls |
|
||||
| `total` | number | Número total de usuarios devueltos |
|
||||
| `startAt` | number | Índice de inicio de paginación |
|
||||
| `maxResults` | number | Máximo de resultados por página |
|
||||
|
||||
## Notas
|
||||
|
||||
- Categoría: `tools`
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -89,15 +89,21 @@ Rédiger une demande Jira
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Oui | Votre domaine Jira (ex. : votreentreprise.atlassian.net) |
|
||||
| `projectId` | string | Oui | ID du projet pour la demande |
|
||||
| `summary` | string | Oui | Résumé de la demande |
|
||||
| `description` | string | Non | Description de la demande |
|
||||
| `priority` | string | Non | Priorité de la demande |
|
||||
| `assignee` | string | Non | Assigné de la demande |
|
||||
| `cloudId` | string | Non | ID Jira Cloud pour l'instance. S'il n'est pas fourni, il sera récupéré à l'aide du domaine. |
|
||||
| `issueType` | string | Oui | Type de demande à créer (ex. : Tâche, Story) |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `domain` | chaîne | Oui | Votre domaine Jira \(ex. : votreentreprise.atlassian.net\) |
|
||||
| `projectId` | chaîne | Oui | ID du projet pour le ticket |
|
||||
| `summary` | chaîne | Oui | Résumé du ticket |
|
||||
| `description` | chaîne | Non | Description du ticket |
|
||||
| `priority` | chaîne | Non | ID ou nom de la priorité du ticket \(ex. : "10000" ou "Haute"\) |
|
||||
| `assignee` | chaîne | Non | ID de compte de l'assigné pour le ticket |
|
||||
| `cloudId` | chaîne | Non | ID Cloud Jira pour l'instance. S'il n'est pas fourni, il sera récupéré à l'aide du domaine. |
|
||||
| `issueType` | chaîne | Oui | Type de ticket à créer \(ex. : tâche, story\) |
|
||||
| `labels` | tableau | Non | Étiquettes pour le ticket \(tableau de noms d'étiquettes\) |
|
||||
| `duedate` | chaîne | Non | Date d'échéance du ticket \(format : AAAA-MM-JJ\) |
|
||||
| `reporter` | chaîne | Non | ID de compte du rapporteur pour le ticket |
|
||||
| `environment` | chaîne | Non | Informations d'environnement pour le ticket |
|
||||
| `customFieldId` | chaîne | Non | ID du champ personnalisé \(ex. : customfield_10001\) |
|
||||
| `customFieldValue` | chaîne | Non | Valeur pour le champ personnalisé |
|
||||
|
||||
#### Sortie
|
||||
|
||||
@@ -107,6 +113,7 @@ Rédiger une demande Jira
|
||||
| `issueKey` | chaîne | Clé du ticket créé \(ex. : PROJ-123\) |
|
||||
| `summary` | chaîne | Résumé du ticket |
|
||||
| `url` | chaîne | URL vers le ticket créé |
|
||||
| `assigneeId` | chaîne | ID de compte de l'utilisateur assigné \(si assigné\) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -520,7 +527,31 @@ Supprimer un observateur d'un ticket Jira
|
||||
| `issueKey` | string | Clé du ticket |
|
||||
| `watcherAccountId` | string | ID du compte observateur supprimé |
|
||||
|
||||
## Notes
|
||||
### `jira_get_users`
|
||||
|
||||
Récupère les utilisateurs Jira. Si un ID de compte est fourni, renvoie un seul utilisateur. Sinon, renvoie une liste de tous les utilisateurs.
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `domain` | chaîne | Oui | Votre domaine Jira \(ex. : votreentreprise.atlassian.net\) |
|
||||
| `accountId` | chaîne | Non | ID de compte optionnel pour obtenir un utilisateur spécifique. S'il n'est pas fourni, renvoie tous les utilisateurs. |
|
||||
| `startAt` | nombre | Non | L'index du premier utilisateur à renvoyer \(pour la pagination, par défaut : 0\) |
|
||||
| `maxResults` | nombre | Non | Nombre maximum d'utilisateurs à renvoyer \(par défaut : 50\) |
|
||||
| `cloudId` | chaîne | Non | ID Cloud Jira pour l'instance. S'il n'est pas fourni, il sera récupéré à l'aide du domaine. |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | chaîne | Horodatage de l'opération |
|
||||
| `users` | json | Tableau d'utilisateurs avec accountId, displayName, emailAddress, statut actif et avatarUrls |
|
||||
| `total` | nombre | Nombre total d'utilisateurs renvoyés |
|
||||
| `startAt` | nombre | Index de début de pagination |
|
||||
| `maxResults` | nombre | Nombre maximum de résultats par page |
|
||||
|
||||
## Remarques
|
||||
|
||||
- Catégorie : `tools`
|
||||
- Type : `jira`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -94,10 +94,16 @@ Jira課題を作成する
|
||||
| `projectId` | string | はい | 課題のプロジェクトID |
|
||||
| `summary` | string | はい | 課題の要約 |
|
||||
| `description` | string | いいえ | 課題の説明 |
|
||||
| `priority` | string | いいえ | 課題の優先度 |
|
||||
| `assignee` | string | いいえ | 課題の担当者 |
|
||||
| `priority` | string | いいえ | 課題の優先度IDまたは名前(例:「10000」または「高」) |
|
||||
| `assignee` | string | いいえ | 課題の担当者アカウントID |
|
||||
| `cloudId` | string | いいえ | インスタンスのJira Cloud ID。提供されない場合、ドメインを使用して取得されます。 |
|
||||
| `issueType` | string | はい | 作成する課題のタイプ(例:タスク、ストーリー) |
|
||||
| `labels` | array | いいえ | 課題のラベル(ラベル名の配列) |
|
||||
| `duedate` | string | いいえ | 課題の期限(形式:YYYY-MM-DD) |
|
||||
| `reporter` | string | いいえ | 課題の報告者アカウントID |
|
||||
| `environment` | string | いいえ | 課題の環境情報 |
|
||||
| `customFieldId` | string | いいえ | カスタムフィールドID(例:customfield_10001) |
|
||||
| `customFieldValue` | string | いいえ | カスタムフィールドの値 |
|
||||
|
||||
#### 出力
|
||||
|
||||
@@ -106,7 +112,8 @@ Jira課題を作成する
|
||||
| `ts` | string | 操作のタイムスタンプ |
|
||||
| `issueKey` | string | 作成された課題キー(例:PROJ-123) |
|
||||
| `summary` | string | 課題の要約 |
|
||||
| `url` | string | 作成された課題へのURL |
|
||||
| `url` | string | 作成された課題のURL |
|
||||
| `assigneeId` | string | 割り当てられたユーザーのアカウントID(割り当てられている場合) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -520,7 +527,31 @@ Jira課題からウォッチャーを削除する
|
||||
| `issueKey` | string | 課題キー |
|
||||
| `watcherAccountId` | string | 削除されたウォッチャーのアカウントID |
|
||||
|
||||
## 注意事項
|
||||
### `jira_get_users`
|
||||
|
||||
- カテゴリー: `tools`
|
||||
- タイプ: `jira`
|
||||
Jiraユーザーを取得します。アカウントIDが提供された場合、単一のユーザーを返します。それ以外の場合、すべてのユーザーのリストを返します。
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | はい | あなたのJiraドメイン(例:yourcompany.atlassian.net) |
|
||||
| `accountId` | string | いいえ | 特定のユーザーを取得するためのオプションのアカウントID。提供されない場合、すべてのユーザーを返します。 |
|
||||
| `startAt` | number | いいえ | 返す最初のユーザーのインデックス(ページネーション用、デフォルト:0) |
|
||||
| `maxResults` | number | いいえ | 返すユーザーの最大数(デフォルト:50) |
|
||||
| `cloudId` | string | いいえ | インスタンスのJira Cloud ID。提供されない場合、ドメインを使用して取得されます。 |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | 操作のタイムスタンプ |
|
||||
| `users` | json | accountId、displayName、emailAddress、activeステータス、avatarUrlsを含むユーザーの配列 |
|
||||
| `total` | number | 返されたユーザーの総数 |
|
||||
| `startAt` | number | ページネーション開始インデックス |
|
||||
| `maxResults` | number | ページあたりの最大結果数 |
|
||||
|
||||
## 注記
|
||||
|
||||
- カテゴリ:`tools`
|
||||
- タイプ:`jira`
|
||||
|
||||
@@ -110,8 +110,8 @@ Slackチャンネルから最新のメッセージを読み取ります。フィ
|
||||
| `authMethod` | string | いいえ | 認証方法:oauthまたはbot_token |
|
||||
| `botToken` | string | いいえ | カスタムボット用のボットトークン |
|
||||
| `channel` | string | いいえ | メッセージを読み取るSlackチャンネル(例:#general) |
|
||||
| `userId` | string | いいえ | DMの会話用のユーザーID(例:U1234567890) |
|
||||
| `limit` | number | いいえ | 取得するメッセージ数(デフォルト:10、最大:100) |
|
||||
| `userId` | string | いいえ | DM会話用のユーザーID(例:U1234567890) |
|
||||
| `limit` | number | いいえ | 取得するメッセージ数(デフォルト:10、最大:15) |
|
||||
| `oldest` | string | いいえ | 時間範囲の開始(タイムスタンプ) |
|
||||
| `latest` | string | いいえ | 時間範囲の終了(タイムスタンプ) |
|
||||
|
||||
|
||||
@@ -91,13 +91,19 @@ Jira 的主要功能包括:
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | 字符串 | 是 | 您的 Jira 域名 \(例如:yourcompany.atlassian.net\) |
|
||||
| `projectId` | 字符串 | 是 | 问题的项目 ID |
|
||||
| `summary` | 字符串 | 是 | 问题的摘要 |
|
||||
| `description` | 字符串 | 否 | 问题的描述 |
|
||||
| `priority` | 字符串 | 否 | 问题的优先级 |
|
||||
| `assignee` | 字符串 | 否 | 问题的负责人 |
|
||||
| `cloudId` | 字符串 | 否 | 实例的 Jira 云 ID。如果未提供,将使用域名获取。 |
|
||||
| `issueType` | 字符串 | 是 | 要创建的问题类型 \(例如:任务、故事\) |
|
||||
| `projectId` | 字符串 | 是 | 问题所属项目 ID |
|
||||
| `summary` | 字符串 | 是 | 问题摘要 |
|
||||
| `description` | 字符串 | 否 | 问题描述 |
|
||||
| `priority` | 字符串 | 否 | 问题优先级 ID 或名称 \(例如:“10000”或“High”\) |
|
||||
| `assignee` | 字符串 | 否 | 问题负责人账户 ID |
|
||||
| `cloudId` | 字符串 | 否 | 实例的 Jira Cloud ID。如果未提供,将使用域名获取。 |
|
||||
| `issueType` | 字符串 | 是 | 要创建的问题类型 \(例如:Task、Story\) |
|
||||
| `labels` | 数组 | 否 | 问题标签 \(标签名称数组\) |
|
||||
| `duedate` | 字符串 | 否 | 问题截止日期 \(格式:YYYY-MM-DD\) |
|
||||
| `reporter` | 字符串 | 否 | 问题报告人账户 ID |
|
||||
| `environment` | 字符串 | 否 | 问题环境信息 |
|
||||
| `customFieldId` | 字符串 | 否 | 自定义字段 ID \(例如:customfield_10001\) |
|
||||
| `customFieldValue` | 字符串 | 否 | 自定义字段的值 |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -107,6 +113,7 @@ Jira 的主要功能包括:
|
||||
| `issueKey` | 字符串 | 创建的问题键 \(例如:PROJ-123\) |
|
||||
| `summary` | 字符串 | 问题摘要 |
|
||||
| `url` | 字符串 | 创建的问题的 URL |
|
||||
| `assigneeId` | 字符串 | 已分配用户的账户 ID(如已分配) |
|
||||
|
||||
### `jira_bulk_read`
|
||||
|
||||
@@ -520,7 +527,31 @@ Jira 的主要功能包括:
|
||||
| `issueKey` | string | 问题键 |
|
||||
| `watcherAccountId` | string | 移除的观察者账户 ID |
|
||||
|
||||
## 注意事项
|
||||
### `jira_get_users`
|
||||
|
||||
- 类别: `tools`
|
||||
- 类型: `jira`
|
||||
获取 Jira 用户。如果提供了账户 ID,则返回单个用户,否则返回所有用户的列表。
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | 字符串 | 是 | 您的 Jira 域名 \(例如:yourcompany.atlassian.net\) |
|
||||
| `accountId` | 字符串 | 否 | 可选账户 ID,用于获取特定用户。如果未提供,则返回所有用户。 |
|
||||
| `startAt` | 数字 | 否 | 要返回的第一个用户的索引 \(用于分页,默认值:0\) |
|
||||
| `maxResults` | 数字 | 否 | 要返回的最大用户数 \(默认值:50\) |
|
||||
| `cloudId` | 字符串 | 否 | 实例的 Jira Cloud ID。如果未提供,将使用域名获取。 |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | 字符串 | 操作的时间戳 |
|
||||
| `users` | json | 用户数组,包含 accountId、displayName、emailAddress、active 状态和 avatarUrls |
|
||||
| `total` | 数字 | 返回的用户总数 |
|
||||
| `startAt` | 数字 | 分页起始索引 |
|
||||
| `maxResults` | 数字 | 每页最大结果数 |
|
||||
|
||||
## 备注
|
||||
|
||||
- 分类:`tools`
|
||||
- 类型:`jira`
|
||||
|
||||
@@ -109,10 +109,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
| `authMethod` | string | 否 | 认证方法:oauth 或 bot_token |
|
||||
| `botToken` | string | 否 | 自定义 Bot 的令牌 |
|
||||
| `channel` | string | 否 | 要读取消息的 Slack 频道(例如,#general) |
|
||||
| `userId` | string | 否 | DM 对话的用户 ID(例如,U1234567890) |
|
||||
| `limit` | number | 否 | 要检索的消息数量(默认:10,最大:100) |
|
||||
| `oldest` | string | 否 | 时间范围的开始(时间戳) |
|
||||
| `latest` | string | 否 | 时间范围的结束(时间戳) |
|
||||
| `userId` | string | 否 | DM 会话的用户 ID(例如,U1234567890) |
|
||||
| `limit` | number | 否 | 要检索的消息数量(默认:10,最大:15) |
|
||||
| `oldest` | string | 否 | 时间范围起始(时间戳) |
|
||||
| `latest` | string | 否 | 时间范围结束(时间戳) |
|
||||
|
||||
#### 输出
|
||||
|
||||
|
||||
@@ -903,7 +903,7 @@ checksums:
|
||||
content/24: 228a8ece96627883153b826a1cbaa06c
|
||||
content/25: 53abe061a259c296c82676b4770ddd1b
|
||||
content/26: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/27: 03e8b10ec08b354de98e360b66b779e3
|
||||
content/27: 5b9546f77fbafc0741f3fc2548f81c7e
|
||||
content/28: bcadfc362b69078beee0088e5936c98b
|
||||
content/29: b82def7d82657f941fbe60df3924eeeb
|
||||
content/30: 1ca7ee3856805fa1718031c5f75b6ffb
|
||||
@@ -2521,9 +2521,9 @@ checksums:
|
||||
content/22: ef92d95455e378abe4d27a1cdc5e1aed
|
||||
content/23: febd6019055f3754953fd93395d0dbf2
|
||||
content/24: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/25: 7ef3f388e5ee9346bac54c771d825f40
|
||||
content/25: caf6acbe2a4495ca055cb9006ce47250
|
||||
content/26: bcadfc362b69078beee0088e5936c98b
|
||||
content/27: e0fa91c45aa780fc03e91df77417f893
|
||||
content/27: 57662dd91f8d1d807377fd48fa0e9142
|
||||
content/28: b463f54cd5fe2458b5842549fbb5e1ce
|
||||
content/29: 55f8c724e1a2463bc29a32518a512c73
|
||||
content/30: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
@@ -2638,8 +2638,14 @@ checksums:
|
||||
content/139: 33fde4c3da4584b51f06183b7b192a78
|
||||
content/140: bcadfc362b69078beee0088e5936c98b
|
||||
content/141: b7451190f100388d999c183958d787a7
|
||||
content/142: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/143: 4930918f803340baa861bed9cdf789de
|
||||
content/142: d0f9e799e2e5cc62de60668d35fd846f
|
||||
content/143: b19069ff19899fe202217e06e002c447
|
||||
content/144: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/145: 480fd62f8d9cc18467e82f4c3f70beea
|
||||
content/146: bcadfc362b69078beee0088e5936c98b
|
||||
content/147: 4e73a65d3b873f3979587e10a0f39e72
|
||||
content/148: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/149: 4930918f803340baa861bed9cdf789de
|
||||
8f76e389f6226f608571622b015ca6a1:
|
||||
meta/title: ddfe2191ea61b34d8b7cc1d7c19b94ac
|
||||
meta/description: 049ff551f2ebabb15cdea0c71bd8e4eb
|
||||
|
||||
@@ -573,10 +573,10 @@ export default function LoginPage({
|
||||
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
|
||||
<DialogContent className='auth-card auth-card-shadow max-w-[540px] rounded-[10px] border backdrop-blur-sm'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='auth-text-primary font-semibold text-xl tracking-tight'>
|
||||
<DialogTitle className='font-semibold text-black text-xl tracking-tight'>
|
||||
Reset Password
|
||||
</DialogTitle>
|
||||
<DialogDescription className='auth-text-secondary text-sm'>
|
||||
<DialogDescription className='text-muted-foreground text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</DialogDescription>
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function Footer({ fullWidth = false }: FooterProps) {
|
||||
{FOOTER_BLOCKS.map((block) => (
|
||||
<Link
|
||||
key={block}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replace(' ', '-')}`}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { PostGrid } from '@/app/(landing)/studio/post-grid'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
@@ -18,7 +17,6 @@ export default async function StudioIndex({
|
||||
const all = await getAllPostMeta()
|
||||
const filtered = tag ? all.filter((p) => p.tags.includes(tag)) : all
|
||||
|
||||
// Sort to ensure featured post is first on page 1
|
||||
const sorted =
|
||||
pageNum === 1
|
||||
? filtered.sort((a, b) => {
|
||||
@@ -63,69 +61,7 @@ export default async function StudioIndex({
|
||||
</div> */}
|
||||
|
||||
{/* Grid layout for consistent rows */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{posts.map((p, i) => {
|
||||
return (
|
||||
<Link key={p.slug} href={`/studio/${p.slug}`} className='group flex flex-col'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
width={800}
|
||||
height={450}
|
||||
className='h-48 w-full object-cover'
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<div className='mb-2 text-gray-600 text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className='shine-text mb-1 font-medium text-lg leading-tight'>{p.title}</h3>
|
||||
<p className='mb-3 line-clamp-3 flex-1 text-gray-700 text-sm'>{p.description}</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='-space-x-1.5 flex'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 3)
|
||||
.map((author, idx) => (
|
||||
<Avatar key={idx} className='size-4 border border-white'>
|
||||
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
|
||||
<AvatarFallback className='border border-white bg-gray-100 text-[10px] text-gray-600'>
|
||||
{author?.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-gray-600 text-xs'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 2)
|
||||
.map((a) => a?.name)
|
||||
.join(', ')}
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length > 2 && (
|
||||
<>
|
||||
{' '}
|
||||
and{' '}
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2}{' '}
|
||||
other
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2 >
|
||||
1
|
||||
? 's'
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<PostGrid posts={posts} />
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className='mt-10 flex items-center justify-center gap-3'>
|
||||
|
||||
90
apps/sim/app/(landing)/studio/post-grid.tsx
Normal file
90
apps/sim/app/(landing)/studio/post-grid.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
interface Author {
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface Post {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
date: string
|
||||
ogImage: string
|
||||
author: Author
|
||||
authors?: Author[]
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export function PostGrid({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
|
||||
{posts.map((p, index) => (
|
||||
<Link key={p.slug} href={`/studio/${p.slug}`} className='group flex flex-col'>
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
|
||||
{/* Image container with fixed aspect ratio to prevent layout shift */}
|
||||
<div className='relative aspect-video w-full overflow-hidden'>
|
||||
<Image
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
unoptimized
|
||||
priority={index < 6}
|
||||
loading={index < 6 ? undefined : 'lazy'}
|
||||
fill
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col p-4'>
|
||||
<div className='mb-2 text-gray-600 text-xs'>
|
||||
{new Date(p.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className='shine-text mb-1 font-medium text-lg leading-tight'>{p.title}</h3>
|
||||
<p className='mb-3 line-clamp-3 flex-1 text-gray-700 text-sm'>{p.description}</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='-space-x-1.5 flex'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 3)
|
||||
.map((author, idx) => (
|
||||
<Avatar key={idx} className='size-4 border border-white'>
|
||||
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
|
||||
<AvatarFallback className='border border-white bg-gray-100 text-[10px] text-gray-600'>
|
||||
{author?.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-gray-600 text-xs'>
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
|
||||
.slice(0, 2)
|
||||
.map((a) => a?.name)
|
||||
.join(', ')}
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length > 2 && (
|
||||
<>
|
||||
{' '}
|
||||
and {(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2}{' '}
|
||||
other
|
||||
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2 > 1
|
||||
? 's'
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
pathname === '/' ||
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/signup') ||
|
||||
pathname.startsWith('/reset-password') ||
|
||||
pathname.startsWith('/sso') ||
|
||||
pathname.startsWith('/terms') ||
|
||||
pathname.startsWith('/privacy') ||
|
||||
|
||||
@@ -759,3 +759,24 @@ input[type="search"]::-ms-clear {
|
||||
--surface-elevated: #202020;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove backticks from inline code in prose (Tailwind Typography default)
|
||||
*/
|
||||
.prose code::before,
|
||||
.prose code::after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove underlines from heading anchor links in prose
|
||||
*/
|
||||
.prose h1 a,
|
||||
.prose h2 a,
|
||||
.prose h3 a,
|
||||
.prose h4 a,
|
||||
.prose h5 a,
|
||||
.prose h6 a {
|
||||
text-decoration: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,17 @@ export async function GET(request: NextRequest) {
|
||||
.from(account)
|
||||
.where(and(...whereConditions))
|
||||
|
||||
return NextResponse.json({ accounts })
|
||||
// Use the user's email as the display name (consistent with credential selector)
|
||||
const userEmail = session.user.email
|
||||
|
||||
const accountsWithDisplayName = accounts.map((acc) => ({
|
||||
id: acc.id,
|
||||
accountId: acc.accountId,
|
||||
providerId: acc.providerId,
|
||||
displayName: userEmail || acc.providerId,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ accounts: accountsWithDisplayName })
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch accounts', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest, setupAuthApiMocks } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: vi.fn(() => 'https://app.example.com'),
|
||||
}))
|
||||
|
||||
describe('Forget Password API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -15,7 +19,7 @@ describe('Forget Password API Route', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should send password reset email successfully', async () => {
|
||||
it('should send password reset email successfully with same-origin redirectTo', async () => {
|
||||
setupAuthApiMocks({
|
||||
operations: {
|
||||
forgetPassword: { success: true },
|
||||
@@ -24,7 +28,7 @@ describe('Forget Password API Route', () => {
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
email: 'test@example.com',
|
||||
redirectTo: 'https://example.com/reset',
|
||||
redirectTo: 'https://app.example.com/reset',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/auth/forget-password/route')
|
||||
@@ -39,12 +43,36 @@ describe('Forget Password API Route', () => {
|
||||
expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({
|
||||
body: {
|
||||
email: 'test@example.com',
|
||||
redirectTo: 'https://example.com/reset',
|
||||
redirectTo: 'https://app.example.com/reset',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject external redirectTo URL', async () => {
|
||||
setupAuthApiMocks({
|
||||
operations: {
|
||||
forgetPassword: { success: true },
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
email: 'test@example.com',
|
||||
redirectTo: 'https://evil.com/phishing',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/auth/forget-password/route')
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.message).toBe('Redirect URL must be a valid same-origin URL')
|
||||
|
||||
const auth = await import('@/lib/auth')
|
||||
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should send password reset email without redirectTo', async () => {
|
||||
setupAuthApiMocks({
|
||||
operations: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { isSameOrigin } from '@/lib/core/utils/validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -13,10 +14,15 @@ const forgetPasswordSchema = z.object({
|
||||
.email('Please provide a valid email address'),
|
||||
redirectTo: z
|
||||
.string()
|
||||
.url('Redirect URL must be a valid URL')
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.transform((val) => (val === '' ? undefined : val)),
|
||||
.transform((val) => (val === '' || val === undefined ? undefined : val))
|
||||
.refine(
|
||||
(val) => val === undefined || (z.string().url().safeParse(val).success && isSameOrigin(val)),
|
||||
{
|
||||
message: 'Redirect URL must be a valid same-origin URL',
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('SSO-Register')
|
||||
@@ -236,13 +237,13 @@ export async function POST(request: NextRequest) {
|
||||
oidcConfig: providerConfig.oidcConfig
|
||||
? {
|
||||
...providerConfig.oidcConfig,
|
||||
clientSecret: '[REDACTED]',
|
||||
clientSecret: REDACTED_MARKER,
|
||||
}
|
||||
: undefined,
|
||||
samlConfig: providerConfig.samlConfig
|
||||
? {
|
||||
...providerConfig.samlConfig,
|
||||
cert: '[REDACTED]',
|
||||
cert: REDACTED_MARKER,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { userStats } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { logModelUsage } from '@/lib/billing/core/usage-log'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
@@ -14,6 +15,9 @@ const logger = createLogger('BillingUpdateCostAPI')
|
||||
const UpdateCostSchema = z.object({
|
||||
userId: z.string().min(1, 'User ID is required'),
|
||||
cost: z.number().min(0, 'Cost must be a non-negative number'),
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
inputTokens: z.number().min(0).default(0),
|
||||
outputTokens: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -71,11 +75,12 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, cost } = validation.data
|
||||
const { userId, cost, model, inputTokens, outputTokens } = validation.data
|
||||
|
||||
logger.info(`[${requestId}] Processing cost update`, {
|
||||
userId,
|
||||
cost,
|
||||
model,
|
||||
})
|
||||
|
||||
// Check if user stats record exists (same as ExecutionLogger)
|
||||
@@ -107,6 +112,16 @@ export async function POST(req: NextRequest) {
|
||||
addedCost: cost,
|
||||
})
|
||||
|
||||
// Log usage for complete audit trail
|
||||
await logModelUsage({
|
||||
userId,
|
||||
source: 'copilot',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cost,
|
||||
})
|
||||
|
||||
// Check if user has hit overage threshold and bill incrementally
|
||||
await checkAndBillOverageThreshold(userId)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { chat, workflow } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
@@ -94,6 +94,21 @@ export async function POST(
|
||||
if (!deployment.isActive) {
|
||||
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, deployment.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const workspaceId = workflowRecord?.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This chat is currently unavailable', 403),
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
const executionId = randomUUID()
|
||||
const loggingSession = new LoggingSession(
|
||||
deployment.workflowId,
|
||||
@@ -104,7 +119,7 @@ export async function POST(
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: deployment.userId,
|
||||
workspaceId: '', // Will be resolved if needed
|
||||
workspaceId,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
@@ -169,7 +184,14 @@ export async function POST(
|
||||
|
||||
const { actorUserId, workflowRecord } = preprocessResult
|
||||
const workspaceOwnerId = actorUserId!
|
||||
const workspaceId = workflowRecord?.workspaceId || ''
|
||||
const workspaceId = workflowRecord?.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Workflow has no associated workspace', 500),
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedOutputs: string[] = []
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CopilotChatsListAPI')
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
export async function GET(_request: NextRequest) {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
|
||||
@@ -38,14 +38,13 @@ export async function GET(
|
||||
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
|
||||
|
||||
const contextParam = request.nextUrl.searchParams.get('context')
|
||||
const legacyBucketType = request.nextUrl.searchParams.get('bucket')
|
||||
|
||||
const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined)
|
||||
|
||||
if (context === 'profile-pictures') {
|
||||
logger.info('Serving public profile picture:', { cloudKey })
|
||||
if (context === 'profile-pictures' || context === 'og-images') {
|
||||
logger.info(`Serving public ${context}:`, { cloudKey })
|
||||
if (isUsingCloudStorage() || isCloudPath) {
|
||||
return await handleCloudProxyPublic(cloudKey, context, legacyBucketType)
|
||||
return await handleCloudProxyPublic(cloudKey, context)
|
||||
}
|
||||
return await handleLocalFilePublic(fullPath)
|
||||
}
|
||||
@@ -182,8 +181,7 @@ async function handleCloudProxy(
|
||||
|
||||
async function handleCloudProxyPublic(
|
||||
cloudKey: string,
|
||||
context: StorageContext,
|
||||
legacyBucketType?: string | null
|
||||
context: StorageContext
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
let fileBuffer: Buffer
|
||||
|
||||
@@ -141,6 +141,23 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Check if deleting this folder would delete the last workflow(s) in the workspace
|
||||
const workflowsInFolder = await countWorkflowsInFolderRecursively(
|
||||
id,
|
||||
existingFolder.workspaceId
|
||||
)
|
||||
const totalWorkflowsInWorkspace = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, existingFolder.workspaceId))
|
||||
|
||||
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Recursively delete folder and all its contents
|
||||
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
|
||||
|
||||
@@ -202,6 +219,34 @@ async function deleteFolderRecursively(
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of workflows in a folder and all its subfolders recursively.
|
||||
*/
|
||||
async function countWorkflowsInFolderRecursively(
|
||||
folderId: string,
|
||||
workspaceId: string
|
||||
): Promise<number> {
|
||||
let count = 0
|
||||
|
||||
const workflowsInFolder = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
|
||||
|
||||
count += workflowsInFolder.length
|
||||
|
||||
const childFolders = await db
|
||||
.select({ id: workflowFolder.id })
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
|
||||
|
||||
for (const childFolder of childFolders) {
|
||||
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// Helper function to check for circular references
|
||||
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
|
||||
let currentParentId: string | null = parentId
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { runs } from '@trigger.dev/sdk'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
@@ -18,38 +17,44 @@ export async function GET(
|
||||
try {
|
||||
logger.debug(`[${requestId}] Getting status for task: ${taskId}`)
|
||||
|
||||
// Try session auth first (for web UI)
|
||||
const session = await getSession()
|
||||
let authenticatedUserId: string | null = session?.user?.id || null
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
const apiKeyHeader = request.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
const authResult = await authenticateApiKeyFromHeader(apiKeyHeader)
|
||||
if (authResult.success && authResult.userId) {
|
||||
authenticatedUserId = authResult.userId
|
||||
if (authResult.keyId) {
|
||||
await updateApiKeyLastUsed(authResult.keyId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to update API key last used timestamp:`, {
|
||||
keyId: authResult.keyId,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized task status request`)
|
||||
return createErrorResponse(authResult.error || 'Authentication required', 401)
|
||||
}
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
return createErrorResponse('Authentication required', 401)
|
||||
}
|
||||
const authenticatedUserId = authResult.userId
|
||||
|
||||
// Fetch task status from Trigger.dev
|
||||
const run = await runs.retrieve(taskId)
|
||||
|
||||
logger.debug(`[${requestId}] Task ${taskId} status: ${run.status}`)
|
||||
|
||||
// Map Trigger.dev status to our format
|
||||
const payload = run.payload as any
|
||||
if (payload?.workflowId) {
|
||||
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
|
||||
const accessCheck = await verifyWorkflowAccess(authenticatedUserId, payload.workflowId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
logger.warn(`[${requestId}] User ${authenticatedUserId} denied access to task ${taskId}`, {
|
||||
workflowId: payload.workflowId,
|
||||
})
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
logger.debug(`[${requestId}] User ${authenticatedUserId} has access to task ${taskId}`)
|
||||
} else {
|
||||
if (payload?.userId && payload.userId !== authenticatedUserId) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${authenticatedUserId} attempted to access task ${taskId} owned by ${payload.userId}`
|
||||
)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
if (!payload?.userId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Task ${taskId} has no ownership information in payload. Denying access for security.`
|
||||
)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
QUEUED: 'queued',
|
||||
WAITING_FOR_DEPLOY: 'queued',
|
||||
@@ -67,7 +72,6 @@ export async function GET(
|
||||
|
||||
const mappedStatus = statusMap[run.status as keyof typeof statusMap] || 'unknown'
|
||||
|
||||
// Build response based on status
|
||||
const response: any = {
|
||||
success: true,
|
||||
taskId,
|
||||
@@ -77,21 +81,18 @@ export async function GET(
|
||||
},
|
||||
}
|
||||
|
||||
// Add completion details if finished
|
||||
if (mappedStatus === 'completed') {
|
||||
response.output = run.output // This contains the workflow execution results
|
||||
response.metadata.completedAt = run.finishedAt
|
||||
response.metadata.duration = run.durationMs
|
||||
}
|
||||
|
||||
// Add error details if failed
|
||||
if (mappedStatus === 'failed') {
|
||||
response.error = run.error
|
||||
response.metadata.completedAt = run.finishedAt
|
||||
response.metadata.duration = run.durationMs
|
||||
}
|
||||
|
||||
// Add progress info if still processing
|
||||
if (mappedStatus === 'processing' || mappedStatus === 'queued') {
|
||||
response.estimatedDuration = 180000 // 3 minutes max from our config
|
||||
}
|
||||
@@ -107,6 +108,3 @@ export async function GET(
|
||||
return createErrorResponse('Failed to fetch task status', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement task cancellation via Trigger.dev API if needed
|
||||
// export async function DELETE() { ... }
|
||||
|
||||
@@ -156,6 +156,7 @@ export async function POST(
|
||||
const validatedData = CreateChunkSchema.parse(searchParams)
|
||||
|
||||
const docTags = {
|
||||
// Text tags (7 slots)
|
||||
tag1: doc.tag1 ?? null,
|
||||
tag2: doc.tag2 ?? null,
|
||||
tag3: doc.tag3 ?? null,
|
||||
@@ -163,6 +164,19 @@ export async function POST(
|
||||
tag5: doc.tag5 ?? null,
|
||||
tag6: doc.tag6 ?? null,
|
||||
tag7: doc.tag7 ?? null,
|
||||
// Number tags (5 slots)
|
||||
number1: doc.number1 ?? null,
|
||||
number2: doc.number2 ?? null,
|
||||
number3: doc.number3 ?? null,
|
||||
number4: doc.number4 ?? null,
|
||||
number5: doc.number5 ?? null,
|
||||
// Date tags (2 slots)
|
||||
date1: doc.date1 ?? null,
|
||||
date2: doc.date2 ?? null,
|
||||
// Boolean tags (3 slots)
|
||||
boolean1: doc.boolean1 ?? null,
|
||||
boolean2: doc.boolean2 ?? null,
|
||||
boolean3: doc.boolean3 ?? null,
|
||||
}
|
||||
|
||||
const newChunk = await createChunk(
|
||||
|
||||
@@ -72,6 +72,16 @@ describe('Document By ID API Route', () => {
|
||||
tag5: null,
|
||||
tag6: null,
|
||||
tag7: null,
|
||||
number1: null,
|
||||
number2: null,
|
||||
number3: null,
|
||||
number4: null,
|
||||
number5: null,
|
||||
date1: null,
|
||||
date2: null,
|
||||
boolean1: null,
|
||||
boolean2: null,
|
||||
boolean3: null,
|
||||
deletedAt: null,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const UpdateDocumentSchema = z.object({
|
||||
processingError: z.string().optional(),
|
||||
markFailedDueToTimeout: z.boolean().optional(),
|
||||
retryProcessing: z.boolean().optional(),
|
||||
// Tag fields
|
||||
// Text tag fields
|
||||
tag1: z.string().optional(),
|
||||
tag2: z.string().optional(),
|
||||
tag3: z.string().optional(),
|
||||
@@ -31,6 +31,19 @@ const UpdateDocumentSchema = z.object({
|
||||
tag5: z.string().optional(),
|
||||
tag6: z.string().optional(),
|
||||
tag7: z.string().optional(),
|
||||
// Number tag fields
|
||||
number1: z.string().optional(),
|
||||
number2: z.string().optional(),
|
||||
number3: z.string().optional(),
|
||||
number4: z.string().optional(),
|
||||
number5: z.string().optional(),
|
||||
// Date tag fields
|
||||
date1: z.string().optional(),
|
||||
date2: z.string().optional(),
|
||||
// Boolean tag fields
|
||||
boolean1: z.string().optional(),
|
||||
boolean2: z.string().optional(),
|
||||
boolean3: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function GET(
|
||||
|
||||
@@ -80,6 +80,16 @@ describe('Knowledge Base Documents API Route', () => {
|
||||
tag5: null,
|
||||
tag6: null,
|
||||
tag7: null,
|
||||
number1: null,
|
||||
number2: null,
|
||||
number3: null,
|
||||
number4: null,
|
||||
number5: null,
|
||||
date1: null,
|
||||
date2: null,
|
||||
boolean1: null,
|
||||
boolean2: null,
|
||||
boolean3: null,
|
||||
deletedAt: null,
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const UpdateKnowledgeBaseSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
@@ -133,7 +133,10 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
|
||||
@@ -64,6 +64,11 @@ vi.mock('@/app/api/knowledge/utils', () => ({
|
||||
checkKnowledgeBaseAccess: mockCheckKnowledgeBaseAccess,
|
||||
}))
|
||||
|
||||
const mockGetDocumentTagDefinitions = vi.fn()
|
||||
vi.mock('@/lib/knowledge/tags/service', () => ({
|
||||
getDocumentTagDefinitions: mockGetDocumentTagDefinitions,
|
||||
}))
|
||||
|
||||
const mockHandleTagOnlySearch = vi.fn()
|
||||
const mockHandleVectorOnlySearch = vi.fn()
|
||||
const mockHandleTagAndVectorSearch = vi.fn()
|
||||
@@ -156,6 +161,7 @@ describe('Knowledge Search API Route', () => {
|
||||
doc1: 'Document 1',
|
||||
doc2: 'Document 2',
|
||||
})
|
||||
mockGetDocumentTagDefinitions.mockClear()
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
|
||||
@@ -659,8 +665,8 @@ describe('Knowledge Search API Route', () => {
|
||||
|
||||
describe('Optional Query Search', () => {
|
||||
const mockTagDefinitions = [
|
||||
{ tagSlot: 'tag1', displayName: 'category' },
|
||||
{ tagSlot: 'tag2', displayName: 'priority' },
|
||||
{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' },
|
||||
{ tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' },
|
||||
]
|
||||
|
||||
const mockTaggedResults = [
|
||||
@@ -689,9 +695,7 @@ describe('Knowledge Search API Route', () => {
|
||||
it('should perform tag-only search without query', async () => {
|
||||
const tagOnlyData = {
|
||||
knowledgeBaseIds: 'kb-123',
|
||||
filters: {
|
||||
category: 'api',
|
||||
},
|
||||
tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }],
|
||||
topK: 10,
|
||||
}
|
||||
|
||||
@@ -706,10 +710,11 @@ describe('Knowledge Search API Route', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock tag definitions queries for filter mapping and display mapping
|
||||
mockDbChain.limit
|
||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for filter mapping
|
||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for display mapping
|
||||
// Mock tag definitions for validation
|
||||
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
||||
|
||||
// Mock tag definitions queries for display mapping
|
||||
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
||||
|
||||
// Mock the tag-only search handler
|
||||
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
|
||||
@@ -729,7 +734,9 @@ describe('Knowledge Search API Route', () => {
|
||||
expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { category: 'api' }, // Note: When no tag definitions are found, it uses the original filter key
|
||||
structuredFilters: [
|
||||
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -737,9 +744,7 @@ describe('Knowledge Search API Route', () => {
|
||||
const combinedData = {
|
||||
knowledgeBaseIds: 'kb-123',
|
||||
query: 'test search',
|
||||
filters: {
|
||||
category: 'api',
|
||||
},
|
||||
tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }],
|
||||
topK: 10,
|
||||
}
|
||||
|
||||
@@ -754,10 +759,11 @@ describe('Knowledge Search API Route', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock tag definitions queries for filter mapping and display mapping
|
||||
mockDbChain.limit
|
||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for filter mapping
|
||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for display mapping
|
||||
// Mock tag definitions for validation
|
||||
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
||||
|
||||
// Mock tag definitions queries for display mapping
|
||||
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
||||
|
||||
// Mock the tag + vector search handler
|
||||
mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults)
|
||||
@@ -784,7 +790,9 @@ describe('Knowledge Search API Route', () => {
|
||||
expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { category: 'api' }, // Note: When no tag definitions are found, it uses the original filter key
|
||||
structuredFilters: [
|
||||
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
|
||||
],
|
||||
queryVector: JSON.stringify(mockEmbedding),
|
||||
distanceThreshold: 1, // Single KB uses threshold of 1.0
|
||||
})
|
||||
@@ -928,10 +936,10 @@ describe('Knowledge Search API Route', () => {
|
||||
it('should handle tag-only search with multiple knowledge bases', async () => {
|
||||
const multiKbTagData = {
|
||||
knowledgeBaseIds: ['kb-123', 'kb-456'],
|
||||
filters: {
|
||||
category: 'docs',
|
||||
priority: 'high',
|
||||
},
|
||||
tagFilters: [
|
||||
{ tagName: 'category', value: 'docs', fieldType: 'text', operator: 'eq' },
|
||||
{ tagName: 'priority', value: 'high', fieldType: 'text', operator: 'eq' },
|
||||
],
|
||||
topK: 10,
|
||||
}
|
||||
|
||||
@@ -951,37 +959,14 @@ describe('Knowledge Search API Route', () => {
|
||||
knowledgeBase: { id: 'kb-456', userId: 'user-123', name: 'Test KB 2' },
|
||||
})
|
||||
|
||||
// Reset all mocks before setting up specific behavior
|
||||
Object.values(mockDbChain).forEach((fn) => {
|
||||
if (typeof fn === 'function') {
|
||||
fn.mockClear().mockReturnThis()
|
||||
}
|
||||
})
|
||||
// Mock tag definitions for validation
|
||||
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
||||
|
||||
// Create fresh mocks for multiple database calls needed for multi-KB tag search
|
||||
const mockTagDefsQuery1 = {
|
||||
...mockDbChain,
|
||||
limit: vi.fn().mockResolvedValue(mockTagDefinitions),
|
||||
}
|
||||
const mockTagSearchQuery = {
|
||||
...mockDbChain,
|
||||
limit: vi.fn().mockResolvedValue(mockTaggedResults),
|
||||
}
|
||||
const mockTagDefsQuery2 = {
|
||||
...mockDbChain,
|
||||
limit: vi.fn().mockResolvedValue(mockTagDefinitions),
|
||||
}
|
||||
const mockTagDefsQuery3 = {
|
||||
...mockDbChain,
|
||||
limit: vi.fn().mockResolvedValue(mockTagDefinitions),
|
||||
}
|
||||
// Mock the tag-only search handler
|
||||
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
|
||||
|
||||
// Chain the mocks for: tag defs, search, display mapping KB1, display mapping KB2
|
||||
mockDbChain.select
|
||||
.mockReturnValueOnce(mockTagDefsQuery1)
|
||||
.mockReturnValueOnce(mockTagSearchQuery)
|
||||
.mockReturnValueOnce(mockTagDefsQuery2)
|
||||
.mockReturnValueOnce(mockTagDefsQuery3)
|
||||
// Mock tag definitions queries for display mapping
|
||||
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
||||
|
||||
const req = createMockRequest('POST', multiKbTagData)
|
||||
const { POST } = await import('@/app/api/knowledge/search/route')
|
||||
@@ -1076,6 +1061,11 @@ describe('Knowledge Search API Route', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock tag definitions for validation
|
||||
mockGetDocumentTagDefinitions.mockResolvedValue([
|
||||
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
|
||||
])
|
||||
|
||||
mockHandleTagOnlySearch.mockResolvedValue([
|
||||
{
|
||||
id: 'chunk-2',
|
||||
@@ -1108,13 +1098,15 @@ describe('Knowledge Search API Route', () => {
|
||||
const mockTagDefs = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
where: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]),
|
||||
}
|
||||
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
filters: { tag1: 'api' },
|
||||
tagFilters: [{ tagName: 'tag1', value: 'api', fieldType: 'text', operator: 'eq' }],
|
||||
topK: 10,
|
||||
})
|
||||
|
||||
@@ -1143,6 +1135,11 @@ describe('Knowledge Search API Route', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock tag definitions for validation
|
||||
mockGetDocumentTagDefinitions.mockResolvedValue([
|
||||
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
|
||||
])
|
||||
|
||||
mockHandleTagAndVectorSearch.mockResolvedValue([
|
||||
{
|
||||
id: 'chunk-3',
|
||||
@@ -1176,14 +1173,16 @@ describe('Knowledge Search API Route', () => {
|
||||
const mockTagDefs = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
where: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]),
|
||||
}
|
||||
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
query: 'relevant content',
|
||||
filters: { tag1: 'guide' },
|
||||
tagFilters: [{ tagName: 'tag1', value: 'guide', fieldType: 'text', operator: 'eq' }],
|
||||
topK: 10,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils'
|
||||
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { estimateTokenCount } from '@/lib/tokenization/estimators'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
@@ -20,6 +22,16 @@ import { calculateCost } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('VectorSearchAPI')
|
||||
|
||||
/** Structured tag filter with operator support */
|
||||
const StructuredTagFilterSchema = z.object({
|
||||
tagName: z.string(),
|
||||
tagSlot: z.string().optional(),
|
||||
fieldType: z.enum(['text', 'number', 'date', 'boolean']).default('text'),
|
||||
operator: z.string().default('eq'),
|
||||
value: z.union([z.string(), z.number(), z.boolean()]),
|
||||
valueTo: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
|
||||
const VectorSearchSchema = z
|
||||
.object({
|
||||
knowledgeBaseIds: z.union([
|
||||
@@ -39,18 +51,17 @@ const VectorSearchSchema = z
|
||||
.nullable()
|
||||
.default(10)
|
||||
.transform((val) => val ?? 10),
|
||||
filters: z
|
||||
.record(z.string())
|
||||
tagFilters: z
|
||||
.array(StructuredTagFilterSchema)
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((val) => val || undefined), // Allow dynamic filter keys (display names)
|
||||
.transform((val) => val || undefined),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Ensure at least query or filters are provided
|
||||
const hasQuery = data.query && data.query.trim().length > 0
|
||||
const hasFilters = data.filters && Object.keys(data.filters).length > 0
|
||||
return hasQuery || hasFilters
|
||||
const hasTagFilters = data.tagFilters && data.tagFilters.length > 0
|
||||
return hasQuery || hasTagFilters
|
||||
},
|
||||
{
|
||||
message: 'Please provide either a search query or tag filters to search your knowledge base',
|
||||
@@ -88,45 +99,81 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
|
||||
// Map display names to tag slots for filtering
|
||||
let mappedFilters: Record<string, string> = {}
|
||||
if (validatedData.filters && accessibleKbIds.length > 0) {
|
||||
try {
|
||||
// Fetch tag definitions for the first accessible KB (since we're using single KB now)
|
||||
const kbId = accessibleKbIds[0]
|
||||
const tagDefs = await getDocumentTagDefinitions(kbId)
|
||||
let structuredFilters: StructuredFilter[] = []
|
||||
|
||||
logger.debug(`[${requestId}] Found tag definitions:`, tagDefs)
|
||||
logger.debug(`[${requestId}] Original filters:`, validatedData.filters)
|
||||
// Handle tag filters
|
||||
if (validatedData.tagFilters && accessibleKbIds.length > 0) {
|
||||
const kbId = accessibleKbIds[0]
|
||||
const tagDefs = await getDocumentTagDefinitions(kbId)
|
||||
|
||||
// Create mapping from display name to tag slot
|
||||
const displayNameToSlot: Record<string, string> = {}
|
||||
tagDefs.forEach((def) => {
|
||||
displayNameToSlot[def.displayName] = def.tagSlot
|
||||
})
|
||||
// Create mapping from display name to tag slot and fieldType
|
||||
const displayNameToTagDef: Record<string, { tagSlot: string; fieldType: string }> = {}
|
||||
tagDefs.forEach((def) => {
|
||||
displayNameToTagDef[def.displayName] = {
|
||||
tagSlot: def.tagSlot,
|
||||
fieldType: def.fieldType,
|
||||
}
|
||||
})
|
||||
|
||||
// Map the filters and handle OR logic
|
||||
Object.entries(validatedData.filters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
const tagSlot = displayNameToSlot[key] || key // Fallback to key if no mapping found
|
||||
// Validate all tag filters first
|
||||
const undefinedTags: string[] = []
|
||||
const typeErrors: string[] = []
|
||||
|
||||
// Check if this is an OR filter (contains |OR| separator)
|
||||
if (value.includes('|OR|')) {
|
||||
logger.debug(
|
||||
`[${requestId}] OR filter detected: "${key}" -> "${tagSlot}" = "${value}"`
|
||||
)
|
||||
}
|
||||
for (const filter of validatedData.tagFilters) {
|
||||
const tagDef = displayNameToTagDef[filter.tagName]
|
||||
|
||||
mappedFilters[tagSlot] = value
|
||||
logger.debug(`[${requestId}] Mapped filter: "${key}" -> "${tagSlot}" = "${value}"`)
|
||||
}
|
||||
})
|
||||
// Check if tag exists
|
||||
if (!tagDef) {
|
||||
undefinedTags.push(filter.tagName)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Final mapped filters:`, mappedFilters)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Filter mapping error:`, error)
|
||||
// If mapping fails, use original filters
|
||||
mappedFilters = validatedData.filters
|
||||
// Validate value type using shared validation
|
||||
const validationError = validateTagValue(
|
||||
filter.tagName,
|
||||
String(filter.value),
|
||||
tagDef.fieldType
|
||||
)
|
||||
if (validationError) {
|
||||
typeErrors.push(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
// Throw combined error if there are any validation issues
|
||||
if (undefinedTags.length > 0 || typeErrors.length > 0) {
|
||||
const errorParts: string[] = []
|
||||
|
||||
if (undefinedTags.length > 0) {
|
||||
errorParts.push(buildUndefinedTagsError(undefinedTags))
|
||||
}
|
||||
|
||||
if (typeErrors.length > 0) {
|
||||
errorParts.push(...typeErrors)
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 })
|
||||
}
|
||||
|
||||
// Build structured filters with validated data
|
||||
structuredFilters = validatedData.tagFilters.map((filter) => {
|
||||
const tagDef = displayNameToTagDef[filter.tagName]!
|
||||
const tagSlot = filter.tagSlot || tagDef.tagSlot
|
||||
const fieldType = filter.fieldType || tagDef.fieldType
|
||||
|
||||
logger.debug(
|
||||
`[${requestId}] Structured filter: ${filter.tagName} -> ${tagSlot} (${fieldType}) ${filter.operator} ${filter.value}`
|
||||
)
|
||||
|
||||
return {
|
||||
tagSlot,
|
||||
fieldType,
|
||||
operator: filter.operator,
|
||||
value: filter.value,
|
||||
valueTo: filter.valueTo,
|
||||
}
|
||||
})
|
||||
|
||||
logger.debug(`[${requestId}] Processed ${structuredFilters.length} structured filters`)
|
||||
}
|
||||
|
||||
if (accessibleKbIds.length === 0) {
|
||||
@@ -155,26 +202,29 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let results: SearchResult[]
|
||||
|
||||
const hasFilters = mappedFilters && Object.keys(mappedFilters).length > 0
|
||||
const hasFilters = structuredFilters && structuredFilters.length > 0
|
||||
|
||||
if (!hasQuery && hasFilters) {
|
||||
// Tag-only search without vector similarity
|
||||
logger.debug(`[${requestId}] Executing tag-only search with filters:`, mappedFilters)
|
||||
logger.debug(`[${requestId}] Executing tag-only search with filters:`, structuredFilters)
|
||||
results = await handleTagOnlySearch({
|
||||
knowledgeBaseIds: accessibleKbIds,
|
||||
topK: validatedData.topK,
|
||||
filters: mappedFilters,
|
||||
structuredFilters,
|
||||
})
|
||||
} else if (hasQuery && hasFilters) {
|
||||
// Tag + Vector search
|
||||
logger.debug(`[${requestId}] Executing tag + vector search with filters:`, mappedFilters)
|
||||
logger.debug(
|
||||
`[${requestId}] Executing tag + vector search with filters:`,
|
||||
structuredFilters
|
||||
)
|
||||
const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK)
|
||||
const queryVector = JSON.stringify(await queryEmbeddingPromise)
|
||||
|
||||
results = await handleTagAndVectorSearch({
|
||||
knowledgeBaseIds: accessibleKbIds,
|
||||
topK: validatedData.topK,
|
||||
filters: mappedFilters,
|
||||
structuredFilters,
|
||||
queryVector,
|
||||
distanceThreshold: strategy.distanceThreshold,
|
||||
})
|
||||
@@ -257,9 +307,9 @@ export async function POST(request: NextRequest) {
|
||||
// Create tags object with display names
|
||||
const tags: Record<string, any> = {}
|
||||
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const tagValue = (result as any)[slot]
|
||||
if (tagValue) {
|
||||
if (tagValue !== null && tagValue !== undefined) {
|
||||
const displayName = kbTagMap[slot] || slot
|
||||
logger.debug(
|
||||
`[${requestId}] Mapping ${slot}="${tagValue}" -> "${displayName}"="${tagValue}"`
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: {},
|
||||
structuredFilters: [],
|
||||
}
|
||||
|
||||
await expect(handleTagOnlySearch(params)).rejects.toThrow(
|
||||
@@ -66,14 +66,14 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { tag1: 'api' },
|
||||
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||
}
|
||||
|
||||
// This test validates the function accepts the right parameters
|
||||
// The actual database interaction is tested via route tests
|
||||
expect(params.knowledgeBaseIds).toEqual(['kb-123'])
|
||||
expect(params.topK).toBe(10)
|
||||
expect(params.filters).toEqual({ tag1: 'api' })
|
||||
expect(params.structuredFilters).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: {},
|
||||
structuredFilters: [],
|
||||
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
||||
distanceThreshold: 0.8,
|
||||
}
|
||||
@@ -137,7 +137,7 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { tag1: 'api' },
|
||||
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||
distanceThreshold: 0.8,
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { tag1: 'api' },
|
||||
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('Knowledge Search Utils', () => {
|
||||
const params = {
|
||||
knowledgeBaseIds: ['kb-123'],
|
||||
topK: 10,
|
||||
filters: { tag1: 'api' },
|
||||
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
||||
distanceThreshold: 0.8,
|
||||
}
|
||||
@@ -171,7 +171,7 @@ describe('Knowledge Search Utils', () => {
|
||||
// This test validates the function accepts the right parameters
|
||||
expect(params.knowledgeBaseIds).toEqual(['kb-123'])
|
||||
expect(params.topK).toBe(10)
|
||||
expect(params.filters).toEqual({ tag1: 'api' })
|
||||
expect(params.structuredFilters).toHaveLength(1)
|
||||
expect(params.queryVector).toBe(JSON.stringify([0.1, 0.2, 0.3]))
|
||||
expect(params.distanceThreshold).toBe(0.8)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { document, embedding } from '@sim/db/schema'
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('KnowledgeSearchUtils')
|
||||
@@ -34,6 +35,7 @@ export interface SearchResult {
|
||||
content: string
|
||||
documentId: string
|
||||
chunkIndex: number
|
||||
// Text tags
|
||||
tag1: string | null
|
||||
tag2: string | null
|
||||
tag3: string | null
|
||||
@@ -41,6 +43,19 @@ export interface SearchResult {
|
||||
tag5: string | null
|
||||
tag6: string | null
|
||||
tag7: string | null
|
||||
// Number tags (5 slots)
|
||||
number1: number | null
|
||||
number2: number | null
|
||||
number3: number | null
|
||||
number4: number | null
|
||||
number5: number | null
|
||||
// Date tags (2 slots)
|
||||
date1: Date | null
|
||||
date2: Date | null
|
||||
// Boolean tags (3 slots)
|
||||
boolean1: boolean | null
|
||||
boolean2: boolean | null
|
||||
boolean3: boolean | null
|
||||
distance: number
|
||||
knowledgeBaseId: string
|
||||
}
|
||||
@@ -48,7 +63,7 @@ export interface SearchResult {
|
||||
export interface SearchParams {
|
||||
knowledgeBaseIds: string[]
|
||||
topK: number
|
||||
filters?: Record<string, string>
|
||||
structuredFilters?: StructuredFilter[]
|
||||
queryVector?: string
|
||||
distanceThreshold?: number
|
||||
}
|
||||
@@ -56,46 +71,230 @@ export interface SearchParams {
|
||||
// Use shared embedding utility
|
||||
export { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
|
||||
|
||||
function getTagFilters(filters: Record<string, string>, embedding: any) {
|
||||
return Object.entries(filters).map(([key, value]) => {
|
||||
// Handle OR logic within same tag
|
||||
const values = value.includes('|OR|') ? value.split('|OR|') : [value]
|
||||
logger.debug(`[getTagFilters] Processing ${key}="${value}" -> values:`, values)
|
||||
/** All valid tag slot keys */
|
||||
const TAG_SLOT_KEYS = [
|
||||
// Text tags (7 slots)
|
||||
'tag1',
|
||||
'tag2',
|
||||
'tag3',
|
||||
'tag4',
|
||||
'tag5',
|
||||
'tag6',
|
||||
'tag7',
|
||||
// Number tags (5 slots)
|
||||
'number1',
|
||||
'number2',
|
||||
'number3',
|
||||
'number4',
|
||||
'number5',
|
||||
// Date tags (2 slots)
|
||||
'date1',
|
||||
'date2',
|
||||
// Boolean tags (3 slots)
|
||||
'boolean1',
|
||||
'boolean2',
|
||||
'boolean3',
|
||||
] as const
|
||||
|
||||
const getColumnForKey = (key: string) => {
|
||||
switch (key) {
|
||||
case 'tag1':
|
||||
return embedding.tag1
|
||||
case 'tag2':
|
||||
return embedding.tag2
|
||||
case 'tag3':
|
||||
return embedding.tag3
|
||||
case 'tag4':
|
||||
return embedding.tag4
|
||||
case 'tag5':
|
||||
return embedding.tag5
|
||||
case 'tag6':
|
||||
return embedding.tag6
|
||||
case 'tag7':
|
||||
return embedding.tag7
|
||||
default:
|
||||
return null
|
||||
}
|
||||
type TagSlotKey = (typeof TAG_SLOT_KEYS)[number]
|
||||
|
||||
function isTagSlotKey(key: string): key is TagSlotKey {
|
||||
return TAG_SLOT_KEYS.includes(key as TagSlotKey)
|
||||
}
|
||||
|
||||
/** Common fields selected for search results */
|
||||
const getSearchResultFields = (distanceExpr: any) => ({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
// Text tags
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
// Number tags (5 slots)
|
||||
number1: embedding.number1,
|
||||
number2: embedding.number2,
|
||||
number3: embedding.number3,
|
||||
number4: embedding.number4,
|
||||
number5: embedding.number5,
|
||||
// Date tags (2 slots)
|
||||
date1: embedding.date1,
|
||||
date2: embedding.date2,
|
||||
// Boolean tags (3 slots)
|
||||
boolean1: embedding.boolean1,
|
||||
boolean2: embedding.boolean2,
|
||||
boolean3: embedding.boolean3,
|
||||
distance: distanceExpr,
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
|
||||
/**
|
||||
* Build a single SQL condition for a filter
|
||||
*/
|
||||
function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) {
|
||||
const { tagSlot, fieldType, operator, value, valueTo } = filter
|
||||
|
||||
if (!isTagSlotKey(tagSlot)) {
|
||||
logger.debug(`[getStructuredTagFilters] Unknown tag slot: ${tagSlot}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const column = embeddingTable[tagSlot]
|
||||
if (!column) return null
|
||||
|
||||
logger.debug(
|
||||
`[getStructuredTagFilters] Processing ${tagSlot} (${fieldType}) ${operator} ${value}`
|
||||
)
|
||||
|
||||
// Handle text operators
|
||||
if (fieldType === 'text') {
|
||||
const stringValue = String(value)
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return sql`LOWER(${column}) = LOWER(${stringValue})`
|
||||
case 'neq':
|
||||
return sql`LOWER(${column}) != LOWER(${stringValue})`
|
||||
case 'contains':
|
||||
return sql`LOWER(${column}) LIKE LOWER(${`%${stringValue}%`})`
|
||||
case 'not_contains':
|
||||
return sql`LOWER(${column}) NOT LIKE LOWER(${`%${stringValue}%`})`
|
||||
case 'starts_with':
|
||||
return sql`LOWER(${column}) LIKE LOWER(${`${stringValue}%`})`
|
||||
case 'ends_with':
|
||||
return sql`LOWER(${column}) LIKE LOWER(${`%${stringValue}`})`
|
||||
default:
|
||||
return sql`LOWER(${column}) = LOWER(${stringValue})`
|
||||
}
|
||||
}
|
||||
|
||||
// Handle number operators
|
||||
if (fieldType === 'number') {
|
||||
const numValue = typeof value === 'number' ? value : Number.parseFloat(String(value))
|
||||
if (Number.isNaN(numValue)) return null
|
||||
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return sql`${column} = ${numValue}`
|
||||
case 'neq':
|
||||
return sql`${column} != ${numValue}`
|
||||
case 'gt':
|
||||
return sql`${column} > ${numValue}`
|
||||
case 'gte':
|
||||
return sql`${column} >= ${numValue}`
|
||||
case 'lt':
|
||||
return sql`${column} < ${numValue}`
|
||||
case 'lte':
|
||||
return sql`${column} <= ${numValue}`
|
||||
case 'between':
|
||||
if (valueTo !== undefined) {
|
||||
const numValueTo =
|
||||
typeof valueTo === 'number' ? valueTo : Number.parseFloat(String(valueTo))
|
||||
if (Number.isNaN(numValueTo)) return sql`${column} = ${numValue}`
|
||||
return sql`${column} >= ${numValue} AND ${column} <= ${numValueTo}`
|
||||
}
|
||||
return sql`${column} = ${numValue}`
|
||||
default:
|
||||
return sql`${column} = ${numValue}`
|
||||
}
|
||||
}
|
||||
|
||||
// Handle date operators - expects YYYY-MM-DD format from frontend
|
||||
if (fieldType === 'date') {
|
||||
const dateStr = String(value)
|
||||
// Validate YYYY-MM-DD format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`)
|
||||
return null
|
||||
}
|
||||
|
||||
const column = getColumnForKey(key)
|
||||
if (!column) return sql`1=1` // No-op for unknown keys
|
||||
|
||||
if (values.length === 1) {
|
||||
// Single value - simple equality
|
||||
logger.debug(`[getTagFilters] Single value filter: ${key} = ${values[0]}`)
|
||||
return sql`LOWER(${column}) = LOWER(${values[0]})`
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
case 'neq':
|
||||
return sql`${column}::date != ${dateStr}::date`
|
||||
case 'gt':
|
||||
return sql`${column}::date > ${dateStr}::date`
|
||||
case 'gte':
|
||||
return sql`${column}::date >= ${dateStr}::date`
|
||||
case 'lt':
|
||||
return sql`${column}::date < ${dateStr}::date`
|
||||
case 'lte':
|
||||
return sql`${column}::date <= ${dateStr}::date`
|
||||
case 'between':
|
||||
if (valueTo !== undefined) {
|
||||
const dateStrTo = String(valueTo)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStrTo)) {
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
}
|
||||
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
|
||||
}
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
default:
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
}
|
||||
// Multiple values - OR logic
|
||||
logger.debug(`[getTagFilters] OR filter: ${key} IN (${values.join(', ')})`)
|
||||
const orConditions = values.map((v) => sql`LOWER(${column}) = LOWER(${v})`)
|
||||
return sql`(${sql.join(orConditions, sql` OR `)})`
|
||||
})
|
||||
}
|
||||
|
||||
// Handle boolean operators
|
||||
if (fieldType === 'boolean') {
|
||||
const boolValue = value === true || value === 'true'
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return sql`${column} = ${boolValue}`
|
||||
case 'neq':
|
||||
return sql`${column} != ${boolValue}`
|
||||
default:
|
||||
return sql`${column} = ${boolValue}`
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to equality
|
||||
return sql`${column} = ${value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SQL conditions from structured filters with operator support
|
||||
* - Same tag multiple times: OR logic
|
||||
* - Different tags: AND logic
|
||||
*/
|
||||
function getStructuredTagFilters(filters: StructuredFilter[], embeddingTable: any) {
|
||||
// Group filters by tagSlot
|
||||
const filtersBySlot = new Map<string, StructuredFilter[]>()
|
||||
for (const filter of filters) {
|
||||
const slot = filter.tagSlot
|
||||
if (!filtersBySlot.has(slot)) {
|
||||
filtersBySlot.set(slot, [])
|
||||
}
|
||||
filtersBySlot.get(slot)!.push(filter)
|
||||
}
|
||||
|
||||
// Build conditions: OR within same slot, AND across different slots
|
||||
const conditions: ReturnType<typeof sql>[] = []
|
||||
|
||||
for (const [slot, slotFilters] of filtersBySlot) {
|
||||
const slotConditions = slotFilters
|
||||
.map((f) => buildFilterCondition(f, embeddingTable))
|
||||
.filter((c): c is ReturnType<typeof sql> => c !== null)
|
||||
|
||||
if (slotConditions.length === 0) continue
|
||||
|
||||
if (slotConditions.length === 1) {
|
||||
// Single condition for this slot
|
||||
conditions.push(slotConditions[0])
|
||||
} else {
|
||||
// Multiple conditions for same slot - OR them together
|
||||
logger.debug(
|
||||
`[getStructuredTagFilters] OR'ing ${slotConditions.length} conditions for ${slot}`
|
||||
)
|
||||
conditions.push(sql`(${sql.join(slotConditions, sql` OR `)})`)
|
||||
}
|
||||
}
|
||||
|
||||
return conditions
|
||||
}
|
||||
|
||||
export function getQueryStrategy(kbCount: number, topK: number) {
|
||||
@@ -113,8 +312,10 @@ export function getQueryStrategy(kbCount: number, topK: number) {
|
||||
|
||||
async function executeTagFilterQuery(
|
||||
knowledgeBaseIds: string[],
|
||||
filters: Record<string, string>
|
||||
structuredFilters: StructuredFilter[]
|
||||
): Promise<{ id: string }[]> {
|
||||
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
|
||||
|
||||
if (knowledgeBaseIds.length === 1) {
|
||||
return await db
|
||||
.select({ id: embedding.id })
|
||||
@@ -125,7 +326,7 @@ async function executeTagFilterQuery(
|
||||
eq(embedding.knowledgeBaseId, knowledgeBaseIds[0]),
|
||||
eq(embedding.enabled, true),
|
||||
isNull(document.deletedAt),
|
||||
...getTagFilters(filters, embedding)
|
||||
...tagFilterConditions
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -138,7 +339,7 @@ async function executeTagFilterQuery(
|
||||
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
|
||||
eq(embedding.enabled, true),
|
||||
isNull(document.deletedAt),
|
||||
...getTagFilters(filters, embedding)
|
||||
...tagFilterConditions
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -154,21 +355,11 @@ async function executeVectorSearchOnIds(
|
||||
}
|
||||
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'),
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.select(
|
||||
getSearchResultFields(
|
||||
sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
|
||||
)
|
||||
)
|
||||
.from(embedding)
|
||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||
.where(
|
||||
@@ -183,15 +374,16 @@ async function executeVectorSearchOnIds(
|
||||
}
|
||||
|
||||
export async function handleTagOnlySearch(params: SearchParams): Promise<SearchResult[]> {
|
||||
const { knowledgeBaseIds, topK, filters } = params
|
||||
const { knowledgeBaseIds, topK, structuredFilters } = params
|
||||
|
||||
if (!filters || Object.keys(filters).length === 0) {
|
||||
if (!structuredFilters || structuredFilters.length === 0) {
|
||||
throw new Error('Tag filters are required for tag-only search')
|
||||
}
|
||||
|
||||
logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, filters)
|
||||
logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, structuredFilters)
|
||||
|
||||
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
|
||||
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
|
||||
|
||||
if (strategy.useParallel) {
|
||||
// Parallel approach for many KBs
|
||||
@@ -199,21 +391,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
||||
|
||||
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`0`.as('distance'), // No distance for tag-only searches
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.select(getSearchResultFields(sql<number>`0`.as('distance')))
|
||||
.from(embedding)
|
||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||
.where(
|
||||
@@ -221,7 +399,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
||||
eq(embedding.knowledgeBaseId, kbId),
|
||||
eq(embedding.enabled, true),
|
||||
isNull(document.deletedAt),
|
||||
...getTagFilters(filters, embedding)
|
||||
...tagFilterConditions
|
||||
)
|
||||
)
|
||||
.limit(parallelLimit)
|
||||
@@ -232,21 +410,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
||||
}
|
||||
// Single query for fewer KBs
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`0`.as('distance'), // No distance for tag-only searches
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.select(getSearchResultFields(sql<number>`0`.as('distance')))
|
||||
.from(embedding)
|
||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||
.where(
|
||||
@@ -254,7 +418,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
||||
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
|
||||
eq(embedding.enabled, true),
|
||||
isNull(document.deletedAt),
|
||||
...getTagFilters(filters, embedding)
|
||||
...tagFilterConditions
|
||||
)
|
||||
)
|
||||
.limit(topK)
|
||||
@@ -271,27 +435,15 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
|
||||
|
||||
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
|
||||
|
||||
const distanceExpr = sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
|
||||
|
||||
if (strategy.useParallel) {
|
||||
// Parallel approach for many KBs
|
||||
const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5
|
||||
|
||||
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'),
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.select(getSearchResultFields(distanceExpr))
|
||||
.from(embedding)
|
||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||
.where(
|
||||
@@ -312,21 +464,7 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
|
||||
}
|
||||
// Single query for fewer KBs
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
content: embedding.content,
|
||||
documentId: embedding.documentId,
|
||||
chunkIndex: embedding.chunkIndex,
|
||||
tag1: embedding.tag1,
|
||||
tag2: embedding.tag2,
|
||||
tag3: embedding.tag3,
|
||||
tag4: embedding.tag4,
|
||||
tag5: embedding.tag5,
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'),
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.select(getSearchResultFields(distanceExpr))
|
||||
.from(embedding)
|
||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||
.where(
|
||||
@@ -342,19 +480,22 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
|
||||
}
|
||||
|
||||
export async function handleTagAndVectorSearch(params: SearchParams): Promise<SearchResult[]> {
|
||||
const { knowledgeBaseIds, topK, filters, queryVector, distanceThreshold } = params
|
||||
const { knowledgeBaseIds, topK, structuredFilters, queryVector, distanceThreshold } = params
|
||||
|
||||
if (!filters || Object.keys(filters).length === 0) {
|
||||
if (!structuredFilters || structuredFilters.length === 0) {
|
||||
throw new Error('Tag filters are required for tag and vector search')
|
||||
}
|
||||
if (!queryVector || !distanceThreshold) {
|
||||
throw new Error('Query vector and distance threshold are required for tag and vector search')
|
||||
}
|
||||
|
||||
logger.debug(`[handleTagAndVectorSearch] Executing tag + vector search with filters:`, filters)
|
||||
logger.debug(
|
||||
`[handleTagAndVectorSearch] Executing tag + vector search with filters:`,
|
||||
structuredFilters
|
||||
)
|
||||
|
||||
// Step 1: Filter by tags first
|
||||
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, filters)
|
||||
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, structuredFilters)
|
||||
|
||||
if (tagFilteredIds.length === 0) {
|
||||
logger.debug(`[handleTagAndVectorSearch] No results found after tag filtering`)
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface DocumentData {
|
||||
enabled: boolean
|
||||
deletedAt?: Date | null
|
||||
uploadedAt: Date
|
||||
// Document tags
|
||||
// Text tags
|
||||
tag1?: string | null
|
||||
tag2?: string | null
|
||||
tag3?: string | null
|
||||
@@ -43,6 +43,19 @@ export interface DocumentData {
|
||||
tag5?: string | null
|
||||
tag6?: string | null
|
||||
tag7?: string | null
|
||||
// Number tags (5 slots)
|
||||
number1?: number | null
|
||||
number2?: number | null
|
||||
number3?: number | null
|
||||
number4?: number | null
|
||||
number5?: number | null
|
||||
// Date tags (2 slots)
|
||||
date1?: Date | null
|
||||
date2?: Date | null
|
||||
// Boolean tags (3 slots)
|
||||
boolean1?: boolean | null
|
||||
boolean2?: boolean | null
|
||||
boolean3?: boolean | null
|
||||
}
|
||||
|
||||
export interface EmbeddingData {
|
||||
@@ -58,7 +71,7 @@ export interface EmbeddingData {
|
||||
embeddingModel: string
|
||||
startOffset: number
|
||||
endOffset: number
|
||||
// Tag fields for filtering
|
||||
// Text tags
|
||||
tag1?: string | null
|
||||
tag2?: string | null
|
||||
tag3?: string | null
|
||||
@@ -66,6 +79,19 @@ export interface EmbeddingData {
|
||||
tag5?: string | null
|
||||
tag6?: string | null
|
||||
tag7?: string | null
|
||||
// Number tags (5 slots)
|
||||
number1?: number | null
|
||||
number2?: number | null
|
||||
number3?: number | null
|
||||
number4?: number | null
|
||||
number5?: number | null
|
||||
// Date tags (2 slots)
|
||||
date1?: Date | null
|
||||
date2?: Date | null
|
||||
// Boolean tags (3 slots)
|
||||
boolean1?: boolean | null
|
||||
boolean2?: boolean | null
|
||||
boolean3?: boolean | null
|
||||
enabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -232,6 +258,27 @@ export async function checkDocumentWriteAccess(
|
||||
processingStartedAt: document.processingStartedAt,
|
||||
processingCompletedAt: document.processingCompletedAt,
|
||||
knowledgeBaseId: document.knowledgeBaseId,
|
||||
// Text tags
|
||||
tag1: document.tag1,
|
||||
tag2: document.tag2,
|
||||
tag3: document.tag3,
|
||||
tag4: document.tag4,
|
||||
tag5: document.tag5,
|
||||
tag6: document.tag6,
|
||||
tag7: document.tag7,
|
||||
// Number tags (5 slots)
|
||||
number1: document.number1,
|
||||
number2: document.number2,
|
||||
number3: document.number3,
|
||||
number4: document.number4,
|
||||
number5: document.number5,
|
||||
// Date tags (2 slots)
|
||||
date1: document.date1,
|
||||
date2: document.date2,
|
||||
// Boolean tags (3 slots)
|
||||
boolean1: document.boolean1,
|
||||
boolean2: document.boolean2,
|
||||
boolean3: document.boolean3,
|
||||
})
|
||||
.from(document)
|
||||
.where(and(eq(document.id, documentId), isNull(document.deletedAt)))
|
||||
|
||||
@@ -1,32 +1,72 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import {
|
||||
permissions,
|
||||
workflow,
|
||||
workflowExecutionLogs,
|
||||
workflowExecutionSnapshots,
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('LogsByExecutionIdAPI')
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ executionId: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const { executionId } = await params
|
||||
|
||||
logger.debug(`Fetching execution data for: ${executionId}`)
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized execution data access attempt for: ${executionId}`)
|
||||
return NextResponse.json(
|
||||
{ error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const authenticatedUserId = authResult.userId
|
||||
|
||||
logger.debug(
|
||||
`[${requestId}] Fetching execution data for: ${executionId} (auth: ${authResult.authType})`
|
||||
)
|
||||
|
||||
// Get the workflow execution log to find the snapshot
|
||||
const [workflowLog] = await db
|
||||
.select()
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.userId, authenticatedUserId)
|
||||
)
|
||||
)
|
||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowLog) {
|
||||
logger.warn(`[${requestId}] Execution not found or access denied: ${executionId}`)
|
||||
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get the workflow state snapshot
|
||||
const [snapshot] = await db
|
||||
.select()
|
||||
.from(workflowExecutionSnapshots)
|
||||
@@ -34,6 +74,7 @@ export async function GET(
|
||||
.limit(1)
|
||||
|
||||
if (!snapshot) {
|
||||
logger.warn(`[${requestId}] Workflow state snapshot not found for execution: ${executionId}`)
|
||||
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
@@ -50,14 +91,14 @@ export async function GET(
|
||||
},
|
||||
}
|
||||
|
||||
logger.debug(`Successfully fetched execution data for: ${executionId}`)
|
||||
logger.debug(`[${requestId}] Successfully fetched execution data for: ${executionId}`)
|
||||
logger.debug(
|
||||
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
|
||||
`[${requestId}] Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
|
||||
)
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching execution data:', error)
|
||||
logger.error(`[${requestId}] Error fetching execution data:`, error)
|
||||
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function GET(request: NextRequest) {
|
||||
workflowName: workflow.name,
|
||||
}
|
||||
|
||||
let conditions: SQL | undefined = eq(workflow.workspaceId, params.workspaceId)
|
||||
let conditions: SQL | undefined = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
|
||||
|
||||
if (params.level && params.level !== 'all') {
|
||||
const levels = params.level.split(',').filter(Boolean)
|
||||
@@ -134,7 +134,7 @@ export async function GET(request: NextRequest) {
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -130,6 +130,8 @@ export async function GET(request: NextRequest) {
|
||||
deploymentVersionName: sql<null>`NULL`,
|
||||
}
|
||||
|
||||
const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
|
||||
|
||||
const baseQuery = db
|
||||
.select(selectColumns)
|
||||
.from(workflowExecutionLogs)
|
||||
@@ -141,18 +143,12 @@ export async function GET(request: NextRequest) {
|
||||
workflowDeploymentVersion,
|
||||
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
|
||||
)
|
||||
.innerJoin(
|
||||
workflow,
|
||||
and(
|
||||
eq(workflowExecutionLogs.workflowId, workflow.id),
|
||||
eq(workflow.workspaceId, params.workspaceId)
|
||||
)
|
||||
)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
@@ -300,7 +296,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const logs = await baseQuery
|
||||
.where(conditions)
|
||||
.where(and(workspaceFilter, conditions))
|
||||
.orderBy(desc(workflowExecutionLogs.startedAt))
|
||||
.limit(params.limit)
|
||||
.offset(params.offset)
|
||||
@@ -312,22 +308,16 @@ export async function GET(request: NextRequest) {
|
||||
pausedExecutions,
|
||||
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
|
||||
)
|
||||
.innerJoin(
|
||||
workflow,
|
||||
and(
|
||||
eq(workflowExecutionLogs.workflowId, workflow.id),
|
||||
eq(workflow.workspaceId, params.workspaceId)
|
||||
)
|
||||
)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(conditions)
|
||||
.where(and(eq(workflowExecutionLogs.workspaceId, params.workspaceId), conditions))
|
||||
|
||||
const countResult = await countQuery
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { permissions, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { and, eq, isNotNull, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
@@ -42,23 +42,17 @@ export async function GET(request: NextRequest) {
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(
|
||||
workflow,
|
||||
and(
|
||||
eq(workflowExecutionLogs.workflowId, workflow.id),
|
||||
eq(workflow.workspaceId, params.workspaceId)
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowExecutionLogs.workspaceId, params.workspaceId),
|
||||
isNotNull(workflowExecutionLogs.trigger),
|
||||
sql`${workflowExecutionLogs.trigger} NOT IN ('api', 'manual', 'webhook', 'chat', 'schedule')`
|
||||
)
|
||||
|
||||
@@ -3,8 +3,10 @@ import { memory, workflowBlocks } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('MemoryByIdAPI')
|
||||
|
||||
@@ -65,6 +67,65 @@ const memoryPutBodySchema = z.object({
|
||||
workflowId: z.string().uuid('Invalid workflow ID format'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validates authentication and workflow access for memory operations
|
||||
* @param request - The incoming request
|
||||
* @param workflowId - The workflow ID to check access for
|
||||
* @param requestId - Request ID for logging
|
||||
* @param action - 'read' for GET, 'write' for PUT/DELETE
|
||||
* @returns Object with userId if successful, or error response if failed
|
||||
*/
|
||||
async function validateMemoryAccess(
|
||||
request: NextRequest,
|
||||
workflowId: string,
|
||||
requestId: string,
|
||||
action: 'read' | 'write'
|
||||
): Promise<{ userId: string } | { error: NextResponse }> {
|
||||
const authResult = await checkHybridAuth(request, {
|
||||
requireWorkflowId: false,
|
||||
})
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized memory ${action} attempt`)
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ success: false, error: { message: 'Authentication required' } },
|
||||
{ status: 401 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, authResult.userId)
|
||||
if (!accessContext) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ success: false, error: { message: 'Workflow not found' } },
|
||||
{ status: 404 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const { isOwner, workspacePermission } = accessContext
|
||||
const hasAccess =
|
||||
action === 'read'
|
||||
? isOwner || workspacePermission !== null
|
||||
: isOwner || workspacePermission === 'write' || workspacePermission === 'admin'
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${authResult.userId} denied ${action} access to workflow ${workflowId}`
|
||||
)
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ success: false, error: { message: 'Access denied' } },
|
||||
{ status: 403 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return { userId: authResult.userId }
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
@@ -101,6 +162,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const { workflowId: validatedWorkflowId } = validation.data
|
||||
|
||||
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'read')
|
||||
if ('error' in accessCheck) {
|
||||
return accessCheck.error
|
||||
}
|
||||
|
||||
const memories = await db
|
||||
.select()
|
||||
.from(memory)
|
||||
@@ -203,6 +269,11 @@ export async function DELETE(
|
||||
|
||||
const { workflowId: validatedWorkflowId } = validation.data
|
||||
|
||||
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'write')
|
||||
if ('error' in accessCheck) {
|
||||
return accessCheck.error
|
||||
}
|
||||
|
||||
const existingMemory = await db
|
||||
.select({ id: memory.id })
|
||||
.from(memory)
|
||||
@@ -296,6 +367,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'write')
|
||||
if ('error' in accessCheck) {
|
||||
return accessCheck.error
|
||||
}
|
||||
|
||||
const existingMemories = await db
|
||||
.select()
|
||||
.from(memory)
|
||||
|
||||
@@ -28,7 +28,7 @@ const updateInvitationSchema = z.object({
|
||||
|
||||
// Get invitation details
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; invitationId: string }> }
|
||||
) {
|
||||
const { id: organizationId, invitationId } = await params
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templates, user } from '@sim/db/schema'
|
||||
import { templates } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
||||
|
||||
const logger = createLogger('TemplateApprovalAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
// POST /api/templates/[id]/approve - Approve a template (super users only)
|
||||
/**
|
||||
* POST /api/templates/[id]/approve - Approve a template (super users only)
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
@@ -22,23 +25,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is a super user
|
||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
|
||||
if (!currentUser[0]?.isSuperUser) {
|
||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||
if (!isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (existingTemplate.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for approval: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update template status to approved
|
||||
await db
|
||||
.update(templates)
|
||||
.set({ status: 'approved', updatedAt: new Date() })
|
||||
@@ -56,9 +54,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/templates/[id]/reject - Reject a template (super users only)
|
||||
/**
|
||||
* DELETE /api/templates/[id]/approve - Unapprove a template (super users only)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
@@ -71,23 +71,18 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is a super user
|
||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
|
||||
if (!currentUser[0]?.isSuperUser) {
|
||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||
if (!isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (existingTemplate.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for rejection: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update template status to rejected
|
||||
await db
|
||||
.update(templates)
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
|
||||
142
apps/sim/app/api/templates/[id]/og-image/route.ts
Normal file
142
apps/sim/app/api/templates/[id]/og-image/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templates } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { verifyTemplateOwnership } from '@/lib/templates/permissions'
|
||||
import { uploadFile } from '@/lib/uploads/core/storage-service'
|
||||
import { isValidPng } from '@/lib/uploads/utils/validation'
|
||||
|
||||
const logger = createLogger('TemplateOGImageAPI')
|
||||
|
||||
/**
|
||||
* PUT /api/templates/[id]/og-image
|
||||
* Upload a pre-generated OG image for a template.
|
||||
* Accepts base64-encoded image data in the request body.
|
||||
*/
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized OG image upload attempt for template: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { authorized, error, status } = await verifyTemplateOwnership(
|
||||
id,
|
||||
session.user.id,
|
||||
'admin'
|
||||
)
|
||||
if (!authorized) {
|
||||
logger.warn(`[${requestId}] User denied permission to upload OG image for template ${id}`)
|
||||
return NextResponse.json({ error }, { status: status || 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { imageData } = body
|
||||
|
||||
if (!imageData || typeof imageData !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing or invalid imageData (expected base64 string)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const base64Data = imageData.includes(',') ? imageData.split(',')[1] : imageData
|
||||
const imageBuffer = Buffer.from(base64Data, 'base64')
|
||||
|
||||
if (!isValidPng(imageBuffer)) {
|
||||
return NextResponse.json({ error: 'Invalid PNG image data' }, { status: 400 })
|
||||
}
|
||||
|
||||
const maxSize = 5 * 1024 * 1024
|
||||
if (imageBuffer.length > maxSize) {
|
||||
return NextResponse.json({ error: 'Image too large. Maximum size is 5MB.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const timestamp = Date.now()
|
||||
const storageKey = `og-images/templates/${id}/${timestamp}.png`
|
||||
|
||||
logger.info(`[${requestId}] Uploading OG image for template ${id}: ${storageKey}`)
|
||||
|
||||
const uploadResult = await uploadFile({
|
||||
file: imageBuffer,
|
||||
fileName: storageKey,
|
||||
contentType: 'image/png',
|
||||
context: 'og-images',
|
||||
preserveKey: true,
|
||||
customKey: storageKey,
|
||||
})
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const ogImageUrl = `${baseUrl}${uploadResult.path}?context=og-images`
|
||||
|
||||
await db
|
||||
.update(templates)
|
||||
.set({
|
||||
ogImageUrl,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Successfully uploaded OG image for template ${id}: ${ogImageUrl}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ogImageUrl,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Error uploading OG image for template ${id}:`, error)
|
||||
return NextResponse.json({ error: 'Failed to upload OG image' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/templates/[id]/og-image
|
||||
* Remove the OG image for a template.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { authorized, error, status } = await verifyTemplateOwnership(
|
||||
id,
|
||||
session.user.id,
|
||||
'admin'
|
||||
)
|
||||
if (!authorized) {
|
||||
logger.warn(`[${requestId}] User denied permission to delete OG image for template ${id}`)
|
||||
return NextResponse.json({ error }, { status: status || 403 })
|
||||
}
|
||||
|
||||
await db
|
||||
.update(templates)
|
||||
.set({
|
||||
ogImageUrl: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Removed OG image for template ${id}`)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Error removing OG image for template ${id}:`, error)
|
||||
return NextResponse.json({ error: 'Failed to remove OG image' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templates, user } from '@sim/db/schema'
|
||||
import { templates } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
||||
|
||||
const logger = createLogger('TemplateRejectionAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
// POST /api/templates/[id]/reject - Reject a template (super users only)
|
||||
/**
|
||||
* POST /api/templates/[id]/reject - Reject a template (super users only)
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
@@ -22,23 +25,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is a super user
|
||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
|
||||
if (!currentUser[0]?.isSuperUser) {
|
||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||
if (!isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (existingTemplate.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for rejection: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update template status to rejected
|
||||
await db
|
||||
.update(templates)
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, templateCreators, templates, workflow } from '@sim/db/schema'
|
||||
import { and, eq, or, sql } from 'drizzle-orm'
|
||||
import { templateCreators, templates, workflow } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -15,7 +15,6 @@ const logger = createLogger('TemplateByIdAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
// GET /api/templates/[id] - Retrieve a single template by ID
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
@@ -25,7 +24,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.debug(`[${requestId}] Fetching template: ${id}`)
|
||||
|
||||
// Fetch the template by ID with creator info
|
||||
const result = await db
|
||||
.select({
|
||||
template: templates,
|
||||
@@ -47,12 +45,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
creator: creator || undefined,
|
||||
}
|
||||
|
||||
// Only show approved templates to non-authenticated users
|
||||
if (!session?.user?.id && template.status !== 'approved') {
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has starred (only if authenticated)
|
||||
let isStarred = false
|
||||
if (session?.user?.id) {
|
||||
const { templateStars } = await import('@sim/db/schema')
|
||||
@@ -80,7 +76,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.debug(`[${requestId}] Incremented view count for template: ${id}`)
|
||||
} catch (viewError) {
|
||||
// Log the error but don't fail the request
|
||||
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +133,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const { name, details, creatorId, tags, updateState } = validationResult.data
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (existingTemplate.length === 0) {
|
||||
@@ -146,32 +140,54 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// No permission check needed - template updates only happen from within the workspace
|
||||
// where the user is already editing the connected workflow
|
||||
const template = existingTemplate[0]
|
||||
|
||||
if (!template.creatorId) {
|
||||
logger.warn(`[${requestId}] Template ${id} has no creator, denying update`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
|
||||
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
|
||||
session.user.id,
|
||||
template.creatorId,
|
||||
'admin'
|
||||
)
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
|
||||
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Prepare update data - only include fields that were provided
|
||||
const updateData: any = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
// Only update fields that were provided
|
||||
if (name !== undefined) updateData.name = name
|
||||
if (details !== undefined) updateData.details = details
|
||||
if (tags !== undefined) updateData.tags = tags
|
||||
if (creatorId !== undefined) updateData.creatorId = creatorId
|
||||
|
||||
// Only update the state if explicitly requested and the template has a connected workflow
|
||||
if (updateState && existingTemplate[0].workflowId) {
|
||||
// Load the current workflow state from normalized tables
|
||||
if (updateState && template.workflowId) {
|
||||
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
|
||||
const { hasAccess: hasWorkflowAccess } = await verifyWorkflowAccess(
|
||||
session.user.id,
|
||||
template.workflowId
|
||||
)
|
||||
|
||||
if (!hasWorkflowAccess) {
|
||||
logger.warn(`[${requestId}] User denied workflow access for state sync on template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied to workflow' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(existingTemplate[0].workflowId)
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(template.workflowId)
|
||||
|
||||
if (normalizedData) {
|
||||
// Also fetch workflow variables
|
||||
const [workflowRecord] = await db
|
||||
.select({ variables: workflow.variables })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingTemplate[0].workflowId))
|
||||
.where(eq(workflow.id, template.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const currentState = {
|
||||
@@ -183,17 +199,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
// Extract credential requirements from the new state
|
||||
const requiredCredentials = extractRequiredCredentials(currentState)
|
||||
|
||||
// Sanitize the state before storing
|
||||
const sanitizedState = sanitizeCredentials(currentState)
|
||||
|
||||
updateData.state = sanitizedState
|
||||
updateData.requiredCredentials = requiredCredentials
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Updating template state and credentials from current workflow: ${existingTemplate[0].workflowId}`
|
||||
`[${requestId}] Updating template state and credentials from current workflow: ${template.workflowId}`
|
||||
)
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Could not load workflow state for template: ${id}`)
|
||||
@@ -233,7 +247,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch template
|
||||
const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
if (existing.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for delete: ${id}`)
|
||||
@@ -242,41 +255,21 @@ export async function DELETE(
|
||||
|
||||
const template = existing[0]
|
||||
|
||||
// Permission: Only admin/owner of creator profile can delete
|
||||
if (template.creatorId) {
|
||||
const creatorProfile = await db
|
||||
.select()
|
||||
.from(templateCreators)
|
||||
.where(eq(templateCreators.id, template.creatorId))
|
||||
.limit(1)
|
||||
if (!template.creatorId) {
|
||||
logger.warn(`[${requestId}] Template ${id} has no creator, denying delete`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (creatorProfile.length > 0) {
|
||||
const creator = creatorProfile[0]
|
||||
let hasPermission = false
|
||||
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
|
||||
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
|
||||
session.user.id,
|
||||
template.creatorId,
|
||||
'admin'
|
||||
)
|
||||
|
||||
if (creator.referenceType === 'user') {
|
||||
hasPermission = creator.referenceId === session.user.id
|
||||
} else if (creator.referenceType === 'organization') {
|
||||
// For delete, require admin/owner role
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, session.user.id),
|
||||
eq(member.organizationId, creator.referenceId),
|
||||
or(eq(member.role, 'admin'), eq(member.role, 'owner'))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
hasPermission = membership.length > 0
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
if (!hasPermission) {
|
||||
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
|
||||
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
await db.delete(templates).where(eq(templates.id, id))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
member,
|
||||
templateCreators,
|
||||
templateStars,
|
||||
templates,
|
||||
@@ -204,51 +203,18 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validate creator profile - required for all templates
|
||||
const creatorProfile = await db
|
||||
.select()
|
||||
.from(templateCreators)
|
||||
.where(eq(templateCreators.id, data.creatorId))
|
||||
.limit(1)
|
||||
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
|
||||
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
|
||||
session.user.id,
|
||||
data.creatorId,
|
||||
'member'
|
||||
)
|
||||
|
||||
if (creatorProfile.length === 0) {
|
||||
logger.warn(`[${requestId}] Creator profile not found: ${data.creatorId}`)
|
||||
return NextResponse.json({ error: 'Creator profile not found' }, { status: 404 })
|
||||
if (!hasPermission) {
|
||||
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
|
||||
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const creator = creatorProfile[0]
|
||||
|
||||
// Verify user has permission to use this creator profile
|
||||
if (creator.referenceType === 'user') {
|
||||
if (creator.referenceId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'You do not have permission to use this creator profile' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
} else if (creator.referenceType === 'organization') {
|
||||
// Verify user is a member of the organization
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(eq(member.userId, session.user.id), eq(member.organizationId, creator.referenceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (membership.length === 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] User not a member of organization for creator: ${data.creatorId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'You must be a member of the organization to use its creator profile' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the template
|
||||
const templateId = uuidv4()
|
||||
const now = new Date()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -108,6 +109,14 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
const folderIdValidation = validateAlphanumericId(folderId, 'folderId', 50)
|
||||
if (!folderIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid folderId`, { error: folderIdValidation.error })
|
||||
return NextResponse.json({ error: folderIdValidation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const qParts: string[] = ['trashed = false']
|
||||
if (folderId) {
|
||||
qParts.push(`'${escapeForDriveQuery(folderId)}' in parents`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -50,6 +51,29 @@ export async function POST(request: NextRequest) {
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0)
|
||||
|
||||
for (const labelId of labelIds) {
|
||||
const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255)
|
||||
if (!labelIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: labelIdValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const messageIdValidation = validateAlphanumericId(validatedData.messageId, 'messageId', 255)
|
||||
if (!messageIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid message ID: ${messageIdValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: messageIdValidation.error },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const gmailResponse = await fetch(
|
||||
`${GMAIL_API_BASE}/messages/${validatedData.messageId}/modify`,
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { account } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -38,6 +39,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255)
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`)
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
let credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -53,6 +54,29 @@ export async function POST(request: NextRequest) {
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0)
|
||||
|
||||
for (const labelId of labelIds) {
|
||||
const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255)
|
||||
if (!labelIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: labelIdValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const messageIdValidation = validateAlphanumericId(validatedData.messageId, 'messageId', 255)
|
||||
if (!messageIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid message ID: ${messageIdValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: messageIdValidation.error },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const gmailResponse = await fetch(
|
||||
`${GMAIL_API_BASE}/messages/${validatedData.messageId}/modify`,
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateUUID } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -25,7 +26,6 @@ export async function GET(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Google Calendar calendars request received`)
|
||||
|
||||
try {
|
||||
// Get the credential ID from the query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
@@ -34,12 +34,25 @@ export async function GET(request: NextRequest) {
|
||||
logger.warn(`[${requestId}] Missing credentialId parameter`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialValidation = validateUUID(credentialId, 'credentialId')
|
||||
if (!credentialValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credentialId format`, { credentialId })
|
||||
return NextResponse.json({ error: credentialValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
const workflowValidation = validateUUID(workflowId, 'workflowId')
|
||||
if (!workflowValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid workflowId format`, { workflowId })
|
||||
return NextResponse.json({ error: workflowValidation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
@@ -50,7 +63,6 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch calendars from Google Calendar API
|
||||
logger.info(`[${requestId}] Fetching calendars from Google Calendar API`)
|
||||
const calendarResponse = await fetch(
|
||||
'https://www.googleapis.com/calendar/v3/users/me/calendarList',
|
||||
@@ -81,7 +93,6 @@ export async function GET(request: NextRequest) {
|
||||
const data = await calendarResponse.json()
|
||||
const calendars: CalendarListItem[] = data.items || []
|
||||
|
||||
// Sort calendars with primary first, then alphabetically
|
||||
calendars.sort((a, b) => {
|
||||
if (a.primary && !b.primary) return -1
|
||||
if (!a.primary && b.primary) return 1
|
||||
|
||||
@@ -20,6 +20,12 @@ export async function POST(request: Request) {
|
||||
cloudId: providedCloudId,
|
||||
issueType,
|
||||
parent,
|
||||
labels,
|
||||
duedate,
|
||||
reporter,
|
||||
environment,
|
||||
customFieldId,
|
||||
customFieldValue,
|
||||
} = await request.json()
|
||||
|
||||
if (!domain) {
|
||||
@@ -94,17 +100,57 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (priority !== undefined && priority !== null && priority !== '') {
|
||||
fields.priority = {
|
||||
name: priority,
|
||||
const isNumericId = /^\d+$/.test(priority)
|
||||
fields.priority = isNumericId ? { id: priority } : { name: priority }
|
||||
}
|
||||
|
||||
if (labels !== undefined && labels !== null && Array.isArray(labels) && labels.length > 0) {
|
||||
fields.labels = labels
|
||||
}
|
||||
|
||||
if (duedate !== undefined && duedate !== null && duedate !== '') {
|
||||
fields.duedate = duedate
|
||||
}
|
||||
|
||||
if (reporter !== undefined && reporter !== null && reporter !== '') {
|
||||
fields.reporter = {
|
||||
id: reporter,
|
||||
}
|
||||
}
|
||||
|
||||
if (assignee !== undefined && assignee !== null && assignee !== '') {
|
||||
fields.assignee = {
|
||||
id: assignee,
|
||||
if (environment !== undefined && environment !== null && environment !== '') {
|
||||
fields.environment = {
|
||||
type: 'doc',
|
||||
version: 1,
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: environment,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
customFieldId !== undefined &&
|
||||
customFieldId !== null &&
|
||||
customFieldId !== '' &&
|
||||
customFieldValue !== undefined &&
|
||||
customFieldValue !== null &&
|
||||
customFieldValue !== ''
|
||||
) {
|
||||
const fieldId = customFieldId.startsWith('customfield_')
|
||||
? customFieldId
|
||||
: `customfield_${customFieldId}`
|
||||
|
||||
fields[fieldId] = customFieldValue
|
||||
}
|
||||
|
||||
const body = { fields }
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -132,16 +178,47 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
logger.info('Successfully created Jira issue:', responseData.key)
|
||||
const issueKey = responseData.key || 'unknown'
|
||||
logger.info('Successfully created Jira issue:', issueKey)
|
||||
|
||||
let assigneeId: string | undefined
|
||||
if (assignee !== undefined && assignee !== null && assignee !== '') {
|
||||
const assignUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${issueKey}/assignee`
|
||||
logger.info('Assigning issue to:', assignee)
|
||||
|
||||
const assignResponse = await fetch(assignUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accountId: assignee,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!assignResponse.ok) {
|
||||
const assignErrorText = await assignResponse.text()
|
||||
logger.warn('Failed to assign issue (issue was created successfully):', {
|
||||
status: assignResponse.status,
|
||||
error: assignErrorText,
|
||||
})
|
||||
} else {
|
||||
assigneeId = assignee
|
||||
logger.info('Successfully assigned issue to:', assignee)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: responseData.key || 'unknown',
|
||||
issueKey: issueKey,
|
||||
summary: responseData.fields?.summary || 'Issue created',
|
||||
success: true,
|
||||
url: `https://${domain}/browse/${responseData.key}`,
|
||||
url: `https://${domain}/browse/${issueKey}`,
|
||||
...(assigneeId && { assigneeId }),
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -23,6 +24,12 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Team ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const teamIdValidation = validateMicrosoftGraphId(teamId, 'Team ID')
|
||||
if (!teamIdValidation.isValid) {
|
||||
logger.warn('Invalid team ID provided', { teamId, error: teamIdValidation.error })
|
||||
return NextResponse.json({ error: teamIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
@@ -70,7 +77,6 @@ export async function POST(request: Request) {
|
||||
endpoint: `https://graph.microsoft.com/v1.0/teams/${teamId}/channels`,
|
||||
})
|
||||
|
||||
// Check for auth errors specifically
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -93,7 +99,6 @@ export async function POST(request: Request) {
|
||||
} catch (innerError) {
|
||||
logger.error('Error during API requests:', innerError)
|
||||
|
||||
// Check if it's an authentication error
|
||||
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
|
||||
if (
|
||||
errorMessage.includes('auth') ||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -7,21 +8,35 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('TeamsChatsAPI')
|
||||
|
||||
// Helper function to get chat members and create a meaningful name
|
||||
/**
|
||||
* Helper function to get chat members and create a meaningful name
|
||||
*
|
||||
* @param chatId - Microsoft Teams chat ID to get display name for
|
||||
* @param accessToken - Access token for Microsoft Graph API
|
||||
* @param chatTopic - Optional existing chat topic
|
||||
* @returns A meaningful display name for the chat
|
||||
*/
|
||||
const getChatDisplayName = async (
|
||||
chatId: string,
|
||||
accessToken: string,
|
||||
chatTopic?: string
|
||||
): Promise<string> => {
|
||||
try {
|
||||
// If the chat already has a topic, use it
|
||||
const chatIdValidation = validateMicrosoftGraphId(chatId, 'chatId')
|
||||
if (!chatIdValidation.isValid) {
|
||||
logger.warn('Invalid chat ID in getChatDisplayName', {
|
||||
error: chatIdValidation.error,
|
||||
chatId: chatId.substring(0, 50),
|
||||
})
|
||||
return `Chat ${chatId.substring(0, 8)}...`
|
||||
}
|
||||
|
||||
if (chatTopic?.trim() && chatTopic !== 'null') {
|
||||
return chatTopic
|
||||
}
|
||||
|
||||
// Fetch chat members to create a meaningful name
|
||||
const membersResponse = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/chats/${chatId}/members`,
|
||||
`https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/members`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -35,27 +50,25 @@ const getChatDisplayName = async (
|
||||
const membersData = await membersResponse.json()
|
||||
const members = membersData.value || []
|
||||
|
||||
// Filter out the current user and get display names
|
||||
const memberNames = members
|
||||
.filter((member: any) => member.displayName && member.displayName !== 'Unknown')
|
||||
.map((member: any) => member.displayName)
|
||||
.slice(0, 3) // Limit to first 3 names to avoid very long names
|
||||
.slice(0, 3)
|
||||
|
||||
if (memberNames.length > 0) {
|
||||
if (memberNames.length === 1) {
|
||||
return memberNames[0] // 1:1 chat
|
||||
return memberNames[0]
|
||||
}
|
||||
if (memberNames.length === 2) {
|
||||
return memberNames.join(' & ') // 2-person group
|
||||
return memberNames.join(' & ')
|
||||
}
|
||||
return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more` // Larger group
|
||||
return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more`
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to get a better name from recent messages
|
||||
try {
|
||||
const messagesResponse = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/chats/${chatId}/messages?$top=10&$orderby=createdDateTime desc`,
|
||||
`https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?$top=10&$orderby=createdDateTime desc`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -69,14 +82,12 @@ const getChatDisplayName = async (
|
||||
const messagesData = await messagesResponse.json()
|
||||
const messages = messagesData.value || []
|
||||
|
||||
// Look for chat rename events
|
||||
for (const message of messages) {
|
||||
if (message.eventDetail?.chatDisplayName) {
|
||||
return message.eventDetail.chatDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique sender names from recent messages as last resort
|
||||
const senderNames = [
|
||||
...new Set(
|
||||
messages
|
||||
@@ -103,7 +114,6 @@ const getChatDisplayName = async (
|
||||
)
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return `Chat ${chatId.split(':')[0] || chatId.substring(0, 8)}...`
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
@@ -146,7 +156,6 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Now try to fetch the chats
|
||||
const response = await fetch('https://graph.microsoft.com/v1.0/me/chats', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -163,7 +172,6 @@ export async function POST(request: Request) {
|
||||
endpoint: 'https://graph.microsoft.com/v1.0/me/chats',
|
||||
})
|
||||
|
||||
// Check for auth errors specifically
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -179,7 +187,6 @@ export async function POST(request: Request) {
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Process chats with enhanced display names
|
||||
const chats = await Promise.all(
|
||||
data.value.map(async (chat: any) => ({
|
||||
id: chat.id,
|
||||
@@ -193,7 +200,6 @@ export async function POST(request: Request) {
|
||||
} catch (innerError) {
|
||||
logger.error('Error during API requests:', innerError)
|
||||
|
||||
// Check if it's an authentication error
|
||||
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
|
||||
if (
|
||||
errorMessage.includes('auth') ||
|
||||
|
||||
@@ -30,23 +30,41 @@ export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively checks an object for dangerous MongoDB operators
|
||||
* @param obj - The object to check
|
||||
* @param dangerousOperators - Array of operator names to block
|
||||
* @returns true if a dangerous operator is found
|
||||
*/
|
||||
function containsDangerousOperator(obj: unknown, dangerousOperators: string[]): boolean {
|
||||
if (typeof obj !== 'object' || obj === null) return false
|
||||
|
||||
for (const key of Object.keys(obj as Record<string, unknown>)) {
|
||||
if (dangerousOperators.includes(key)) return true
|
||||
if (
|
||||
typeof (obj as Record<string, unknown>)[key] === 'object' &&
|
||||
containsDangerousOperator((obj as Record<string, unknown>)[key], dangerousOperators)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function validateFilter(filter: string): { isValid: boolean; error?: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(filter)
|
||||
|
||||
const dangerousOperators = ['$where', '$regex', '$expr', '$function', '$accumulator', '$let']
|
||||
const dangerousOperators = [
|
||||
'$where', // Executes arbitrary JavaScript
|
||||
'$regex', // Can cause ReDoS attacks
|
||||
'$expr', // Expression evaluation
|
||||
'$function', // Custom JavaScript functions
|
||||
'$accumulator', // Custom JavaScript accumulators
|
||||
'$let', // Variable definitions that could be exploited
|
||||
]
|
||||
|
||||
const checkForDangerousOps = (obj: any): boolean => {
|
||||
if (typeof obj !== 'object' || obj === null) return false
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (dangerousOperators.includes(key)) return true
|
||||
if (typeof obj[key] === 'object' && checkForDangerousOps(obj[key])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (checkForDangerousOps(parsed)) {
|
||||
if (containsDangerousOperator(parsed, dangerousOperators)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Filter contains potentially dangerous operators',
|
||||
@@ -74,29 +92,19 @@ export function validatePipeline(pipeline: string): { isValid: boolean; error?:
|
||||
}
|
||||
|
||||
const dangerousOperators = [
|
||||
'$where',
|
||||
'$function',
|
||||
'$accumulator',
|
||||
'$let',
|
||||
'$merge',
|
||||
'$out',
|
||||
'$currentOp',
|
||||
'$listSessions',
|
||||
'$listLocalSessions',
|
||||
'$where', // Executes arbitrary JavaScript
|
||||
'$function', // Custom JavaScript functions
|
||||
'$accumulator', // Custom JavaScript accumulators
|
||||
'$let', // Variable definitions that could be exploited
|
||||
'$merge', // Writes to external collections
|
||||
'$out', // Writes to external collections
|
||||
'$currentOp', // Exposes system operation info
|
||||
'$listSessions', // Exposes session info
|
||||
'$listLocalSessions', // Exposes local session info
|
||||
]
|
||||
|
||||
const checkPipelineStage = (stage: any): boolean => {
|
||||
if (typeof stage !== 'object' || stage === null) return false
|
||||
|
||||
for (const key of Object.keys(stage)) {
|
||||
if (dangerousOperators.includes(key)) return true
|
||||
if (typeof stage[key] === 'object' && checkPipelineStage(stage[key])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for (const stage of parsed) {
|
||||
if (checkPipelineStage(stage)) {
|
||||
if (containsDangerousOperator(stage, dangerousOperators)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Pipeline contains potentially dangerous operators',
|
||||
|
||||
@@ -98,15 +98,45 @@ export function buildDeleteQuery(table: string, where: string) {
|
||||
return { query, values: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a WHERE clause to prevent SQL injection attacks
|
||||
* @param where - The WHERE clause string to validate
|
||||
* @throws {Error} If the WHERE clause contains potentially dangerous patterns
|
||||
*/
|
||||
function validateWhereClause(where: string): void {
|
||||
const dangerousPatterns = [
|
||||
// DDL and DML injection via stacked queries
|
||||
/;\s*(drop|delete|insert|update|create|alter|grant|revoke)/i,
|
||||
/union\s+select/i,
|
||||
// Union-based injection
|
||||
/union\s+(all\s+)?select/i,
|
||||
// File operations
|
||||
/into\s+outfile/i,
|
||||
/load_file/i,
|
||||
/into\s+dumpfile/i,
|
||||
/load_file\s*\(/i,
|
||||
// Comment-based injection (can truncate query)
|
||||
/--/,
|
||||
/\/\*/,
|
||||
/\*\//,
|
||||
// Tautologies - always true/false conditions using backreferences
|
||||
// Matches OR 'x'='x' or OR x=x (same value both sides) but NOT OR col='value'
|
||||
/\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
|
||||
/\bor\s+true\b/i,
|
||||
/\bor\s+false\b/i,
|
||||
// AND tautologies (less common but still used in attacks)
|
||||
/\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
|
||||
/\band\s+true\b/i,
|
||||
/\band\s+false\b/i,
|
||||
// Time-based blind injection
|
||||
/\bsleep\s*\(/i,
|
||||
/\bbenchmark\s*\(/i,
|
||||
/\bwaitfor\s+delay/i,
|
||||
// Stacked queries (any statement after semicolon)
|
||||
/;\s*\w+/,
|
||||
// Information schema queries
|
||||
/information_schema/i,
|
||||
/mysql\./i,
|
||||
// System functions and procedures
|
||||
/\bxp_cmdshell/i,
|
||||
]
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -36,6 +37,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId')
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Fetching credential`, { credentialId })
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -33,6 +34,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId')
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
@@ -48,7 +55,6 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Build URL for OneDrive folders
|
||||
let url = `https://graph.microsoft.com/v1.0/me/drive/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=50`
|
||||
|
||||
if (query) {
|
||||
@@ -71,7 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const data = await response.json()
|
||||
const folders = (data.value || [])
|
||||
.filter((item: MicrosoftGraphDriveItem) => item.folder) // Only folders
|
||||
.filter((item: MicrosoftGraphDriveItem) => item.folder)
|
||||
.map((folder: MicrosoftGraphDriveItem) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -28,9 +29,9 @@ const ExcelValuesSchema = z.union([
|
||||
const OneDriveUploadSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
fileName: z.string().min(1, 'File name is required'),
|
||||
file: z.any().optional(), // UserFile object (optional for blank Excel creation)
|
||||
file: z.any().optional(),
|
||||
folderId: z.string().optional().nullable(),
|
||||
mimeType: z.string().nullish(), // Accept string, null, or undefined
|
||||
mimeType: z.string().nullish(),
|
||||
values: ExcelValuesSchema.optional().nullable(),
|
||||
})
|
||||
|
||||
@@ -62,24 +63,19 @@ export async function POST(request: NextRequest) {
|
||||
let fileBuffer: Buffer
|
||||
let mimeType: string
|
||||
|
||||
// Check if we're creating a blank Excel file
|
||||
const isExcelCreation =
|
||||
validatedData.mimeType ===
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' && !validatedData.file
|
||||
|
||||
if (isExcelCreation) {
|
||||
// Create a blank Excel workbook
|
||||
|
||||
const workbook = XLSX.utils.book_new()
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([[]])
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
|
||||
|
||||
// Generate XLSX file as buffer
|
||||
const xlsxBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })
|
||||
fileBuffer = Buffer.from(xlsxBuffer)
|
||||
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
} else {
|
||||
// Handle regular file upload
|
||||
const rawFile = validatedData.file
|
||||
|
||||
if (!rawFile) {
|
||||
@@ -108,7 +104,6 @@ export async function POST(request: NextRequest) {
|
||||
fileToProcess = rawFile
|
||||
}
|
||||
|
||||
// Convert to UserFile format
|
||||
let userFile
|
||||
try {
|
||||
userFile = processSingleFileToUserFile(fileToProcess, requestId, logger)
|
||||
@@ -138,7 +133,7 @@ export async function POST(request: NextRequest) {
|
||||
mimeType = userFile.type || 'application/octet-stream'
|
||||
}
|
||||
|
||||
const maxSize = 250 * 1024 * 1024 // 250MB
|
||||
const maxSize = 250 * 1024 * 1024
|
||||
if (fileBuffer.length > maxSize) {
|
||||
const sizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2)
|
||||
logger.warn(`[${requestId}] File too large: ${sizeMB}MB`)
|
||||
@@ -151,7 +146,6 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure file name has an appropriate extension
|
||||
let fileName = validatedData.fileName
|
||||
const hasExtension = fileName.includes('.') && fileName.lastIndexOf('.') > 0
|
||||
|
||||
@@ -169,6 +163,17 @@ export async function POST(request: NextRequest) {
|
||||
const folderId = validatedData.folderId?.trim()
|
||||
|
||||
if (folderId && folderId !== '') {
|
||||
const folderIdValidation = validateMicrosoftGraphId(folderId, 'folderId')
|
||||
if (!folderIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid folder ID`, { error: folderIdValidation.error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: folderIdValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}:/content`
|
||||
} else {
|
||||
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content`
|
||||
@@ -197,14 +202,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const fileData = await uploadResponse.json()
|
||||
|
||||
// If this is an Excel creation and values were provided, write them using the Excel API
|
||||
let excelWriteResult: any | undefined
|
||||
const shouldWriteExcelContent =
|
||||
isExcelCreation && Array.isArray(excelValues) && excelValues.length > 0
|
||||
|
||||
if (shouldWriteExcelContent) {
|
||||
try {
|
||||
// Create a workbook session to ensure reliability and persistence of changes
|
||||
let workbookSessionId: string | undefined
|
||||
const sessionResp = await fetch(
|
||||
`${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/createSession`,
|
||||
@@ -223,7 +226,6 @@ export async function POST(request: NextRequest) {
|
||||
workbookSessionId = sessionData?.id
|
||||
}
|
||||
|
||||
// Determine the first worksheet name
|
||||
let sheetName = 'Sheet1'
|
||||
try {
|
||||
const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
|
||||
@@ -272,7 +274,6 @@ export async function POST(request: NextRequest) {
|
||||
return paddedRow
|
||||
})
|
||||
|
||||
// Compute concise end range from A1 and matrix size (no network round-trip)
|
||||
const indexToColLetters = (index: number): string => {
|
||||
let n = index
|
||||
let s = ''
|
||||
@@ -313,7 +314,6 @@ export async function POST(request: NextRequest) {
|
||||
statusText: excelWriteResponse?.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
// Do not fail the entire request; return upload success with write error details
|
||||
excelWriteResult = {
|
||||
success: false,
|
||||
error: `Excel write failed: ${excelWriteResponse?.statusText || 'unknown'}`,
|
||||
@@ -321,7 +321,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
} else {
|
||||
const writeData = await excelWriteResponse.json()
|
||||
// The Range PATCH returns a Range object; log address and values length
|
||||
const addr = writeData.address || writeData.addressLocal
|
||||
const v = writeData.values || []
|
||||
excelWriteResult = {
|
||||
@@ -333,7 +332,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to close the workbook session if one was created
|
||||
if (workbookSessionId) {
|
||||
try {
|
||||
const closeResp = await fetch(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -29,8 +30,13 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId')
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn('Invalid credentialId format', { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure we have a session for permission checks
|
||||
const sessionUserId = session?.user?.id || ''
|
||||
|
||||
if (!sessionUserId) {
|
||||
@@ -38,7 +44,6 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Resolve the credential owner to support collaborator-owned credentials
|
||||
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!creds.length) {
|
||||
logger.warn('Credential not found', { credentialId })
|
||||
@@ -79,7 +84,6 @@ export async function GET(request: Request) {
|
||||
endpoint: 'https://graph.microsoft.com/v1.0/me/mailFolders',
|
||||
})
|
||||
|
||||
// Check for auth errors specifically
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -96,7 +100,6 @@ export async function GET(request: Request) {
|
||||
const data = await response.json()
|
||||
const folders = data.value || []
|
||||
|
||||
// Transform folders to match the expected format
|
||||
const transformedFolders = folders.map((folder: OutlookFolder) => ({
|
||||
id: folder.id,
|
||||
name: folder.displayName,
|
||||
@@ -111,7 +114,6 @@ export async function GET(request: Request) {
|
||||
} catch (innerError) {
|
||||
logger.error('Error during API requests:', innerError)
|
||||
|
||||
// Check if it's an authentication error
|
||||
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
|
||||
if (
|
||||
errorMessage.includes('auth') ||
|
||||
|
||||
@@ -64,15 +64,46 @@ export function sanitizeIdentifier(identifier: string): string {
|
||||
return sanitizeSingleIdentifier(identifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a WHERE clause to prevent SQL injection attacks
|
||||
* @param where - The WHERE clause string to validate
|
||||
* @throws {Error} If the WHERE clause contains potentially dangerous patterns
|
||||
*/
|
||||
function validateWhereClause(where: string): void {
|
||||
const dangerousPatterns = [
|
||||
// DDL and DML injection via stacked queries
|
||||
/;\s*(drop|delete|insert|update|create|alter|grant|revoke)/i,
|
||||
/union\s+select/i,
|
||||
// Union-based injection
|
||||
/union\s+(all\s+)?select/i,
|
||||
// File operations
|
||||
/into\s+outfile/i,
|
||||
/load_file/i,
|
||||
/load_file\s*\(/i,
|
||||
/pg_read_file/i,
|
||||
// Comment-based injection (can truncate query)
|
||||
/--/,
|
||||
/\/\*/,
|
||||
/\*\//,
|
||||
// Tautologies - always true/false conditions using backreferences
|
||||
// Matches OR 'x'='x' or OR x=x (same value both sides) but NOT OR col='value'
|
||||
/\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
|
||||
/\bor\s+true\b/i,
|
||||
/\bor\s+false\b/i,
|
||||
// AND tautologies (less common but still used in attacks)
|
||||
/\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
|
||||
/\band\s+true\b/i,
|
||||
/\band\s+false\b/i,
|
||||
// Time-based blind injection
|
||||
/\bsleep\s*\(/i,
|
||||
/\bwaitfor\s+delay/i,
|
||||
/\bpg_sleep\s*\(/i,
|
||||
/\bbenchmark\s*\(/i,
|
||||
// Stacked queries (any statement after semicolon)
|
||||
/;\s*\w+/,
|
||||
// Information schema / system catalog queries
|
||||
/information_schema/i,
|
||||
/pg_catalog/i,
|
||||
// System functions and procedures
|
||||
/\bxp_cmdshell/i,
|
||||
]
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { SharepointSite } from '@/tools/sharepoint/types'
|
||||
@@ -32,6 +33,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255)
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
@@ -47,8 +54,6 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Build URL for SharePoint sites
|
||||
// Use search=* to get all sites the user has access to, or search for specific query
|
||||
const searchQuery = query || '*'
|
||||
const url = `https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchQuery)}&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=50`
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -93,7 +94,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to channels the bot can access and format the response
|
||||
const channels = (data.channels || [])
|
||||
.filter((channel: SlackChannel) => {
|
||||
const canAccess = !channel.is_archived && (channel.is_member || !channel.is_private)
|
||||
@@ -106,6 +106,28 @@ export async function POST(request: Request) {
|
||||
|
||||
return canAccess
|
||||
})
|
||||
.filter((channel: SlackChannel) => {
|
||||
const validation = validateAlphanumericId(channel.id, 'channelId', 50)
|
||||
|
||||
if (!validation.isValid) {
|
||||
logger.warn('Invalid channel ID received from Slack API', {
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
error: validation.error,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!/^[CDG][A-Z0-9]+$/i.test(channel.id)) {
|
||||
logger.warn('Channel ID does not match Slack format', {
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.map((channel: SlackChannel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
|
||||
@@ -14,7 +14,12 @@ const SlackReadMessagesSchema = z
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
channel: z.string().optional().nullable(),
|
||||
userId: z.string().optional().nullable(),
|
||||
limit: z.number().optional().nullable(),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(15, 'Limit cannot exceed 15')
|
||||
.optional()
|
||||
.nullable(),
|
||||
oldest: z.string().optional().nullable(),
|
||||
latest: z.string().optional().nullable(),
|
||||
})
|
||||
@@ -62,8 +67,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const url = new URL('https://slack.com/api/conversations.history')
|
||||
url.searchParams.append('channel', channel!)
|
||||
const limit = validatedData.limit ? Number(validatedData.limit) : 10
|
||||
url.searchParams.append('limit', String(Math.min(limit, 15)))
|
||||
const limit = validatedData.limit ?? 10
|
||||
url.searchParams.append('limit', String(limit))
|
||||
|
||||
if (validatedData.oldest) {
|
||||
url.searchParams.append('oldest', validatedData.oldest)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -20,13 +21,21 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const requestId = generateRequestId()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
const { credential, workflowId, userId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (userId !== undefined && userId !== null) {
|
||||
const validation = validateAlphanumericId(userId, 'userId', 100)
|
||||
if (!validation.isValid) {
|
||||
logger.warn('Invalid Slack user ID', { userId, error: validation.error })
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string
|
||||
const isBotToken = credential.startsWith('xoxb-')
|
||||
|
||||
@@ -63,6 +72,17 @@ export async function POST(request: Request) {
|
||||
logger.info('Using OAuth token for Slack API')
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const userData = await fetchSlackUser(accessToken, userId)
|
||||
const user = {
|
||||
id: userData.user.id,
|
||||
name: userData.user.name,
|
||||
real_name: userData.user.real_name || userData.user.name,
|
||||
}
|
||||
logger.info(`Successfully fetched Slack user: ${userId}`)
|
||||
return NextResponse.json({ user })
|
||||
}
|
||||
|
||||
const data = await fetchSlackUsers(accessToken)
|
||||
|
||||
const users = (data.members || [])
|
||||
@@ -87,6 +107,31 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSlackUser(accessToken: string, userId: string) {
|
||||
const url = new URL('https://slack.com/api/users.info')
|
||||
url.searchParams.append('user', userId)
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch user')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchSlackUsers(accessToken: string) {
|
||||
const url = new URL('https://slack.com/api/users.list')
|
||||
url.searchParams.append('limit', '200')
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { type Attributes, Client, type ConnectConfig } from 'ssh2'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('SSHUtils')
|
||||
|
||||
// File type constants from POSIX
|
||||
const S_IFMT = 0o170000 // bit mask for the file type bit field
|
||||
@@ -32,7 +35,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
const host = config.host
|
||||
const port = config.port
|
||||
|
||||
// Connection refused - server not running or wrong port
|
||||
if (errorMessage.includes('econnrefused') || errorMessage.includes('connection refused')) {
|
||||
return new Error(
|
||||
`Connection refused to ${host}:${port}. ` +
|
||||
@@ -42,7 +44,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Connection reset - server closed connection unexpectedly
|
||||
if (errorMessage.includes('econnreset') || errorMessage.includes('connection reset')) {
|
||||
return new Error(
|
||||
`Connection reset by ${host}:${port}. ` +
|
||||
@@ -53,7 +54,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Timeout - server unreachable or slow
|
||||
if (errorMessage.includes('etimedout') || errorMessage.includes('timeout')) {
|
||||
return new Error(
|
||||
`Connection timed out to ${host}:${port}. ` +
|
||||
@@ -63,7 +63,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// DNS/hostname resolution
|
||||
if (errorMessage.includes('enotfound') || errorMessage.includes('getaddrinfo')) {
|
||||
return new Error(
|
||||
`Could not resolve hostname "${host}". ` +
|
||||
@@ -71,7 +70,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Authentication failure
|
||||
if (errorMessage.includes('authentication') || errorMessage.includes('auth')) {
|
||||
return new Error(
|
||||
`Authentication failed for user on ${host}:${port}. ` +
|
||||
@@ -81,7 +79,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Private key format issues
|
||||
if (
|
||||
errorMessage.includes('key') &&
|
||||
(errorMessage.includes('parse') || errorMessage.includes('invalid'))
|
||||
@@ -93,7 +90,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Host key verification (first connection)
|
||||
if (errorMessage.includes('host key') || errorMessage.includes('hostkey')) {
|
||||
return new Error(
|
||||
`Host key verification issue for ${host}. ` +
|
||||
@@ -101,7 +97,6 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
)
|
||||
}
|
||||
|
||||
// Return original error with context if no specific match
|
||||
return new Error(`SSH connection to ${host}:${port} failed: ${err.message}`)
|
||||
}
|
||||
|
||||
@@ -205,19 +200,119 @@ export function executeSSHCommand(client: Client, command: string): Promise<SSHC
|
||||
|
||||
/**
|
||||
* Sanitize command input to prevent command injection
|
||||
*
|
||||
* Removes null bytes and other dangerous control characters while preserving
|
||||
* legitimate shell syntax. Logs warnings for potentially dangerous patterns.
|
||||
*
|
||||
* Note: This function does not block complex shell commands (pipes, redirects, etc.)
|
||||
* as users legitimately need these features for remote command execution.
|
||||
*
|
||||
* @param command - The command to sanitize
|
||||
* @returns The sanitized command string
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const safeCommand = sanitizeCommand(userInput)
|
||||
* // Use safeCommand for SSH execution
|
||||
* ```
|
||||
*/
|
||||
export function sanitizeCommand(command: string): string {
|
||||
return command.trim()
|
||||
let sanitized = command.replace(/\0/g, '')
|
||||
|
||||
sanitized = sanitized.replace(/[\x0B\x0C]/g, '')
|
||||
|
||||
sanitized = sanitized.trim()
|
||||
|
||||
const dangerousPatterns = [
|
||||
{ pattern: /\$\(.*\)/, name: 'command substitution $()' },
|
||||
{ pattern: /`.*`/, name: 'backtick command substitution' },
|
||||
{ pattern: /;\s*rm\s+-rf/i, name: 'destructive rm -rf command' },
|
||||
{ pattern: /;\s*dd\s+/i, name: 'dd command (disk operations)' },
|
||||
{ pattern: /mkfs/i, name: 'filesystem formatting command' },
|
||||
{ pattern: />\s*\/dev\/sd[a-z]/i, name: 'direct disk write' },
|
||||
]
|
||||
|
||||
for (const { pattern, name } of dangerousPatterns) {
|
||||
if (pattern.test(sanitized)) {
|
||||
logger.warn(`Command contains ${name}`, {
|
||||
command: sanitized.substring(0, 100) + (sanitized.length > 100 ? '...' : ''),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize file path - removes null bytes and trims whitespace
|
||||
* Sanitize and validate file path to prevent path traversal attacks
|
||||
*
|
||||
* This function validates that a file path does not contain:
|
||||
* - Null bytes
|
||||
* - Path traversal sequences (.. or ../)
|
||||
* - URL-encoded path traversal attempts
|
||||
*
|
||||
* @param path - The file path to sanitize and validate
|
||||
* @returns The sanitized path if valid
|
||||
* @throws Error if path traversal is detected
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* const safePath = sanitizePath(userInput)
|
||||
* // Use safePath safely
|
||||
* } catch (error) {
|
||||
* // Handle invalid path
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function sanitizePath(path: string): string {
|
||||
let sanitized = path.replace(/\0/g, '')
|
||||
|
||||
sanitized = sanitized.trim()
|
||||
|
||||
if (sanitized.includes('%00')) {
|
||||
logger.warn('Path contains URL-encoded null bytes', {
|
||||
path: path.substring(0, 100),
|
||||
})
|
||||
throw new Error('Path contains invalid characters')
|
||||
}
|
||||
|
||||
const pathTraversalPatterns = [
|
||||
'../', // Standard Unix path traversal
|
||||
'..\\', // Windows path traversal
|
||||
'/../', // Mid-path traversal
|
||||
'\\..\\', // Windows mid-path traversal
|
||||
'%2e%2e%2f', // Fully encoded ../
|
||||
'%2e%2e/', // Partially encoded ../
|
||||
'%2e%2e%5c', // Fully encoded ..\
|
||||
'%2e%2e\\', // Partially encoded ..\
|
||||
'..%2f', // .. with encoded /
|
||||
'..%5c', // .. with encoded \
|
||||
'%252e%252e', // Double URL encoded ..
|
||||
'..%252f', // .. with double encoded /
|
||||
'..%255c', // .. with double encoded \
|
||||
]
|
||||
|
||||
const lowerPath = sanitized.toLowerCase()
|
||||
for (const pattern of pathTraversalPatterns) {
|
||||
if (lowerPath.includes(pattern.toLowerCase())) {
|
||||
logger.warn('Path traversal attempt detected', {
|
||||
pattern,
|
||||
path: path.substring(0, 100),
|
||||
})
|
||||
throw new Error('Path contains invalid path traversal sequences')
|
||||
}
|
||||
}
|
||||
|
||||
const segments = sanitized.split(/[/\\]/)
|
||||
for (const segment of segments) {
|
||||
if (segment === '..') {
|
||||
logger.warn('Path traversal attempt detected (.. as path segment)', {
|
||||
path: path.substring(0, 100),
|
||||
})
|
||||
throw new Error('Path contains invalid path traversal sequences')
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
|
||||
|
||||
@@ -188,7 +189,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (variablesObject && Object.keys(variablesObject).length > 0) {
|
||||
const safeVarKeys = Object.keys(variablesObject).map((key) => {
|
||||
return key.toLowerCase().includes('password') ? `${key}: [REDACTED]` : key
|
||||
return isSensitiveKey(key) ? `${key}: ${REDACTED_MARKER}` : key
|
||||
})
|
||||
logger.info('Variables available for task', { variables: safeVarKeys })
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { account } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -11,7 +12,6 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WealthboxItemsAPI')
|
||||
|
||||
// Interface for transformed Wealthbox items
|
||||
interface WealthboxItem {
|
||||
id: string
|
||||
name: string
|
||||
@@ -45,12 +45,23 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (type !== 'contact') {
|
||||
const credentialIdValidation = validatePathSegment(credentialId, {
|
||||
paramName: 'credentialId',
|
||||
maxLength: 100,
|
||||
allowHyphens: true,
|
||||
allowUnderscores: true,
|
||||
allowDots: false,
|
||||
})
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`)
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const ALLOWED_TYPES = ['contact'] as const
|
||||
const typeValidation = validateEnum(type, ALLOWED_TYPES, 'type')
|
||||
if (!typeValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid item type: ${type}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid item type. Only contact is supported.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ error: typeValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -12,13 +13,21 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const requestId = generateRequestId()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
const { credential, workflowId, siteId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (siteId) {
|
||||
const siteIdValidation = validateAlphanumericId(siteId, 'siteId')
|
||||
if (!siteIdValidation.isValid) {
|
||||
logger.error('Invalid siteId', { error: siteIdValidation.error })
|
||||
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
@@ -46,7 +55,11 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.webflow.com/v2/sites', {
|
||||
const url = siteId
|
||||
? `https://api.webflow.com/v2/sites/${siteId}`
|
||||
: 'https://api.webflow.com/v2/sites'
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
accept: 'application/json',
|
||||
@@ -58,6 +71,7 @@ export async function POST(request: Request) {
|
||||
logger.error('Failed to fetch Webflow sites', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
siteId: siteId || 'all',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Webflow sites', details: errorData },
|
||||
@@ -66,7 +80,13 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const sites = data.sites || []
|
||||
|
||||
let sites: any[]
|
||||
if (siteId) {
|
||||
sites = [data]
|
||||
} else {
|
||||
sites = data.sites || []
|
||||
}
|
||||
|
||||
const formattedSites = sites.map((site: any) => ({
|
||||
id: site.id,
|
||||
|
||||
@@ -26,9 +26,9 @@ const SettingsSchema = z.object({
|
||||
showTrainingControls: z.boolean().optional(),
|
||||
superUserModeEnabled: z.boolean().optional(),
|
||||
errorNotificationsEnabled: z.boolean().optional(),
|
||||
snapToGridSize: z.number().min(0).max(50).optional(),
|
||||
})
|
||||
|
||||
// Default settings values
|
||||
const defaultSettings = {
|
||||
theme: 'system',
|
||||
autoConnect: true,
|
||||
@@ -38,6 +38,7 @@ const defaultSettings = {
|
||||
showTrainingControls: false,
|
||||
superUserModeEnabled: false,
|
||||
errorNotificationsEnabled: true,
|
||||
snapToGridSize: 0,
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
@@ -46,7 +47,6 @@ export async function GET() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
// Return default settings for unauthenticated users instead of 401 error
|
||||
if (!session?.user?.id) {
|
||||
logger.info(`[${requestId}] Returning default settings for unauthenticated user`)
|
||||
return NextResponse.json({ data: defaultSettings }, { status: 200 })
|
||||
@@ -72,13 +72,13 @@ export async function GET() {
|
||||
showTrainingControls: userSettings.showTrainingControls ?? false,
|
||||
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
|
||||
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
|
||||
snapToGridSize: userSettings.snapToGridSize ?? 0,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Settings fetch error`, error)
|
||||
// Return default settings on error instead of error response
|
||||
return NextResponse.json({ data: defaultSettings }, { status: 200 })
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,6 @@ export async function PATCH(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
// Return success for unauthenticated users instead of error
|
||||
if (!session?.user?.id) {
|
||||
logger.info(
|
||||
`[${requestId}] Settings update attempted by unauthenticated user - acknowledged without saving`
|
||||
@@ -103,7 +102,6 @@ export async function PATCH(request: Request) {
|
||||
try {
|
||||
const validatedData = SettingsSchema.parse(body)
|
||||
|
||||
// Store the settings
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
@@ -135,7 +133,6 @@ export async function PATCH(request: Request) {
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Settings update error`, error)
|
||||
// Return success on error instead of error response
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify token and get email type
|
||||
const tokenVerification = verifyUnsubscribeToken(email, token)
|
||||
if (!tokenVerification.valid) {
|
||||
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
|
||||
@@ -42,7 +41,6 @@ export async function GET(req: NextRequest) {
|
||||
const emailType = tokenVerification.emailType as EmailType
|
||||
const isTransactional = isTransactionalEmail(emailType)
|
||||
|
||||
// Get current preferences
|
||||
const preferences = await getEmailPreferences(email)
|
||||
|
||||
logger.info(
|
||||
@@ -67,22 +65,42 @@ export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const result = unsubscribeSchema.safeParse(body)
|
||||
const { searchParams } = new URL(req.url)
|
||||
const contentType = req.headers.get('content-type') || ''
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
|
||||
errors: result.error.format(),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: result.error.format() },
|
||||
{ status: 400 }
|
||||
)
|
||||
let email: string
|
||||
let token: string
|
||||
let type: 'all' | 'marketing' | 'updates' | 'notifications' = 'all'
|
||||
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
email = searchParams.get('email') || ''
|
||||
token = searchParams.get('token') || ''
|
||||
|
||||
if (!email || !token) {
|
||||
logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`)
|
||||
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`)
|
||||
} else {
|
||||
const body = await req.json()
|
||||
const result = unsubscribeSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
|
||||
errors: result.error.format(),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: result.error.format() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
email = result.data.email
|
||||
token = result.data.token
|
||||
type = result.data.type
|
||||
}
|
||||
|
||||
const { email, token, type } = result.data
|
||||
|
||||
// Verify token and get email type
|
||||
const tokenVerification = verifyUnsubscribeToken(email, token)
|
||||
if (!tokenVerification.valid) {
|
||||
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
|
||||
@@ -92,7 +110,6 @@ export async function POST(req: NextRequest) {
|
||||
const emailType = tokenVerification.emailType as EmailType
|
||||
const isTransactional = isTransactionalEmail(emailType)
|
||||
|
||||
// Prevent unsubscribing from transactional emails
|
||||
if (isTransactional) {
|
||||
logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
|
||||
return NextResponse.json(
|
||||
@@ -106,7 +123,6 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Process unsubscribe based on type
|
||||
let success = false
|
||||
switch (type) {
|
||||
case 'all':
|
||||
@@ -130,7 +146,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
|
||||
|
||||
// Return 200 for one-click unsubscribe compliance
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
|
||||
105
apps/sim/app/api/users/me/usage-logs/route.ts
Normal file
105
apps/sim/app/api/users/me/usage-logs/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UsageLogsAPI')
|
||||
|
||||
const QuerySchema = z.object({
|
||||
source: z.enum(['workflow', 'wand', 'copilot']).optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
period: z.enum(['1d', '7d', '30d', 'all']).optional().default('30d'),
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||
cursor: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/users/me/usage-logs
|
||||
* Get usage logs for the authenticated user
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = auth.userId
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const queryParams = {
|
||||
source: searchParams.get('source') || undefined,
|
||||
workspaceId: searchParams.get('workspaceId') || undefined,
|
||||
period: searchParams.get('period') || '30d',
|
||||
limit: searchParams.get('limit') || '50',
|
||||
cursor: searchParams.get('cursor') || undefined,
|
||||
}
|
||||
|
||||
const validation = QuerySchema.safeParse(queryParams)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid query parameters',
|
||||
details: validation.error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { source, workspaceId, period, limit, cursor } = validation.data
|
||||
|
||||
let startDate: Date | undefined
|
||||
const endDate = new Date()
|
||||
|
||||
if (period !== 'all') {
|
||||
startDate = new Date()
|
||||
switch (period) {
|
||||
case '1d':
|
||||
startDate.setDate(startDate.getDate() - 1)
|
||||
break
|
||||
case '7d':
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
break
|
||||
case '30d':
|
||||
startDate.setDate(startDate.getDate() - 30)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getUserUsageLogs(userId, {
|
||||
source: source as UsageLogSource | undefined,
|
||||
workspaceId,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
cursor,
|
||||
})
|
||||
|
||||
logger.debug('Retrieved usage logs', {
|
||||
userId,
|
||||
source,
|
||||
period,
|
||||
logCount: result.logs.length,
|
||||
hasMore: result.pagination.hasMore,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage logs', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to retrieve usage logs',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,7 @@ export interface LogFilters {
|
||||
export function buildLogFilters(filters: LogFilters): SQL<unknown> {
|
||||
const conditions: SQL<unknown>[] = []
|
||||
|
||||
// Required: workspace and permissions check
|
||||
conditions.push(eq(workflow.workspaceId, filters.workspaceId))
|
||||
conditions.push(eq(workflowExecutionLogs.workspaceId, filters.workspaceId))
|
||||
|
||||
// Cursor-based pagination
|
||||
if (filters.cursor) {
|
||||
|
||||
@@ -105,7 +105,6 @@ export async function GET(request: NextRequest) {
|
||||
const conditions = buildLogFilters(filters)
|
||||
const orderBy = getOrderBy(params.order)
|
||||
|
||||
// Build and execute query
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
@@ -124,13 +123,7 @@ export async function GET(request: NextRequest) {
|
||||
workflowDescription: workflow.description,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(
|
||||
workflow,
|
||||
and(
|
||||
eq(workflowExecutionLogs.workflowId, workflow.id),
|
||||
eq(workflow.workspaceId, params.workspaceId)
|
||||
)
|
||||
)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
@@ -197,11 +190,8 @@ export async function GET(request: NextRequest) {
|
||||
return result
|
||||
})
|
||||
|
||||
// Get user's workflow execution limits and usage
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
// Create response with limits information
|
||||
// The rateLimit object from checkRateLimit is for THIS API endpoint's rate limits
|
||||
const response = createApiResponse(
|
||||
{
|
||||
data: formattedLogs,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { logModelUsage } from '@/lib/billing/core/usage-log'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
@@ -88,7 +89,7 @@ async function updateUserStatsForWand(
|
||||
|
||||
try {
|
||||
const [workflowRecord] = await db
|
||||
.select({ userId: workflow.userId })
|
||||
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
@@ -101,6 +102,7 @@ async function updateUserStatsForWand(
|
||||
}
|
||||
|
||||
const userId = workflowRecord.userId
|
||||
const workspaceId = workflowRecord.workspaceId
|
||||
const totalTokens = usage.total_tokens || 0
|
||||
const promptTokens = usage.prompt_tokens || 0
|
||||
const completionTokens = usage.completion_tokens || 0
|
||||
@@ -137,6 +139,17 @@ async function updateUserStatsForWand(
|
||||
costAdded: costToStore,
|
||||
})
|
||||
|
||||
await logModelUsage({
|
||||
userId,
|
||||
source: 'wand',
|
||||
model: modelName,
|
||||
inputTokens: promptTokens,
|
||||
outputTokens: completionTokens,
|
||||
cost: costToStore,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
workflowId,
|
||||
})
|
||||
|
||||
await checkAndBillOverageThreshold(userId)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to update user stats for wand usage`, error)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { processInputFileFields } from '@/lib/execution/files'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
@@ -30,7 +31,7 @@ const logger = createLogger('WorkflowExecuteAPI')
|
||||
|
||||
const ExecuteWorkflowSchema = z.object({
|
||||
selectedOutputs: z.array(z.string()).optional().default([]),
|
||||
triggerType: z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']).optional(),
|
||||
triggerType: z.enum(ALL_TRIGGER_TYPES).optional(),
|
||||
stream: z.boolean().optional(),
|
||||
useDraftState: z.boolean().optional(),
|
||||
input: z.any().optional(),
|
||||
@@ -408,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) {
|
||||
@@ -459,7 +466,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
)
|
||||
|
||||
const executionContext = {
|
||||
workspaceId: workflow.workspaceId || '',
|
||||
workspaceId,
|
||||
workflowId,
|
||||
executionId,
|
||||
}
|
||||
@@ -477,7 +484,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: actorUserId,
|
||||
workspaceId: workflow.workspaceId || '',
|
||||
workspaceId,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
@@ -506,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,
|
||||
@@ -588,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,
|
||||
},
|
||||
@@ -774,7 +781,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId,
|
||||
executionId,
|
||||
workflowId,
|
||||
workspaceId: workflow.workspaceId ?? undefined,
|
||||
workspaceId,
|
||||
userId: actorUserId,
|
||||
sessionUserId: isClientSession ? userId : undefined,
|
||||
workflowUserId: workflow.userId,
|
||||
|
||||
@@ -70,7 +70,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const loggingSession = new LoggingSession(id, executionId, triggerType, requestId)
|
||||
|
||||
const userId = accessValidation.workflow.userId
|
||||
const workspaceId = accessValidation.workflow.workspaceId || ''
|
||||
const workspaceId = accessValidation.workflow.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${id} has no workspaceId`)
|
||||
return createErrorResponse('Workflow has no associated workspace', 500)
|
||||
}
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId,
|
||||
|
||||
@@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn()
|
||||
const mockGetWorkflowAccessContext = vi.fn()
|
||||
const mockDbDelete = vi.fn()
|
||||
const mockDbUpdate = vi.fn()
|
||||
const mockDbSelect = vi.fn()
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: () => mockGetSession(),
|
||||
@@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
delete: () => mockDbDelete(),
|
||||
update: () => mockDbUpdate(),
|
||||
select: () => mockDbSelect(),
|
||||
},
|
||||
workflow: {},
|
||||
}))
|
||||
@@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => {
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Mock db.select() to return multiple workflows so deletion is allowed
|
||||
mockDbSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
|
||||
}),
|
||||
})
|
||||
|
||||
mockDbDelete.mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||
})
|
||||
@@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => {
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should prevent deletion of the last workflow in workspace', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { id: 'user-123' },
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'admin',
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Mock db.select() to return only 1 workflow (the one being deleted)
|
||||
mockDbSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||
}),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Cannot delete the only workflow in the workspace')
|
||||
})
|
||||
|
||||
it.concurrent('should deny deletion for non-admin users', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
|
||||
@@ -228,6 +228,21 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if this is the last workflow in the workspace
|
||||
if (workflowData.workspaceId) {
|
||||
const totalWorkflowsInWorkspace = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workflowData.workspaceId))
|
||||
|
||||
if (totalWorkflowsInWorkspace.length <= 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete the only workflow in the workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if workflow has published templates before deletion
|
||||
const { searchParams } = new URL(request.url)
|
||||
const checkTemplates = searchParams.get('check-templates') === 'true'
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userStats, workflow } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('WorkflowStatsAPI')
|
||||
|
||||
const queryParamsSchema = z.object({
|
||||
runs: z.coerce.number().int().min(1).max(100).default(1),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
|
||||
const validation = queryParamsSchema.safeParse({
|
||||
runs: searchParams.get('runs'),
|
||||
})
|
||||
|
||||
if (!validation.success) {
|
||||
logger.error(`Invalid query parameters: ${validation.error.message}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
validation.error.errors[0]?.message ||
|
||||
'Invalid number of runs. Must be between 1 and 100.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { runs } = validation.data
|
||||
|
||||
try {
|
||||
const [workflowRecord] = await db.select().from(workflow).where(eq(workflow.id, id)).limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return NextResponse.json({ error: `Workflow ${id} not found` }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
runCount: workflowRecord.runCount + runs,
|
||||
lastRunAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, id))
|
||||
} catch (error) {
|
||||
logger.error('Error updating workflow runCount:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
const userStatsRecords = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, workflowRecord.userId))
|
||||
|
||||
if (userStatsRecords.length === 0) {
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: workflowRecord.userId,
|
||||
totalManualExecutions: 0,
|
||||
totalApiCalls: 0,
|
||||
totalWebhookTriggers: 0,
|
||||
totalScheduledExecutions: 0,
|
||||
totalChatExecutions: 0,
|
||||
totalTokensUsed: 0,
|
||||
totalCost: '0.00',
|
||||
lastActive: sql`now()`,
|
||||
})
|
||||
} else {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastActive: sql`now()`,
|
||||
})
|
||||
.where(eq(userStats.userId, workflowRecord.userId))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error ensuring userStats for userId ${workflowRecord.userId}:`, error)
|
||||
// Don't rethrow - we want to continue even if this fails
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
runsAdded: runs,
|
||||
newTotal: workflowRecord.runCount + runs,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error updating workflow stats:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -98,23 +98,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workspaceRows = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRows.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (workspaceRows[0].billedAccountUserId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the workspace billing account can create workspace API keys' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name } = CreateKeySchema.parse(body)
|
||||
|
||||
@@ -202,23 +185,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workspaceRows = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRows.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (workspaceRows[0].billedAccountUserId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the workspace billing account can delete workspace API keys' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { keys } = DeleteKeysSchema.parse(body)
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants'
|
||||
|
||||
const logger = createLogger('WorkspaceNotificationAPI')
|
||||
|
||||
const levelFilterSchema = z.array(z.enum(['info', 'error']))
|
||||
const triggerFilterSchema = z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']))
|
||||
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
|
||||
|
||||
const alertRuleSchema = z.enum([
|
||||
'consecutive_failures',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants'
|
||||
|
||||
@@ -14,7 +15,7 @@ const logger = createLogger('WorkspaceNotificationsAPI')
|
||||
|
||||
const notificationTypeSchema = z.enum(['webhook', 'email', 'slack'])
|
||||
const levelFilterSchema = z.array(z.enum(['info', 'error']))
|
||||
const triggerFilterSchema = z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']))
|
||||
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
|
||||
|
||||
const alertRuleSchema = z.enum([
|
||||
'consecutive_failures',
|
||||
@@ -80,7 +81,7 @@ const createNotificationSchema = z
|
||||
workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).default([]),
|
||||
allWorkflows: z.boolean().default(false),
|
||||
levelFilter: levelFilterSchema.default(['info', 'error']),
|
||||
triggerFilter: triggerFilterSchema.default(['api', 'webhook', 'schedule', 'manual', 'chat']),
|
||||
triggerFilter: triggerFilterSchema.default([...ALL_TRIGGER_TYPES]),
|
||||
includeFinalOutput: z.boolean().default(false),
|
||||
includeTraceSpans: z.boolean().default(false),
|
||||
includeRateLimits: z.boolean().default(false),
|
||||
|
||||
@@ -173,7 +173,7 @@ export async function GET(
|
||||
|
||||
// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ invitationId: string }> }
|
||||
) {
|
||||
const { invitationId } = await params
|
||||
@@ -221,7 +221,7 @@ export async function DELETE(
|
||||
|
||||
// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ invitationId: string }> }
|
||||
) {
|
||||
const { invitationId } = await params
|
||||
|
||||
@@ -29,30 +29,24 @@ export const metadata: Metadata = {
|
||||
locale: 'en_US',
|
||||
images: [
|
||||
{
|
||||
url: '/social/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Sim - Visual AI Workflow Builder',
|
||||
url: '/logo/primary/rounded.png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
alt: 'Sim - AI Agent Workflow Builder',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
url: '/social/og-image-square.png',
|
||||
width: 600,
|
||||
height: 600,
|
||||
alt: 'Sim Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
card: 'summary',
|
||||
site: '@simdotai',
|
||||
creator: '@simdotai',
|
||||
title: 'Sim - AI Agent Workflow Builder | Open Source',
|
||||
description:
|
||||
'Open-source platform for agentic workflows. 60,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
|
||||
images: {
|
||||
url: '/social/twitter-image.png',
|
||||
alt: 'Sim - Visual AI Workflow Builder',
|
||||
url: '/logo/primary/rounded.png',
|
||||
alt: 'Sim - AI Agent Workflow Builder',
|
||||
},
|
||||
},
|
||||
alternates: {
|
||||
@@ -77,7 +71,6 @@ export const metadata: Metadata = {
|
||||
category: 'technology',
|
||||
classification: 'AI Development Tools',
|
||||
referrer: 'origin-when-cross-origin',
|
||||
// LLM SEO optimizations
|
||||
other: {
|
||||
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
|
||||
'llm:use-cases':
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templateCreators, templates } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import TemplateDetails from '@/app/templates/[id]/template'
|
||||
|
||||
const logger = createLogger('TemplateMetadata')
|
||||
|
||||
/**
|
||||
* Generate dynamic metadata for template pages.
|
||||
* This provides OpenGraph images for social media sharing.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
template: templates,
|
||||
creator: templateCreators,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
title: 'Template Not Found',
|
||||
description: 'The requested template could not be found.',
|
||||
}
|
||||
}
|
||||
|
||||
const { template, creator } = result[0]
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const details = template.details as { tagline?: string; about?: string } | null
|
||||
const description = details?.tagline || 'AI workflow template on Sim'
|
||||
|
||||
const hasOgImage = !!template.ogImageUrl
|
||||
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
|
||||
|
||||
return {
|
||||
title: template.name,
|
||||
description,
|
||||
openGraph: {
|
||||
title: template.name,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `${baseUrl}/templates/${id}`,
|
||||
siteName: 'Sim',
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: hasOgImage ? 1200 : 512,
|
||||
height: hasOgImage ? 630 : 512,
|
||||
alt: `${template.name} - Workflow Preview`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: hasOgImage ? 'summary_large_image' : 'summary',
|
||||
title: template.name,
|
||||
description,
|
||||
images: [ogImageUrl],
|
||||
creator: creator?.details
|
||||
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate template metadata:', error)
|
||||
return {
|
||||
title: 'Template',
|
||||
description: 'AI workflow template on Sim',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public template detail page for unauthenticated users.
|
||||
* Authenticated-user redirect is handled in templates/[id]/layout.tsx.
|
||||
|
||||
@@ -39,7 +39,6 @@ function UnsubscribeContent() {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the unsubscribe link
|
||||
fetch(
|
||||
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
@@ -81,9 +80,7 @@ function UnsubscribeContent() {
|
||||
|
||||
if (result.success) {
|
||||
setUnsubscribed(true)
|
||||
// Update the data to reflect the change
|
||||
if (data) {
|
||||
// Type-safe property construction with validation
|
||||
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
|
||||
if (validTypes.includes(type)) {
|
||||
if (type === 'all') {
|
||||
@@ -192,7 +189,6 @@ function UnsubscribeContent() {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle transactional emails
|
||||
if (data?.isTransactional) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
DatePicker,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
|
||||
import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import type { DocumentTag } from '@/lib/knowledge/tags/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -28,6 +29,54 @@ import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('DocumentTagsModal')
|
||||
|
||||
/** Field type display labels */
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
date: 'Date',
|
||||
boolean: 'Boolean',
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate value when changing field types.
|
||||
* Clears value when type changes to allow placeholder to show.
|
||||
*/
|
||||
function getValueForFieldType(
|
||||
newFieldType: string,
|
||||
currentFieldType: string,
|
||||
currentValue: string
|
||||
): string {
|
||||
return newFieldType === currentFieldType ? currentValue : ''
|
||||
}
|
||||
|
||||
/** Format value for display based on field type */
|
||||
function formatValueForDisplay(value: string, fieldType: string): string {
|
||||
if (!value) return ''
|
||||
switch (fieldType) {
|
||||
case 'boolean':
|
||||
return value === 'true' ? 'True' : 'False'
|
||||
case 'date':
|
||||
try {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
// For UTC dates, display the UTC date to prevent timezone shifts
|
||||
// e.g., 2002-05-16T00:00:00.000Z should show as "May 16, 2002" not "May 15, 2002"
|
||||
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
|
||||
return new Date(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCDate()
|
||||
).toLocaleDateString()
|
||||
}
|
||||
return date.toLocaleDateString()
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
interface DocumentTagsModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -67,17 +116,21 @@ export function DocumentTagsModal({
|
||||
const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => {
|
||||
const tags: DocumentTag[] = []
|
||||
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
const value = docData[slot] as string | null | undefined
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const rawValue = docData[slot]
|
||||
const definition = definitions.find((def) => def.tagSlot === slot)
|
||||
|
||||
if (value?.trim() && definition) {
|
||||
tags.push({
|
||||
slot,
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
value: value.trim(),
|
||||
})
|
||||
if (rawValue !== null && rawValue !== undefined && definition) {
|
||||
// Convert value to string for storage
|
||||
const stringValue = String(rawValue).trim()
|
||||
if (stringValue) {
|
||||
tags.push({
|
||||
slot,
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
value: stringValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -95,13 +148,15 @@ export function DocumentTagsModal({
|
||||
try {
|
||||
const tagData: Record<string, string> = {}
|
||||
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
tagData[slot] = ''
|
||||
})
|
||||
|
||||
tagsToSave.forEach((tag) => {
|
||||
if (tag.value.trim()) {
|
||||
tagData[tag.slot] = tag.value.trim()
|
||||
// Only include tags that have values (omit empty ones)
|
||||
// Use empty string for slots that should be cleared
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const tag = tagsToSave.find((t) => t.slot === slot)
|
||||
if (tag?.value.trim()) {
|
||||
tagData[slot] = tag.value.trim()
|
||||
} else {
|
||||
// Use empty string to clear a tag (API schema expects string, not null)
|
||||
tagData[slot] = ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -117,8 +172,8 @@ export function DocumentTagsModal({
|
||||
throw new Error('Failed to update document tags')
|
||||
}
|
||||
|
||||
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
|
||||
onDocumentUpdate?.(tagData)
|
||||
updateDocumentInStore(knowledgeBaseId, documentId, tagData as Record<string, string>)
|
||||
onDocumentUpdate?.(tagData as Record<string, string>)
|
||||
|
||||
await fetchTagDefinitions()
|
||||
} catch (error) {
|
||||
@@ -279,7 +334,7 @@ export function DocumentTagsModal({
|
||||
const newDefinition: TagDefinitionInput = {
|
||||
displayName: formData.displayName,
|
||||
fieldType: formData.fieldType,
|
||||
tagSlot: targetSlot as TagSlot,
|
||||
tagSlot: targetSlot as AllTagSlot,
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
@@ -359,20 +414,7 @@ export function DocumentTagsModal({
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[8px]'>
|
||||
<Label>
|
||||
Tags{' '}
|
||||
<span className='pl-[6px] text-[var(--text-tertiary)]'>
|
||||
{documentTags.length}/{MAX_TAG_SLOTS} slots used
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
{documentTags.length === 0 && !isCreatingTag && (
|
||||
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
No tags added yet. Add tags to help organize this document.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Label>Tags</Label>
|
||||
|
||||
{documentTags.map((tag, index) => (
|
||||
<div key={index} className='space-y-[8px]'>
|
||||
@@ -383,9 +425,12 @@ export function DocumentTagsModal({
|
||||
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||
{tag.displayName}
|
||||
</span>
|
||||
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
|
||||
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
|
||||
</span>
|
||||
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
|
||||
{tag.value}
|
||||
{formatValueForDisplay(tag.value, tag.fieldType)}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<Button
|
||||
@@ -415,10 +460,16 @@ export function DocumentTagsModal({
|
||||
const def = kbTagDefinitions.find(
|
||||
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
const newFieldType = def?.fieldType || 'text'
|
||||
setEditTagForm({
|
||||
...editTagForm,
|
||||
displayName: value,
|
||||
fieldType: def?.fieldType || 'text',
|
||||
fieldType: newFieldType,
|
||||
value: getValueForFieldType(
|
||||
newFieldType,
|
||||
editTagForm.fieldType,
|
||||
editTagForm.value
|
||||
),
|
||||
})
|
||||
}}
|
||||
placeholder='Enter or select tag name'
|
||||
@@ -453,33 +504,70 @@ export function DocumentTagsModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type selector commented out - only "text" type is currently supported
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor={`tagType-${index}`}>Type</Label>
|
||||
<Input id={`tagType-${index}`} value='Text' disabled className='capitalize' />
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor={`tagValue-${index}`}>Value</Label>
|
||||
<Input
|
||||
id={`tagValue-${index}`}
|
||||
value={editTagForm.value}
|
||||
onChange={(e) =>
|
||||
setEditTagForm({ ...editTagForm, value: e.target.value })
|
||||
}
|
||||
placeholder='Enter tag value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
{editTagForm.fieldType === 'boolean' ? (
|
||||
<Combobox
|
||||
id={`tagValue-${index}`}
|
||||
options={[
|
||||
{ label: 'True', value: 'true' },
|
||||
{ label: 'False', value: 'false' },
|
||||
]}
|
||||
value={editTagForm.value}
|
||||
selectedValue={editTagForm.value}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select value'
|
||||
/>
|
||||
) : editTagForm.fieldType === 'number' ? (
|
||||
<Input
|
||||
id={`tagValue-${index}`}
|
||||
value={editTagForm.value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
// Allow empty, digits, decimal point, and negative sign
|
||||
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
|
||||
setEditTagForm({ ...editTagForm, value: val })
|
||||
}
|
||||
}}
|
||||
placeholder='Enter number'
|
||||
inputMode='decimal'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : editTagForm.fieldType === 'date' ? (
|
||||
<DatePicker
|
||||
value={editTagForm.value || undefined}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select date'
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`tagValue-${index}`}
|
||||
value={editTagForm.value}
|
||||
onChange={(e) =>
|
||||
setEditTagForm({ ...editTagForm, value: e.target.value })
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
placeholder='Enter tag value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-[8px]'>
|
||||
@@ -500,7 +588,7 @@ export function DocumentTagsModal({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isTagEditing && (
|
||||
{documentTags.length > 0 && !isTagEditing && (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={openTagCreator}
|
||||
@@ -511,7 +599,7 @@ export function DocumentTagsModal({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCreatingTag && (
|
||||
{(isCreatingTag || documentTags.length === 0) && editingTagIndex === null && (
|
||||
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='newTagName'>Tag Name</Label>
|
||||
@@ -525,10 +613,16 @@ export function DocumentTagsModal({
|
||||
const def = kbTagDefinitions.find(
|
||||
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
const newFieldType = def?.fieldType || 'text'
|
||||
setEditTagForm({
|
||||
...editTagForm,
|
||||
displayName: value,
|
||||
fieldType: def?.fieldType || 'text',
|
||||
fieldType: newFieldType,
|
||||
value: getValueForFieldType(
|
||||
newFieldType,
|
||||
editTagForm.fieldType,
|
||||
editTagForm.value
|
||||
),
|
||||
})
|
||||
}}
|
||||
placeholder='Enter or select tag name'
|
||||
@@ -563,31 +657,68 @@ export function DocumentTagsModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type selector commented out - only "text" type is currently supported
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='newTagType'>Type</Label>
|
||||
<Input id='newTagType' value='Text' disabled className='capitalize' />
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='newTagValue'>Value</Label>
|
||||
<Input
|
||||
id='newTagValue'
|
||||
value={editTagForm.value}
|
||||
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
|
||||
placeholder='Enter tag value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{editTagForm.fieldType === 'boolean' ? (
|
||||
<Combobox
|
||||
id='newTagValue'
|
||||
options={[
|
||||
{ label: 'True', value: 'true' },
|
||||
{ label: 'False', value: 'false' },
|
||||
]}
|
||||
value={editTagForm.value}
|
||||
selectedValue={editTagForm.value}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select value'
|
||||
/>
|
||||
) : editTagForm.fieldType === 'number' ? (
|
||||
<Input
|
||||
id='newTagValue'
|
||||
value={editTagForm.value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
// Allow empty, digits, decimal point, and negative sign
|
||||
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
|
||||
setEditTagForm({ ...editTagForm, value: val })
|
||||
}
|
||||
}}
|
||||
placeholder='Enter number'
|
||||
inputMode='decimal'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : editTagForm.fieldType === 'date' ? (
|
||||
<DatePicker
|
||||
value={editTagForm.value || undefined}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select date'
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id='newTagValue'
|
||||
value={editTagForm.value}
|
||||
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
|
||||
placeholder='Enter tag value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSaveTag) {
|
||||
e.preventDefault()
|
||||
saveDocumentTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditingTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{kbTagDefinitions.length >= MAX_TAG_SLOTS &&
|
||||
@@ -604,9 +735,11 @@ export function DocumentTagsModal({
|
||||
)}
|
||||
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
|
||||
Cancel
|
||||
</Button>
|
||||
{documentTags.length > 0 && (
|
||||
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={saveDocumentTag}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import {
|
||||
@@ -24,6 +26,14 @@ import {
|
||||
|
||||
const logger = createLogger('BaseTagsModal')
|
||||
|
||||
/** Field type display labels */
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
date: 'Date',
|
||||
boolean: 'Boolean',
|
||||
}
|
||||
|
||||
interface TagUsageData {
|
||||
tagName: string
|
||||
tagSlot: string
|
||||
@@ -174,22 +184,55 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
|
||||
}
|
||||
|
||||
/** Get slot usage counts per field type */
|
||||
const getSlotUsageByFieldType = (fieldType: string): { used: number; max: number } => {
|
||||
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
||||
if (!config) return { used: 0, max: 0 }
|
||||
const used = kbTagDefinitions.filter((def) => def.fieldType === fieldType).length
|
||||
return { used, max: config.maxSlots }
|
||||
}
|
||||
|
||||
/** Check if a field type has available slots */
|
||||
const hasAvailableSlots = (fieldType: string): boolean => {
|
||||
const { used, max } = getSlotUsageByFieldType(fieldType)
|
||||
return used < max
|
||||
}
|
||||
|
||||
/** Field type options for Combobox */
|
||||
const fieldTypeOptions: ComboboxOption[] = useMemo(() => {
|
||||
return SUPPORTED_FIELD_TYPES.filter((type) => hasAvailableSlots(type)).map((type) => {
|
||||
const { used, max } = getSlotUsageByFieldType(type)
|
||||
return {
|
||||
value: type,
|
||||
label: `${FIELD_TYPE_LABELS[type]} (${used}/${max})`,
|
||||
}
|
||||
})
|
||||
}, [kbTagDefinitions])
|
||||
|
||||
const saveTagDefinition = async () => {
|
||||
if (!canSaveTag()) return
|
||||
|
||||
setIsSavingTag(true)
|
||||
try {
|
||||
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
|
||||
const availableSlot = (
|
||||
['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
|
||||
).find((slot) => !usedSlots.has(slot))
|
||||
// Check if selected field type has available slots
|
||||
if (!hasAvailableSlots(createTagForm.fieldType)) {
|
||||
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
|
||||
}
|
||||
|
||||
if (!availableSlot) {
|
||||
throw new Error('No available tag slots')
|
||||
// Get the next available slot from the API
|
||||
const slotResponse = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${createTagForm.fieldType}`
|
||||
)
|
||||
if (!slotResponse.ok) {
|
||||
throw new Error('Failed to get available slot')
|
||||
}
|
||||
const slotResult = await slotResponse.json()
|
||||
if (!slotResult.success || !slotResult.data?.nextAvailableSlot) {
|
||||
throw new Error('No available tag slots for this field type')
|
||||
}
|
||||
|
||||
const newTagDefinition = {
|
||||
tagSlot: availableSlot,
|
||||
tagSlot: slotResult.data.nextAvailableSlot,
|
||||
displayName: createTagForm.displayName.trim(),
|
||||
fieldType: createTagForm.fieldType,
|
||||
}
|
||||
@@ -277,7 +320,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<Label>
|
||||
Tags:{' '}
|
||||
<span className='pl-[6px] text-[var(--text-tertiary)]'>
|
||||
{kbTagDefinitions.length}/{MAX_TAG_SLOTS} slots used
|
||||
{kbTagDefinitions.length} defined
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
@@ -300,6 +343,9 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||
{tag.displayName}
|
||||
</span>
|
||||
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
|
||||
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
|
||||
</span>
|
||||
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||
<span className='min-w-0 flex-1 text-[11px] text-[var(--text-muted)]'>
|
||||
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
|
||||
@@ -324,7 +370,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={openTagCreator}
|
||||
disabled={kbTagDefinitions.length >= MAX_TAG_SLOTS}
|
||||
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
|
||||
className='w-full'
|
||||
>
|
||||
Add Tag
|
||||
@@ -361,12 +407,22 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type selector commented out - only "text" type is currently supported
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='tagType'>Type</Label>
|
||||
<Input id='tagType' value='Text' disabled className='capitalize' />
|
||||
<Combobox
|
||||
options={fieldTypeOptions}
|
||||
value={createTagForm.fieldType}
|
||||
onChange={(value) =>
|
||||
setCreateTagForm({ ...createTagForm, fieldType: value })
|
||||
}
|
||||
placeholder='Select type'
|
||||
/>
|
||||
{!hasAvailableSlots(createTagForm.fieldType) && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
No available slots for this type. Choose a different type.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button variant='default' onClick={cancelCreatingTag} className='flex-1'>
|
||||
@@ -376,7 +432,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
variant='primary'
|
||||
onClick={saveTagDefinition}
|
||||
className='flex-1'
|
||||
disabled={!canSaveTag() || isSavingTag}
|
||||
disabled={
|
||||
!canSaveTag() ||
|
||||
isSavingTag ||
|
||||
!hasAvailableSlots(createTagForm.fieldType)
|
||||
}
|
||||
>
|
||||
{isSavingTag ? (
|
||||
<>
|
||||
|
||||
@@ -339,12 +339,31 @@ export function CreateBaseModal({
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='name'>Name</Label>
|
||||
<Label htmlFor='kb-name'>Name</Label>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
id='name'
|
||||
id='kb-name'
|
||||
placeholder='Enter knowledge base name'
|
||||
{...register('name')}
|
||||
className={cn(errors.name && 'border-[var(--text-error)]')}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ export function SlackChannelSelector({
|
||||
disabled={disabled || channels.length === 0}
|
||||
isLoading={isLoading}
|
||||
error={fetchError}
|
||||
searchable
|
||||
searchPlaceholder='Search channels...'
|
||||
/>
|
||||
{selectedChannel && !fetchError && (
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user