mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(types): enforce patterns outside just hooks directory and fix CI check + fix tracing billing issue (#4367)
* improvement(types): enforce outside just hooks dir and update CI checks * fix billing account details for kb embeddings * more fixes * fix byok issue * address comments * fix * more comments * address bugbot
This commit is contained in:
+23
-7
@@ -5,6 +5,7 @@ These rules apply to files under `apps/sim/` in addition to the repository root
|
||||
## Architecture
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **Single Responsibility**: Each component, hook, store has one clear purpose
|
||||
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
|
||||
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
|
||||
@@ -47,6 +48,7 @@ feature/
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Components**: PascalCase (`WorkflowList`)
|
||||
- **Hooks**: `use` prefix (`useWorkflowOperations`)
|
||||
- **Files**: kebab-case (`workflow-list.tsx`)
|
||||
@@ -71,7 +73,7 @@ feature/
|
||||
|
||||
## API Contracts
|
||||
|
||||
Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**` (one file per resource family). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract.
|
||||
Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/`** live in `apps/sim/lib/api/contracts/**` (one file per resource family). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract.
|
||||
|
||||
- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` from `@/lib/api/contracts`.
|
||||
- Contracts export named schemas AND named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input<typeof createFolderBodySchema>`). Clients import the named aliases — never `z.input<...>` / `z.output<...>` in hooks.
|
||||
@@ -83,10 +85,10 @@ Boundary HTTP request and response shapes for all routes under `apps/sim/app/api
|
||||
|
||||
A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes four annotation forms:
|
||||
|
||||
- `// boundary-raw-fetch: <reason>` — placed on the line directly above a raw `fetch(` call inside `apps/sim/hooks/queries/**` or `apps/sim/hooks/selectors/**`. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests.
|
||||
- `// boundary-raw-fetch: <reason>` — placed on the line directly above a raw `fetch(` call inside `apps/sim/hooks/queries/**`, `apps/sim/hooks/selectors/**`, or any other client/UI source under `apps/sim/**` that targets a same-origin `/api/...` URL. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests.
|
||||
- `// double-cast-allowed: <reason>` — placed on the line directly above an `as unknown as X` cast outside test files.
|
||||
- `// boundary-raw-json: <reason>` — placed on the line directly above a raw `await request.json()` / `await req.json()` read in a route handler. Use only when the body is a JSON-RPC envelope, a tolerant `.catch(() => ({}))` parse, or otherwise cannot go through `parseRequest`.
|
||||
- `// untyped-response: <reason>` — placed on the line directly above a `schema: z.unknown()` response declaration in a contract file. Use only when the response body is genuinely opaque (user-supplied data, third-party passthrough).
|
||||
- `// boundary-raw-json: <reason>` — placed on the line directly above a raw `await request.json()` / `await req.json()` read (or the multi-line `await request.clone().json()` shim variant) in a route handler. Use only when the body is a JSON-RPC envelope, a tolerant `.catch(() => ({}))` parse, or otherwise cannot go through `parseRequest`.
|
||||
- `// untyped-response: <reason>` — placed on the line directly above a `schema: z.unknown()` / `schema: z.object({}).passthrough()` / `schema: z.record(z.string(), z.unknown())` response declaration (or a simple alias to one of those) in a contract file. Use only when the response body is genuinely opaque (user-supplied data, third-party passthrough).
|
||||
|
||||
Placement rule: the annotation must immediately precede the call or cast. Up to three non-empty preceding comment lines are tolerated, so additional context comments above the annotation are fine. The reason must be non-empty after trimming — annotations with empty reasons fail strict mode (`annotationsMissingReason`).
|
||||
|
||||
@@ -104,6 +106,19 @@ const response = await fetch(`/api/copilot/chat/stream?chatId=${chatId}`, { sign
|
||||
const provider = config as unknown as LegacyProvider
|
||||
```
|
||||
|
||||
```ts
|
||||
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
|
||||
const body = await request
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => undefined)
|
||||
```
|
||||
|
||||
```ts
|
||||
// untyped-response: forwards firecrawl /v2/parse response unchanged for downstream tool consumers
|
||||
output: z.unknown(),
|
||||
```
|
||||
|
||||
## API Route Pattern
|
||||
|
||||
Routes never `import { z } from 'zod'` and never define route-local boundary schemas. They consume the contract from `@/lib/api/contracts/**` and validate with canonical helpers from `@/lib/api/server`:
|
||||
@@ -149,18 +164,18 @@ When adding a new route + client surface, follow this order. Each step has one p
|
||||
|
||||
LLMs will write contracts that compile but are sloppy. The human reviewer should optimize attention on:
|
||||
|
||||
- **`required` vs `optional` vs `nullable` is correct**. `optional()` allows omission; `nullable()` allows `null`; chaining both creates a tri-state that's almost never what you want.
|
||||
- `**required` vs `optional` vs `nullable` is correct**. `optional()` allows omission; `nullable()` allows `null`; chaining both creates a tri-state that's almost never what you want.
|
||||
- **Response schema matches the route's actual JSON output**. The most common drift bug — route emits a field the schema doesn't declare, or omits a required field. Walk every `NextResponse.json(...)` callsite against the schema.
|
||||
- **Error messages are descriptive**. `'fileName cannot be empty'` beats `'Required'`. Use the second arg of `min(1, '...')`, `nonempty('...')`, etc. For cross-field refines, use `superRefine` with a `path` and a message that names the failing field.
|
||||
- **Bounds are set** on arrays (`.min(1)`, `.max(N)`), strings (`.min(1).max(N)` for IDs/names), and numbers (`.min().max()` for limits/sizes).
|
||||
- **`z.unknown()` is a smell** unless the data is genuinely arbitrary (provider passthrough, user-defined tool result, JSON-RPC envelope). When kept, must be annotated `// untyped-response: <specific reason>` in a `schema:` slot.
|
||||
- `**z.unknown()` is a smell** unless the data is genuinely arbitrary (provider passthrough, user-defined tool result, JSON-RPC envelope). When kept, must be annotated `// untyped-response: <specific reason>` in a `schema:` slot.
|
||||
- **Discriminated unions over plain unions** when the wire has a discriminant field — gives clients exhaustive narrowing.
|
||||
|
||||
CI (`bun run check:api-validation:strict`) catches structural violations (Zod imports in routes, raw `request.json()`, double casts, missing annotations). It does **not** catch these schema-quality judgments — that's the human's job in PR review.
|
||||
|
||||
## React Query Client Boundary
|
||||
|
||||
Hooks in `apps/sim/hooks/queries/**` consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`:
|
||||
Hooks in `apps/sim/hooks/queries/`** consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`:
|
||||
|
||||
- Hooks import named type aliases from `@/lib/api/contracts/**`. Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code.
|
||||
- `requestJson` parses params, query, body, and headers against the contract on the way out and validates the JSON response on the way back. Hooks always forward `signal` for cancellation.
|
||||
@@ -204,3 +219,4 @@ export function useEntityList(workspaceId?: string) {
|
||||
- **Create `utils.ts` when** 2+ files need the same helper
|
||||
- **Check existing sources** before duplicating (`lib/` has many utilities)
|
||||
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
ModalDescription,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { forgetPasswordContract } from '@/lib/api/contracts'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
|
||||
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
|
||||
@@ -282,20 +284,16 @@ export default function LoginPage({
|
||||
setIsSubmittingReset(true)
|
||||
setResetStatus({ type: null, message: '' })
|
||||
|
||||
const response = await fetch('/api/auth/forget-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: forgotPasswordEmail,
|
||||
redirectTo: `${getBaseUrl()}/reset-password`,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
let errorMessage = errorData.message || 'Failed to request password reset'
|
||||
try {
|
||||
await requestJson(forgetPasswordContract, {
|
||||
body: {
|
||||
email: forgotPasswordEmail,
|
||||
redirectTo: `${getBaseUrl()}/reset-password`,
|
||||
},
|
||||
})
|
||||
} catch (requestError) {
|
||||
let errorMessage =
|
||||
requestError instanceof Error ? requestError.message : 'Failed to request password reset'
|
||||
|
||||
if (
|
||||
errorMessage.includes('Invalid body parameters') ||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ArrowLeftRight } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button, Loader } from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { oauthAuthorizeParamsContract } from '@/lib/api/contracts/oauth-connections'
|
||||
import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
|
||||
@@ -44,6 +46,7 @@ export default function OAuthConsentPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: better-auth catch-all OAuth client lookup, no app-level contract
|
||||
fetch(`/api/auth/oauth2/client/${encodeURIComponent(clientId)}`, { credentials: 'include' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return
|
||||
@@ -65,6 +68,7 @@ export default function OAuthConsentPage() {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// boundary-raw-fetch: better-auth catch-all OAuth consent submission, no app-level contract
|
||||
const res = await fetch('/api/auth/oauth2/consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -100,15 +104,15 @@ export default function OAuthConsentPage() {
|
||||
const handleSwitchAccount = useCallback(async () => {
|
||||
if (!consentCode) return
|
||||
|
||||
const res = await fetch(`/api/auth/oauth2/authorize-params?consent_code=${consentCode}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const params = await requestJson(oauthAuthorizeParamsContract, {
|
||||
query: { consent_code: consentCode },
|
||||
}).catch(() => null)
|
||||
|
||||
if (!params) {
|
||||
setError('Unable to switch accounts. Please re-initiate the connection.')
|
||||
return
|
||||
}
|
||||
|
||||
const params = (await res.json()) as Record<string, string | null>
|
||||
const authorizeUrl = new URL('/api/auth/oauth2/authorize', window.location.origin)
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) authorizeUrl.searchParams.set(key, value)
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Suspense, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { resetPasswordContract } from '@/lib/api/contracts'
|
||||
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
|
||||
|
||||
const logger = createLogger('ResetPasswordPage')
|
||||
@@ -27,26 +29,18 @@ function ResetPasswordContent() {
|
||||
: null
|
||||
|
||||
const handleResetPassword = async (password: string) => {
|
||||
if (!token) return
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
setStatusMessage({ type: null, text: '' })
|
||||
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
await requestJson(resetPasswordContract, {
|
||||
body: {
|
||||
token,
|
||||
newPassword: password,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || 'Failed to reset password')
|
||||
}
|
||||
|
||||
setStatusMessage({
|
||||
type: 'success',
|
||||
text: 'Password reset successful! Redirecting to login...',
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
ModalTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { GithubIcon, GoogleIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
@@ -30,13 +32,9 @@ interface AuthModalProps {
|
||||
source: PostHogEventMap['auth_modal_opened']['source']
|
||||
}
|
||||
|
||||
interface ProviderStatus {
|
||||
githubAvailable: boolean
|
||||
googleAvailable: boolean
|
||||
registrationDisabled: boolean
|
||||
}
|
||||
type ProviderStatus = AuthProviderStatusResponse
|
||||
|
||||
let fetchPromise: Promise<ProviderStatus> | null = null
|
||||
let fetchPromise: Promise<AuthProviderStatusResponse> | null = null
|
||||
|
||||
const FALLBACK_STATUS: ProviderStatus = {
|
||||
githubAvailable: false,
|
||||
@@ -49,12 +47,8 @@ const SOCIAL_BTN =
|
||||
|
||||
function fetchProviderStatus(): Promise<ProviderStatus> {
|
||||
if (fetchPromise) return fetchPromise
|
||||
fetchPromise = fetch('/api/auth/providers')
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json()
|
||||
})
|
||||
.then(({ githubAvailable, googleAvailable, registrationDisabled }: ProviderStatus) => ({
|
||||
fetchPromise = requestJson(getAuthProvidersContract, {})
|
||||
.then(({ githubAvailable, googleAvailable, registrationDisabled }) => ({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
registrationDisabled,
|
||||
|
||||
@@ -7,10 +7,13 @@ import { useMutation } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { Combobox, Input, Textarea } from '@/components/emcn'
|
||||
import { Check } from '@/components/emcn/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
CONTACT_TOPIC_OPTIONS,
|
||||
type ContactRequestPayload,
|
||||
contactRequestSchema,
|
||||
type SubmitContactBody,
|
||||
submitContactContract,
|
||||
} from '@/lib/api/contracts/contact'
|
||||
import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
@@ -53,29 +56,8 @@ const LANDING_SUBMIT =
|
||||
const LANDING_LABEL =
|
||||
'font-[500] font-season text-[13px] text-[var(--landing-text)] tracking-[0.02em]'
|
||||
|
||||
interface SubmitContactRequestInput extends ContactRequestPayload {
|
||||
website: string
|
||||
captchaToken?: string
|
||||
captchaUnavailable?: boolean
|
||||
}
|
||||
|
||||
async function submitContactRequest(payload: SubmitContactRequestInput) {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = (await response.json().catch(() => null)) as {
|
||||
error?: string
|
||||
message?: string
|
||||
} | null
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.error || 'Failed to send message')
|
||||
}
|
||||
|
||||
return result
|
||||
async function submitContactRequest(payload: SubmitContactBody) {
|
||||
return requestJson(submitContactContract, { body: payload })
|
||||
}
|
||||
|
||||
export function ContactForm() {
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { Check } from '@/components/emcn/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
DEMO_REQUEST_COMPANY_SIZE_OPTIONS,
|
||||
type DemoRequestPayload,
|
||||
demoRequestSchema,
|
||||
submitDemoRequestContract,
|
||||
} from '@/lib/api/contracts/demo-requests'
|
||||
import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
@@ -56,22 +58,7 @@ const LANDING_INPUT =
|
||||
'h-[32px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 font-[430] font-season text-[13.5px] text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none'
|
||||
|
||||
async function submitDemoRequest(payload: DemoRequestPayload) {
|
||||
const response = await fetch('/api/demo-requests', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = (await response.json().catch(() => null)) as {
|
||||
error?: string
|
||||
message?: string
|
||||
} | null
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.error || 'Failed to submit demo request')
|
||||
}
|
||||
|
||||
return result
|
||||
return requestJson(submitDemoRequestContract, { body: payload })
|
||||
}
|
||||
|
||||
export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalProps) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { integrationRequestContract } from '@/lib/api/contracts/common'
|
||||
|
||||
type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'
|
||||
|
||||
@@ -46,18 +48,14 @@ export function RequestIntegrationModal() {
|
||||
setStatus('submitting')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/help/integration-request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
await requestJson(integrationRequestContract, {
|
||||
body: {
|
||||
integrationName: integrationName.trim(),
|
||||
email: email.trim(),
|
||||
useCase: useCase.trim() || undefined,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Request failed')
|
||||
|
||||
setStatus('success')
|
||||
setTimeout(() => setOpen(false), 1500)
|
||||
} catch {
|
||||
|
||||
@@ -4,6 +4,8 @@ import type React from 'react'
|
||||
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listCreatorOrganizationsContract } from '@/lib/api/contracts/creator-profile'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
|
||||
|
||||
@@ -92,14 +94,9 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/organizations')
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const orgData = await requestJson(listCreatorOrganizationsContract, {}).catch(() => null)
|
||||
if (!orgData) return
|
||||
|
||||
const orgData = (await response.json()) as {
|
||||
organizations?: Array<{ id: string }>
|
||||
}
|
||||
const organizationId = orgData.organizations?.[0]?.id
|
||||
|
||||
if (!organizationId || isCancelled) {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { verification } from '@sim/db/schema'
|
||||
import { and, eq, gt } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { oauthAuthorizeParamsQuerySchema } from '@/lib/api/contracts/oauth-connections'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { oauthAuthorizeParamsContract } from '@/lib/api/contracts/oauth-connections'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
@@ -19,16 +19,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsedQuery = oauthAuthorizeParamsQuerySchema.safeParse({
|
||||
consent_code: request.nextUrl.searchParams.get('consent_code') || undefined,
|
||||
})
|
||||
if (!parsedQuery.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(parsedQuery.error) },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const consentCode = parsedQuery.data.consent_code
|
||||
const parsed = await parseRequest(oauthAuthorizeParamsContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const consentCode = parsed.data.query.consent_code
|
||||
|
||||
const [record] = await db
|
||||
.select({ value: verification.value })
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getAuthProvidersContract } from '@/lib/api/contracts/auth'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const GET = withRouteHandler(async () => {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const parsed = await parseRequest(getAuthProvidersContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { githubAvailable, googleAvailable } = await getOAuthProviderStatus()
|
||||
return NextResponse.json({
|
||||
githubAvailable,
|
||||
|
||||
@@ -2,7 +2,8 @@ import { db, member, ssoProvider } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { ssoProvidersQuerySchema } from '@/lib/api/contracts/auth'
|
||||
import { listSsoProvidersContract } from '@/lib/api/contracts/auth'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
@@ -12,11 +13,9 @@ const logger = createLogger('SSOProvidersRoute')
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = ssoProvidersQuerySchema.parse({
|
||||
organizationId: searchParams.get('organizationId') || undefined,
|
||||
})
|
||||
const { organizationId } = query
|
||||
const parsed = await parseRequest(listSsoProvidersContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { organizationId } = parsed.data.query
|
||||
|
||||
let providers
|
||||
if (session?.user?.id) {
|
||||
|
||||
@@ -2,9 +2,9 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { renderHelpConfirmationEmail } from '@/components/emails'
|
||||
import {
|
||||
contactRequestSchema,
|
||||
getContactTopicLabel,
|
||||
mapContactTopicToHelpType,
|
||||
submitContactBodySchema,
|
||||
} from '@/lib/api/contracts/contact'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
@@ -120,7 +120,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
const validationResult = contactRequestSchema.safeParse(body)
|
||||
const validationResult = submitContactBodySchema.safeParse(body)
|
||||
|
||||
if (!validationResult.success) {
|
||||
logger.warn(`[${requestId}] Invalid contact request data`, {
|
||||
|
||||
@@ -10,7 +10,11 @@ import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { credentialSetInviteTokenParamsSchema } from '@/lib/api/contracts/credential-sets'
|
||||
import {
|
||||
acceptCredentialSetInvitationContract,
|
||||
getCredentialSetInvitationContract,
|
||||
} from '@/lib/api/contracts/credential-sets'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { normalizeEmail } from '@/lib/invitations/core'
|
||||
@@ -19,8 +23,10 @@ import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
const logger = createLogger('CredentialSetInviteToken')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, { params }: { params: Promise<{ token: string }> }) => {
|
||||
const { token } = credentialSetInviteTokenParamsSchema.parse(await params)
|
||||
async (req: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const parsed = await parseRequest(getCredentialSetInvitationContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
|
||||
const [invitation] = await db
|
||||
.select({
|
||||
@@ -69,8 +75,10 @@ export const GET = withRouteHandler(
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, { params }: { params: Promise<{ token: string }> }) => {
|
||||
const { token } = credentialSetInviteTokenParamsSchema.parse(await params)
|
||||
async (req: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const parsed = await parseRequest(acceptCredentialSetInvitationContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
invitationActionBodySchema,
|
||||
invitationActionParamsSchema,
|
||||
} from '@/lib/api/contracts/invitations'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { acceptInvitationContract } from '@/lib/api/contracts/invitations'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { acceptInvitation } from '@/lib/invitations/core'
|
||||
@@ -13,35 +10,23 @@ import { acceptInvitation } from '@/lib/invitations/core'
|
||||
const logger = createLogger('InvitationAcceptAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const parsedParams = invitationActionParamsSchema.safeParse(await params)
|
||||
if (!parsedParams.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(parsedParams.error) },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id } = parsedParams.data
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id || !session.user.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const parsed = invitationActionBodySchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(parsed.error, 'Invalid request body') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const parsed = await parseRequest(acceptInvitationContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const result = await acceptInvitation({
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email,
|
||||
invitationId: id,
|
||||
token: parsed.data.token ?? null,
|
||||
token: parsed.data.body.token ?? null,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
invitationActionBodySchema,
|
||||
invitationActionParamsSchema,
|
||||
} from '@/lib/api/contracts/invitations'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { rejectInvitationContract } from '@/lib/api/contracts/invitations'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { rejectInvitation } from '@/lib/invitations/core'
|
||||
@@ -13,35 +10,23 @@ import { rejectInvitation } from '@/lib/invitations/core'
|
||||
const logger = createLogger('InvitationRejectAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const parsedParams = invitationActionParamsSchema.safeParse(await params)
|
||||
if (!parsedParams.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(parsedParams.error) },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id } = parsedParams.data
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id || !session.user.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const parsed = invitationActionBodySchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(parsed.error, 'Invalid request body') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const parsed = await parseRequest(rejectInvitationContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const result = await rejectInvitation({
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email,
|
||||
invitationId: id,
|
||||
token: parsed.data.token ?? null,
|
||||
token: parsed.data.body.token ?? null,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getInvitationContract,
|
||||
invitationParamsSchema,
|
||||
invitationQuerySchema,
|
||||
updateInvitationContract,
|
||||
} from '@/lib/api/contracts/invitations'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
@@ -19,30 +19,25 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
const logger = createLogger('InvitationsAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const parsedParams = invitationParamsSchema.safeParse(await params)
|
||||
if (!parsedParams.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(parsedParams.error) },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id } = parsedParams.data
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getInvitationContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
const { token } = parsed.data.query
|
||||
|
||||
try {
|
||||
const inv = await getInvitationById(id)
|
||||
if (!inv) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { token } = invitationQuerySchema.parse({
|
||||
token: request.nextUrl.searchParams.get('token') || undefined,
|
||||
})
|
||||
const isInvitee = normalizeEmail(session.user.email || '') === normalizeEmail(inv.email)
|
||||
const tokenMatches = !!token && token === inv.token
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({
|
||||
retryWithExponentialBackoff: (fn: any) => fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/utils', () => ({
|
||||
getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('user1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/knowledge/documents/document-processor', () => ({
|
||||
processDocument: vi.fn().mockResolvedValue({
|
||||
chunks: [
|
||||
@@ -211,7 +215,7 @@ describe('Knowledge Utils', () => {
|
||||
kbRows.push({
|
||||
id: 'kb1',
|
||||
userId: 'user1',
|
||||
workspaceId: null,
|
||||
workspaceId: 'workspace1',
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 },
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { mothershipChatAbortEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks'
|
||||
import { validationErrorResponse } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { POST as copilotAbortPost } from '@/app/api/copilot/chat/abort/route'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
|
||||
const body = await request
|
||||
.clone()
|
||||
.json()
|
||||
@@ -14,4 +16,4 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
return copilotAbortPost(request, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import { mothershipChatResourceEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks'
|
||||
import { validationErrorResponse } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
DELETE as copilotResourcesDelete,
|
||||
PATCH as copilotResourcesPatch,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from '@/app/api/copilot/chat/resources/route'
|
||||
|
||||
async function validateResourceRequestEnvelope(request: NextRequest): Promise<NextResponse | null> {
|
||||
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
|
||||
const body = await request
|
||||
.clone()
|
||||
.json()
|
||||
@@ -19,23 +21,23 @@ async function validateResourceRequestEnvelope(request: NextRequest): Promise<Ne
|
||||
return null
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const validationResponse = await validateResourceRequestEnvelope(request)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
return copilotResourcesPost(request, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
export const PATCH = withRouteHandler(async (request: NextRequest) => {
|
||||
const validationResponse = await validateResourceRequestEnvelope(request)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
return copilotResourcesPatch(request, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
export const DELETE = withRouteHandler(async (request: NextRequest) => {
|
||||
const validationResponse = await validateResourceRequestEnvelope(request)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
return copilotResourcesDelete(request, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
mothershipChatGetQuerySchema,
|
||||
mothershipChatPostEnvelopeSchema,
|
||||
} from '@/lib/api/contracts/mothership-tasks'
|
||||
import { validationErrorResponse } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries'
|
||||
@@ -21,6 +22,12 @@ export const GET = withRouteHandler((request: NextRequest) => {
|
||||
})
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
|
||||
const body = await request
|
||||
.clone()
|
||||
.json()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { mothershipChatStopEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks'
|
||||
import { validationErrorResponse } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { POST as copilotStopPost } from '@/app/api/copilot/chat/stop/route'
|
||||
|
||||
// Unified stop route surface.
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
|
||||
const body = await request
|
||||
.clone()
|
||||
.json()
|
||||
@@ -15,4 +16,4 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
return copilotStopPost(request, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,9 +3,11 @@ import { db } from '@sim/db'
|
||||
import { member, organization, subscription as subscriptionTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, or } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { listCreatorOrganizationsContract } from '@/lib/api/contracts/creator-profile'
|
||||
import { createOrganizationBodySchema } from '@/lib/api/contracts/organization'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
|
||||
import {
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
|
||||
const logger = createLogger('OrganizationsAPI')
|
||||
|
||||
export const GET = withRouteHandler(async () => {
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
@@ -34,6 +36,9 @@ export const GET = withRouteHandler(async () => {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(listCreatorOrganizationsContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const userOrganizations = await db
|
||||
.select({
|
||||
id: organization.id,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { templates } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateTemplateOgImageBodySchema } from '@/lib/api/contracts/templates'
|
||||
import { parseJsonBody } from '@/lib/api/server'
|
||||
import { updateTemplateOgImageContract } from '@/lib/api/contracts/templates'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -21,17 +21,21 @@ const logger = createLogger('TemplateOGImageAPI')
|
||||
* Accepts base64-encoded image data in the request body.
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
async (request: NextRequest, context: { 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}`)
|
||||
logger.warn(`[${requestId}] Unauthorized OG image upload attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateTemplateOgImageContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { imageData } = parsed.data.body
|
||||
|
||||
const { authorized, error, status } = await verifyTemplateOwnership(
|
||||
id,
|
||||
session.user.id,
|
||||
@@ -42,22 +46,6 @@ export const PUT = withRouteHandler(
|
||||
return NextResponse.json({ error }, { status: status || 403 })
|
||||
}
|
||||
|
||||
const parsedBody = await parseJsonBody(request)
|
||||
const bodyResult = parsedBody.success
|
||||
? updateTemplateOgImageBodySchema.safeParse(parsedBody.data)
|
||||
: null
|
||||
const body = bodyResult?.success
|
||||
? bodyResult.data
|
||||
: ({ imageData: undefined } as { imageData?: string })
|
||||
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')
|
||||
|
||||
@@ -105,7 +93,7 @@ export const PUT = withRouteHandler(
|
||||
ogImageUrl,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Error uploading OG image for template ${id}:`, error)
|
||||
logger.error(`[${requestId}] Error uploading OG image:`, error)
|
||||
return NextResponse.json({ error: 'Failed to upload OG image' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { useTemplateBodySchema } from '@/lib/api/contracts/templates'
|
||||
import { parseJsonBody } from '@/lib/api/server'
|
||||
import { useTemplateContract } from '@/lib/api/contracts/templates'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -31,28 +31,20 @@ interface TemplateDetails {
|
||||
|
||||
// POST /api/templates/[id]/use - Use a template (increment views and create workflow)
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized use attempt for template: ${id}`)
|
||||
logger.warn(`[${requestId}] Unauthorized template use attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsedBody = await parseJsonBody(request)
|
||||
const bodyResult = parsedBody.success
|
||||
? useTemplateBodySchema.safeParse(parsedBody.data)
|
||||
: null
|
||||
const body = bodyResult?.success
|
||||
? bodyResult.data
|
||||
: ({ workspaceId: undefined, connectToTemplate: false } as {
|
||||
workspaceId?: string
|
||||
connectToTemplate?: boolean
|
||||
})
|
||||
const { workspaceId, connectToTemplate = false } = body
|
||||
const parsed = await parseRequest(useTemplateContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { workspaceId, connectToTemplate = false } = parsed.data.body
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Missing workspaceId in request body`)
|
||||
@@ -236,7 +228,7 @@ export const POST = withRouteHandler(
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error using template: ${id}`, error)
|
||||
logger.error(`[${requestId}] Error using template`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-f
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { enrichTableSchema } from '@/lib/table/llm/wand'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { extractResponseText, parseResponsesUsage } from '@/providers/openai/utils'
|
||||
import { getModelPricing } from '@/providers/utils'
|
||||
@@ -86,7 +87,8 @@ Use this context to calculate relative dates like "yesterday", "last week", "beg
|
||||
}
|
||||
|
||||
async function updateUserStatsForWand(
|
||||
userId: string,
|
||||
billingUserId: string,
|
||||
workspaceId: string | null,
|
||||
usage: {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
@@ -128,7 +130,8 @@ async function updateUserStatsForWand(
|
||||
}
|
||||
|
||||
await recordUsage({
|
||||
userId,
|
||||
userId: billingUserId,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
entries: [
|
||||
{
|
||||
category: 'model',
|
||||
@@ -143,7 +146,7 @@ async function updateUserStatsForWand(
|
||||
},
|
||||
})
|
||||
|
||||
await checkAndBillOverageThreshold(userId)
|
||||
await checkAndBillOverageThreshold(billingUserId)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to update user stats for wand usage`, error)
|
||||
}
|
||||
@@ -223,6 +226,21 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
let billingUserId = session.user.id
|
||||
if (workspaceId) {
|
||||
const workspaceBilledAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId)
|
||||
if (!workspaceBilledAccountUserId) {
|
||||
logger.error(`[${requestId}] Unable to resolve billed account for workspace`, {
|
||||
workspaceId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unable to resolve billing account for this workspace' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
billingUserId = workspaceBilledAccountUserId
|
||||
}
|
||||
|
||||
let isBYOK = false
|
||||
let activeOpenAIKey = openaiApiKey
|
||||
|
||||
@@ -339,7 +357,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
}
|
||||
|
||||
usageRecorded = true
|
||||
await updateUserStatsForWand(session.user.id, finalUsage, requestId, isBYOK)
|
||||
await updateUserStatsForWand(
|
||||
billingUserId,
|
||||
workspaceId,
|
||||
finalUsage,
|
||||
requestId,
|
||||
isBYOK
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -556,7 +580,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
const usage = parseResponsesUsage(completion.usage)
|
||||
if (usage) {
|
||||
await updateUserStatsForWand(
|
||||
session.user.id,
|
||||
billingUserId,
|
||||
workspaceId,
|
||||
{
|
||||
prompt_tokens: usage.promptTokens,
|
||||
completion_tokens: usage.completionTokens,
|
||||
|
||||
@@ -81,6 +81,20 @@ export const GET = withRouteHandler(
|
||||
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
|
||||
// Stamp `workflowId` from the path param on each variable so the
|
||||
// global client-side variables store can filter by workflow without
|
||||
// requiring persisted variables to carry a redundant `workflowId`.
|
||||
// The persisted blob may or may not include `workflowId` depending on
|
||||
// when the variable was last written; the path param is authoritative.
|
||||
const persistedVariables =
|
||||
(workflowData.variables as Record<string, Record<string, unknown>>) || {}
|
||||
const stampedVariables: Record<string, Record<string, unknown>> = {}
|
||||
for (const [variableId, variable] of Object.entries(persistedVariables)) {
|
||||
if (variable && typeof variable === 'object') {
|
||||
stampedVariables[variableId] = { ...variable, workflowId }
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedData) {
|
||||
const finalWorkflowData = {
|
||||
...workflowData,
|
||||
@@ -97,7 +111,7 @@ export const GET = withRouteHandler(
|
||||
description: workflowData.description,
|
||||
},
|
||||
},
|
||||
variables: workflowData.variables || {},
|
||||
variables: stampedVariables,
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
|
||||
@@ -122,7 +136,7 @@ export const GET = withRouteHandler(
|
||||
description: workflowData.description,
|
||||
},
|
||||
},
|
||||
variables: workflowData.variables || {},
|
||||
variables: stampedVariables,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: emptyWorkflowData }, { status: 200 })
|
||||
@@ -333,12 +347,22 @@ export const PUT = withRouteHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// Update the workflow
|
||||
const [updatedWorkflow] = await db
|
||||
.update(workflow)
|
||||
.set(updateData)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.returning()
|
||||
.returning({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
color: workflow.color,
|
||||
workspaceId: workflow.workspaceId,
|
||||
folderId: workflow.folderId,
|
||||
sortOrder: workflow.sortOrder,
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
archivedAt: workflow.archivedAt,
|
||||
})
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, {
|
||||
|
||||
@@ -53,12 +53,25 @@ export const GET = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Stamp `workflowId` from the path param on each variable so the
|
||||
// global client-side variables store can filter by workflow without
|
||||
// requiring clients to thread the path param through. The read
|
||||
// contract requires this server-stamped field.
|
||||
const persistedVariables =
|
||||
(authorization.workflow?.variables as Record<string, Record<string, unknown>>) || {}
|
||||
const variables: Record<string, Record<string, unknown>> = {}
|
||||
for (const [variableId, variable] of Object.entries(persistedVariables)) {
|
||||
if (variable && typeof variable === 'object') {
|
||||
variables[variableId] = { ...variable, workflowId }
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
blocks: normalized.blocks,
|
||||
edges: normalized.edges,
|
||||
loops: normalized.loops || {},
|
||||
parallels: normalized.parallels || {},
|
||||
variables: authorization.workflow?.variables || {},
|
||||
variables,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch workflow state', {
|
||||
@@ -116,25 +129,10 @@ export const PUT = withRouteHandler(
|
||||
)
|
||||
}
|
||||
|
||||
if (state.variables) {
|
||||
const mismatchedVariable = Object.values(state.variables).find(
|
||||
(variable) => variable.workflowId !== workflowId
|
||||
)
|
||||
if (mismatchedVariable) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid workflow state',
|
||||
details: [
|
||||
{
|
||||
path: ['variables', mismatchedVariable.id, 'workflowId'],
|
||||
message: 'Variable workflowId must match the workflow route parameter',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
// Note: prior versions cross-checked that each variable's `workflowId`
|
||||
// equalled the path param. The write contract does not carry `workflowId`
|
||||
// per variable (the path param is the source of truth), so the check
|
||||
// is unreachable and was removed.
|
||||
|
||||
// Sanitize custom tools in agent blocks before saving
|
||||
const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getWorkflowVariablesContract } from '@/lib/api/contracts/workflows'
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
@@ -94,7 +95,17 @@ describe('Workflow Variables API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.data).toEqual(mockWorkflow.variables)
|
||||
expect(data.data).toEqual({
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
workflowId: 'workflow-123',
|
||||
},
|
||||
})
|
||||
const parsed = getWorkflowVariablesContract.response.schema.parse(data)
|
||||
expect(parsed.data['var-1'].workflowId).toBe('workflow-123')
|
||||
})
|
||||
|
||||
it('should allow access when user has workspace permissions', async () => {
|
||||
@@ -126,7 +137,16 @@ describe('Workflow Variables API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.data).toEqual(mockWorkflow.variables)
|
||||
// GET stamps `workflowId` from the path param on each variable.
|
||||
expect(data.data).toEqual({
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
workflowId: 'workflow-123',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should deny access when user has no workspace permissions', async () => {
|
||||
|
||||
@@ -53,23 +53,10 @@ export const POST = withRouteHandler(
|
||||
const parsed = await parseRequest(workflowVariablesContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { variables } = parsed.data.body
|
||||
const mismatchedVariable = Object.values(variables).find(
|
||||
(variable) => variable.workflowId !== workflowId
|
||||
)
|
||||
if (mismatchedVariable) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid request data',
|
||||
details: [
|
||||
{
|
||||
path: ['variables', mismatchedVariable.id, 'workflowId'],
|
||||
message: 'Variable workflowId must match the workflow route parameter',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
// Note: prior versions cross-checked that each variable's `workflowId`
|
||||
// equalled the path param. The write contract does not carry `workflowId`
|
||||
// per variable (the path param is the source of truth), so the check
|
||||
// is unreachable and was removed.
|
||||
|
||||
// Variables are already in Record format - use directly
|
||||
// The frontend is the source of truth for what variables should exist
|
||||
@@ -143,8 +130,17 @@ export const GET = withRouteHandler(
|
||||
)
|
||||
}
|
||||
|
||||
// Return variables if they exist
|
||||
const variables = (workflowData.variables as Record<string, Variable>) || {}
|
||||
// Return variables if they exist. Stamp `workflowId` from the path
|
||||
// param on each entry so the global client-side variables store can
|
||||
// filter by workflow; the read contract requires this stamped field.
|
||||
const persistedVariables =
|
||||
(workflowData.variables as Record<string, Record<string, unknown>>) || {}
|
||||
const variables: Record<string, Variable> = {}
|
||||
for (const [variableId, variable] of Object.entries(persistedVariables)) {
|
||||
if (variable && typeof variable === 'object') {
|
||||
variables[variableId] = { ...variable, workflowId } as Variable
|
||||
}
|
||||
}
|
||||
|
||||
// Add cache headers to prevent frequent reloading
|
||||
const variableHash = JSON.stringify(variables).length
|
||||
|
||||
@@ -13,7 +13,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { deduplicateWorkflowName, listWorkflows } from '@/lib/workflows/utils'
|
||||
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -69,10 +69,39 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
|
||||
let workflows
|
||||
|
||||
/**
|
||||
* Project only the columns declared in `workflowListItemSchema` so the
|
||||
* wire response matches the contract shape exactly. The full row is
|
||||
* larger (`state`, `variables`, `apiKey`, `runCount`, etc.) and would
|
||||
* be dropped client-side by Zod parse anyway — narrowing here saves
|
||||
* bytes over the wire. Keep this list aligned with the contract.
|
||||
*/
|
||||
const listColumns = {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
color: workflow.color,
|
||||
workspaceId: workflow.workspaceId,
|
||||
folderId: workflow.folderId,
|
||||
sortOrder: workflow.sortOrder,
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
archivedAt: workflow.archivedAt,
|
||||
} as const
|
||||
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
|
||||
|
||||
if (workspaceId) {
|
||||
workflows = await listWorkflows(workspaceId, { scope })
|
||||
workflows = await db
|
||||
.select(listColumns)
|
||||
.from(workflow)
|
||||
.where(
|
||||
scope === 'all'
|
||||
? eq(workflow.workspaceId, workspaceId)
|
||||
: scope === 'archived'
|
||||
? and(eq(workflow.workspaceId, workspaceId), sql`${workflow.archivedAt} IS NOT NULL`)
|
||||
: and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))
|
||||
)
|
||||
.orderBy(...orderByClause)
|
||||
} else {
|
||||
const workspacePermissionRows = await db
|
||||
.select({ workspaceId: permissions.entityId })
|
||||
@@ -83,7 +112,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json({ data: [] }, { status: 200 })
|
||||
}
|
||||
workflows = await db
|
||||
.select()
|
||||
.select(listColumns)
|
||||
.from(workflow)
|
||||
.where(
|
||||
scope === 'all'
|
||||
|
||||
@@ -5,7 +5,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createWorkspaceContract, listWorkspacesQuerySchema } from '@/lib/api/contracts'
|
||||
import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
|
||||
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
|
||||
@@ -275,6 +275,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
files: payload.files ? `${payload.files.length} files` : undefined,
|
||||
})
|
||||
|
||||
// boundary-raw-fetch: deployed chat endpoint returns an SSE stream consumed by handleStreamedResponse via response.body.getReader()
|
||||
const response = await fetch(`/api/chat/${identifier}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { Mic, MicOff, Phone } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { speechTokenContract } from '@/lib/api/contracts/media-tools'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
|
||||
import {
|
||||
@@ -152,22 +154,21 @@ export function VoiceInterface({
|
||||
|
||||
const connectWebSocket = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const body: Record<string, string> = {}
|
||||
if (chatId) body.chatId = chatId
|
||||
|
||||
const tokenResponse = await fetch('/api/speech/token', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
logger.error('Failed to get STT token', { status: tokenResponse.status })
|
||||
let tokenData: Awaited<ReturnType<typeof requestJson<typeof speechTokenContract>>>
|
||||
try {
|
||||
tokenData = await requestJson(speechTokenContract, {
|
||||
body: chatId ? { chatId } : {},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to get STT token', err)
|
||||
return false
|
||||
}
|
||||
|
||||
const { token } = await tokenResponse.json()
|
||||
const token = typeof tokenData.token === 'string' ? tokenData.token : undefined
|
||||
if (!token) {
|
||||
logger.error('STT token missing from response')
|
||||
return false
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
|
||||
@@ -92,6 +92,7 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume()
|
||||
}
|
||||
// boundary-raw-fetch: TTS proxy returns raw audio bytes consumed via response.arrayBuffer() and decoded by AudioContext.decodeAudioData
|
||||
const response = await fetch('/api/proxy/tts/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -5,21 +5,28 @@ import { createLogger } from '@sim/logger'
|
||||
import { Mail } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { GmailIcon, OutlookIcon } from '@/components/icons'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
acceptCredentialSetInvitationContract,
|
||||
type CredentialSetInvitePreview,
|
||||
getCredentialSetInvitationContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import { listOAuthConnectionsContract } from '@/lib/api/contracts/oauth-connections'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { getProviderDisplayName, isPollingProvider } from '@/lib/credential-sets/providers'
|
||||
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
|
||||
|
||||
const logger = createLogger('CredentialAccount')
|
||||
|
||||
interface InvitationInfo {
|
||||
credentialSetName: string
|
||||
organizationName: string
|
||||
providerId: string | null
|
||||
email: string | null
|
||||
}
|
||||
|
||||
type AcceptedState = 'connecting' | 'already-connected'
|
||||
|
||||
function getErrorMessageFromBody(body: unknown): string | undefined {
|
||||
if (!body || typeof body !== 'object') return undefined
|
||||
const error = (body as { error?: unknown }).error
|
||||
return typeof error === 'string' ? error : undefined
|
||||
}
|
||||
|
||||
export default function CredentialAccountInvitePage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
@@ -27,7 +34,7 @@ export default function CredentialAccountInvitePage() {
|
||||
|
||||
const { data: session, isPending: sessionLoading } = useSession()
|
||||
|
||||
const [invitation, setInvitation] = useState<InvitationInfo | null>(null)
|
||||
const [invitation, setInvitation] = useState<CredentialSetInvitePreview | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
@@ -36,16 +43,16 @@ export default function CredentialAccountInvitePage() {
|
||||
useEffect(() => {
|
||||
async function fetchInvitation() {
|
||||
try {
|
||||
const res = await fetch(`/api/credential-sets/invite/${token}`)
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Failed to load invitation')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
const data = await requestJson(getCredentialSetInvitationContract, {
|
||||
params: { token },
|
||||
})
|
||||
setInvitation(data.invitation)
|
||||
} catch {
|
||||
setError('Failed to load invitation')
|
||||
} catch (fetchError) {
|
||||
if (fetchError instanceof ApiClientError) {
|
||||
setError(getErrorMessageFromBody(fetchError.body) || fetchError.message)
|
||||
} else {
|
||||
setError('Failed to load invitation')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -64,34 +71,33 @@ export default function CredentialAccountInvitePage() {
|
||||
|
||||
setAccepting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/credential-sets/invite/${token}`, {
|
||||
method: 'POST',
|
||||
const acceptResponse = await requestJson(acceptCredentialSetInvitationContract, {
|
||||
params: { token },
|
||||
}).catch((acceptError: unknown) => {
|
||||
const fallback = 'Failed to accept invitation'
|
||||
if (acceptError instanceof ApiClientError) {
|
||||
setError(getErrorMessageFromBody(acceptError.body) || acceptError.message || fallback)
|
||||
} else {
|
||||
setError(fallback)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Failed to accept invitation')
|
||||
if (!acceptResponse) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const credentialSetProviderId = data.providerId || invitation?.providerId
|
||||
const credentialSetProviderId = acceptResponse.providerId || invitation?.providerId
|
||||
|
||||
// Check if user already has this provider connected
|
||||
let isAlreadyConnected = false
|
||||
if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) {
|
||||
try {
|
||||
const connectionsRes = await fetch('/api/auth/oauth/connections')
|
||||
if (connectionsRes.ok) {
|
||||
const connectionsData = await connectionsRes.json()
|
||||
const connections = connectionsData.connections || []
|
||||
isAlreadyConnected = connections.some(
|
||||
(conn: { provider: string; accounts?: { id: string }[] }) =>
|
||||
conn.provider === credentialSetProviderId &&
|
||||
conn.accounts &&
|
||||
conn.accounts.length > 0
|
||||
)
|
||||
}
|
||||
const connectionsData = await requestJson(listOAuthConnectionsContract, {})
|
||||
isAlreadyConnected = (connectionsData.connections ?? []).some(
|
||||
(conn) =>
|
||||
conn.provider === credentialSetProviderId && conn.accounts && conn.accounts.length > 0
|
||||
)
|
||||
} catch {
|
||||
// If we can't check connections, proceed with OAuth flow
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export default function Form({ identifier }: { identifier: string }) {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
// boundary-raw-fetch: GET /api/form/[identifier] is a polymorphic form-discovery endpoint that returns either a form config OR a 401 envelope carrying `error: 'auth_required_password' | 'auth_required_email'` plus partial title/customizations for the auth gate; modelling this as a contract requires a discriminated response schema and a custom error path that surfaces 401-as-data without throwing
|
||||
const response = await fetch(`/api/form/${identifier}`, { signal })
|
||||
if (signal?.aborted) return
|
||||
|
||||
@@ -165,6 +166,7 @@ export default function Form({ identifier }: { identifier: string }) {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
// boundary-raw-fetch: POST /api/form/[identifier] is the public form submission endpoint; the same route also accepts `{ password }` or `{ email }` auth gate bodies (handled by fetchFormConfig/handlePasswordAuth) and runs workflow execution with CORS headers/streaming envelopes that don't fit the current `requestJson` contract surface
|
||||
const response = await fetch(`/api/form/${identifier}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -201,6 +203,7 @@ export default function Form({ identifier }: { identifier: string }) {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
// boundary-raw-fetch: POST /api/form/[identifier] doubles as the password auth gate; same polymorphic route as the form submission above (separate `{ password }` body branch on the server)
|
||||
const response = await fetch(`/api/form/${identifier}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -4,6 +4,13 @@ import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
acceptInvitationContract,
|
||||
getInvitationContract,
|
||||
type InvitationDetails,
|
||||
} from '@/lib/api/contracts/invitations'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
@@ -37,26 +44,6 @@ interface InviteError {
|
||||
canRetry?: boolean
|
||||
}
|
||||
|
||||
type InvitationKind = 'organization' | 'workspace'
|
||||
|
||||
interface InvitationDetails {
|
||||
id: string
|
||||
kind: InvitationKind
|
||||
email: string
|
||||
organizationId: string | null
|
||||
organizationName: string | null
|
||||
role: string
|
||||
status: string
|
||||
expiresAt: string
|
||||
inviterName: string | null
|
||||
inviterEmail: string | null
|
||||
grants: Array<{
|
||||
workspaceId: string
|
||||
workspaceName: string | null
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}>
|
||||
}
|
||||
|
||||
function getInviteError(code: string): InviteError {
|
||||
const errorMap: Record<string, InviteError> = {
|
||||
'missing-token': {
|
||||
@@ -156,6 +143,15 @@ function codeFromStatus(status: number): InviteErrorCode {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function codeFromApiClientError(error: ApiClientError): string {
|
||||
if (error.body && typeof error.body === 'object') {
|
||||
const code = (error.body as { error?: unknown }).error
|
||||
if (typeof code === 'string' && code.length > 0) return code
|
||||
}
|
||||
|
||||
return codeFromStatus(error.status)
|
||||
}
|
||||
|
||||
export default function Invite() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
@@ -200,23 +196,19 @@ export default function Invite() {
|
||||
async function fetchInvitation() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''
|
||||
const response = await fetch(`/api/invitations/${inviteId}${tokenParam}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
const code = data.error || codeFromStatus(response.status)
|
||||
setError(getInviteError(code))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setInvitation(data.invitation as InvitationDetails)
|
||||
const data = await requestJson(getInvitationContract, {
|
||||
params: { id: inviteId },
|
||||
query: { token: token ?? undefined },
|
||||
})
|
||||
setInvitation(data.invitation)
|
||||
setError(null)
|
||||
} catch (fetchError) {
|
||||
logger.error('Error fetching invitation:', fetchError)
|
||||
setError(getInviteError('network-error'))
|
||||
const code =
|
||||
fetchError instanceof ApiClientError
|
||||
? codeFromApiClientError(fetchError)
|
||||
: 'network-error'
|
||||
setError(getInviteError(code))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -230,23 +222,11 @@ export default function Invite() {
|
||||
setIsAccepting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invitations/${inviteId}/accept`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ token: token ?? undefined }),
|
||||
const data = await requestJson(acceptInvitationContract, {
|
||||
params: { id: inviteId },
|
||||
body: { token: token ?? undefined },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
const code = data.error || codeFromStatus(response.status)
|
||||
setError(getInviteError(code))
|
||||
setIsAccepting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (invitation.organizationId) {
|
||||
try {
|
||||
await client.organization.setActive({ organizationId: invitation.organizationId })
|
||||
@@ -263,11 +243,14 @@ export default function Invite() {
|
||||
setAccepted(true)
|
||||
setIsAccepting(false)
|
||||
|
||||
const redirectPath = typeof data.redirectPath === 'string' ? data.redirectPath : '/workspace'
|
||||
setTimeout(() => router.push(redirectPath), 1200)
|
||||
setTimeout(() => router.push(data.redirectPath), 1200)
|
||||
} catch (acceptError) {
|
||||
logger.error('Error accepting invitation:', acceptError)
|
||||
setError(getInviteError('network-error'))
|
||||
const code =
|
||||
acceptError instanceof ApiClientError
|
||||
? codeFromApiClientError(acceptError)
|
||||
: 'network-error'
|
||||
setError(getInviteError(code))
|
||||
setIsAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { resumeWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
import type { ResumeStatus } from '@/executor/types'
|
||||
@@ -533,6 +535,7 @@ export default function ResumeExecutionPage({
|
||||
const loadDetail = async () => {
|
||||
setLoadingDetail(true)
|
||||
try {
|
||||
// boundary-raw-fetch: GET /api/resume/[workflowId]/[executionId]/[contextId] has no contract (server route only models POST resume submission)
|
||||
const response = await fetch(
|
||||
`/api/resume/${workflowId}/${executionId}/${selectedContextId}`,
|
||||
{
|
||||
@@ -615,13 +618,11 @@ export default function ResumeExecutionPage({
|
||||
const refreshExecutionDetail = useCallback(async () => {
|
||||
setRefreshingExecution(true)
|
||||
try {
|
||||
const response = await fetch(`/api/resume/${workflowId}/${executionId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
const raw = await requestJson(resumeWorkflowExecutionContract, {
|
||||
params: { workflowId, executionId },
|
||||
})
|
||||
if (!response.ok) return
|
||||
const data: PausedExecutionDetail = await response.json()
|
||||
// double-cast-allowed: contract pause-point shape is z.record(z.string(), z.unknown()) but the page works against the more specific local PausedExecutionDetail / PausePointWithQueue interfaces
|
||||
const data = raw as unknown as PausedExecutionDetail
|
||||
setExecutionDetail(data)
|
||||
if (!selectedContextId) {
|
||||
const first =
|
||||
@@ -640,6 +641,7 @@ export default function ResumeExecutionPage({
|
||||
async (contextId: string, showLoader = true) => {
|
||||
try {
|
||||
if (showLoader) setLoadingDetail(true)
|
||||
// boundary-raw-fetch: GET /api/resume/[workflowId]/[executionId]/[contextId] has no contract (server route only models POST resume submission)
|
||||
const response = await fetch(`/api/resume/${workflowId}/${executionId}/${contextId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
@@ -750,6 +752,7 @@ export default function ResumeExecutionPage({
|
||||
resumePayload = parsedInput
|
||||
}
|
||||
try {
|
||||
// boundary-raw-fetch: resume-context POST contract has no body schema (route uses tolerant raw JSON parse for resume input forwarded to PauseResumeManager)
|
||||
const response = await fetch(
|
||||
`/api/resume/${workflowId}/${executionId}/${selectedContextId}`,
|
||||
{
|
||||
|
||||
@@ -31,6 +31,13 @@ import {
|
||||
Skeleton,
|
||||
} from '@/components/emcn'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
listCreatorOrganizationsContract,
|
||||
updateCreatorProfileContract,
|
||||
} from '@/lib/api/contracts/creator-profile'
|
||||
import { updateTemplateContract, useTemplateContract } from '@/lib/api/contracts/templates'
|
||||
import { listWorkspacesContract } from '@/lib/api/contracts/workspaces'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -170,18 +177,15 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
if (!currentUserId) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/organizations')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const orgs = data.organizations || []
|
||||
const orgIds = orgs.map((org: any) => org.id)
|
||||
const orgRoles = orgs.map((org: any) => ({
|
||||
const data = await requestJson(listCreatorOrganizationsContract, {})
|
||||
const orgs = data.organizations
|
||||
setCurrentUserOrgs(orgs.map((org) => org.id))
|
||||
setCurrentUserOrgRoles(
|
||||
orgs.map((org) => ({
|
||||
organizationId: org.id,
|
||||
role: org.role,
|
||||
}))
|
||||
setCurrentUserOrgs(orgIds)
|
||||
setCurrentUserOrgRoles(orgRoles)
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organizations:', error)
|
||||
}
|
||||
@@ -196,18 +200,13 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
const fetchWorkspaces = async () => {
|
||||
try {
|
||||
setIsLoadingWorkspaces(true)
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const availableWorkspaces = data.workspaces
|
||||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws: any) => ({
|
||||
id: ws.id,
|
||||
name: ws.name,
|
||||
permissions: ws.permissions,
|
||||
}))
|
||||
setWorkspaces(availableWorkspaces)
|
||||
}
|
||||
const data = await requestJson(listWorkspacesContract, { query: { scope: 'active' } })
|
||||
const availableWorkspaces = data.workspaces.flatMap((ws) =>
|
||||
ws.permissions === 'write' || ws.permissions === 'admin'
|
||||
? [{ id: ws.id, name: ws.name, permissions: ws.permissions }]
|
||||
: []
|
||||
)
|
||||
setWorkspaces(availableWorkspaces)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching workspaces:', error)
|
||||
} finally {
|
||||
@@ -262,6 +261,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
}
|
||||
|
||||
try {
|
||||
// boundary-raw-fetch: workflow access probe needs HTTP status discrimination (200 vs 403 vs other) without parsing the body
|
||||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||||
if (checkResponse.status === 403) {
|
||||
setHasWorkspaceAccess(false)
|
||||
@@ -391,6 +391,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
if (isWorkspaceContext && workspaceId && template.workflowId) {
|
||||
setIsEditing(true)
|
||||
try {
|
||||
// boundary-raw-fetch: workflow access probe needs HTTP status check (200 vs not-ok) without parsing the body
|
||||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||||
|
||||
if (checkResponse.ok) {
|
||||
@@ -407,6 +408,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
if (template.workflowId && !isWorkspaceContext) {
|
||||
setIsEditing(true)
|
||||
try {
|
||||
// boundary-raw-fetch: workflow probe reads passthrough data.workspaceId (not in getWorkflowStateContract typed response) and discriminates 403 vs 200
|
||||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||||
|
||||
if (checkResponse.status === 403) {
|
||||
@@ -441,18 +443,11 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
setIsUsing(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${template.id}/use`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
const { workflowId } = await requestJson(useTemplateContract, {
|
||||
params: { id: template.id },
|
||||
body: { workspaceId },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to use template')
|
||||
}
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
@@ -467,18 +462,11 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
setIsUsing(true)
|
||||
setShowWorkspaceSelectorForEdit(false)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${template.id}/use`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId, connectToTemplate: true }),
|
||||
const { workflowId } = await requestJson(useTemplateContract, {
|
||||
params: { id: template.id },
|
||||
body: { workspaceId, connectToTemplate: true },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to import template for editing')
|
||||
}
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
|
||||
} catch (error) {
|
||||
logger.error('Error importing template for editing:', error)
|
||||
@@ -492,18 +480,14 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
setIsApproving(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${template.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'approved' }),
|
||||
await requestJson(updateTemplateContract, {
|
||||
params: { id: template.id },
|
||||
body: { status: 'approved' },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
} else {
|
||||
router.push('/templates')
|
||||
}
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
} else {
|
||||
router.push('/templates')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error approving template:', error)
|
||||
@@ -517,18 +501,14 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
setIsRejecting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${template.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'rejected' }),
|
||||
await requestJson(updateTemplateContract, {
|
||||
params: { id: template.id },
|
||||
body: { status: 'rejected' },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
} else {
|
||||
router.push('/templates')
|
||||
}
|
||||
if (isWorkspaceContext && workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
} else {
|
||||
router.push('/templates')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error rejecting template:', error)
|
||||
@@ -542,23 +522,14 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
|
||||
setIsVerifying(true)
|
||||
try {
|
||||
const response = await fetch(`/api/creators/${template.creator.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ verified: !template.creator.verified }),
|
||||
await requestJson(updateCreatorProfileContract, {
|
||||
params: { id: template.creator.id },
|
||||
body: { verified: !template.creator.verified },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh page to show updated verification status
|
||||
window.location.reload()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
logger.error('Error toggling verification:', error)
|
||||
alert(`Failed to ${template.creator.verified ? 'unverify' : 'verify'} creator`)
|
||||
}
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
logger.error('Error toggling verification:', error)
|
||||
alert('An error occurred while toggling verification')
|
||||
alert(`Failed to ${template.creator.verified ? 'unverify' : 'verify'} creator`)
|
||||
} finally {
|
||||
setIsVerifying(false)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Layout, Search } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listWorkspacesContract } from '@/lib/api/contracts/workspaces'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
|
||||
import type { CreatorProfileDetails } from '@/app/_types/creator-profile'
|
||||
import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card'
|
||||
@@ -66,13 +68,10 @@ export default function Templates({
|
||||
if (currentUserId) {
|
||||
const redirectToWorkspace = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const defaultWorkspace = data.workspaces?.[0]
|
||||
if (defaultWorkspace) {
|
||||
router.push(`/workspace/${defaultWorkspace.id}/templates`)
|
||||
}
|
||||
const data = await requestJson(listWorkspacesContract, { query: { scope: 'active' } })
|
||||
const defaultWorkspace = data.workspaces[0]
|
||||
if (defaultWorkspace) {
|
||||
router.push(`/workspace/${defaultWorkspace.id}/templates`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error redirecting to workspace:', error)
|
||||
|
||||
@@ -3,22 +3,13 @@
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Loader } from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractJsonResponse } from '@/lib/api/contracts'
|
||||
import { unsubscribeGetContract, unsubscribePostContract } from '@/lib/api/contracts/user'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { InviteLayout } from '@/app/invite/components'
|
||||
|
||||
interface UnsubscribeData {
|
||||
success: boolean
|
||||
email: string
|
||||
token: string
|
||||
emailType: string
|
||||
isTransactional: boolean
|
||||
currentPreferences: {
|
||||
unsubscribeAll?: boolean
|
||||
unsubscribeMarketing?: boolean
|
||||
unsubscribeUpdates?: boolean
|
||||
unsubscribeNotifications?: boolean
|
||||
}
|
||||
}
|
||||
type UnsubscribeData = ContractJsonResponse<typeof unsubscribeGetContract>
|
||||
|
||||
function UnsubscribeContent() {
|
||||
const searchParams = useSearchParams()
|
||||
@@ -38,19 +29,13 @@ function UnsubscribeContent() {
|
||||
return
|
||||
}
|
||||
|
||||
fetch(
|
||||
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
setData(data)
|
||||
} else {
|
||||
setError(data.error || 'Invalid unsubscribe link')
|
||||
}
|
||||
requestJson(unsubscribeGetContract, { query: { email, token } })
|
||||
.then((response) => {
|
||||
setData(response)
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to validate unsubscribe link')
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : 'Failed to validate unsubscribe link'
|
||||
setError(message)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
@@ -63,53 +48,40 @@ function UnsubscribeContent() {
|
||||
setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/me/settings/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
token,
|
||||
type,
|
||||
}),
|
||||
await requestJson(unsubscribePostContract, {
|
||||
body: { email, token, type },
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setUnsubscribed(true)
|
||||
if (data) {
|
||||
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
|
||||
if (validTypes.includes(type)) {
|
||||
if (type === 'all') {
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
unsubscribeAll: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
|
||||
| 'unsubscribeMarketing'
|
||||
| 'unsubscribeUpdates'
|
||||
| 'unsubscribeNotifications'
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
[propertyKey]: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
setUnsubscribed(true)
|
||||
if (data) {
|
||||
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
|
||||
if (validTypes.includes(type)) {
|
||||
if (type === 'all') {
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
unsubscribeAll: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
|
||||
| 'unsubscribeMarketing'
|
||||
| 'unsubscribeUpdates'
|
||||
| 'unsubscribeNotifications'
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
[propertyKey]: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to unsubscribe')
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to process unsubscribe request')
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to process unsubscribe request'
|
||||
setError(message)
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ export const DocxPreview = memo(function DocxPreview({
|
||||
try {
|
||||
setRendering(true)
|
||||
|
||||
// boundary-raw-fetch: route returns binary DOCX (read via response.arrayBuffer()), not JSON
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/docx/preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -175,6 +175,7 @@ const IframePreview = memo(function IframePreview({
|
||||
try {
|
||||
setRendering(true)
|
||||
|
||||
// boundary-raw-fetch: route returns binary PDF (read via response.arrayBuffer()), not JSON
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/pdf/preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -157,6 +157,7 @@ export const PptxPreview = memo(function PptxPreview({
|
||||
try {
|
||||
setRendering(true)
|
||||
|
||||
// boundary-raw-fetch: route returns binary PPTX (read via response.arrayBuffer()), not JSON
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/pptx/preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { PanelLeft } from '@/components/emcn/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { createWorkflowContract } from '@/lib/api/contracts'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
LandingPromptStorage,
|
||||
@@ -54,24 +56,15 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
descriptionOverride: seed.workflowDescription || 'Imported from landing template',
|
||||
colorOverride: seed.color,
|
||||
createWorkflow: async ({ name, description, color, workspaceId }) => {
|
||||
const response = await fetch('/api/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
return requestJson(createWorkflowContract, {
|
||||
body: {
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
workspaceId,
|
||||
deduplicate: true,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to create workflow')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1405,6 +1405,7 @@ export function useChat(
|
||||
|
||||
const persistChatId = chatIdRef.current ?? selectedChatIdRef.current
|
||||
if (persistChatId) {
|
||||
// boundary-raw-fetch: fire-and-forget side-effect during stream lifecycle; intentionally avoids requestJson's response parsing/throw semantics so a failure here cannot interrupt the active turn
|
||||
fetch('/api/mothership/chat/resources', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1595,6 +1596,7 @@ export function useChat(
|
||||
void recoverPendingClientWorkflowTools(mappedMessages)
|
||||
|
||||
if (chatHistory.resources.some((r) => r.id === 'streaming-file')) {
|
||||
// boundary-raw-fetch: fire-and-forget cleanup during chat-history hydration; failures are silently swallowed to keep hydration non-blocking
|
||||
fetch('/api/mothership/chat/resources', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2730,6 +2732,7 @@ export function useChat(
|
||||
afterCursor: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StreamBatchResponse> => {
|
||||
// boundary-raw-fetch: stream-resume batch endpoint requires dynamic per-request traceparent header propagation that the contract layer does not model, and the response is consumed alongside live SSE tail fetches
|
||||
const response = await fetch(
|
||||
`/api/mothership/chat/stream?streamId=${encodeURIComponent(streamId)}&after=${encodeURIComponent(afterCursor)}&batch=true`,
|
||||
{
|
||||
@@ -2811,6 +2814,7 @@ export function useChat(
|
||||
|
||||
logger.info('Opening live stream tail', { streamId, afterCursor: latestCursor })
|
||||
|
||||
// boundary-raw-fetch: live SSE tail endpoint streams events consumed via response.body.getReader() and processSSEStream
|
||||
const sseRes = await fetch(
|
||||
`/api/mothership/chat/stream?streamId=${encodeURIComponent(streamId)}&after=${encodeURIComponent(latestCursor)}`,
|
||||
{
|
||||
@@ -3618,6 +3622,7 @@ export function useChat(
|
||||
const executionId = execState.getCurrentExecutionId(workflowId)
|
||||
if (executionId) {
|
||||
execState.setCurrentExecutionId(workflowId, null)
|
||||
// boundary-raw-fetch: fire-and-forget execution cancellation invoked from a stop-generation barrier; failures are silently swallowed so the stop teardown cannot stall on a contract-validation throw
|
||||
fetch(`/api/workflows/${workflowId}/executions/${executionId}/cancel`, {
|
||||
method: 'POST',
|
||||
}).catch(() => {})
|
||||
@@ -3751,6 +3756,7 @@ export function useChat(
|
||||
const resolvedChatId = chatIdRef.current
|
||||
const abortPromise = sid
|
||||
? (async () => {
|
||||
// boundary-raw-fetch: stream-abort endpoint requires propagating the snapshotted traceparent header from the in-flight stream and has no contract authored yet
|
||||
const res = await fetch('/api/mothership/chat/abort', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
+10
-7
@@ -2,6 +2,9 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Label, Switch } from '@/components/emcn'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getKnowledgeChunkContract } from '@/lib/api/contracts/knowledge'
|
||||
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
|
||||
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
|
||||
import { useCreateChunk, useUpdateChunk } from '@/hooks/queries/kb/knowledge'
|
||||
@@ -64,16 +67,15 @@ export function ChunkEditor({
|
||||
useEffect(() => {
|
||||
if (isCreateMode || !chunk?.id) return
|
||||
const controller = new AbortController()
|
||||
const chunkId = chunk.id
|
||||
const handleVisibility = async () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentData.id}/chunks/${chunk.id}`,
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const serverContent: string = json.data?.content ?? ''
|
||||
const json = await requestJson(getKnowledgeChunkContract, {
|
||||
params: { id: knowledgeBaseId, documentId: documentData.id, chunkId },
|
||||
signal: controller.signal,
|
||||
})
|
||||
const serverContent = json.data.content ?? ''
|
||||
if (serverContent === savedContentRef.current) return
|
||||
const isClean = editedContentRef.current === savedContentRef.current
|
||||
savedContentRef.current = serverContent
|
||||
@@ -83,6 +85,7 @@ export function ChunkEditor({
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') return
|
||||
if (isApiClientError(err)) return
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibility)
|
||||
|
||||
+5
-12
@@ -15,6 +15,8 @@ import {
|
||||
ModalHeader,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getTagUsageContract, type TagUsageData } from '@/lib/api/contracts/knowledge'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
@@ -33,13 +35,6 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
boolean: 'Boolean',
|
||||
}
|
||||
|
||||
interface TagUsageData {
|
||||
tagName: string
|
||||
tagSlot: string
|
||||
documentCount: number
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
}
|
||||
|
||||
interface DocumentListProps {
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
totalCount: number
|
||||
@@ -107,11 +102,9 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
if (!knowledgeBaseId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tag usage')
|
||||
}
|
||||
const result = await response.json()
|
||||
const result = await requestJson(getTagUsageContract, {
|
||||
params: { id: knowledgeBaseId },
|
||||
})
|
||||
if (result.success) {
|
||||
setTagUsageData(result.data)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { createKnowledgeDocumentsContract } from '@/lib/api/contracts/knowledge/documents'
|
||||
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
|
||||
|
||||
@@ -59,7 +62,7 @@ class KnowledgeUploadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: any
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'KnowledgeUploadError'
|
||||
@@ -67,49 +70,75 @@ class KnowledgeUploadError extends Error {
|
||||
}
|
||||
|
||||
class PresignedUrlError extends KnowledgeUploadError {
|
||||
constructor(message: string, details?: any) {
|
||||
constructor(message: string, details?: unknown) {
|
||||
super(message, 'PRESIGNED_URL_ERROR', details)
|
||||
}
|
||||
}
|
||||
|
||||
class DirectUploadError extends KnowledgeUploadError {
|
||||
constructor(message: string, details?: any) {
|
||||
constructor(message: string, details?: unknown) {
|
||||
super(message, 'DIRECT_UPLOAD_ERROR', details)
|
||||
}
|
||||
}
|
||||
|
||||
class ProcessingError extends KnowledgeUploadError {
|
||||
constructor(message: string, details?: any) {
|
||||
constructor(message: string, details?: unknown) {
|
||||
super(message, 'PROCESSING_ERROR', details)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a failed `Response`'s JSON body and produces a user-facing error
|
||||
* string that combines the top-level `error`/`message` with any Zod
|
||||
* `details[].message` entries. Falls back to `statusText` then status code
|
||||
* when the body is unreadable.
|
||||
* Loosely-typed shape of a failed `Response` JSON body returned by routes
|
||||
* in this codebase. Routes generally surface `{ error?, message?, details? }`
|
||||
* where `details` is a Zod issue array (`{ message: string }[]`). Treated
|
||||
* as a structural read-only view; missing/undefined fields are tolerated.
|
||||
*/
|
||||
async function readResponseError(
|
||||
interface ApiErrorBodyShape {
|
||||
error?: unknown
|
||||
message?: unknown
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a failed `Response`'s JSON body into a typed shape and returns the
|
||||
* parsed body plus a human-readable error string that combines the
|
||||
* top-level `error`/`message` with any Zod `details[].message` entries.
|
||||
* Falls back to `statusText` then status code when the body is unreadable.
|
||||
*/
|
||||
async function readApiResponseError(
|
||||
response: Response,
|
||||
fallback = 'Unknown error'
|
||||
): Promise<{ message: string; body: any }> {
|
||||
let body: any = null
|
||||
): Promise<{ message: string; body: ApiErrorBodyShape | null }> {
|
||||
let body: ApiErrorBodyShape | null = null
|
||||
try {
|
||||
body = await response.json()
|
||||
const parsed: unknown = await response.json()
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
body = parsed as ApiErrorBodyShape
|
||||
}
|
||||
} catch {
|
||||
body = null
|
||||
}
|
||||
const base =
|
||||
body?.error || body?.message || response.statusText || `HTTP ${response.status}` || fallback
|
||||
|
||||
const baseError =
|
||||
(typeof body?.error === 'string' && body.error) ||
|
||||
(typeof body?.message === 'string' && body.message) ||
|
||||
response.statusText ||
|
||||
`HTTP ${response.status}` ||
|
||||
fallback
|
||||
|
||||
const detailMessages = Array.isArray(body?.details)
|
||||
? body.details
|
||||
.map((d: any) => (typeof d?.message === 'string' ? d.message : null))
|
||||
.filter((m: string | null): m is string => Boolean(m))
|
||||
.map((d) =>
|
||||
d && typeof d === 'object' && 'message' in d && typeof d.message === 'string'
|
||||
? d.message
|
||||
: null
|
||||
)
|
||||
.filter((m): m is string => Boolean(m))
|
||||
.join(', ')
|
||||
: ''
|
||||
|
||||
return {
|
||||
message: detailMessages ? `${base}: ${detailMessages}` : base,
|
||||
message: detailMessages ? `${baseError}: ${detailMessages}` : baseError,
|
||||
body,
|
||||
}
|
||||
}
|
||||
@@ -309,6 +338,7 @@ const getPresignedData = async (
|
||||
const startTime = getHighResTime()
|
||||
|
||||
try {
|
||||
// boundary-raw-fetch: presigned URL coordination is part of the multipart-signed-url upload flow tracked together with XHR PUT progress; keeping this on raw fetch preserves a single retry/timeout AbortController shared with the direct upload XHR
|
||||
const presignedResponse = await fetch('/api/files/presigned?type=knowledge-base', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -323,12 +353,18 @@ const getPresignedData = async (
|
||||
})
|
||||
|
||||
if (!presignedResponse.ok) {
|
||||
const { message, body } = await readResponseError(presignedResponse)
|
||||
const { message: fullError, body: errorDetails } =
|
||||
await readApiResponseError(presignedResponse)
|
||||
|
||||
logger.error('Presigned URL request failed', {
|
||||
status: presignedResponse.status,
|
||||
fileSize: file.size,
|
||||
})
|
||||
throw new PresignedUrlError(`Failed to get presigned URL for ${file.name}: ${message}`, body)
|
||||
|
||||
throw new PresignedUrlError(
|
||||
`Failed to get presigned URL for ${file.name}: ${fullError}`,
|
||||
errorDetails
|
||||
)
|
||||
}
|
||||
|
||||
const presignedData = await presignedResponse.json()
|
||||
@@ -628,6 +664,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
throw new Error('workspaceId is required for multipart upload')
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: multipart-signed-url initiate step coordinates the cloud multipart upload token consumed by raw-fetch PUTs to provider-issued part URLs below
|
||||
const initiateResponse = await fetch('/api/files/multipart?action=initiate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -650,6 +687,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
const numParts = Math.ceil(file.size / chunkSize)
|
||||
const partNumbers = Array.from({ length: numParts }, (_, i) => i + 1)
|
||||
|
||||
// boundary-raw-fetch: multipart-signed-url get-part-urls step issues provider-signed PUT URLs consumed by the raw-fetch PUT loop below; kept on raw fetch to preserve retry/abort coordination with the part PUTs
|
||||
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -660,6 +698,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
})
|
||||
|
||||
if (!partUrlsResponse.ok) {
|
||||
// boundary-raw-fetch: multipart-signed-url abort step issued from inside the multipart upload error path; keeps cleanup on the same raw-fetch path as the rest of the multipart flow
|
||||
await fetch('/api/files/multipart?action=abort', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -743,6 +782,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
clearTimeout(multipartTimeoutId)
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: multipart-signed-url complete step finalizes the multipart upload coordinated with raw-fetch part PUTs above
|
||||
const completeResponse = await fetch('/api/files/multipart?action=complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -799,6 +839,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
formData.append('workspaceId', options.workspaceId)
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
|
||||
const uploadResponse = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@@ -806,8 +847,8 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const { message, body } = await readResponseError(uploadResponse)
|
||||
throw new DirectUploadError(`Failed to upload ${file.name}: ${message}`, body)
|
||||
const { message: fullError, body: errorData } = await readApiResponseError(uploadResponse)
|
||||
throw new DirectUploadError(`Failed to upload ${file.name}: ${fullError}`, errorData)
|
||||
}
|
||||
|
||||
const uploadResult = await uploadResponse.json()
|
||||
@@ -882,6 +923,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
})),
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: signed-URL batch coordination for direct uploads handed to raw-fetch PUTs against provider URLs; kept on raw fetch to preserve the same controller/timeout used by the multipart flow
|
||||
const batchResponse = await fetch('/api/files/presigned/batch?type=knowledge-base', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -889,13 +931,8 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
})
|
||||
|
||||
if (!batchResponse.ok) {
|
||||
const { message } = await readResponseError(batchResponse)
|
||||
logger.error('Batch presigned URL generation failed', {
|
||||
batchIndex: batchIndex + 1,
|
||||
totalBatches: batches.length,
|
||||
status: batchResponse.status,
|
||||
})
|
||||
throw new Error(message)
|
||||
const { message: fullError } = await readApiResponseError(batchResponse)
|
||||
throw new Error(`Batch ${batchIndex + 1} presigned URL generation failed: ${fullError}`)
|
||||
}
|
||||
|
||||
const { files: presignedData } = await batchResponse.json()
|
||||
@@ -1028,39 +1065,32 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
bulk: true,
|
||||
}
|
||||
|
||||
const processResponse = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(processPayload),
|
||||
})
|
||||
|
||||
if (!processResponse.ok) {
|
||||
const { message, body } = await readResponseError(processResponse)
|
||||
logger.error('Document processing failed:', {
|
||||
status: processResponse.status,
|
||||
error: body,
|
||||
uploadedFiles: uploadedFiles.map((f) => ({
|
||||
filename: f.filename,
|
||||
fileUrl: f.fileUrl,
|
||||
fileSize: f.fileSize,
|
||||
mimeType: f.mimeType,
|
||||
})),
|
||||
let processResult: Awaited<
|
||||
ReturnType<typeof requestJson<typeof createKnowledgeDocumentsContract>>
|
||||
>
|
||||
try {
|
||||
processResult = await requestJson(createKnowledgeDocumentsContract, {
|
||||
params: { id: knowledgeBaseId },
|
||||
body: processPayload,
|
||||
})
|
||||
throw new ProcessingError(`Failed to start document processing: ${message}`, body)
|
||||
} catch (err) {
|
||||
if (isApiClientError(err)) {
|
||||
logger.error('Document processing failed:', {
|
||||
status: err.status,
|
||||
error: err.body,
|
||||
uploadedFiles: uploadedFiles.map((f) => ({
|
||||
filename: f.filename,
|
||||
fileUrl: f.fileUrl,
|
||||
fileSize: f.fileSize,
|
||||
mimeType: f.mimeType,
|
||||
})),
|
||||
})
|
||||
throw new ProcessingError(`Failed to start document processing: ${err.message}`, err.body)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const processResult = await processResponse.json()
|
||||
|
||||
if (!processResult.success) {
|
||||
throw new ProcessingError(
|
||||
`Document processing failed: ${processResult.error || 'Unknown error'}`,
|
||||
processResult
|
||||
)
|
||||
}
|
||||
|
||||
if (!processResult.data || !processResult.data.documentsCreated) {
|
||||
if (!('documentsCreated' in processResult.data)) {
|
||||
throw new ProcessingError(
|
||||
'Invalid processing response: missing document data',
|
||||
processResult
|
||||
|
||||
+11
-12
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Hash, Lock } from 'lucide-react'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { slackChannelsSelectorContract } from '@/lib/api/contracts'
|
||||
|
||||
const logger = createLogger('SlackChannelSelector')
|
||||
|
||||
@@ -45,19 +47,16 @@ export function SlackChannelSelector({
|
||||
setFetchError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tools/slack/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: accountId }),
|
||||
const data = await requestJson(slackChannelsSelectorContract, {
|
||||
body: { credential: accountId },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to fetch channels')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setChannels(data.channels || [])
|
||||
setChannels(
|
||||
(data.channels ?? []).map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
isPrivate: channel.isPrivate,
|
||||
}))
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch Slack channels', { error: err })
|
||||
setFetchError(err instanceof Error ? err.message : 'Failed to fetch channels')
|
||||
|
||||
+5
-7
@@ -3,7 +3,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import type { z } from 'zod'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -25,9 +24,9 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import type {
|
||||
alertRuleSchema,
|
||||
notificationLevelSchema,
|
||||
notificationTypeSchema,
|
||||
NotificationAlertRule,
|
||||
NotificationLogLevel,
|
||||
NotificationType,
|
||||
} from '@/lib/api/contracts/notifications'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
@@ -53,10 +52,9 @@ const logger = createLogger('NotificationSettings')
|
||||
const TRIGGER_OPTIONS = getTriggerOptions()
|
||||
const ALL_TRIGGER_VALUES = TRIGGER_OPTIONS.map((t) => t.value)
|
||||
|
||||
type NotificationType = z.output<typeof notificationTypeSchema>
|
||||
type LogLevel = z.output<typeof notificationLevelSchema>
|
||||
type LogLevel = NotificationLogLevel
|
||||
/** Contract alert rule plus a UI-only `'none'` sentinel meaning "no alert config". */
|
||||
type AlertRule = z.output<typeof alertRuleSchema> | 'none'
|
||||
type AlertRule = NotificationAlertRule | 'none'
|
||||
|
||||
const ALERT_RULES: { value: AlertRule; label: string; description: string }[] = [
|
||||
{ value: 'none', label: 'None', description: 'Notify on every matching execution' },
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { telemetryContract } from '@/lib/api/contracts/telemetry'
|
||||
import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
@@ -230,14 +232,12 @@ export function General() {
|
||||
|
||||
if (checked) {
|
||||
if (typeof window !== 'undefined') {
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
requestJson(telemetryContract, {
|
||||
body: {
|
||||
category: 'consent',
|
||||
action: 'enable_from_settings',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
getIssueBadgeLabel,
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import { McpServerFormModal, McpServerSkeleton } from './components'
|
||||
|
||||
const logger = createLogger('McpSettings')
|
||||
@@ -272,14 +275,16 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
|
||||
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${activeWorkflowId}`)
|
||||
if (response.ok) {
|
||||
const { data: workflowData } = await response.json()
|
||||
if (workflowData?.state?.blocks) {
|
||||
useSubBlockStore
|
||||
.getState()
|
||||
.initializeFromWorkflow(activeWorkflowId, workflowData.state.blocks)
|
||||
}
|
||||
const { data: workflowData } = await requestJson(getWorkflowStateContract, {
|
||||
params: { id: activeWorkflowId },
|
||||
})
|
||||
if (workflowData?.state?.blocks) {
|
||||
useSubBlockStore
|
||||
.getState()
|
||||
.initializeFromWorkflow(
|
||||
activeWorkflowId,
|
||||
workflowData.state.blocks as Record<string, BlockState>
|
||||
)
|
||||
}
|
||||
} catch (reloadError) {
|
||||
logger.warn('Failed to reload workflow subblock values:', reloadError)
|
||||
|
||||
+11
-25
@@ -18,6 +18,8 @@ import {
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription'
|
||||
import { useSession, useSubscription } from '@/lib/auth/auth-client'
|
||||
import { USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
@@ -559,13 +561,9 @@ export function Subscription() {
|
||||
'Interval switching is not available on legacy plans. Please upgrade first.'
|
||||
)
|
||||
}
|
||||
const res = await fetch('/api/billing/switch-plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ targetPlanName: subscription.plan, interval }),
|
||||
await requestJson(billingSwitchPlanContract, {
|
||||
body: { targetPlanName: subscription.plan, interval },
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed to switch interval')
|
||||
await refetchSubscription()
|
||||
},
|
||||
[refetchSubscription, subscription.plan, isLegacyPlan]
|
||||
@@ -779,16 +777,12 @@ export function Subscription() {
|
||||
? async () => {
|
||||
const planType = subscription.isTeam ? 'team' : 'pro'
|
||||
try {
|
||||
const res = await fetch('/api/billing/switch-plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
await requestJson(billingSwitchPlanContract, {
|
||||
body: {
|
||||
targetPlanName: `${planType}_${MAX_TIER.credits}`,
|
||||
interval: isAnnual ? 'year' : 'month',
|
||||
}),
|
||||
},
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed to upgrade')
|
||||
await refetchSubscription()
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to upgrade')
|
||||
@@ -861,13 +855,9 @@ export function Subscription() {
|
||||
const planType = subscription.isTeam ? 'team' : 'pro'
|
||||
const targetPlanName = `${planType}_${targetTier.credits}`
|
||||
try {
|
||||
const res = await fetch('/api/billing/switch-plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ targetPlanName }),
|
||||
await requestJson(billingSwitchPlanContract, {
|
||||
body: { targetPlanName },
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed to switch plan')
|
||||
await refetchSubscription()
|
||||
setManagePlanModalOpen(false)
|
||||
} catch (e) {
|
||||
@@ -882,13 +872,9 @@ export function Subscription() {
|
||||
const planType = subscription.isTeam ? 'team' : 'pro'
|
||||
const targetPlanName = `${planType}_${currentTier.credits}`
|
||||
try {
|
||||
const res = await fetch('/api/billing/switch-plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ targetPlanName }),
|
||||
await requestJson(billingSwitchPlanContract, {
|
||||
body: { targetPlanName },
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed to migrate plan')
|
||||
await refetchSubscription()
|
||||
setManagePlanModalOpen(false)
|
||||
} catch (e) {
|
||||
|
||||
@@ -72,6 +72,7 @@ export function useProfilePictureUpload({
|
||||
formData.append('workspaceId', workspaceId)
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
|
||||
+1
@@ -142,6 +142,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
formData.append('workspaceId', workspaceId)
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
|
||||
const uploadResponse = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
|
||||
+28
-28
@@ -3,6 +3,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listCopilotChatsContract } from '@/lib/api/contracts/copilot'
|
||||
import { listKnowledgeBasesContract } from '@/lib/api/contracts/knowledge/base'
|
||||
import { listLogsContract } from '@/lib/api/contracts/logs'
|
||||
import { listTemplatesContract } from '@/lib/api/contracts/templates'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -222,19 +227,17 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
if (isLoadingPastChats || pastChats.length > 0) return
|
||||
try {
|
||||
setIsLoadingPastChats(true)
|
||||
const resp = await fetch('/api/copilot/chats')
|
||||
if (!resp.ok) throw new Error(`Failed to load chats: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.chats) ? data.chats : []
|
||||
const data = await requestJson(listCopilotChatsContract, {})
|
||||
const items = data.chats
|
||||
|
||||
const currentWorkflowChats = items.filter((c: any) => c.workflowId === workflowId)
|
||||
const currentWorkflowChats = items.filter((c) => c.workflowId === workflowId)
|
||||
|
||||
setPastChats(
|
||||
currentWorkflowChats.map((c: any) => ({
|
||||
currentWorkflowChats.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title ?? null,
|
||||
workflowId: c.workflowId ?? null,
|
||||
updatedAt: c.updatedAt,
|
||||
updatedAt: c.updatedAt ?? undefined,
|
||||
}))
|
||||
)
|
||||
} catch {
|
||||
@@ -250,16 +253,16 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
if (isLoadingKnowledge || knowledgeBases.length > 0) return
|
||||
try {
|
||||
setIsLoadingKnowledge(true)
|
||||
const resp = await fetch(`/api/knowledge?workspaceId=${workspaceId}`)
|
||||
if (!resp.ok) throw new Error(`Failed to load knowledge bases: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const sorted = [...items].sort((a: any, b: any) => {
|
||||
const result = await requestJson(listKnowledgeBasesContract, {
|
||||
query: { workspaceId },
|
||||
})
|
||||
const items = result.data
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
|
||||
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
|
||||
return tb - ta
|
||||
})
|
||||
setKnowledgeBases(sorted.map((k: any) => ({ id: k.id, name: k.name || 'Untitled' })))
|
||||
setKnowledgeBases(sorted.map((k) => ({ id: k.id, name: k.name || 'Untitled' })))
|
||||
} catch {
|
||||
} finally {
|
||||
setIsLoadingKnowledge(false)
|
||||
@@ -321,13 +324,13 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
if (isLoadingTemplates || templatesList.length > 0) return
|
||||
try {
|
||||
setIsLoadingTemplates(true)
|
||||
const resp = await fetch('/api/templates?limit=50&offset=0')
|
||||
if (!resp.ok) throw new Error(`Failed to load templates: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const data = await requestJson(listTemplatesContract, {
|
||||
query: { limit: 50, offset: 0 },
|
||||
})
|
||||
const items = data.data
|
||||
const mapped = items
|
||||
.map((t: any) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
|
||||
.sort((a: any, b: any) => b.stars - a.stars)
|
||||
.map((t) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
|
||||
.sort((a, b) => b.stars - a.stars)
|
||||
setTemplatesList(mapped)
|
||||
} catch {
|
||||
} finally {
|
||||
@@ -342,20 +345,17 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
if (isLoadingLogs || logsList.length > 0) return
|
||||
try {
|
||||
setIsLoadingLogs(true)
|
||||
const resp = await fetch(`/api/logs?workspaceId=${workspaceId}&limit=50&details=full`)
|
||||
if (!resp.ok) throw new Error(`Failed to load logs: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const mapped = items.map((l: any) => ({
|
||||
const data = await requestJson(listLogsContract, {
|
||||
query: { workspaceId, limit: 50, details: 'full' },
|
||||
})
|
||||
const items = data.data
|
||||
const mapped = items.map((l) => ({
|
||||
id: l.id,
|
||||
executionId: l.executionId || l.id,
|
||||
level: l.level,
|
||||
trigger: l.trigger || null,
|
||||
createdAt: l.createdAt,
|
||||
workflowName:
|
||||
(l.workflow && (l.workflow.name || l.workflow.title)) ||
|
||||
l.workflowName ||
|
||||
'Untitled Workflow',
|
||||
workflowName: l.workflow?.name ?? 'Untitled Workflow',
|
||||
}))
|
||||
setLogsList(mapped)
|
||||
} catch {
|
||||
|
||||
+13
-12
@@ -6,6 +6,9 @@ import { X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { fileDeleteContract } from '@/lib/api/contracts/storage-transfer'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
@@ -315,6 +318,7 @@ export function FileUpload({
|
||||
formData.append('workspaceId', workspaceId)
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@@ -486,18 +490,15 @@ export function FileUpload({
|
||||
(decodedPath.includes(`/${workspaceId}/`) || decodedPath.includes(`${workspaceId}/`))
|
||||
|
||||
if (!isWorkspaceFile) {
|
||||
const response = await fetch('/api/files/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filePath: file.path }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText }))
|
||||
const errorMessage = errorData.error || `Failed to delete file: ${response.status}`
|
||||
throw new Error(errorMessage)
|
||||
try {
|
||||
await requestJson(fileDeleteContract, {
|
||||
body: { filePath: file.path },
|
||||
})
|
||||
} catch (err) {
|
||||
if (isApiClientError(err)) {
|
||||
throw new Error(err.message || `Failed to delete file: ${err.status}`)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Lock, Unlock, Upload } from '@/components/emcn/icons'
|
||||
import { VariableIcon } from '@/components/icons'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
createWorkflowCopilotChatContract,
|
||||
deleteCopilotChatContract,
|
||||
} from '@/lib/api/contracts/copilot'
|
||||
import { getWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
|
||||
@@ -284,18 +290,16 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
|
||||
const handleCopilotDeleteChat = useCallback(
|
||||
(chatId: string) => {
|
||||
fetch('/api/copilot/chat/delete', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chatId }),
|
||||
})
|
||||
requestJson(deleteCopilotChatContract, { body: { chatId } })
|
||||
.then(() => {
|
||||
if (copilotChatId === chatId) {
|
||||
setCopilotChatId(undefined)
|
||||
}
|
||||
loadCopilotChats()
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to delete copilot chat', { error: toError(err).message, chatId })
|
||||
})
|
||||
},
|
||||
[copilotChatId, loadCopilotChats, setCopilotChatId]
|
||||
)
|
||||
@@ -308,11 +312,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
|
||||
const baselineWorkflow = captureBaselineSnapshot(workflowId)
|
||||
|
||||
fetch(`/api/workflows/${workflowId}/state`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`State fetch failed: ${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
requestJson(getWorkflowNormalizedStateContract, { params: { id: workflowId } })
|
||||
.then((freshState) => {
|
||||
const diffStore = useWorkflowDiffStore.getState()
|
||||
return diffStore.setProposedChanges(freshState as WorkflowState, undefined, {
|
||||
@@ -353,14 +353,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
|
||||
const handleCopilotNewChat = useCallback(() => {
|
||||
if (!activeWorkflowId || !workspaceId) return
|
||||
fetch('/api/copilot/chats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId, workflowId: activeWorkflowId }),
|
||||
requestJson(createWorkflowCopilotChatContract, {
|
||||
body: { workspaceId, workflowId: activeWorkflowId },
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('create chat failed'))))
|
||||
.then((data: { id?: string }) => {
|
||||
if (!data?.id) return
|
||||
.then((data) => {
|
||||
// Seed the new chat into the list cache before selecting it. Without this, the
|
||||
// auto-select effect sees a selected id that isn't in the (still-stale) list and
|
||||
// deselects it, which leaves the panel detached from the freshly created row.
|
||||
@@ -368,7 +364,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
copilotChatsKeys.list(activeWorkflowId),
|
||||
(prev) => [
|
||||
{
|
||||
id: data.id!,
|
||||
id: data.id,
|
||||
title: null,
|
||||
workflowId: activeWorkflowId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -381,7 +377,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
loadCopilotChats()
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to create copilot chat', err)
|
||||
logger.error('Failed to create copilot chat', { error: toError(err).message })
|
||||
})
|
||||
}, [activeWorkflowId, workspaceId, loadCopilotChats, setCopilotChatId, queryClient])
|
||||
|
||||
|
||||
+12
-30
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listWebhooksByBlockContract, updateWebhookContract } from '@/lib/api/contracts/webhooks'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
@@ -81,28 +83,16 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
workflowId,
|
||||
blockId,
|
||||
const data = await requestJson(listWebhooksByBlockContract, {
|
||||
query: { workflowId, blockId },
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/webhooks?${params}`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
setWebhookStatus({ isDisabled: false, webhookId: undefined })
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const webhooks = data.webhooks || []
|
||||
const webhooks = data.webhooks
|
||||
|
||||
if (webhooks.length > 0) {
|
||||
const webhook = webhooks[0].webhook
|
||||
const isActive = webhook.isActive !== false
|
||||
setWebhookStatus({
|
||||
isDisabled: !webhook.isActive,
|
||||
isDisabled: !isActive,
|
||||
webhookId: webhook.id,
|
||||
})
|
||||
} else {
|
||||
@@ -121,22 +111,14 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
|
||||
const reactivateWebhook = useCallback(
|
||||
async (webhookId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/webhooks/${webhookId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
await requestJson(updateWebhookContract, {
|
||||
params: { id: webhookId },
|
||||
body: {
|
||||
isActive: true,
|
||||
failedCount: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchWebhookStatus()
|
||||
} else {
|
||||
logger.error('Failed to reactivate webhook')
|
||||
}
|
||||
await fetchWebhookStatus()
|
||||
} catch (error) {
|
||||
logger.error('Error reactivating webhook:', error)
|
||||
}
|
||||
|
||||
+10
-14
@@ -6,6 +6,8 @@ import { generateId } from '@sim/utils/id'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { cancelWorkflowExecutionContract, workflowLogContract } from '@/lib/api/contracts/workflows'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { processStreamingBlockLogs } from '@/lib/tokenization'
|
||||
import {
|
||||
@@ -363,21 +365,15 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/workflows/${activeWorkflowId}/log`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
if (!activeWorkflowId) return executionId
|
||||
await requestJson(workflowLogContract, {
|
||||
params: { id: activeWorkflowId },
|
||||
body: {
|
||||
executionId,
|
||||
result: enrichedResult,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to persist logs')
|
||||
}
|
||||
|
||||
return executionId
|
||||
} catch (error) {
|
||||
logger.error('Error persisting logs:', error)
|
||||
@@ -461,7 +457,7 @@ export function useWorkflowExecution() {
|
||||
formData.append('executionId', executionId)
|
||||
formData.append('workspaceId', workspaceId)
|
||||
|
||||
// Upload the file
|
||||
// boundary-raw-fetch: multipart/form-data file upload, requestJson only supports JSON bodies
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@@ -1523,8 +1519,8 @@ export function useWorkflowExecution() {
|
||||
|
||||
if (storedExecutionId) {
|
||||
setCurrentExecutionId(activeWorkflowId, null)
|
||||
fetch(`/api/workflows/${activeWorkflowId}/executions/${storedExecutionId}/cancel`, {
|
||||
method: 'POST',
|
||||
requestJson(cancelWorkflowExecutionContract, {
|
||||
params: { id: activeWorkflowId, executionId: storedExecutionId },
|
||||
}).catch(() => {})
|
||||
handleExecutionCancelledConsole({
|
||||
workflowId: activeWorkflowId,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
putWorkflowNormalizedStateContract,
|
||||
type WorkflowStateContractInput,
|
||||
workflowAutoLayoutContract,
|
||||
} from '@/lib/api/contracts/workflows'
|
||||
import {
|
||||
DEFAULT_HORIZONTAL_SPACING,
|
||||
DEFAULT_LAYOUT_PADDING,
|
||||
@@ -6,6 +14,7 @@ import {
|
||||
} from '@/lib/workflows/autolayout/constants'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('AutoLayoutUtils')
|
||||
|
||||
@@ -77,40 +86,37 @@ export async function applyAutoLayoutAndUpdateStore(
|
||||
gridSize: options.gridSize,
|
||||
}
|
||||
|
||||
// Call the autolayout API route
|
||||
const response = await fetch(`/api/workflows/${workflowId}/autolayout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...layoutOptions,
|
||||
blocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
const errorMessage = errorData?.error || `Auto layout failed: ${response.statusText}`
|
||||
logger.error('Auto layout API call failed:', {
|
||||
status: response.status,
|
||||
error: errorMessage,
|
||||
let result: Awaited<ReturnType<typeof requestJson<typeof workflowAutoLayoutContract>>>
|
||||
try {
|
||||
result = await requestJson(workflowAutoLayoutContract, {
|
||||
params: { id: workflowId },
|
||||
body: {
|
||||
...layoutOptions,
|
||||
blocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof ApiClientError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Auto layout failed'
|
||||
logger.error('Auto layout API call failed:', { error: errorMessage })
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = result.error || 'Auto layout failed'
|
||||
logger.error('Auto layout failed:', { error: errorMessage })
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
|
||||
const layoutedBlocks = result.data?.layoutedBlocks || blocks
|
||||
// The contract's `workflowBlockStateSchema` and the store's `BlockState`
|
||||
// describe the same runtime shape but TS sees the contract's
|
||||
// `SubBlockState.value` as `unknown` (zod default) while the store
|
||||
// narrows it to the editor's per-input value union. The contract's
|
||||
// shape is structurally a supertype of the store's, so this is a
|
||||
// single safe widening cast (store -> wire) on the way back in.
|
||||
const layoutedBlocks: Record<string, BlockState> =
|
||||
(result.data.layoutedBlocks as Record<string, BlockState>) || blocks
|
||||
const mergedBlocks = mergeSubblockState(layoutedBlocks, workflowId)
|
||||
|
||||
const newWorkflowState = {
|
||||
@@ -129,38 +135,37 @@ export async function applyAutoLayoutAndUpdateStore(
|
||||
|
||||
const { dragStartPosition, ...stateToSave } = newWorkflowState
|
||||
|
||||
const cleanedWorkflowState = {
|
||||
type ContractEdgeInput = WorkflowStateContractInput['edges'][number]
|
||||
|
||||
// Mirror the diff store's sanitization: schema rejects nullable
|
||||
// sourceHandle/targetHandle (input type is `string | undefined`),
|
||||
// but the store's reactflow `Edge` type carries `string | null |
|
||||
// undefined`. Drop nulls before sending so the contract input
|
||||
// parses cleanly.
|
||||
const sanitizedEdges: ContractEdgeInput[] = (stateToSave.edges || []).map((edge: Edge) => {
|
||||
const { sourceHandle, targetHandle, ...rest } = edge
|
||||
const sanitized: ContractEdgeInput = { ...rest } as ContractEdgeInput
|
||||
if (typeof sourceHandle === 'string' && sourceHandle.length > 0) {
|
||||
sanitized.sourceHandle = sourceHandle
|
||||
}
|
||||
if (typeof targetHandle === 'string' && targetHandle.length > 0) {
|
||||
sanitized.targetHandle = targetHandle
|
||||
}
|
||||
return sanitized
|
||||
})
|
||||
|
||||
const cleanedWorkflowState: WorkflowStateContractInput = {
|
||||
...stateToSave,
|
||||
loops: stateToSave.loops || {},
|
||||
parallels: stateToSave.parallels || {},
|
||||
edges: (stateToSave.edges || []).map((edge: any) => {
|
||||
const { sourceHandle, targetHandle, ...rest } = edge || {}
|
||||
const sanitized: any = { ...rest }
|
||||
if (typeof sourceHandle === 'string' && sourceHandle.length > 0) {
|
||||
sanitized.sourceHandle = sourceHandle
|
||||
}
|
||||
if (typeof targetHandle === 'string' && targetHandle.length > 0) {
|
||||
sanitized.targetHandle = targetHandle
|
||||
}
|
||||
return sanitized
|
||||
}),
|
||||
edges: sanitizedEdges,
|
||||
}
|
||||
|
||||
const saveResponse = await fetch(`/api/workflows/${workflowId}/state`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(cleanedWorkflowState),
|
||||
await requestJson(putWorkflowNormalizedStateContract, {
|
||||
params: { id: workflowId },
|
||||
body: cleanedWorkflowState,
|
||||
})
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
const errorData = await saveResponse.json()
|
||||
throw new Error(
|
||||
errorData.error || `HTTP ${saveResponse.status}: ${saveResponse.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Auto layout successfully persisted to database', { workflowId })
|
||||
return { success: true }
|
||||
} catch (saveError) {
|
||||
|
||||
@@ -641,6 +641,7 @@ export async function executeWorkflowWithFullLogging(
|
||||
: {}),
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: workflow execute returns an SSE stream consumed via response.body.getReader() in processSSEStream
|
||||
const response = await fetch(`/api/workflows/${targetWorkflowId}/execute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
+1
@@ -108,6 +108,7 @@ async function submitHelpRequest({ data, images, workflowId, workspaceId }: Subm
|
||||
formData.append(`image_${index}`, image)
|
||||
})
|
||||
|
||||
// boundary-raw-fetch: multipart/form-data submission with image attachments, requestJson only supports JSON bodies
|
||||
const response = await fetch('/api/help', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
|
||||
+1
-3
@@ -104,9 +104,7 @@ export function SettingsSidebar({
|
||||
const isSSOProviderOwner = useMemo(() => {
|
||||
if (isHosted) return null
|
||||
if (!userId || isLoadingSSO) return null
|
||||
return (
|
||||
ssoProvidersData?.providers?.some((p: { userId?: string }) => p.userId === userId) || false
|
||||
)
|
||||
return ssoProvidersData?.providers?.some((p) => p.userId === userId) || false
|
||||
}, [userId, ssoProvidersData?.providers, isLoadingSSO])
|
||||
|
||||
const navigationItems = useMemo(() => {
|
||||
|
||||
+7
-11
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Badge, Skeleton } from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { createBillingPortalContract } from '@/lib/api/contracts'
|
||||
import { USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
import {
|
||||
@@ -444,18 +446,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const context = isOrgScoped ? 'organization' : 'user'
|
||||
const organizationId = subscriptionData?.data?.organization?.id
|
||||
|
||||
const response = await fetch('/api/billing/portal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ context, organizationId }),
|
||||
const { url } = await requestJson(createBillingPortalContract, {
|
||||
body: { context, organizationId },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const { url } = await response.json()
|
||||
window.open(url, '_blank')
|
||||
logger.info('Opened billing portal for blocked account', { context, organizationId })
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank')
|
||||
logger.info('Opened billing portal for blocked account', { context, organizationId })
|
||||
return
|
||||
} catch (portalError) {
|
||||
logger.warn('Failed to open billing portal, falling back to settings', {
|
||||
error: portalError,
|
||||
|
||||
+1
@@ -67,6 +67,7 @@ export function useWorkspaceLogoUpload({
|
||||
formData.append('workspaceId', workspaceIdRef.current)
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: multipart/form-data upload (FileUpload boundary), incompatible with requestJson which JSON-stringifies bodies
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
|
||||
+4
-4
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { updateUserSettingsContract } from '@/lib/api/contracts'
|
||||
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
|
||||
import { useLeaveWorkspace } from '@/hooks/queries/invitations'
|
||||
import {
|
||||
@@ -80,10 +82,8 @@ export function useWorkspaceManagement({
|
||||
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current)
|
||||
syncTimerRef.current = setTimeout(() => {
|
||||
fetch('/api/users/me/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lastActiveWorkspaceId: id }),
|
||||
requestJson(updateUserSettingsContract, {
|
||||
body: { lastActiveWorkspaceId: id },
|
||||
}).catch(() => {})
|
||||
}, 1000)
|
||||
}, [])
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { duplicateWorkspaceContract } from '@/lib/api/contracts'
|
||||
|
||||
const logger = createLogger('useDuplicateWorkspace')
|
||||
|
||||
@@ -36,20 +38,11 @@ export function useDuplicateWorkspace({ workspaceId, onSuccess }: UseDuplicateWo
|
||||
|
||||
setIsDuplicating(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `${workspaceName} (Copy)`,
|
||||
}),
|
||||
const duplicatedWorkspace = await requestJson(duplicateWorkspaceContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { name: `${workspaceName} (Copy)` },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to duplicate workspace: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const duplicatedWorkspace = await response.json()
|
||||
|
||||
logger.info('Workspace duplicated successfully', {
|
||||
sourceWorkspaceId: workspaceId,
|
||||
newWorkspaceId: duplicatedWorkspace.id,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getWorkspaceContract,
|
||||
listFoldersContract,
|
||||
listWorkflowsContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import {
|
||||
downloadFile,
|
||||
exportWorkspaceToZip,
|
||||
@@ -32,24 +38,20 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
|
||||
try {
|
||||
logger.info('Exporting workspace', { workspaceId })
|
||||
|
||||
const workflowsResponse = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
|
||||
if (!workflowsResponse.ok) {
|
||||
throw new Error('Failed to fetch workflows')
|
||||
}
|
||||
const { data: workflows } = await workflowsResponse.json()
|
||||
const { data: workflows } = await requestJson(listWorkflowsContract, {
|
||||
query: { workspaceId },
|
||||
})
|
||||
|
||||
const foldersResponse = await fetch(`/api/folders?workspaceId=${workspaceId}`)
|
||||
if (!foldersResponse.ok) {
|
||||
throw new Error('Failed to fetch folders')
|
||||
}
|
||||
const foldersData = await foldersResponse.json()
|
||||
const foldersData = await requestJson(listFoldersContract, {
|
||||
query: { workspaceId },
|
||||
})
|
||||
|
||||
const workflowsToExport: WorkflowExportData[] = []
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const exportData = await fetchWorkflowForExport(workflow.id, {
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
description: workflow.description ?? undefined,
|
||||
color: workflow.color,
|
||||
folderId: workflow.folderId,
|
||||
})
|
||||
@@ -59,19 +61,22 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
|
||||
}
|
||||
}
|
||||
|
||||
const foldersToExport: FolderExportData[] = (foldersData.folders || []).map(
|
||||
(folder: FolderExportData) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId,
|
||||
sortOrder: folder.sortOrder,
|
||||
})
|
||||
)
|
||||
const foldersToExport: FolderExportData[] = (foldersData.folders || []).map((folder) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId,
|
||||
sortOrder: folder.sortOrder,
|
||||
}))
|
||||
|
||||
const workspaceResponse = await fetch(`/api/workspaces/${workspaceId}`)
|
||||
const workspaceColor = workspaceResponse.ok
|
||||
? ((await workspaceResponse.json()).workspace?.color as string | undefined)
|
||||
: undefined
|
||||
let workspaceColor: string | undefined
|
||||
try {
|
||||
const workspaceData = await requestJson(getWorkspaceContract, {
|
||||
params: { id: workspaceId },
|
||||
})
|
||||
workspaceColor = workspaceData.workspace?.color
|
||||
} catch {
|
||||
workspaceColor = undefined
|
||||
}
|
||||
|
||||
const zipBlob = await exportWorkspaceToZip(
|
||||
workspaceName,
|
||||
|
||||
@@ -2,6 +2,14 @@ import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
createWorkflowContract,
|
||||
createWorkspaceContract,
|
||||
putWorkflowNormalizedStateContract,
|
||||
type WorkflowStateContractInput,
|
||||
workflowVariablesContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import {
|
||||
extractWorkflowName,
|
||||
extractWorkflowsFromZip,
|
||||
@@ -58,21 +66,13 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
}
|
||||
|
||||
const workspaceName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '')
|
||||
const createResponse = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
const { workspace: newWorkspace } = await requestJson(createWorkspaceContract, {
|
||||
body: {
|
||||
name: workspaceName,
|
||||
...(metadata?.workspaceColor && { color: metadata.workspaceColor }),
|
||||
skipDefaultWorkflow: true,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error('Failed to create workspace')
|
||||
}
|
||||
|
||||
const { workspace: newWorkspace } = await createResponse.json()
|
||||
logger.info('Created new workspace:', newWorkspace)
|
||||
|
||||
const folderMap = new Map<string, string>()
|
||||
@@ -168,34 +168,52 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
const workflowColor =
|
||||
(workflowData.metadata as { color?: string } | undefined)?.color || '#3972F6'
|
||||
|
||||
const createWorkflowResponse = await fetch('/api/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: workflowName,
|
||||
description: workflowData.metadata?.description || 'Imported from workspace export',
|
||||
color: workflowColor,
|
||||
workspaceId: newWorkspace.id,
|
||||
folderId: targetFolderId,
|
||||
deduplicate: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!createWorkflowResponse.ok) {
|
||||
logger.error(`Failed to create workflow ${workflowName}`)
|
||||
let newWorkflow: Awaited<ReturnType<typeof requestJson<typeof createWorkflowContract>>>
|
||||
try {
|
||||
newWorkflow = await requestJson(createWorkflowContract, {
|
||||
body: {
|
||||
name: workflowName,
|
||||
description:
|
||||
workflowData.metadata?.description || 'Imported from workspace export',
|
||||
color: workflowColor,
|
||||
workspaceId: newWorkspace.id,
|
||||
folderId: targetFolderId,
|
||||
deduplicate: true,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create workflow ${workflowName}`, { error })
|
||||
continue
|
||||
}
|
||||
|
||||
const newWorkflow = await createWorkflowResponse.json()
|
||||
type ContractEdgeInput = WorkflowStateContractInput['edges'][number]
|
||||
|
||||
const stateResponse = await fetch(`/api/workflows/${newWorkflow.id}/state`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(workflowData),
|
||||
const sanitizedEdges: ContractEdgeInput[] = (workflowData.edges || []).map((edge) => {
|
||||
const { sourceHandle, targetHandle, ...rest } = edge
|
||||
const sanitized: ContractEdgeInput = { ...rest } as ContractEdgeInput
|
||||
if (typeof sourceHandle === 'string' && sourceHandle.length > 0) {
|
||||
sanitized.sourceHandle = sourceHandle
|
||||
}
|
||||
if (typeof targetHandle === 'string' && targetHandle.length > 0) {
|
||||
sanitized.targetHandle = targetHandle
|
||||
}
|
||||
return sanitized
|
||||
})
|
||||
|
||||
if (!stateResponse.ok) {
|
||||
logger.error(`Failed to save workflow state for ${newWorkflow.id}`)
|
||||
const workflowStateBody: WorkflowStateContractInput = {
|
||||
...workflowData,
|
||||
loops: workflowData.loops || {},
|
||||
parallels: workflowData.parallels || {},
|
||||
edges: sanitizedEdges,
|
||||
}
|
||||
|
||||
try {
|
||||
await requestJson(putWorkflowNormalizedStateContract, {
|
||||
params: { id: newWorkflow.id },
|
||||
body: workflowStateBody,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save workflow state for ${newWorkflow.id}`, { error })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -205,33 +223,29 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
: Object.values(workflowData.variables)
|
||||
|
||||
if (variablesArray.length > 0) {
|
||||
const variablesRecord: Record<
|
||||
string,
|
||||
{ id: string; workflowId: string; name: string; type: string; value: unknown }
|
||||
> = {}
|
||||
type WorkflowVariablesBodyInput = NonNullable<
|
||||
Parameters<typeof requestJson<typeof workflowVariablesContract>>[1]['body']
|
||||
>
|
||||
|
||||
const variablesRecord: WorkflowVariablesBodyInput['variables'] = {}
|
||||
|
||||
for (const v of variablesArray) {
|
||||
const id = typeof v.id === 'string' && v.id.trim() ? v.id : generateId()
|
||||
variablesRecord[id] = {
|
||||
id,
|
||||
workflowId: newWorkflow.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
}
|
||||
}
|
||||
|
||||
const variablesResponse = await fetch(
|
||||
`/api/workflows/${newWorkflow.id}/variables`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ variables: variablesRecord }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!variablesResponse.ok) {
|
||||
logger.error(`Failed to save variables for ${newWorkflow.id}`)
|
||||
try {
|
||||
await requestJson(workflowVariablesContract, {
|
||||
params: { id: newWorkflow.id },
|
||||
body: { variables: variablesRecord },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save variables for ${newWorkflow.id}`, { error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
|
||||
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
|
||||
import { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
|
||||
@@ -82,15 +85,14 @@ async function handleWorkflowRedirect(
|
||||
router: ReturnType<typeof useRouter>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${workflowId}`)
|
||||
if (response.ok) {
|
||||
const workflowData = await response.json()
|
||||
const workspaceId = workflowData.data?.workspaceId
|
||||
if (workspaceId) {
|
||||
logger.info(`Redirecting workflow ${workflowId} to workspace ${workspaceId}`)
|
||||
router.replace(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
return
|
||||
}
|
||||
const workflowData = await requestJson(getWorkflowStateContract, {
|
||||
params: { id: workflowId },
|
||||
})
|
||||
const workspaceId = workflowData.data.workspaceId
|
||||
if (workspaceId) {
|
||||
logger.info(`Redirecting workflow ${workflowId} to workspace ${workspaceId}`)
|
||||
router.replace(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching workflow for redirect:', error)
|
||||
@@ -114,18 +116,13 @@ async function handleNoWorkspaces(
|
||||
|
||||
logger.warn('No workspaces found, creating default workspace')
|
||||
try {
|
||||
const response = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'My Workspace' }),
|
||||
const data = await requestJson(createWorkspaceContract, {
|
||||
body: { name: 'My Workspace' },
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.workspace?.id) {
|
||||
logger.info(`Created default workspace: ${data.workspace.id}`)
|
||||
router.replace(`/workspace/${data.workspace.id}/home`)
|
||||
return
|
||||
}
|
||||
if (data.workspace?.id) {
|
||||
logger.info(`Created default workspace: ${data.workspace.id}`)
|
||||
router.replace(`/workspace/${data.workspace.id}/home`)
|
||||
return
|
||||
}
|
||||
logger.error('Failed to create default workspace')
|
||||
} catch (error) {
|
||||
|
||||
@@ -303,6 +303,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
)
|
||||
|
||||
const generateSocketToken = async (): Promise<string> => {
|
||||
// boundary-raw-fetch: pre-bootstrap one-time socket token mint — Better Auth's
|
||||
// generateOneTimeToken handler (in INDIRECT_ZOD_ROUTES baseline) has no client
|
||||
// contract; called from Socket.IO auth callback before any contract-bound client.
|
||||
const res = await fetch('/api/auth/socket-token', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { z } from 'zod'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
bulkAddPermissionGroupMembersContract,
|
||||
@@ -10,17 +9,15 @@ import {
|
||||
getUserPermissionConfigContract,
|
||||
listPermissionGroupMembersContract,
|
||||
listPermissionGroupsContract,
|
||||
type permissionGroupMemberSchema,
|
||||
type permissionGroupSchema,
|
||||
type PermissionGroup,
|
||||
type PermissionGroupMember,
|
||||
removePermissionGroupMemberContract,
|
||||
type UserPermissionConfig,
|
||||
updatePermissionGroupContract,
|
||||
type userPermissionConfigSchema,
|
||||
} from '@/lib/api/contracts'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
|
||||
export type PermissionGroup = z.output<typeof permissionGroupSchema>
|
||||
export type PermissionGroupMember = z.output<typeof permissionGroupMemberSchema>
|
||||
export type UserPermissionConfig = z.output<typeof userPermissionConfigSchema>
|
||||
export type { PermissionGroup, PermissionGroupMember, UserPermissionConfig }
|
||||
|
||||
export const permissionGroupKeys = {
|
||||
all: ['permissionGroups'] as const,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { type AuditLogPage, listAuditLogsContract } from '@/lib/api/contracts/audit-logs'
|
||||
|
||||
export const auditLogKeys = {
|
||||
all: ['audit-logs'] as const,
|
||||
@@ -16,33 +17,24 @@ export interface AuditLogFilters {
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
interface AuditLogPage {
|
||||
success: boolean
|
||||
data: EnterpriseAuditLogEntry[]
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
async function fetchAuditLogs(
|
||||
filters: AuditLogFilters,
|
||||
cursor?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<AuditLogPage> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', '50')
|
||||
if (filters.search) params.set('search', filters.search)
|
||||
if (filters.action) params.set('action', filters.action)
|
||||
if (filters.resourceType) params.set('resourceType', filters.resourceType)
|
||||
if (filters.actorId) params.set('actorId', filters.actorId)
|
||||
if (filters.startDate) params.set('startDate', filters.startDate)
|
||||
if (filters.endDate) params.set('endDate', filters.endDate)
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
|
||||
const response = await fetch(`/api/audit-logs?${params.toString()}`, { signal })
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new Error(body.error || `Failed to fetch audit logs: ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
return requestJson(listAuditLogsContract, {
|
||||
query: {
|
||||
limit: '50',
|
||||
search: filters.search,
|
||||
action: filters.action,
|
||||
resourceType: filters.resourceType,
|
||||
actorId: filters.actorId,
|
||||
startDate: filters.startDate,
|
||||
endDate: filters.endDate,
|
||||
cursor,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAuditLogs(filters: AuditLogFilters, enabled = true) {
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getOrganizationDataRetentionContract,
|
||||
type OrganizationDataRetention,
|
||||
type OrganizationRetentionValues,
|
||||
updateOrganizationDataRetentionContract,
|
||||
} from '@/lib/api/contracts/organization'
|
||||
|
||||
export interface RetentionValues {
|
||||
logRetentionHours: number | null
|
||||
softDeleteRetentionHours: number | null
|
||||
taskCleanupHours: number | null
|
||||
}
|
||||
|
||||
export interface DataRetentionResponse {
|
||||
isEnterprise: boolean
|
||||
defaults: RetentionValues
|
||||
configured: RetentionValues
|
||||
effective: RetentionValues
|
||||
}
|
||||
export type RetentionValues = OrganizationRetentionValues
|
||||
export type DataRetentionResponse = OrganizationDataRetention
|
||||
|
||||
export const dataRetentionKeys = {
|
||||
all: ['dataRetention'] as const,
|
||||
@@ -24,15 +21,11 @@ async function fetchDataRetention(
|
||||
orgId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<DataRetentionResponse> {
|
||||
const response = await fetch(`/api/organizations/${orgId}/data-retention`, { signal })
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error ?? 'Failed to fetch data retention settings')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
return data as DataRetentionResponse
|
||||
const { data } = await requestJson(getOrganizationDataRetentionContract, {
|
||||
params: { id: orgId },
|
||||
signal,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export function useOrganizationRetention(orgId: string | undefined) {
|
||||
@@ -53,21 +46,11 @@ export function useUpdateOrganizationRetention() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ orgId, settings }: UpdateRetentionVariables) => {
|
||||
const response = await fetch(`/api/organizations/${orgId}/data-retention`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error ?? 'Failed to update data retention settings')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
return data as DataRetentionResponse
|
||||
},
|
||||
mutationFn: ({ orgId, settings }: UpdateRetentionVariables) =>
|
||||
requestJson(updateOrganizationDataRetentionContract, {
|
||||
params: { id: orgId },
|
||||
body: settings,
|
||||
}),
|
||||
onSettled: (_data, _error, { orgId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: dataRetentionKeys.settings(orgId) })
|
||||
},
|
||||
|
||||
@@ -4,6 +4,9 @@ import { type KeyboardEvent, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Input, Label, Loader } from '@/components/emcn'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { authenticateDeployedChatContract } from '@/lib/api/contracts/chats'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
@@ -66,28 +69,21 @@ export default function SSOAuth({ identifier }: SSOAuthProps) {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const checkResponse = await fetch(`/api/chat/${identifier}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({ email, checkSSOAccess: true }),
|
||||
await requestJson(authenticateDeployedChatContract, {
|
||||
params: { identifier },
|
||||
body: { email, checkSSOAccess: true },
|
||||
})
|
||||
|
||||
if (!checkResponse.ok) {
|
||||
const errorData = await checkResponse.json()
|
||||
setEmailErrors([errorData.error || 'Email not authorized for this chat'])
|
||||
setShowEmailValidationError(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const callbackUrl = `/chat/${identifier}`
|
||||
const ssoUrl = `/sso?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
router.push(ssoUrl)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError) {
|
||||
setEmailErrors([error.message || 'Email not authorized for this chat'])
|
||||
setShowEmailValidationError(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
logger.error('SSO authentication error:', error)
|
||||
setEmailErrors(['An error occurred during authentication'])
|
||||
setShowEmailValidationError(true)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
listSsoProvidersContract,
|
||||
type SsoRegistrationBody,
|
||||
ssoRegistrationContract,
|
||||
} from '@/lib/api/contracts/auth'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
|
||||
/**
|
||||
@@ -15,14 +21,10 @@ export const ssoKeys = {
|
||||
* Fetch SSO providers
|
||||
*/
|
||||
async function fetchSSOProviders(signal: AbortSignal, organizationId?: string) {
|
||||
const url = organizationId
|
||||
? `/api/auth/sso/providers?organizationId=${encodeURIComponent(organizationId)}`
|
||||
: '/api/auth/sso/providers'
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch SSO providers')
|
||||
}
|
||||
return response.json()
|
||||
return requestJson(listSsoProvidersContract, {
|
||||
query: organizationId ? { organizationId } : {},
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,20 +54,10 @@ export function useConfigureSSO() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (config: ConfigureSSOParams) => {
|
||||
const response = await fetch('/api/auth/sso/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || error.details || 'Failed to configure SSO')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
mutationFn: (config: ConfigureSSOParams) =>
|
||||
requestJson(ssoRegistrationContract, {
|
||||
body: config as SsoRegistrationBody,
|
||||
}),
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getOrganizationWhitelabelContract,
|
||||
updateOrganizationWhitelabelContract,
|
||||
} from '@/lib/api/contracts/organization'
|
||||
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
|
||||
@@ -25,15 +30,11 @@ async function fetchWhitelabelSettings(
|
||||
orgId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<OrganizationWhitelabelSettings> {
|
||||
const response = await fetch(`/api/organizations/${orgId}/whitelabel`, { signal })
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error ?? 'Failed to fetch whitelabel settings')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
return data as OrganizationWhitelabelSettings
|
||||
const { data } = await requestJson(getOrganizationWhitelabelContract, {
|
||||
params: { id: orgId },
|
||||
signal,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,19 +62,11 @@ export function useUpdateWhitelabelSettings() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ orgId, settings }: UpdateWhitelabelVariables) => {
|
||||
const response = await fetch(`/api/organizations/${orgId}/whitelabel`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
const result = await requestJson(updateOrganizationWhitelabelContract, {
|
||||
params: { id: orgId },
|
||||
body: settings,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error ?? 'Failed to update whitelabel settings')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
return data as OrganizationWhitelabelSettings
|
||||
return result.data
|
||||
},
|
||||
onSettled: (_data, _error, { orgId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: whitelabelKeys.settings(orgId) })
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
addMothershipChatResourceContract,
|
||||
createMothershipChatContract,
|
||||
deleteMothershipChatContract,
|
||||
forkMothershipChatContract,
|
||||
getMothershipChatContract,
|
||||
listMothershipChatsContract,
|
||||
type MothershipTask,
|
||||
removeMothershipChatResourceContract,
|
||||
@@ -226,14 +228,21 @@ export async function fetchChatHistory(
|
||||
chatId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<TaskChatHistory> {
|
||||
// boundary-raw-fetch: mothership chat GET returns variable shape with optional stream snapshots
|
||||
const mothershipRes = await fetch(`/api/mothership/chats/${chatId}`, { signal })
|
||||
|
||||
if (mothershipRes.ok) {
|
||||
return parseChatHistory(await mothershipRes.json(), 'mothership')
|
||||
try {
|
||||
const data = await requestJson(getMothershipChatContract, {
|
||||
params: { chatId },
|
||||
signal,
|
||||
})
|
||||
return parseChatHistory(data, 'mothership')
|
||||
} catch (error) {
|
||||
if (!isApiClientError(error)) throw error
|
||||
// Fall through to the legacy copilot-shape alias on any HTTP error (typically 404
|
||||
// when the chat lives in the older copilot table and isn't a mothership-typed row).
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: copilot chat fallback returns a different mothership/copilot lifecycle shape
|
||||
// boundary-raw-fetch: legacy alias path /api/mothership/chat?chatId=... returns the
|
||||
// copilot lifecycle shape (activeStreamId, not conversationId) for chats stored under
|
||||
// the older copilot table; no contract exists for this alias path
|
||||
const copilotRes = await fetch(`/api/mothership/chat?chatId=${encodeURIComponent(chatId)}`, {
|
||||
signal,
|
||||
})
|
||||
@@ -546,6 +555,7 @@ export function useCreateTask(workspaceId?: string) {
|
||||
return createChat(workspaceId)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (!workspaceId) return
|
||||
const existing = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId)) ?? []
|
||||
const newTask: TaskMetadata = {
|
||||
id: data.id,
|
||||
@@ -557,6 +567,7 @@ export function useCreateTask(workspaceId?: string) {
|
||||
queryClient.setQueryData<TaskMetadata[]>(taskKeys.list(workspaceId), [newTask, ...existing])
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!workspaceId) return
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
},
|
||||
})
|
||||
@@ -578,6 +589,7 @@ export function useForkTask(workspaceId?: string) {
|
||||
return useMutation({
|
||||
mutationFn: forkChat,
|
||||
onSuccess: async (data, variables) => {
|
||||
if (!workspaceId) return
|
||||
await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
const existing = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId))
|
||||
if (existing) {
|
||||
@@ -597,6 +609,7 @@ export function useForkTask(workspaceId?: string) {
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!workspaceId) return
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ import { generateId } from '@sim/utils/id'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getWorkflowStateContract } from '@/lib/api/contracts'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { getBlock } from '@/blocks'
|
||||
@@ -30,7 +32,13 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { filterNewEdges, filterValidEdges, mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
|
||||
import type {
|
||||
BlockState,
|
||||
Loop,
|
||||
Parallel,
|
||||
Position,
|
||||
WorkflowState,
|
||||
} from '@/stores/workflows/workflow/types'
|
||||
import { findAllDescendantNodes, isBlockProtected } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
const logger = createLogger('CollaborativeWorkflow')
|
||||
@@ -554,40 +562,52 @@ export function useCollaborativeWorkflow() {
|
||||
}
|
||||
|
||||
const reloadWorkflowFromApi = async (workflowId: string, reason: string): Promise<boolean> => {
|
||||
const response = await fetch(`/api/workflows/${workflowId}`)
|
||||
if (!response.ok) {
|
||||
logger.error(`Failed to fetch workflow data after ${reason}: ${response.statusText}`)
|
||||
// The contract's `state` is `workflowStateSchema` (loose at the wire
|
||||
// level — `subBlocks.value` is `unknown`, optional flags omitted),
|
||||
// but downstream consumers (replaceWorkflowState, the undo/redo
|
||||
// graph) operate on the store's narrower `WorkflowState`. The
|
||||
// server-of-record persists store-shaped values, so the runtime
|
||||
// shape is the store type; we narrow once here at the trust
|
||||
// boundary instead of sprinkling per-field casts.
|
||||
let workflowState: WorkflowState | null = null
|
||||
try {
|
||||
const responseData = await requestJson(getWorkflowStateContract, {
|
||||
params: { id: workflowId },
|
||||
})
|
||||
const wireState = responseData.data?.state
|
||||
if (wireState) {
|
||||
// double-cast-allowed: workflowStateSchema is structurally a supertype of the store's WorkflowState (subBlocks.value is `unknown`, optional booleans, etc.); the server persists store-shaped values so the runtime shape matches
|
||||
workflowState = wireState as unknown as WorkflowState
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch workflow data after ${reason}`, { error })
|
||||
return false
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
const workflowData = responseData.data
|
||||
|
||||
if (!workflowData?.state) {
|
||||
logger.error(`No state found in workflow data after ${reason}`, { workflowData })
|
||||
if (!workflowState) {
|
||||
logger.error(`No state found in workflow data after ${reason}`, { workflowId })
|
||||
return false
|
||||
}
|
||||
|
||||
isApplyingRemoteChange.current = true
|
||||
try {
|
||||
useWorkflowStore.getState().replaceWorkflowState({
|
||||
blocks: workflowData.state.blocks || {},
|
||||
edges: workflowData.state.edges || [],
|
||||
loops: workflowData.state.loops || {},
|
||||
parallels: workflowData.state.parallels || {},
|
||||
lastSaved: workflowData.state.lastSaved || Date.now(),
|
||||
blocks: workflowState.blocks || {},
|
||||
edges: workflowState.edges || [],
|
||||
loops: workflowState.loops || {},
|
||||
parallels: workflowState.parallels || {},
|
||||
lastSaved: workflowState.lastSaved || Date.now(),
|
||||
})
|
||||
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowData.state.blocks || {}).forEach(([blockId, block]) => {
|
||||
const blockState = block as any
|
||||
const subblockValues: Record<string, Record<string, unknown>> = {}
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
subblockValues[blockId] = {}
|
||||
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = (subblock as any).value
|
||||
Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = subblock?.value
|
||||
})
|
||||
})
|
||||
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[workflowId]: subblockValues,
|
||||
@@ -595,10 +615,8 @@ export function useCollaborativeWorkflow() {
|
||||
}))
|
||||
|
||||
const graph = {
|
||||
blocksById: workflowData.state.blocks || {},
|
||||
edgesById: Object.fromEntries(
|
||||
(workflowData.state.edges || []).map((e: any) => [e.id, e])
|
||||
),
|
||||
blocksById: workflowState.blocks || {},
|
||||
edgesById: Object.fromEntries((workflowState.edges || []).map((e) => [e.id, e])),
|
||||
}
|
||||
|
||||
const undoRedoStore = useUndoRedoStore.getState()
|
||||
|
||||
@@ -208,6 +208,7 @@ export function useExecutionStream() {
|
||||
sharedAbortControllers.set(workflowId, abortController)
|
||||
|
||||
try {
|
||||
// boundary-raw-fetch: workflow execute endpoint returns an SSE stream consumed via response.body.getReader() and processSSEStream; also reads the X-Execution-Id response header
|
||||
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -285,6 +286,7 @@ export function useExecutionStream() {
|
||||
sharedAbortControllers.set(workflowId, abortController)
|
||||
|
||||
try {
|
||||
// boundary-raw-fetch: run-from-block endpoint returns an SSE stream consumed via response.body.getReader() and processSSEStream; also reads the X-Execution-Id response header
|
||||
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -358,6 +360,7 @@ export function useExecutionStream() {
|
||||
const abortController = new AbortController()
|
||||
sharedAbortControllers.set(workflowId, abortController)
|
||||
try {
|
||||
// boundary-raw-fetch: execution reconnect endpoint returns an SSE stream consumed via response.body.getReader() and processSSEStream
|
||||
const response = await fetch(
|
||||
`/api/workflows/${workflowId}/executions/${executionId}/stream?from=${fromEventId}`,
|
||||
{ signal: abortController.signal }
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { toast } from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listWorkspaceCredentialsContract } from '@/lib/api/contracts'
|
||||
import {
|
||||
ADD_CONNECTOR_SEARCH_PARAM,
|
||||
consumeOAuthReturnContext,
|
||||
@@ -21,14 +23,10 @@ async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise<string> {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/credentials?workspaceId=${encodeURIComponent(ctx.workspaceId)}&type=oauth`
|
||||
)
|
||||
const data = response.ok ? await response.json() : { credentials: [] }
|
||||
const oauthCredentials = (data.credentials ?? []) as Array<{
|
||||
displayName: string
|
||||
providerId: string | null
|
||||
}>
|
||||
const data = await requestJson(listWorkspaceCredentialsContract, {
|
||||
query: { workspaceId: ctx.workspaceId, type: 'oauth' },
|
||||
})
|
||||
const oauthCredentials = data.credentials ?? []
|
||||
|
||||
const forProvider = oauthCredentials.filter((c) => c.providerId === ctx.providerId)
|
||||
if (forProvider.length > ctx.preCount) {
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getAllowedIntegrationsContract } from '@/lib/api/contracts/common'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
@@ -30,9 +33,16 @@ function useAllowedIntegrationsFromEnv() {
|
||||
return useQuery<AllowedIntegrationsResponse>({
|
||||
queryKey: ['allowedIntegrations', 'env'],
|
||||
queryFn: async ({ signal }) => {
|
||||
const response = await fetch('/api/settings/allowed-integrations', { signal })
|
||||
if (!response.ok) return { allowedIntegrations: null }
|
||||
return response.json()
|
||||
try {
|
||||
return await requestJson(getAllowedIntegrationsContract, { signal })
|
||||
} catch (error) {
|
||||
// Treat any auth/server failure as "no env allowlist configured"
|
||||
// so the UI falls back to the permission-group-driven allowlist.
|
||||
if (error instanceof ApiClientError) {
|
||||
return { allowedIntegrations: null }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getVoiceSettingsContract } from '@/lib/api/contracts/common'
|
||||
import { speechTokenContract } from '@/lib/api/contracts/media-tools'
|
||||
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
|
||||
import {
|
||||
CHUNK_SEND_INTERVAL_MS,
|
||||
@@ -73,8 +77,7 @@ export function useSpeechToText({
|
||||
return
|
||||
}
|
||||
|
||||
fetch('/api/settings/voice', { credentials: 'include' })
|
||||
.then((r) => (r.ok ? r.json() : { sttAvailable: false }))
|
||||
requestJson(getVoiceSettingsContract, {})
|
||||
.then((data) => {
|
||||
if (mountedRef.current) setIsSupported(data.sttAvailable === true)
|
||||
})
|
||||
@@ -163,21 +166,19 @@ export function useSpeechToText({
|
||||
startingRef.current = true
|
||||
|
||||
try {
|
||||
const tokenResponse = await fetch('/api/speech/token', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
if (tokenResponse.status === 402) {
|
||||
let tokenData: Awaited<ReturnType<typeof requestJson<typeof speechTokenContract>>>
|
||||
try {
|
||||
tokenData = await requestJson(speechTokenContract, { body: {} })
|
||||
} catch (err) {
|
||||
if (isApiClientError(err) && err.status === 402) {
|
||||
onUsageLimitExceededRef.current?.()
|
||||
return false
|
||||
}
|
||||
const body = await tokenResponse.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Failed to get speech token')
|
||||
throw err instanceof Error ? err : new Error('Failed to get speech token')
|
||||
}
|
||||
|
||||
const { token } = await tokenResponse.json()
|
||||
const token = typeof tokenData.token === 'string' ? tokenData.token : undefined
|
||||
if (!token) throw new Error('Failed to get speech token')
|
||||
if (!mountedRef.current) return false
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
|
||||
@@ -70,6 +70,7 @@ if (typeof window !== 'undefined') {
|
||||
const sent = navigator.sendBeacon('/api/telemetry', payload)
|
||||
|
||||
if (!sent) {
|
||||
// boundary-raw-fetch: pre-bootstrap instrumentation
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -80,6 +81,7 @@ if (typeof window !== 'undefined') {
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// boundary-raw-fetch: pre-bootstrap instrumentation
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -10,7 +10,12 @@ import type {
|
||||
EmptySchemaOutput,
|
||||
} from '@/lib/api/contracts'
|
||||
|
||||
type MaybeField<Key extends string, Value> = Value extends undefined
|
||||
// Tuple-wrapped to suppress distributive conditionals: when `Value` is a
|
||||
// union (e.g. a discriminated union body), naked `Value extends undefined`
|
||||
// distributes and produces `{ body: A } | { body: B }` instead of
|
||||
// `{ body: A | B }`. The `[Value] extends [undefined]` form preserves the
|
||||
// union as-is. See request.test.ts for repro and rationale.
|
||||
type MaybeField<Key extends string, Value> = [Value] extends [undefined]
|
||||
? { [K in Key]?: never }
|
||||
: { [K in Key]: Value }
|
||||
|
||||
|
||||
@@ -77,6 +77,17 @@ export const v1AdminAuditLogsQuerySchema = z.object({
|
||||
endDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()),
|
||||
})
|
||||
|
||||
/**
|
||||
* Generic wrapper used by v1 admin audit-log responses. The `data` and
|
||||
* `limits` halves are intentionally `z.unknown()` because this proxy returns
|
||||
* provider-shaped payloads that vary per route family; tightening here would
|
||||
* require a discriminated union per route, which is tracked as a follow-up.
|
||||
*
|
||||
* boundary-policy: this is the "validates nothing" alias form that the audit
|
||||
* script's `untyped-response` regex doesn't currently catch. Treat any new
|
||||
* wrapper of this shape the same way and either annotate at the contract use
|
||||
* site with `// untyped-response: <reason>` or replace with a concrete schema.
|
||||
*/
|
||||
const apiResponseWithLimitsSchema = z
|
||||
.object({
|
||||
data: z.unknown(),
|
||||
@@ -84,19 +95,38 @@ const apiResponseWithLimitsSchema = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const enterpriseAuditLogEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string().nullable(),
|
||||
actorId: z.string().nullable(),
|
||||
actorName: z.string().nullable(),
|
||||
actorEmail: z.string().nullable(),
|
||||
action: z.string(),
|
||||
resourceType: z.string(),
|
||||
resourceId: z.string().nullable(),
|
||||
resourceName: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
metadata: z.unknown(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
export type EnterpriseAuditLogEntry = z.output<typeof enterpriseAuditLogEntrySchema>
|
||||
|
||||
export const listAuditLogsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.array(enterpriseAuditLogEntrySchema),
|
||||
nextCursor: z.string().optional(),
|
||||
})
|
||||
|
||||
export type AuditLogPage = z.output<typeof listAuditLogsResponseSchema>
|
||||
|
||||
export const listAuditLogsContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/audit-logs',
|
||||
query: auditLogsQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z
|
||||
.object({
|
||||
success: z.literal(true),
|
||||
data: z.array(z.unknown()),
|
||||
nextCursor: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
schema: listAuditLogsResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
|
||||
export const ssoProvidersQuerySchema = z.object({
|
||||
organizationId: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const authProviderStatusResponseSchema = z.object({
|
||||
githubAvailable: z.boolean(),
|
||||
googleAvailable: z.boolean(),
|
||||
registrationDisabled: z.boolean(),
|
||||
})
|
||||
|
||||
const ssoMappingSchema = z
|
||||
.object({
|
||||
id: z.string().default('sub'),
|
||||
@@ -81,3 +88,38 @@ export const ssoRegistrationContract = defineRouteContract({
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const ssoProviderListEntrySchema = z.object({
|
||||
id: z.string().optional(),
|
||||
providerId: z.string().optional(),
|
||||
domain: z.string().nullable(),
|
||||
issuer: z.string().nullable().optional(),
|
||||
oidcConfig: z.string().nullable().optional(),
|
||||
samlConfig: z.string().nullable().optional(),
|
||||
userId: z.string().nullable().optional(),
|
||||
organizationId: z.string().nullable().optional(),
|
||||
providerType: z.enum(['oidc', 'saml']).optional(),
|
||||
})
|
||||
|
||||
export const listSsoProvidersContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/auth/sso/providers',
|
||||
query: ssoProvidersQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
providers: z.array(ssoProviderListEntrySchema),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const getAuthProvidersContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/auth/providers',
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: authProviderStatusResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
export type AuthProviderStatusResponse = ContractJsonResponse<typeof getAuthProvidersContract>
|
||||
|
||||
@@ -57,6 +57,19 @@ export const getAllowedProvidersContract = defineRouteContract({
|
||||
},
|
||||
})
|
||||
|
||||
export const getAllowedIntegrationsContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/settings/allowed-integrations',
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
// `null` means "no env-derived allowlist" (unrestricted); a non-null
|
||||
// array narrows the visible integrations.
|
||||
allowedIntegrations: z.array(z.string()).nullable(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const getVoiceSettingsContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/settings/voice',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import type { ContractBodyInput } from '@/lib/api/contracts/types'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
@@ -64,6 +65,12 @@ export const contactRequestSchema = z.object({
|
||||
export type ContactRequestPayload = z.infer<typeof contactRequestSchema>
|
||||
export type ContactRequestBody = z.input<typeof contactRequestSchema>
|
||||
|
||||
export const submitContactBodySchema = contactRequestSchema.extend({
|
||||
website: z.string().optional(),
|
||||
captchaToken: z.string().optional(),
|
||||
captchaUnavailable: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export function getContactTopicLabel(value: ContactRequestPayload['topic']): string {
|
||||
return CONTACT_TOPIC_OPTIONS.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
@@ -91,9 +98,11 @@ export const contactResponseSchema = z.object({
|
||||
export const submitContactContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/contact',
|
||||
body: contactRequestSchema,
|
||||
body: submitContactBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: contactResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
export type SubmitContactBody = ContractBodyInput<typeof submitContactContract>
|
||||
|
||||
@@ -63,6 +63,13 @@ export const credentialSetInvitationDetailSchema = z.object({
|
||||
invitedBy: z.string(),
|
||||
})
|
||||
|
||||
export const credentialSetInvitePreviewSchema = z.object({
|
||||
credentialSetName: z.string(),
|
||||
organizationName: z.string(),
|
||||
providerId: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
})
|
||||
|
||||
export const credentialSetMemberSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
@@ -85,6 +92,7 @@ export type CredentialSetMembership = z.output<typeof credentialSetMembershipSch
|
||||
export type CredentialSetInvitation = z.output<typeof credentialSetInvitationSchema>
|
||||
export type CredentialSetMember = z.output<typeof credentialSetMemberSchema>
|
||||
export type CredentialSetInvitationDetail = z.output<typeof credentialSetInvitationDetailSchema>
|
||||
export type CredentialSetInvitePreview = z.output<typeof credentialSetInvitePreviewSchema>
|
||||
|
||||
export const listCredentialSetsQuerySchema = z.object({
|
||||
organizationId: z.string().min(1),
|
||||
@@ -242,6 +250,18 @@ export const listCredentialSetInvitationDetailsContract = defineRouteContract({
|
||||
},
|
||||
})
|
||||
|
||||
export const getCredentialSetInvitationContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/credential-sets/invite/[token]',
|
||||
params: credentialSetInviteTokenParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
invitation: credentialSetInvitePreviewSchema,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const acceptCredentialSetInvitationContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/credential-sets/invite/[token]',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const workflowStateSchema = z.custom<WorkflowState>(
|
||||
const deployedWorkflowStateSchema = z.custom<WorkflowState>(
|
||||
(value) => typeof value === 'object' && value !== null,
|
||||
'Expected workflow state'
|
||||
)
|
||||
@@ -157,7 +157,7 @@ export const updatePublicApiResponseSchema = z.object({
|
||||
export type UpdatePublicApiResponse = z.output<typeof updatePublicApiResponseSchema>
|
||||
|
||||
export const deployedWorkflowStateResponseSchema = z.object({
|
||||
deployedState: workflowStateSchema.nullable(),
|
||||
deployedState: deployedWorkflowStateSchema.nullable(),
|
||||
})
|
||||
|
||||
export type DeployedWorkflowStateResponse = z.output<typeof deployedWorkflowStateResponseSchema>
|
||||
@@ -251,7 +251,7 @@ export const getDeploymentVersionStateContract = defineRouteContract({
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
deployedState: workflowStateSchema,
|
||||
deployedState: deployedWorkflowStateSchema,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -69,6 +69,39 @@ export const invitationActionBodySchema = z.object({
|
||||
token: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const invitationDetailsSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(['organization', 'workspace']),
|
||||
email: z.string(),
|
||||
organizationId: z.string().nullable(),
|
||||
organizationName: z.string().nullable(),
|
||||
membershipIntent: z.enum(['internal', 'external']),
|
||||
role: z.string(),
|
||||
status: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
inviterName: z.string().nullable(),
|
||||
inviterEmail: z.string().nullable(),
|
||||
grants: z.array(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
workspaceName: z.string().nullable(),
|
||||
permission: workspacePermissionSchema,
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export const acceptInvitationResponseSchema = z.object({
|
||||
success: z.literal(true),
|
||||
redirectPath: z.string(),
|
||||
invitation: z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(['organization', 'workspace']),
|
||||
organizationId: z.string().nullable(),
|
||||
acceptedWorkspaceIds: z.array(z.string()),
|
||||
}),
|
||||
})
|
||||
|
||||
const successResponseSchema = z
|
||||
.object({
|
||||
success: z.boolean(),
|
||||
@@ -107,6 +140,41 @@ export const updateInvitationContract = defineRouteContract({
|
||||
},
|
||||
})
|
||||
|
||||
export const getInvitationContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/invitations/[id]',
|
||||
params: invitationParamsSchema,
|
||||
query: invitationQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
invitation: invitationDetailsSchema,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const acceptInvitationContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/invitations/[id]/accept',
|
||||
params: invitationActionParamsSchema,
|
||||
body: invitationActionBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: acceptInvitationResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
export const rejectInvitationContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/invitations/[id]/reject',
|
||||
params: invitationActionParamsSchema,
|
||||
body: invitationActionBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: successResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
export const cancelInvitationContract = defineRouteContract({
|
||||
method: 'DELETE',
|
||||
path: '/api/invitations/[id]',
|
||||
@@ -140,3 +208,4 @@ export const removeWorkspaceMemberContract = defineRouteContract({
|
||||
|
||||
export type PendingInvitationRow = z.infer<typeof pendingWorkspaceInvitationSchema>
|
||||
export type BatchInvitationResult = z.infer<typeof batchInvitationResultSchema>
|
||||
export type InvitationDetails = z.infer<typeof invitationDetailsSchema>
|
||||
|
||||
@@ -109,6 +109,16 @@ export const createKnowledgeChunkContract = defineRouteContract({
|
||||
},
|
||||
})
|
||||
|
||||
export const getKnowledgeChunkContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]',
|
||||
params: knowledgeChunkParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: successResponseSchema(chunkDataSchema),
|
||||
},
|
||||
})
|
||||
|
||||
export const updateKnowledgeChunkContract = defineRouteContract({
|
||||
method: 'PUT',
|
||||
path: '/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]',
|
||||
|
||||
@@ -62,6 +62,21 @@ export const saveDocumentTagDefinitionsDataSchema = z
|
||||
.or(z.array(tagDefinitionDataSchema))
|
||||
export type SaveDocumentTagDefinitionsResult = z.output<typeof saveDocumentTagDefinitionsDataSchema>
|
||||
|
||||
export const tagUsageDocumentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
tagValue: z.string(),
|
||||
})
|
||||
|
||||
export const tagUsageDataSchema = z.object({
|
||||
tagName: z.string(),
|
||||
tagSlot: z.string(),
|
||||
documentCount: z.number(),
|
||||
documents: z.array(tagUsageDocumentSchema),
|
||||
})
|
||||
|
||||
export type TagUsageData = z.output<typeof tagUsageDataSchema>
|
||||
|
||||
export const listTagDefinitionsContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/knowledge/[id]/tag-definitions',
|
||||
@@ -141,6 +156,6 @@ export const getTagUsageContract = defineRouteContract({
|
||||
params: knowledgeBaseParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: successResponseSchema(z.unknown()),
|
||||
schema: successResponseSchema(z.array(tagUsageDataSchema)),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
|
||||
const comparisonOperatorSchema = z.enum(['=', '>', '<', '>=', '<=', '!='])
|
||||
@@ -131,8 +132,8 @@ export const v1ListLogsQuerySchema = z.object({
|
||||
maxCost: z.coerce.number().optional(),
|
||||
model: z.string().optional(),
|
||||
details: z.enum(['basic', 'full']).optional().default('basic'),
|
||||
includeTraceSpans: z.coerce.boolean().optional().default(false),
|
||||
includeFinalOutput: z.coerce.boolean().optional().default(false),
|
||||
includeTraceSpans: booleanQueryFlagSchema.optional().default(false),
|
||||
includeFinalOutput: booleanQueryFlagSchema.optional().default(false),
|
||||
limit: z.coerce.number().optional().default(100),
|
||||
cursor: z.string().optional(),
|
||||
order: z.enum(['desc', 'asc']).optional().default('desc'),
|
||||
|
||||
@@ -333,7 +333,19 @@ export const fileManageBodySchema = z.discriminatedUnion('operation', [
|
||||
fileManageAppendBodySchema,
|
||||
])
|
||||
|
||||
const toolJsonResponseSchema = z.record(z.string(), z.unknown())
|
||||
const toolJsonResponseSchema = z
|
||||
.object({
|
||||
success: z.boolean().optional(),
|
||||
output: z.unknown().optional(),
|
||||
error: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
data: z.unknown().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const speechTokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
})
|
||||
|
||||
export const imageProxyContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
@@ -423,7 +435,7 @@ export const speechTokenContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/speech/token',
|
||||
body: speechTokenBodySchema,
|
||||
response: { mode: 'json', schema: toolJsonResponseSchema },
|
||||
response: { mode: 'json', schema: speechTokenResponseSchema },
|
||||
})
|
||||
|
||||
export const fileManageContract = defineRouteContract({
|
||||
|
||||
@@ -122,6 +122,17 @@ export const oauthAuthorizeParamsQuerySchema = z.object({
|
||||
consent_code: z.string({ error: 'consent_code is required' }).min(1, 'consent_code is required'),
|
||||
})
|
||||
|
||||
export const oauthAuthorizeParamsResponseSchema = z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uri: z.string(),
|
||||
scope: z.string(),
|
||||
code_challenge: z.string(),
|
||||
code_challenge_method: z.string(),
|
||||
state: z.string().nullable(),
|
||||
nonce: z.string().nullable(),
|
||||
response_type: z.literal('code'),
|
||||
})
|
||||
|
||||
const SHOPIFY_SHOP_DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/
|
||||
export const shopifyShopDomainSchema = z.string().regex(SHOPIFY_SHOP_DOMAIN_REGEX)
|
||||
|
||||
@@ -136,6 +147,16 @@ export const listOAuthConnectionsContract = defineRouteContract({
|
||||
},
|
||||
})
|
||||
|
||||
export const oauthAuthorizeParamsContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/auth/oauth2/authorize-params',
|
||||
query: oauthAuthorizeParamsQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: oauthAuthorizeParamsResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
export const disconnectOAuthContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/auth/oauth/disconnect',
|
||||
@@ -187,3 +208,4 @@ export const trelloCallbackContract = defineRouteContract({
|
||||
export type StoreTrelloTokenBody = ContractBody<typeof storeTrelloTokenContract>
|
||||
export type StoreTrelloTokenBodyInput = ContractBodyInput<typeof storeTrelloTokenContract>
|
||||
export type StoreTrelloTokenResponse = ContractJsonResponse<typeof storeTrelloTokenContract>
|
||||
export type OAuthAuthorizeParamsResponse = ContractJsonResponse<typeof oauthAuthorizeParamsContract>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user