Compare commits

..

22 Commits

Author SHA1 Message Date
waleed
6ee73fa22f update change detection to account for synthetic tool ids 2026-02-12 12:28:47 -08:00
waleed
b1cde0265c update styling + move predeploy checks earlier for first time deploys 2026-02-12 12:24:51 -08:00
waleed
54ed579dbd fix spacing and optional tag 2026-02-12 12:05:49 -08:00
waleed
837a13ec4f fix(tool-input): increase gap between SubBlock params for visual clarity
SubBlock's internal gap (10px between label and input) matched the
between-parameter gap (10px), making them indistinguishable. Increase
the between-parameter gap to 14px so consecutive parameters are
visually distinct, matching the separation seen in ParameterWithLabel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:54:45 -08:00
waleed
f707636162 fix(tool-input): apply extra top padding only to SubBlock-first path
Revert container padding to py-[8px] (MCP tools were correct).
Wrap SubBlock-first output in a div with pt-[4px] so only registry
tools get extra breathing room from the container top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:52:24 -08:00
waleed
b65768bc41 fix(tool-input): increase top padding of expanded tool body
Bump the expanded tool body container's top padding from 8px to 12px
for more breathing room between the header bar and the first parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:51:07 -08:00
waleed
3e17627d78 fix(tool-input): align (optional) text to baseline instead of center
Use items-baseline instead of items-center on Label flex containers
so the smaller (optional) text aligns with the label text baseline
rather than sitting slightly below it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:43:15 -08:00
waleed
a25b26e1e9 fix(tool-input): correct workflow selector visibility and tighten (optional) spacing
- Set workflowId param to user-only in workflow_executor tool config
  so "Select Workflow" no longer shows "(optional)" indicator
- Tighten (optional) label spacing with -ml-[3px] to counteract
  parent Label's gap-[6px], making it feel inline with the label text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:42:45 -08:00
waleed
41ed85905d fix(tool-input): auto-refresh workflow inputs after redeploy
After redeploying a child workflow via the stale badge, the workflow
state cache was not invalidated, so WorkflowInputMapperInput kept
showing stale input fields until page refresh. Now invalidates
workflowKeys.state on deploy success.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:27:50 -08:00
waleed
c43f502ffb fix(tool-input): render uncovered tool params alongside subblocks
The SubBlock-first rendering path was hard-returning after rendering
subblocks, so tool params without matching subblocks (like inputMapping
for workflow tools) were never rendered. Now renders subblocks first,
then any remaining displayParams not covered by subblocks via the legacy
ParameterWithLabel fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:23:14 -08:00
waleed
a29afd2757 cleanup 2026-02-12 10:41:07 -08:00
waleed
8af561782d add sibling values to subblock context since subblock store isn't relevant in tool input, and removed unused param 2026-02-12 10:34:25 -08:00
waleed
a0ebe0842b fix(tool-input): restore optional indicator, fix folder selector and canonical toggle, extract components
- Attach resolved paramVisibility to subblocks from getSubBlocksForToolInput
- Add labelSuffix prop to SubBlock for "(optional)" badge on user-or-llm params
- Fix folder selector missing for tools with canonicalParamId (e.g. Google Drive)
- Fix canonical toggle not clickable by letting SubBlock handle dependsOn internally
- Extract ParameterWithLabel, ToolSubBlockRenderer, ToolCredentialSelector to components/tools/
- Extract StoredTool interface to types.ts, selection helpers to utils.ts
- Remove dead code (mcpError, refreshTools, oldParamIds, initialParams)
- Strengthen typing: replace any with proper types on icon components and evaluateParameterCondition
2026-02-12 00:39:22 -08:00
waleed
d236cc8ad0 refactor(tool-input): eliminate SyncWrappers, add canonical toggle and dependsOn gating
Replace 17+ individual SyncWrapper components with a single centralized
ToolSubBlockRenderer that bridges the subblock store with StoredTool.params
via synthetic store keys. This reduces ~1000 lines of duplicated wrapper
code and ensures tool-input renders subblock components identically to
the standalone SubBlock path.

- Add ToolSubBlockRenderer with bidirectional store sync
- Add basic/advanced mode toggle (ArrowLeftRight) using collaborative functions
- Add dependsOn gating via useDependsOnGate (fields disable instead of hiding)
- Add paramVisibility field to SubBlockConfig for tool-input visibility control
- Pass canonicalModeOverrides through getSubBlocksForToolInput
- Show (optional) label for non-user-only fields (LLM can inject at runtime)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 22:46:44 -08:00
Waleed
81dfeb0bb0 fix(terminal): reconnect to running executions after page refresh (#3200)
* fix(terminal): reconnect to running executions after page refresh

* fix(terminal): use ExecutionEvent type instead of any in reconnection stream

* fix(execution): type event buffer with ExecutionEvent instead of Record<string, unknown>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(execution): validate fromEventId query param in reconnection endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix some bugs

* fix(variables): fix tag dropdown and cursor alignment in variables block (#3199)

* feat(confluence): added list space labels, delete label, delete page prop (#3201)

* updated route

* ack comments

* fix(execution): reset execution state in reconnection cleanup to unblock re-entry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(execution): restore running entries when reconnection is interrupted by navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* done

* remove cast in ioredis types

* ack PR comments

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
2026-02-11 19:31:29 -08:00
Waleed
01577a18b4 fix(change-detection): resolve false positive trigger block change detection (#3204) 2026-02-11 17:24:17 -08:00
Vikhyath Mondreti
52aff4d60b fix build 2026-02-11 15:33:22 -08:00
Waleed
3a3bddd6f8 fix(confl): use recommended query param pattern for confluence route (#3202)
* fix(confl): use recommended query param pattern for confluence route

* use unused var
2026-02-11 14:59:26 -08:00
Waleed
639d50d6b9 feat(confluence): added list space labels, delete label, delete page prop (#3201) 2026-02-11 14:40:31 -08:00
Waleed
cec74e09c2 fix(variables): fix tag dropdown and cursor alignment in variables block (#3199) 2026-02-11 14:40:31 -08:00
Waleed
d5a756c9f2 fix(hotkeys): remove C, T, E tab-switching hotkeys (#3197) 2026-02-11 13:24:00 -08:00
Waleed
f3e994baf0 improvement(oom): increase trigger machine size (#3196) 2026-02-11 13:11:28 -08:00
35 changed files with 1165 additions and 13526 deletions

View File

@@ -1,215 +0,0 @@
/**
* POST /api/attribution
*
* Automatic UTM-based referral attribution for new signups.
*
* Reads the `sim_utm` cookie (set by proxy on auth pages), verifies the user
* account was created after the cookie was set, matches a campaign by UTM
* specificity, and atomically inserts an attribution record + applies bonus credits.
*
* Idempotent — the unique constraint on `userId` prevents double-attribution.
*/
import { db } from '@sim/db'
import { referralAttribution, referralCampaigns, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
const logger = createLogger('AttributionAPI')
const COOKIE_NAME = 'sim_utm'
const CLOCK_DRIFT_TOLERANCE_MS = 60 * 1000
const UtmCookieSchema = z.object({
utm_source: z.string().optional(),
utm_medium: z.string().optional(),
utm_campaign: z.string().optional(),
utm_content: z.string().optional(),
referrer_url: z.string().optional(),
landing_page: z.string().optional(),
created_at: z.string().min(1),
})
/**
* Finds the most specific active campaign matching the given UTM params.
* Null fields on a campaign act as wildcards. Ties broken by newest campaign.
*/
async function findMatchingCampaign(utmData: z.infer<typeof UtmCookieSchema>) {
const campaigns = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.isActive, true))
let bestMatch: (typeof campaigns)[number] | null = null
let bestScore = -1
for (const campaign of campaigns) {
let score = 0
let mismatch = false
const fields = [
{ campaignVal: campaign.utmSource, utmVal: utmData.utm_source },
{ campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium },
{ campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign },
{ campaignVal: campaign.utmContent, utmVal: utmData.utm_content },
] as const
for (const { campaignVal, utmVal } of fields) {
if (campaignVal === null) continue
if (campaignVal === utmVal) {
score++
} else {
mismatch = true
break
}
}
if (!mismatch && score > 0) {
if (
score > bestScore ||
(score === bestScore &&
bestMatch &&
campaign.createdAt.getTime() > bestMatch.createdAt.getTime())
) {
bestScore = score
bestMatch = campaign
}
}
}
return bestMatch
}
export async function POST() {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const cookieStore = await cookies()
const utmCookie = cookieStore.get(COOKIE_NAME)
if (!utmCookie?.value) {
return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' })
}
let utmData: z.infer<typeof UtmCookieSchema>
try {
let decoded: string
try {
decoded = decodeURIComponent(utmCookie.value)
} catch {
decoded = utmCookie.value
}
utmData = UtmCookieSchema.parse(JSON.parse(decoded))
} catch {
logger.warn('Failed to parse UTM cookie', { userId: session.user.id })
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
}
const cookieCreatedAt = Number(utmData.created_at)
if (!Number.isFinite(cookieCreatedAt)) {
logger.warn('UTM cookie has invalid created_at timestamp', { userId: session.user.id })
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
}
const userRows = await db
.select({ createdAt: user.createdAt })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
if (userRows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const userCreatedAt = userRows[0].createdAt.getTime()
if (userCreatedAt < cookieCreatedAt - CLOCK_DRIFT_TOLERANCE_MS) {
logger.info('User account predates UTM cookie, skipping attribution', {
userId: session.user.id,
userCreatedAt: new Date(userCreatedAt).toISOString(),
cookieCreatedAt: new Date(cookieCreatedAt).toISOString(),
})
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'account_predates_cookie' })
}
const matchedCampaign = await findMatchingCampaign(utmData)
if (!matchedCampaign) {
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'no_matching_campaign' })
}
const bonusAmount = Number(matchedCampaign.bonusCreditAmount)
let attributed = false
await db.transaction(async (tx) => {
const [existingStats] = await tx
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, session.user.id))
.limit(1)
if (!existingStats) {
await tx.insert(userStats).values({
id: nanoid(),
userId: session.user.id,
})
}
const result = await tx
.insert(referralAttribution)
.values({
id: nanoid(),
userId: session.user.id,
campaignId: matchedCampaign.id,
utmSource: utmData.utm_source || null,
utmMedium: utmData.utm_medium || null,
utmCampaign: utmData.utm_campaign || null,
utmContent: utmData.utm_content || null,
referrerUrl: utmData.referrer_url || null,
landingPage: utmData.landing_page || null,
bonusCreditAmount: bonusAmount.toString(),
})
.onConflictDoNothing({ target: referralAttribution.userId })
.returning({ id: referralAttribution.id })
if (result.length > 0) {
await applyBonusCredits(session.user.id, bonusAmount, tx)
attributed = true
}
})
if (attributed) {
logger.info('Referral attribution created and bonus credits applied', {
userId: session.user.id,
campaignId: matchedCampaign.id,
campaignName: matchedCampaign.name,
utmSource: utmData.utm_source,
utmCampaign: utmData.utm_campaign,
utmContent: utmData.utm_content,
bonusAmount,
})
} else {
logger.info('User already attributed, skipping', { userId: session.user.id })
}
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({
attributed,
bonusAmount: attributed ? bonusAmount : undefined,
})
} catch (error) {
logger.error('Attribution error', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,170 +0,0 @@
/**
* POST /api/referral-code/redeem
*
* Redeem a referral/promo code to receive bonus credits.
*
* Body:
* - code: string — The referral code to redeem
*
* Response: { redeemed: boolean, bonusAmount?: number, error?: string }
*
* Constraints:
* - Enterprise users cannot redeem codes
* - One redemption per user, ever (unique constraint on userId)
* - One redemption per organization for team users (partial unique on organizationId)
*/
import { db } from '@sim/db'
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
const logger = createLogger('ReferralCodeRedemption')
const RedeemCodeSchema = z.object({
code: z.string().min(1, 'Code is required'),
})
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { code } = RedeemCodeSchema.parse(body)
const subscription = await getHighestPrioritySubscription(session.user.id)
if (subscription?.plan === 'enterprise') {
return NextResponse.json({
redeemed: false,
error: 'Enterprise accounts cannot redeem referral codes',
})
}
const isTeam = subscription?.plan === 'team'
const orgId = isTeam ? subscription.referenceId : null
const normalizedCode = code.trim().toUpperCase()
const [campaign] = await db
.select()
.from(referralCampaigns)
.where(and(eq(referralCampaigns.code, normalizedCode), eq(referralCampaigns.isActive, true)))
.limit(1)
if (!campaign) {
logger.info('Invalid code redemption attempt', {
userId: session.user.id,
code: normalizedCode,
})
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 })
}
const [existingUserAttribution] = await db
.select({ id: referralAttribution.id })
.from(referralAttribution)
.where(eq(referralAttribution.userId, session.user.id))
.limit(1)
if (existingUserAttribution) {
return NextResponse.json({
redeemed: false,
error: 'You have already redeemed a code',
})
}
if (orgId) {
const [existingOrgAttribution] = await db
.select({ id: referralAttribution.id })
.from(referralAttribution)
.where(eq(referralAttribution.organizationId, orgId))
.limit(1)
if (existingOrgAttribution) {
return NextResponse.json({
redeemed: false,
error: 'A code has already been redeemed for your organization',
})
}
}
const bonusAmount = Number(campaign.bonusCreditAmount)
let redeemed = false
await db.transaction(async (tx) => {
const [existingStats] = await tx
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, session.user.id))
.limit(1)
if (!existingStats) {
await tx.insert(userStats).values({
id: nanoid(),
userId: session.user.id,
})
}
const result = await tx
.insert(referralAttribution)
.values({
id: nanoid(),
userId: session.user.id,
organizationId: orgId,
campaignId: campaign.id,
utmSource: null,
utmMedium: null,
utmCampaign: null,
utmContent: null,
referrerUrl: null,
landingPage: null,
bonusCreditAmount: bonusAmount.toString(),
})
.onConflictDoNothing()
.returning({ id: referralAttribution.id })
if (result.length > 0) {
await applyBonusCredits(session.user.id, bonusAmount, tx)
redeemed = true
}
})
if (redeemed) {
logger.info('Referral code redeemed', {
userId: session.user.id,
organizationId: orgId,
code: normalizedCode,
campaignId: campaign.id,
campaignName: campaign.name,
bonusAmount,
})
}
if (!redeemed) {
return NextResponse.json({
redeemed: false,
error: 'You have already redeemed a code',
})
}
return NextResponse.json({
redeemed: true,
bonusAmount,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
logger.error('Referral code redemption error', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -66,12 +66,6 @@
* Credits:
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
*
* Referral Campaigns:
* GET /api/v1/admin/referral-campaigns - List campaigns (?active=true/false)
* POST /api/v1/admin/referral-campaigns - Create campaign
* GET /api/v1/admin/referral-campaigns/:id - Get campaign details
* PATCH /api/v1/admin/referral-campaigns/:id - Update campaign fields
*
* Access Control (Permission Groups):
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
@@ -103,7 +97,6 @@ export type {
AdminOrganization,
AdminOrganizationBillingSummary,
AdminOrganizationDetail,
AdminReferralCampaign,
AdminSeatAnalytics,
AdminSingleResponse,
AdminSubscription,
@@ -118,7 +111,6 @@ export type {
AdminWorkspaceMember,
DbMember,
DbOrganization,
DbReferralCampaign,
DbSubscription,
DbUser,
DbUserStats,
@@ -147,7 +139,6 @@ export {
parseWorkflowVariables,
toAdminFolder,
toAdminOrganization,
toAdminReferralCampaign,
toAdminSubscription,
toAdminUser,
toAdminWorkflow,

View File

@@ -1,142 +0,0 @@
/**
* GET /api/v1/admin/referral-campaigns/:id
*
* Get a single referral campaign by ID.
*
* PATCH /api/v1/admin/referral-campaigns/:id
*
* Update campaign fields. All fields are optional.
*
* Body:
* - name: string (non-empty) - Campaign name
* - bonusCreditAmount: number (> 0) - Bonus credits in dollars
* - isActive: boolean - Enable/disable the campaign
* - code: string | null (min 6 chars, auto-uppercased, null to remove) - Redeemable code
* - utmSource: string | null - UTM source match (null = wildcard)
* - utmMedium: string | null - UTM medium match (null = wildcard)
* - utmCampaign: string | null - UTM campaign match (null = wildcard)
* - utmContent: string | null - UTM content match (null = wildcard)
*/
import { db } from '@sim/db'
import { referralCampaigns } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminReferralCampaign } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminReferralCampaignDetailAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
try {
const { id: campaignId } = await context.params
const [campaign] = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.id, campaignId))
.limit(1)
if (!campaign) {
return notFoundResponse('Campaign')
}
logger.info(`Admin API: Retrieved referral campaign ${campaignId}`)
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
} catch (error) {
logger.error('Admin API: Failed to get referral campaign', { error })
return internalErrorResponse('Failed to get referral campaign')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
try {
const { id: campaignId } = await context.params
const body = await request.json()
const [existing] = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.id, campaignId))
.limit(1)
if (!existing) {
return notFoundResponse('Campaign')
}
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (body.name !== undefined) {
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
return badRequestResponse('name must be a non-empty string')
}
updateData.name = body.name.trim()
}
if (body.bonusCreditAmount !== undefined) {
if (
typeof body.bonusCreditAmount !== 'number' ||
!Number.isFinite(body.bonusCreditAmount) ||
body.bonusCreditAmount <= 0
) {
return badRequestResponse('bonusCreditAmount must be a positive number')
}
updateData.bonusCreditAmount = body.bonusCreditAmount.toString()
}
if (body.isActive !== undefined) {
if (typeof body.isActive !== 'boolean') {
return badRequestResponse('isActive must be a boolean')
}
updateData.isActive = body.isActive
}
if (body.code !== undefined) {
if (body.code !== null) {
if (typeof body.code !== 'string') {
return badRequestResponse('code must be a string or null')
}
if (body.code.trim().length < 6) {
return badRequestResponse('code must be at least 6 characters')
}
}
updateData.code = body.code ? body.code.trim().toUpperCase() : null
}
for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) {
if (body[field] !== undefined) {
if (body[field] !== null && typeof body[field] !== 'string') {
return badRequestResponse(`${field} must be a string or null`)
}
updateData[field] = body[field] || null
}
}
const [updated] = await db
.update(referralCampaigns)
.set(updateData)
.where(eq(referralCampaigns.id, campaignId))
.returning()
logger.info(`Admin API: Updated referral campaign ${campaignId}`, {
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
})
return singleResponse(toAdminReferralCampaign(updated, getBaseUrl()))
} catch (error) {
logger.error('Admin API: Failed to update referral campaign', { error })
return internalErrorResponse('Failed to update referral campaign')
}
})

View File

@@ -1,140 +0,0 @@
/**
* GET /api/v1/admin/referral-campaigns
*
* List referral campaigns with optional filtering and pagination.
*
* Query Parameters:
* - active: string (optional) - Filter by active status ('true' or 'false')
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* POST /api/v1/admin/referral-campaigns
*
* Create a new referral campaign.
*
* Body:
* - name: string (required) - Campaign name
* - bonusCreditAmount: number (required, > 0) - Bonus credits in dollars
* - code: string | null (optional, min 6 chars, auto-uppercased) - Redeemable code
* - utmSource: string | null (optional) - UTM source match (null = wildcard)
* - utmMedium: string | null (optional) - UTM medium match (null = wildcard)
* - utmCampaign: string | null (optional) - UTM campaign match (null = wildcard)
* - utmContent: string | null (optional) - UTM content match (null = wildcard)
*/
import { db } from '@sim/db'
import { referralCampaigns } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq, type SQL } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminReferralCampaign,
createPaginationMeta,
parsePaginationParams,
toAdminReferralCampaign,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminReferralCampaignsAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
const activeFilter = url.searchParams.get('active')
try {
const conditions: SQL<unknown>[] = []
if (activeFilter === 'true') {
conditions.push(eq(referralCampaigns.isActive, true))
} else if (activeFilter === 'false') {
conditions.push(eq(referralCampaigns.isActive, false))
}
const whereClause = conditions.length > 0 ? conditions[0] : undefined
const baseUrl = getBaseUrl()
const [countResult, campaigns] = await Promise.all([
db.select({ total: count() }).from(referralCampaigns).where(whereClause),
db
.select()
.from(referralCampaigns)
.where(whereClause)
.orderBy(referralCampaigns.createdAt)
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminReferralCampaign[] = campaigns.map((c) => toAdminReferralCampaign(c, baseUrl))
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} referral campaigns (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list referral campaigns', { error })
return internalErrorResponse('Failed to list referral campaigns')
}
})
export const POST = withAdminAuth(async (request) => {
try {
const body = await request.json()
const { name, code, utmSource, utmMedium, utmCampaign, utmContent, bonusCreditAmount } = body
if (!name || typeof name !== 'string') {
return badRequestResponse('name is required and must be a string')
}
if (
typeof bonusCreditAmount !== 'number' ||
!Number.isFinite(bonusCreditAmount) ||
bonusCreditAmount <= 0
) {
return badRequestResponse('bonusCreditAmount must be a positive number')
}
if (code !== undefined && code !== null) {
if (typeof code !== 'string') {
return badRequestResponse('code must be a string or null')
}
if (code.trim().length < 6) {
return badRequestResponse('code must be at least 6 characters')
}
}
const id = nanoid()
const [campaign] = await db
.insert(referralCampaigns)
.values({
id,
name,
code: code ? code.trim().toUpperCase() : null,
utmSource: utmSource || null,
utmMedium: utmMedium || null,
utmCampaign: utmCampaign || null,
utmContent: utmContent || null,
bonusCreditAmount: bonusCreditAmount.toString(),
})
.returning()
logger.info(`Admin API: Created referral campaign ${id}`, {
name,
code: campaign.code,
bonusCreditAmount,
})
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
} catch (error) {
logger.error('Admin API: Failed to create referral campaign', { error })
return internalErrorResponse('Failed to create referral campaign')
}
})

View File

@@ -8,7 +8,6 @@
import type {
member,
organization,
referralCampaigns,
subscription,
user,
userStats,
@@ -32,7 +31,6 @@ export type DbOrganization = InferSelectModel<typeof organization>
export type DbSubscription = InferSelectModel<typeof subscription>
export type DbMember = InferSelectModel<typeof member>
export type DbUserStats = InferSelectModel<typeof userStats>
export type DbReferralCampaign = InferSelectModel<typeof referralCampaigns>
// =============================================================================
// Pagination
@@ -648,49 +646,3 @@ export interface AdminDeployResult {
export interface AdminUndeployResult {
isDeployed: boolean
}
// =============================================================================
// Referral Campaign Types
// =============================================================================
export interface AdminReferralCampaign {
id: string
name: string
code: string | null
utmSource: string | null
utmMedium: string | null
utmCampaign: string | null
utmContent: string | null
bonusCreditAmount: string
isActive: boolean
signupUrl: string | null
createdAt: string
updatedAt: string
}
export function toAdminReferralCampaign(
dbCampaign: DbReferralCampaign,
baseUrl: string
): AdminReferralCampaign {
const utmParams = new URLSearchParams()
if (dbCampaign.utmSource) utmParams.set('utm_source', dbCampaign.utmSource)
if (dbCampaign.utmMedium) utmParams.set('utm_medium', dbCampaign.utmMedium)
if (dbCampaign.utmCampaign) utmParams.set('utm_campaign', dbCampaign.utmCampaign)
if (dbCampaign.utmContent) utmParams.set('utm_content', dbCampaign.utmContent)
const query = utmParams.toString()
return {
id: dbCampaign.id,
name: dbCampaign.name,
code: dbCampaign.code,
utmSource: dbCampaign.utmSource,
utmMedium: dbCampaign.utmMedium,
utmCampaign: dbCampaign.utmCampaign,
utmContent: dbCampaign.utmContent,
bonusCreditAmount: dbCampaign.bonusCreditAmount,
isActive: dbCampaign.isActive,
signupUrl: query ? `${baseUrl}/signup?${query}` : null,
createdAt: dbCampaign.createdAt.toISOString(),
updatedAt: dbCampaign.updatedAt.toISOString(),
}
}

View File

@@ -1,7 +1,10 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
import { useNotificationStore } from '@/stores/notifications'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('useDeployment')
@@ -35,6 +38,24 @@ export function useDeployment({
return { success: true, shouldOpenModal: true }
}
const { blocks, edges, loops, parallels } = useWorkflowStore.getState()
const liveBlocks = mergeSubblockState(blocks, workflowId)
const checkResult = runPreDeployChecks({
blocks: liveBlocks,
edges,
loops,
parallels,
workflowId,
})
if (!checkResult.passed) {
addNotification({
level: 'error',
message: checkResult.error || 'Pre-deploy validation failed',
workflowId,
})
return { success: false, shouldOpenModal: false }
}
setIsDeploying(true)
try {
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {

View File

@@ -0,0 +1,186 @@
'use client'
import type React from 'react'
import { useRef, useState } from 'react'
import { ArrowLeftRight, ArrowUp } from 'lucide-react'
import { Button, Input, Label, Tooltip } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
/**
* Props for a generic parameter with label component
*/
export interface ParameterWithLabelProps {
paramId: string
title: string
isRequired: boolean
visibility: string
wandConfig?: {
enabled: boolean
prompt?: string
placeholder?: string
}
canonicalToggle?: {
mode: 'basic' | 'advanced'
disabled?: boolean
onToggle?: () => void
}
disabled: boolean
isPreview: boolean
children: (wandControlRef: React.MutableRefObject<WandControlHandlers | null>) => React.ReactNode
}
/**
* Generic wrapper component for parameters that manages wand state and renders label + input
*/
export function ParameterWithLabel({
paramId,
title,
isRequired,
visibility,
wandConfig,
canonicalToggle,
disabled,
isPreview,
children,
}: ParameterWithLabelProps) {
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
const wandControlRef = useRef<WandControlHandlers | null>(null)
const isWandEnabled = wandConfig?.enabled ?? false
const showWand = isWandEnabled && !isPreview && !disabled
const handleSearchClick = (): void => {
setIsSearchActive(true)
setTimeout(() => {
searchInputRef.current?.focus()
}, 0)
}
const handleSearchBlur = (): void => {
if (!searchQuery.trim() && !wandControlRef.current?.isWandStreaming) {
setIsSearchActive(false)
}
}
const handleSearchChange = (value: string): void => {
setSearchQuery(value)
}
const handleSearchSubmit = (): void => {
if (searchQuery.trim() && wandControlRef.current) {
wandControlRef.current.onWandTrigger(searchQuery)
setSearchQuery('')
setIsSearchActive(false)
}
}
const handleSearchCancel = (): void => {
setSearchQuery('')
setIsSearchActive(false)
}
const isStreaming = wandControlRef.current?.isWandStreaming ?? false
return (
<div key={paramId} className='relative min-w-0 space-y-[6px]'>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='flex items-baseline gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
{title}
{isRequired && visibility === 'user-only' && <span className='ml-0.5'>*</span>}
</Label>
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
{showWand &&
(!isSearchActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={handleSearchClick}
>
Generate
</Button>
) : (
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
<Input
ref={searchInputRef}
value={isStreaming ? 'Generating...' : searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleSearchChange(e.target.value)
}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
if (relatedTarget?.closest('button')) return
handleSearchBlur()
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) {
handleSearchSubmit()
} else if (e.key === 'Escape') {
handleSearchCancel()
}
}}
disabled={isStreaming}
className={cn(
'h-5 min-w-[80px] flex-1 text-[11px]',
isStreaming && 'text-muted-foreground'
)}
placeholder='Generate with AI...'
/>
<Button
variant='tertiary'
disabled={!searchQuery.trim() || isStreaming}
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
handleSearchSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
))}
{canonicalToggle && !isPreview && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
onClick={canonicalToggle.onToggle}
disabled={canonicalToggle.disabled || disabled}
aria-label={
canonicalToggle.mode === 'advanced'
? 'Switch to selector'
: 'Switch to manual ID'
}
>
<ArrowLeftRight
className={cn(
'!h-[12px] !w-[12px]',
canonicalToggle.mode === 'advanced'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]'
)}
/>
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{canonicalToggle.mode === 'advanced'
? 'Switch to selector'
: 'Switch to manual ID'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
<div className='relative w-full min-w-0'>{children(wandControlRef)}</div>
</div>
)
}

View File

@@ -0,0 +1,100 @@
'use client'
import { useEffect, useRef } from 'react'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
interface ToolSubBlockRendererProps {
blockId: string
subBlockId: string
toolIndex: number
subBlock: BlockSubBlockConfig
effectiveParamId: string
toolParams: Record<string, string> | undefined
onParamChange: (toolIndex: number, paramId: string, value: string) => void
disabled: boolean
canonicalToggle?: {
mode: 'basic' | 'advanced'
disabled?: boolean
onToggle?: () => void
}
}
/**
* Bridges the subblock store with StoredTool.params via a synthetic store key,
* then delegates all rendering to SubBlock for full parity.
*
* Two effects handle bidirectional sync:
* - tool.params → store (external changes)
* - store → tool.params (user interaction)
*/
export function ToolSubBlockRenderer({
blockId,
subBlockId,
toolIndex,
subBlock,
effectiveParamId,
toolParams,
onParamChange,
disabled,
canonicalToggle,
}: ToolSubBlockRendererProps) {
const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}`
const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId)
const toolParamValue = toolParams?.[effectiveParamId] ?? ''
/** Tracks the last value we pushed to the store from tool.params to avoid echo loops */
const lastPushedToStoreRef = useRef<string | null>(null)
/** Tracks the last value we synced back to tool.params from the store */
const lastPushedToParamsRef = useRef<string | null>(null)
// Sync tool.params → store: push when the prop value changes (including first mount)
useEffect(() => {
if (!toolParamValue && lastPushedToStoreRef.current === null) {
// Skip initializing the store with an empty value on first mount —
// let the SubBlock component use its own default.
lastPushedToStoreRef.current = toolParamValue
lastPushedToParamsRef.current = toolParamValue
return
}
if (toolParamValue !== lastPushedToStoreRef.current) {
lastPushedToStoreRef.current = toolParamValue
lastPushedToParamsRef.current = toolParamValue
setStoreValue(toolParamValue)
}
}, [toolParamValue, setStoreValue])
// Sync store → tool.params: push when the user changes the value via SubBlock
useEffect(() => {
if (storeValue == null) return
const stringValue = typeof storeValue === 'string' ? storeValue : JSON.stringify(storeValue)
if (stringValue !== lastPushedToParamsRef.current) {
lastPushedToParamsRef.current = stringValue
lastPushedToStoreRef.current = stringValue
onParamChange(toolIndex, effectiveParamId, stringValue)
}
}, [storeValue, toolIndex, effectiveParamId, onParamChange])
// Suppress SubBlock's "*" required indicator when the LLM can fill the param
const visibility = subBlock.paramVisibility ?? 'user-or-llm'
const isOptionalForUser = visibility !== 'user-only'
const config = {
...subBlock,
id: syntheticId,
...(isOptionalForUser && { required: false }),
}
return (
<SubBlock
blockId={blockId}
config={config}
isPreview={false}
disabled={disabled}
canonicalToggle={canonicalToggle}
dependencyContext={toolParams}
/>
)
}

View File

@@ -2,37 +2,12 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
interface StoredTool {
type: string
title?: string
toolId?: string
params?: Record<string, string>
customToolId?: string
schema?: any
code?: string
operation?: string
usageControl?: 'auto' | 'force' | 'none'
}
const isMcpToolAlreadySelected = (selectedTools: StoredTool[], mcpToolId: string): boolean => {
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
}
const isCustomToolAlreadySelected = (
selectedTools: StoredTool[],
customToolId: string
): boolean => {
return selectedTools.some(
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
)
}
const isWorkflowAlreadySelected = (selectedTools: StoredTool[], workflowId: string): boolean => {
return selectedTools.some(
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
)
}
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
import {
isCustomToolAlreadySelected,
isMcpToolAlreadySelected,
isWorkflowAlreadySelected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils'
describe('isMcpToolAlreadySelected', () => {
describe('basic functionality', () => {

View File

@@ -0,0 +1,31 @@
/**
* Represents a tool selected and configured in the workflow
*
* @remarks
* For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
* Everything else (title, schema, code) is loaded dynamically from the database.
* Legacy custom tools with inline schema/code are still supported for backwards compatibility.
*/
export interface StoredTool {
/** Block type identifier */
type: string
/** Display title for the tool (optional for new custom tool format) */
title?: string
/** Direct tool ID for execution (optional for new custom tool format) */
toolId?: string
/** Parameter values configured by the user (optional for new custom tool format) */
params?: Record<string, string>
/** Whether the tool details are expanded in UI */
isExpanded?: boolean
/** Database ID for custom tools (new format - reference only) */
customToolId?: string
/** Tool schema for custom tools (legacy format - inline JSON schema) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema?: Record<string, any>
/** Implementation code for custom tools (legacy format - inline) */
code?: string
/** Selected operation for multi-operation tools */
operation?: string
/** Tool usage control mode for LLM */
usageControl?: 'auto' | 'force' | 'none'
}

View File

@@ -0,0 +1,32 @@
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
/**
* Checks if an MCP tool is already selected.
*/
export function isMcpToolAlreadySelected(selectedTools: StoredTool[], mcpToolId: string): boolean {
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
}
/**
* Checks if a custom tool is already selected.
*/
export function isCustomToolAlreadySelected(
selectedTools: StoredTool[],
customToolId: string
): boolean {
return selectedTools.some(
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
)
}
/**
* Checks if a workflow is already selected.
*/
export function isWorkflowAlreadySelected(
selectedTools: StoredTool[],
workflowId: string
): boolean {
return selectedTools.some(
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
)
}

View File

@@ -3,7 +3,6 @@ import { isEqual } from 'lodash'
import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react'
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
import {
CheckboxList,
Code,
@@ -69,13 +68,15 @@ interface SubBlockProps {
isPreview?: boolean
subBlockValues?: Record<string, any>
disabled?: boolean
fieldDiffStatus?: FieldDiffStatus
allowExpandInPreview?: boolean
canonicalToggle?: {
mode: 'basic' | 'advanced'
disabled?: boolean
onToggle?: () => void
}
labelSuffix?: React.ReactNode
/** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */
dependencyContext?: Record<string, unknown>
}
/**
@@ -162,16 +163,14 @@ const getPreviewValue = (
/**
* Renders the label with optional validation and description tooltips.
*
* @remarks
* Handles JSON validation indicators for code blocks and required field markers.
* Includes inline AI generate button when wand is enabled.
*
* @param config - The sub-block configuration defining the label content
* @param isValidJson - Whether the JSON content is valid (for code blocks)
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
* @param wandState - Optional state and handlers for the AI wand feature
* @param canonicalToggle - Optional canonical toggle metadata and handlers
* @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled
* @param wandState - State and handlers for the inline AI generate feature
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
* @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled (includes dependsOn gating)
* @param copyState - State and handler for the copy-to-clipboard button
* @param labelSuffix - Additional content rendered after the label text
* @returns The label JSX element, or `null` for switch types or when no title is defined
*/
const renderLabel = (
@@ -202,7 +201,8 @@ const renderLabel = (
showCopyButton: boolean
copied: boolean
onCopy: () => void
}
},
labelSuffix?: React.ReactNode
): JSX.Element | null => {
if (config.type === 'switch') return null
if (!config.title) return null
@@ -215,9 +215,10 @@ const renderLabel = (
return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='flex items-center gap-[6px] whitespace-nowrap'>
<Label className='flex items-baseline gap-[6px] whitespace-nowrap'>
{config.title}
{required && <span className='ml-0.5'>*</span>}
{labelSuffix}
{config.type === 'code' &&
config.language === 'json' &&
!isValidJson &&
@@ -383,28 +384,25 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
prevProps.isPreview === nextProps.isPreview &&
valueEqual &&
prevProps.disabled === nextProps.disabled &&
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
canonicalToggleEqual
canonicalToggleEqual &&
prevProps.labelSuffix === nextProps.labelSuffix &&
prevProps.dependencyContext === nextProps.dependencyContext
)
}
/**
* Renders a single workflow sub-block input based on config.type.
*
* @remarks
* Supports multiple input types including short-input, long-input, dropdown,
* combobox, slider, table, code, switch, tool-input, and many more.
* Handles preview mode, disabled states, and AI wand generation.
*
* @param blockId - The parent block identifier
* @param config - Configuration defining the input type and properties
* @param isPreview - Whether to render in preview mode
* @param subBlockValues - Current values of all subblocks
* @param disabled - Whether the input is disabled
* @param fieldDiffStatus - Optional diff status for visual indicators
* @param allowExpandInPreview - Whether to allow expanding in preview mode
* @returns The rendered sub-block input component
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
* @param labelSuffix - Additional content rendered after the label text
* @param dependencyContext - Sibling values for dependency resolution in non-preview contexts (e.g. tool-input)
*/
function SubBlockComponent({
blockId,
@@ -412,9 +410,10 @@ function SubBlockComponent({
isPreview = false,
subBlockValues,
disabled = false,
fieldDiffStatus,
allowExpandInPreview,
canonicalToggle,
labelSuffix,
dependencyContext,
}: SubBlockProps): JSX.Element {
const [isValidJson, setIsValidJson] = useState(true)
const [isSearchActive, setIsSearchActive] = useState(false)
@@ -423,7 +422,6 @@ function SubBlockComponent({
const searchInputRef = useRef<HTMLInputElement>(null)
const wandControlRef = useRef<WandControlHandlers | null>(null)
// Use webhook management hook when config has useWebhookUrl enabled
const webhookManagement = useWebhookManagement({
blockId,
triggerId: undefined,
@@ -510,10 +508,12 @@ function SubBlockComponent({
| null
| undefined
const contextValues = dependencyContext ?? (isPreview ? subBlockValues : undefined)
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
disabled,
isPreview,
previewContextValues: isPreview ? subBlockValues : undefined,
previewContextValues: contextValues,
})
const isDisabled = gatedDisabled
@@ -797,7 +797,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -809,7 +809,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -821,7 +821,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -833,7 +833,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -845,7 +845,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -868,7 +868,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -880,7 +880,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -892,7 +892,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -917,7 +917,7 @@ function SubBlockComponent({
isPreview={isPreview}
previewValue={previewValue as any}
disabled={isDisabled}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -953,7 +953,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -987,7 +987,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -999,7 +999,7 @@ function SubBlockComponent({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
previewContextValues={contextValues}
/>
)
@@ -1059,7 +1059,8 @@ function SubBlockComponent({
showCopyButton: Boolean(config.showCopyButton && config.useWebhookUrl),
copied,
onCopy: handleCopy,
}
},
labelSuffix
)}
{renderInput()}
</div>

View File

@@ -571,7 +571,6 @@ export function Editor() {
isPreview={false}
subBlockValues={subBlockState}
disabled={!canEditBlock}
fieldDiffStatus={undefined}
allowExpandInPreview={false}
canonicalToggle={
isCanonicalSwap && canonicalMode && canonicalId
@@ -635,7 +634,6 @@ export function Editor() {
isPreview={false}
subBlockValues={subBlockState}
disabled={!canEditBlock}
fieldDiffStatus={undefined}
allowExpandInPreview={false}
/>
{index < advancedOnlySubBlocks.length - 1 && (

View File

@@ -1,4 +1,3 @@
export { CancelSubscription } from './cancel-subscription'
export { CreditBalance } from './credit-balance'
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
export { ReferralCode } from './referral-code'

View File

@@ -1,101 +0,0 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Button, Input, Label } from '@/components/emcn'
const logger = createLogger('ReferralCode')
interface ReferralCodeProps {
onRedeemComplete?: () => void
}
/**
* Inline referral/promo code entry field with redeem button.
* One-time use per account — shows success or "already redeemed" state.
*/
export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) {
const [code, setCode] = useState('')
const [isRedeeming, setIsRedeeming] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<{ bonusAmount: number } | null>(null)
const handleRedeem = async () => {
const trimmed = code.trim()
if (!trimmed || isRedeeming) return
setIsRedeeming(true)
setError(null)
try {
const response = await fetch('/api/referral-code/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: trimmed }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to redeem code')
}
if (data.redeemed) {
setSuccess({ bonusAmount: data.bonusAmount })
setCode('')
onRedeemComplete?.()
} else {
setError(data.error || 'Code could not be redeemed')
}
} catch (err) {
logger.error('Referral code redemption failed', { error: err })
setError(err instanceof Error ? err.message : 'Failed to redeem code')
} finally {
setIsRedeeming(false)
}
}
if (success) {
return (
<div className='flex items-center justify-between'>
<Label>Referral Code</Label>
<span className='text-[12px] text-[var(--text-secondary)]'>
+${success.bonusAmount} credits applied
</span>
</div>
)
}
return (
<div className='flex items-center justify-between gap-[12px]'>
<Label className='shrink-0'>Referral Code</Label>
<div className='flex items-center gap-[8px]'>
<div className='flex flex-col'>
<Input
type='text'
value={code}
onChange={(e) => {
setCode(e.target.value)
setError(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRedeem()
}}
placeholder='Enter code'
className='h-[32px] w-[140px] text-[12px]'
disabled={isRedeeming}
/>
{error && <span className='mt-[4px] text-[11px] text-[var(--text-error)]'>{error}</span>}
</div>
<Button
variant='active'
className='h-[32px] shrink-0 rounded-[6px] text-[12px]'
onClick={handleRedeem}
disabled={isRedeeming || !code.trim()}
>
{isRedeeming ? 'Redeeming...' : 'Redeem'}
</Button>
</div>
</div>
)
}

View File

@@ -17,7 +17,6 @@ import {
CancelSubscription,
CreditBalance,
PlanCard,
ReferralCode,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components'
import {
ENTERPRISE_PLAN_FEATURES,
@@ -550,10 +549,6 @@ export function Subscription() {
/>
)}
{!subscription.isEnterprise && (
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
)}
{/* Next Billing Date - hidden from team members */}
{subscription.isPaid &&
subscriptionData?.data?.periodEnd &&

View File

@@ -4,14 +4,12 @@ import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { useReferralAttribution } from '@/hooks/use-referral-attribution'
const logger = createLogger('WorkspacePage')
export default function WorkspacePage() {
const router = useRouter()
const { data: session, isPending } = useSession()
useReferralAttribution()
useEffect(() => {
const redirectToFirstWorkspace = async () => {

View File

@@ -196,6 +196,8 @@ export interface SubBlockConfig {
type: SubBlockType
mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode
canonicalParamId?: string
/** Controls parameter visibility in agent/tool-input context */
paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden'
required?:
| boolean
| {

View File

@@ -642,6 +642,10 @@ export function useDeployChildWorkflow() {
queryClient.invalidateQueries({
queryKey: workflowKeys.deploymentStatus(variables.workflowId),
})
// Invalidate workflow state so tool input mappings refresh
queryClient.invalidateQueries({
queryKey: workflowKeys.state(variables.workflowId),
})
// Also invalidate deployment queries
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),

View File

@@ -1,46 +0,0 @@
'use client'
import { useEffect, useRef } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('ReferralAttribution')
const COOKIE_NAME = 'sim_utm'
const TERMINAL_REASONS = new Set([
'account_predates_cookie',
'invalid_cookie',
'no_utm_cookie',
'no_matching_campaign',
])
/**
* Fires a one-shot `POST /api/attribution` when a `sim_utm` cookie is present.
* Retries on transient failures; stops on terminal outcomes.
*/
export function useReferralAttribution() {
const calledRef = useRef(false)
useEffect(() => {
if (calledRef.current) return
if (!document.cookie.includes(COOKIE_NAME)) return
calledRef.current = true
fetch('/api/attribution', { method: 'POST' })
.then((res) => res.json())
.then((data) => {
if (data.attributed) {
logger.info('Referral attribution successful', { bonusAmount: data.bonusAmount })
} else if (data.error || TERMINAL_REASONS.has(data.reason)) {
logger.info('Referral attribution skipped', { reason: data.reason || data.error })
} else {
calledRef.current = false
}
})
.catch((err) => {
logger.warn('Referral attribution failed, will retry', { error: err })
calledRef.current = false
})
}, [])
}

View File

@@ -1,64 +0,0 @@
import { db } from '@sim/db'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import type { DbOrTx } from '@/lib/db/types'
const logger = createLogger('BonusCredits')
/**
* Apply bonus credits to a user (e.g. referral bonuses, promotional codes).
*
* Detects the user's current plan and routes credits accordingly:
* - Free/Pro: adds to `userStats.creditBalance` and increments `currentUsageLimit`
* - Team/Enterprise: adds to `organization.creditBalance` and increments `orgUsageLimit`
*
* Uses direct increment (not recalculation) so it works correctly for free-tier
* users where `setUsageLimitForCredits` would compute planBase=0 and skip the update.
*
* @param tx - Optional Drizzle transaction context. When provided, all DB writes
* participate in the caller's transaction for atomicity.
*/
export async function applyBonusCredits(
userId: string,
amount: number,
tx?: DbOrTx
): Promise<void> {
const dbCtx = tx ?? db
const subscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterprise = subscription?.plan === 'team' || subscription?.plan === 'enterprise'
if (isTeamOrEnterprise && subscription?.referenceId) {
const orgId = subscription.referenceId
await dbCtx
.update(organization)
.set({
creditBalance: sql`${organization.creditBalance} + ${amount}`,
orgUsageLimit: sql`COALESCE(${organization.orgUsageLimit}, '0')::decimal + ${amount}`,
})
.where(eq(organization.id, orgId))
logger.info('Applied bonus credits to organization', {
userId,
organizationId: orgId,
plan: subscription.plan,
amount,
})
} else {
await dbCtx
.update(userStats)
.set({
creditBalance: sql`${userStats.creditBalance} + ${amount}`,
currentUsageLimit: sql`COALESCE(${userStats.currentUsageLimit}, '0')::decimal + ${amount}`,
})
.where(eq(userStats.userId, userId))
logger.info('Applied bonus credits to user', {
userId,
plan: subscription?.plan || 'free',
amount,
})
}
}

View File

@@ -645,6 +645,18 @@ describe('Workflow Normalization Utilities', () => {
const result = filterSubBlockIds(ids)
expect(result).toEqual(['signingSecret'])
})
it.concurrent('should exclude synthetic tool-input subBlock IDs', () => {
const ids = [
'toolConfig',
'toolConfig-tool-0-query',
'toolConfig-tool-0-url',
'toolConfig-tool-1-status',
'systemPrompt',
]
const result = filterSubBlockIds(ids)
expect(result).toEqual(['systemPrompt', 'toolConfig'])
})
})
describe('normalizeTriggerConfigValues', () => {

View File

@@ -411,7 +411,14 @@ export function extractBlockFieldsForComparison(block: BlockState): ExtractedBlo
}
/**
* Filters subBlock IDs to exclude system and trigger runtime subBlocks.
* Pattern matching synthetic subBlock IDs created by ToolSubBlockRenderer.
* These IDs follow the format `{subBlockId}-tool-{index}-{paramId}` and are
* mirrors of values already stored in toolConfig.value.tools[N].params.
*/
const SYNTHETIC_TOOL_SUBBLOCK_RE = /-tool-\d+-/
/**
* Filters subBlock IDs to exclude system, trigger runtime, and synthetic tool subBlocks.
*
* @param subBlockIds - Array of subBlock IDs to filter
* @returns Filtered and sorted array of subBlock IDs
@@ -422,6 +429,7 @@ export function filterSubBlockIds(subBlockIds: string[]): string[] {
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) return false
if (SYSTEM_SUBBLOCK_IDS.some((sysId) => id === sysId || id.startsWith(`${sysId}_`)))
return false
if (SYNTHETIC_TOOL_SUBBLOCK_RE.test(id)) return false
return true
})
.sort()

View File

@@ -137,37 +137,6 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
return null
}
const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content'] as const
const UTM_COOKIE_NAME = 'sim_utm'
const UTM_COOKIE_MAX_AGE = 3600
/**
* Sets a `sim_utm` cookie when UTM params are present on auth pages.
* Captures UTM values, the HTTP Referer, landing page, and a timestamp
* used by the attribution API to verify the user signed up after visiting the link.
*/
function setUtmCookie(request: NextRequest, response: NextResponse): void {
const { searchParams, pathname } = request.nextUrl
const hasUtm = UTM_KEYS.some((key) => searchParams.get(key))
if (!hasUtm) return
const utmData: Record<string, string> = {}
for (const key of UTM_KEYS) {
const value = searchParams.get(key)
if (value) utmData[key] = value
}
utmData.referrer_url = request.headers.get('referer') || ''
utmData.landing_page = pathname
utmData.created_at = Date.now().toString()
response.cookies.set(UTM_COOKIE_NAME, JSON.stringify(utmData), {
path: '/',
maxAge: UTM_COOKIE_MAX_AGE,
sameSite: 'lax',
httpOnly: false, // Client-side hook needs to detect cookie presence
})
}
export async function proxy(request: NextRequest) {
const url = request.nextUrl
@@ -183,7 +152,6 @@ export async function proxy(request: NextRequest) {
}
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
setUtmCookie(request, response)
return response
}

View File

@@ -1,6 +1,7 @@
import {
buildCanonicalIndex,
type CanonicalIndex,
type CanonicalModeOverrides,
evaluateSubBlockCondition,
getCanonicalValues,
isCanonicalPair,
@@ -12,7 +13,10 @@ import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
export {
buildCanonicalIndex,
type CanonicalIndex,
type CanonicalModeOverrides,
evaluateSubBlockCondition,
isCanonicalPair,
resolveCanonicalMode,
type SubBlockCondition,
}

View File

@@ -1,13 +1,17 @@
import { createLogger } from '@sim/logger'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import {
buildCanonicalIndex,
type CanonicalModeOverrides,
evaluateSubBlockCondition,
isCanonicalPair,
resolveCanonicalMode,
type SubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import type { SubBlockConfig as BlockSubBlockConfig, GenerationType } from '@/blocks/types'
import { safeAssign } from '@/tools/safe-assign'
import { isEmptyTagValue } from '@/tools/shared/tags'
import type { ParameterVisibility, ToolConfig } from '@/tools/types'
import type { OAuthConfig, ParameterVisibility, ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
const logger = createLogger('ToolsParams')
@@ -64,6 +68,14 @@ export interface UIComponentConfig {
mode?: 'basic' | 'advanced' | 'both' | 'trigger'
/** The actual subblock ID this config was derived from */
actualSubBlockId?: string
/** Wand configuration for AI assistance */
wandConfig?: {
enabled: boolean
prompt: string
generationType?: GenerationType
placeholder?: string
maintainHistory?: boolean
}
}
export interface SubBlockConfig {
@@ -327,6 +339,7 @@ export function getToolParametersConfig(
canonicalParamId: subBlock.canonicalParamId,
mode: subBlock.mode,
actualSubBlockId: subBlock.id,
wandConfig: subBlock.wandConfig,
}
}
}
@@ -812,3 +825,200 @@ export function formatParameterLabel(paramId: string): string {
// Simple case - just capitalize first letter
return paramId.charAt(0).toUpperCase() + paramId.slice(1)
}
/**
* SubBlock IDs that are "structural" — they control tool routing or auth,
* not user-facing parameters. These are excluded from tool-input rendering
* unless they have an explicit paramVisibility set.
*/
const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation', 'authMethod', 'destinationType'])
/**
* SubBlock types that represent auth/credential inputs handled separately
* by the tool-input OAuth credential selector.
*/
const AUTH_SUBBLOCK_TYPES = new Set(['oauth-input'])
/**
* SubBlock types that should never appear in tool-input context.
*/
const EXCLUDED_SUBBLOCK_TYPES = new Set([
'tool-input',
'skill-input',
'condition-input',
'eval-input',
'webhook-config',
'schedule-info',
'trigger-save',
'input-format',
'response-format',
'mcp-server-selector',
'mcp-tool-selector',
'mcp-dynamic-args',
'input-mapping',
'variables-input',
'messages-input',
'router-input',
'text',
])
export interface SubBlocksForToolInput {
toolConfig: ToolConfig
subBlocks: BlockSubBlockConfig[]
oauthConfig?: OAuthConfig
}
/**
* Returns filtered SubBlockConfig[] for rendering in tool-input context.
* Uses subblock definitions as the primary source of UI metadata,
* getting all features (wandConfig, rich conditions, dependsOn, etc.) for free.
*
* For blocks without paramVisibility annotations, falls back to inferring
* visibility from the tool's param definitions.
*/
export function getSubBlocksForToolInput(
toolId: string,
blockType: string,
currentValues?: Record<string, unknown>,
canonicalModeOverrides?: CanonicalModeOverrides
): SubBlocksForToolInput | null {
try {
const toolConfig = getTool(toolId)
if (!toolConfig) {
logger.warn(`Tool not found: ${toolId}`)
return null
}
const blockConfigs = getBlockConfigurations()
const blockConfig = blockConfigs[blockType]
if (!blockConfig?.subBlocks?.length) {
return null
}
const allSubBlocks = blockConfig.subBlocks as BlockSubBlockConfig[]
const canonicalIndex = buildCanonicalIndex(allSubBlocks)
// Build values for condition evaluation
const values = currentValues || {}
const valuesWithOperation = { ...values }
if (valuesWithOperation.operation === undefined) {
const parts = toolId.split('_')
valuesWithOperation.operation =
parts.length >= 3 ? parts.slice(2).join('_') : parts[parts.length - 1]
}
// Build a map of tool param IDs to their resolved visibility
const toolParamVisibility: Record<string, ParameterVisibility> = {}
for (const [paramId, param] of Object.entries(toolConfig.params || {})) {
toolParamVisibility[paramId] =
param.visibility ?? (param.required ? 'user-or-llm' : 'user-only')
}
// Track which canonical groups we've already included (to avoid duplicates)
const includedCanonicalIds = new Set<string>()
const filtered: BlockSubBlockConfig[] = []
for (const sb of allSubBlocks) {
// Skip excluded types
if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue
// Skip trigger-mode-only subblocks
if (sb.mode === 'trigger') continue
// Determine the effective param ID (canonical or subblock id)
const effectiveParamId = sb.canonicalParamId || sb.id
// Resolve paramVisibility: explicit > inferred from tool params > skip
let visibility = sb.paramVisibility
if (!visibility) {
// Infer from structural checks
if (STRUCTURAL_SUBBLOCK_IDS.has(sb.id)) {
visibility = 'hidden'
} else if (AUTH_SUBBLOCK_TYPES.has(sb.type)) {
visibility = 'hidden'
} else if (
sb.password &&
(sb.id === 'botToken' || sb.id === 'accessToken' || sb.id === 'apiKey')
) {
// Auth tokens without explicit paramVisibility are hidden
// (they're handled by the OAuth credential selector or structurally)
// But only if they don't have a matching tool param
if (!(sb.id in toolParamVisibility)) {
visibility = 'hidden'
} else {
visibility = toolParamVisibility[sb.id] || 'user-or-llm'
}
} else if (effectiveParamId in toolParamVisibility) {
// Fallback: infer from tool param visibility
visibility = toolParamVisibility[effectiveParamId]
} else if (sb.id in toolParamVisibility) {
visibility = toolParamVisibility[sb.id]
} else if (sb.canonicalParamId) {
// SubBlock has a canonicalParamId that doesn't directly match a tool param.
// This means the block's params() function transforms it before sending to the tool
// (e.g. listFolderId → folderId). These are user-facing inputs, default to user-or-llm.
visibility = 'user-or-llm'
} else {
// SubBlock has no corresponding tool param — skip it
continue
}
}
// Filter by visibility: exclude hidden and llm-only
if (visibility === 'hidden' || visibility === 'llm-only') continue
// Evaluate condition against current values
if (sb.condition) {
const conditionMet = evaluateSubBlockCondition(
sb.condition as SubBlockCondition,
valuesWithOperation
)
if (!conditionMet) continue
}
// Handle canonical pairs: only include the active mode variant
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[sb.id]
if (canonicalId) {
const group = canonicalIndex.groupsById[canonicalId]
if (group && isCanonicalPair(group)) {
if (includedCanonicalIds.has(canonicalId)) continue
includedCanonicalIds.add(canonicalId)
// Determine active mode
const mode = resolveCanonicalMode(group, valuesWithOperation, canonicalModeOverrides)
if (mode === 'advanced') {
// Find the advanced variant
const advancedSb = allSubBlocks.find((s) => group.advancedIds.includes(s.id))
if (advancedSb) {
filtered.push({ ...advancedSb, paramVisibility: visibility })
}
} else {
// Include basic variant (current sb if it's the basic one)
if (group.basicId === sb.id) {
filtered.push({ ...sb, paramVisibility: visibility })
} else {
const basicSb = allSubBlocks.find((s) => s.id === group.basicId)
if (basicSb) {
filtered.push({ ...basicSb, paramVisibility: visibility })
}
}
}
continue
}
}
// Non-canonical, non-hidden, condition-passing subblock
filtered.push({ ...sb, paramVisibility: visibility })
}
return {
toolConfig,
subBlocks: filtered,
oauthConfig: toolConfig.oauth,
}
} catch (error) {
logger.error('Error getting subblocks for tool input:', error)
return null
}
}

View File

@@ -18,7 +18,7 @@ export const workflowExecutorTool: ToolConfig<
workflowId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
visibility: 'user-only',
description: 'The ID of the workflow to execute',
},
inputMapping: {

View File

@@ -1,41 +0,0 @@
CREATE TABLE "referral_attribution" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"organization_id" text,
"campaign_id" text,
"utm_source" text,
"utm_medium" text,
"utm_campaign" text,
"utm_content" text,
"referrer_url" text,
"landing_page" text,
"bonus_credit_amount" numeric DEFAULT '0' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "referral_attribution_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "referral_campaigns" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"code" text,
"utm_source" text,
"utm_medium" text,
"utm_campaign" text,
"utm_content" text,
"bonus_credit_amount" numeric NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "referral_campaigns_code_unique" UNIQUE("code")
);
--> statement-breakpoint
ALTER TABLE "referral_attribution" ADD CONSTRAINT "referral_attribution_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "referral_attribution" ADD CONSTRAINT "referral_attribution_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "referral_attribution" ADD CONSTRAINT "referral_attribution_campaign_id_referral_campaigns_id_fk" FOREIGN KEY ("campaign_id") REFERENCES "public"."referral_campaigns"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "referral_attribution_user_id_idx" ON "referral_attribution" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "referral_attribution_org_unique_idx" ON "referral_attribution" USING btree ("organization_id") WHERE "referral_attribution"."organization_id" IS NOT NULL;--> statement-breakpoint
CREATE INDEX "referral_attribution_campaign_id_idx" ON "referral_attribution" USING btree ("campaign_id");--> statement-breakpoint
CREATE INDEX "referral_attribution_utm_campaign_idx" ON "referral_attribution" USING btree ("utm_campaign");--> statement-breakpoint
CREATE INDEX "referral_attribution_utm_content_idx" ON "referral_attribution" USING btree ("utm_content");--> statement-breakpoint
CREATE INDEX "referral_attribution_created_at_idx" ON "referral_attribution" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "referral_campaigns_active_idx" ON "referral_campaigns" USING btree ("is_active");

File diff suppressed because it is too large Load Diff

View File

@@ -1072,13 +1072,6 @@
"when": 1770410282842,
"tag": "0153_complete_arclight",
"breakpoints": true
},
{
"idx": 154,
"version": "7",
"when": 1770869658697,
"tag": "0154_bumpy_living_mummy",
"breakpoints": true
}
]
}

View File

@@ -726,61 +726,6 @@ export const userStats = pgTable('user_stats', {
billingBlockedReason: billingBlockedReasonEnum('billing_blocked_reason'),
})
export const referralCampaigns = pgTable(
'referral_campaigns',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
code: text('code').unique(),
utmSource: text('utm_source'),
utmMedium: text('utm_medium'),
utmCampaign: text('utm_campaign'),
utmContent: text('utm_content'),
bonusCreditAmount: decimal('bonus_credit_amount').notNull(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
activeIdx: index('referral_campaigns_active_idx').on(table.isActive),
})
)
export const referralAttribution = pgTable(
'referral_attribution',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
.unique(),
organizationId: text('organization_id').references(() => organization.id, {
onDelete: 'set null',
}),
campaignId: text('campaign_id').references(() => referralCampaigns.id, {
onDelete: 'set null',
}),
utmSource: text('utm_source'),
utmMedium: text('utm_medium'),
utmCampaign: text('utm_campaign'),
utmContent: text('utm_content'),
referrerUrl: text('referrer_url'),
landingPage: text('landing_page'),
bonusCreditAmount: decimal('bonus_credit_amount').notNull().default('0'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
userIdIdx: index('referral_attribution_user_id_idx').on(table.userId),
orgUniqueIdx: uniqueIndex('referral_attribution_org_unique_idx')
.on(table.organizationId)
.where(sql`${table.organizationId} IS NOT NULL`),
campaignIdIdx: index('referral_attribution_campaign_id_idx').on(table.campaignId),
utmCampaignIdx: index('referral_attribution_utm_campaign_idx').on(table.utmCampaign),
utmContentIdx: index('referral_attribution_utm_content_idx').on(table.utmContent),
createdAtIdx: index('referral_attribution_created_at_idx').on(table.createdAt),
})
)
export const customTools = pgTable(
'custom_tools',
{