mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 03:48:02 -05:00
Compare commits
3 Commits
fix/verbia
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c2294e67 | ||
|
|
1dbf92db3f | ||
|
|
3a923648cb |
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import { InviteLayout } from '@/app/invite/components'
|
||||
|
||||
interface UnsubscribeData {
|
||||
success: boolean
|
||||
@@ -27,7 +30,6 @@ function UnsubscribeContent() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [unsubscribed, setUnsubscribed] = useState(false)
|
||||
const brand = useBrandConfig()
|
||||
|
||||
const email = searchParams.get('email')
|
||||
const token = searchParams.get('token')
|
||||
@@ -109,7 +111,7 @@ function UnsubscribeContent() {
|
||||
} else {
|
||||
setError(result.error || 'Failed to unsubscribe')
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setError('Failed to process unsubscribe request')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
@@ -118,272 +120,171 @@ function UnsubscribeContent() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Loading
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Validating your unsubscribe link...
|
||||
</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This unsubscribe link is invalid or has expired
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-red-50 p-4'>
|
||||
<p className='text-red-800 text-sm'>
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Invalid Unsubscribe Link
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm'>This could happen if:</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>The link is missing required parameters</li>
|
||||
<li>The link has expired or been used already</li>
|
||||
<li>The link was copied incorrectly</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton onClick={() => window.history.back()}>Go Back</BrandedButton>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Need immediate help? Email us at{' '}
|
||||
<a
|
||||
href={`mailto:${brand.supportEmail}`}
|
||||
className='text-muted-foreground hover:underline'
|
||||
>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (data?.isTransactional) {
|
||||
return (
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
|
||||
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This email contains important information about your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-blue-50 p-4'>
|
||||
<p className='text-blue-800 text-sm'>
|
||||
<strong>Transactional emails</strong> like password resets, account confirmations,
|
||||
and security alerts cannot be unsubscribed from as they contain essential
|
||||
information for your account security and functionality.
|
||||
</p>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Important Account Emails
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Transactional emails like password resets, account confirmations, and security alerts
|
||||
cannot be unsubscribed from as they contain essential information for your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-foreground text-sm'>
|
||||
If you no longer wish to receive these emails, you can:
|
||||
</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>Close your account entirely</li>
|
||||
<li>Contact our support team for assistance</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-blue-600 text-white hover:bg-blue-700'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.close()} variant='outline' className='w-full'>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (unsubscribed) {
|
||||
return (
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
|
||||
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
If you change your mind, you can always update your email preferences in your account
|
||||
settings or contact us at{' '}
|
||||
<a
|
||||
href={`mailto:${brand.supportEmail}`}
|
||||
className='text-muted-foreground hover:underline'
|
||||
>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Successfully Unsubscribed
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const isAlreadyUnsubscribedFromAll = data?.currentPreferences.unsubscribeAll
|
||||
|
||||
return (
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>We're sorry to see you go!</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
We understand email preferences are personal. Choose which emails you'd like to
|
||||
stop receiving from Sim.
|
||||
</CardDescription>
|
||||
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Email: <span className='font-medium text-foreground'>{data?.email}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || data?.currentPreferences.unsubscribeAll}
|
||||
variant='destructive'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeAll ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{processing
|
||||
? 'Unsubscribing...'
|
||||
: data?.currentPreferences.unsubscribeAll
|
||||
? 'Unsubscribed from All Emails'
|
||||
: 'Unsubscribe from All Marketing Emails'}
|
||||
</Button>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Email Preferences
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Choose which emails you'd like to stop receiving.
|
||||
</p>
|
||||
<p className={`${inter.className} mt-2 font-[380] text-[14px] text-muted-foreground`}>
|
||||
{data?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='text-center text-muted-foreground text-sm'>
|
||||
or choose specific types:
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || isAlreadyUnsubscribedFromAll}
|
||||
loading={processing}
|
||||
loadingText='Unsubscribing'
|
||||
>
|
||||
{isAlreadyUnsubscribedFromAll
|
||||
? 'Unsubscribed from All Emails'
|
||||
: 'Unsubscribe from All Marketing Emails'}
|
||||
</BrandedButton>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('marketing')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
: 'Unsubscribe from Marketing Emails'}
|
||||
</Button>
|
||||
<div className='py-2 text-center'>
|
||||
<span className={`${inter.className} font-[380] text-[14px] text-muted-foreground`}>
|
||||
or choose specific types
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('updates')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
: 'Unsubscribe from Product Updates'}
|
||||
</Button>
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('marketing')}
|
||||
disabled={
|
||||
processing ||
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
: 'Unsubscribe from Marketing Emails'}
|
||||
</BrandedButton>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('notifications')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
: 'Unsubscribe from Notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('updates')}
|
||||
disabled={
|
||||
processing ||
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
: 'Unsubscribe from Product Updates'}
|
||||
</BrandedButton>
|
||||
|
||||
<div className='mt-6 space-y-3'>
|
||||
<div className='rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
<strong>Note:</strong> You'll continue receiving important account emails like
|
||||
password resets and security alerts.
|
||||
</p>
|
||||
</div>
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('notifications')}
|
||||
disabled={
|
||||
processing ||
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
: 'Unsubscribe from Notifications'}
|
||||
</BrandedButton>
|
||||
</div>
|
||||
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
Questions? Contact us at{' '}
|
||||
<a
|
||||
href={`mailto:${brand.supportEmail}`}
|
||||
className='text-muted-foreground hover:underline'
|
||||
>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-6 max-w-[410px] text-center`}>
|
||||
<p className='font-[380] text-[13px] text-muted-foreground'>
|
||||
You'll continue receiving important account emails like password resets and security
|
||||
alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -391,13 +292,20 @@ export default function Unsubscribe() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Loading
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Validating your unsubscribe link...
|
||||
</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
}
|
||||
>
|
||||
<UnsubscribeContent />
|
||||
|
||||
@@ -34,7 +34,7 @@ export function OTPVerificationEmail({
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return (
|
||||
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)}>
|
||||
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Your verification code:</Text>
|
||||
|
||||
<Section style={baseStyles.codeContainer}>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`Reset your ${brand.name} password`}>
|
||||
<EmailLayout preview={`Reset your ${brand.name} password`} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Hello {username},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
A password reset was requested for your {brand.name} account. Click below to set a new
|
||||
|
||||
@@ -13,7 +13,7 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`Welcome to ${brand.name}`}>
|
||||
<EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hey ${userName},` : 'Hey,'}
|
||||
</Text>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CreditPurchaseEmail({
|
||||
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText}>
|
||||
<EmailLayout preview={previewText} showUnsubscribe={false}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
@@ -18,7 +18,10 @@ export function EnterpriseSubscriptionEmail({
|
||||
const effectiveLoginLink = loginLink || `${baseUrl}/login`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`Your Enterprise Plan is now active on ${brand.name}`}>
|
||||
<EmailLayout
|
||||
preview={`Your Enterprise Plan is now active on ${brand.name}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
|
||||
|
||||
@@ -31,7 +31,7 @@ export function FreeTierUpgradeEmail({
|
||||
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText}>
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function PaymentFailedEmail({
|
||||
const previewText = `${brand.name}: Payment Failed - Action Required`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText}>
|
||||
<EmailLayout preview={previewText} showUnsubscribe={false}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE
|
||||
const previewText = `${brand.name}: Your ${planName} plan is active`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText}>
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function UsageThresholdEmail({
|
||||
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText}>
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
@@ -20,7 +20,10 @@ export function CareersConfirmationEmail({
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`Your application to ${brand.name} has been received`}>
|
||||
<EmailLayout
|
||||
preview={`Your application to ${brand.name} has been received`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello {name},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
We've received your application for <strong>{position}</strong>. Our team reviews every
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CareersSubmissionEmail({
|
||||
submittedDate = new Date(),
|
||||
}: CareersSubmissionEmailProps) {
|
||||
return (
|
||||
<EmailLayout preview={`New Career Application from ${name}`} hideFooter>
|
||||
<EmailLayout preview={`New Career Application from ${name}`} hideFooter showUnsubscribe={false}>
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.paragraph,
|
||||
|
||||
@@ -4,22 +4,29 @@ import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
interface UnsubscribeOptions {
|
||||
unsubscribeToken?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
interface EmailFooterProps {
|
||||
baseUrl?: string
|
||||
unsubscribe?: UnsubscribeOptions
|
||||
messageId?: string
|
||||
/**
|
||||
* Whether to show unsubscribe link. Defaults to true.
|
||||
* Set to false for transactional emails where unsubscribe doesn't apply.
|
||||
*/
|
||||
showUnsubscribe?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Email footer component styled to match Stripe's email design.
|
||||
* Sits in the gray area below the main white card.
|
||||
*
|
||||
* For non-transactional emails, the unsubscribe link uses placeholders
|
||||
* {{UNSUBSCRIBE_TOKEN}} and {{UNSUBSCRIBE_EMAIL}} which are replaced
|
||||
* by the mailer when sending.
|
||||
*/
|
||||
export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: EmailFooterProps) {
|
||||
export function EmailFooter({
|
||||
baseUrl = getBaseUrl(),
|
||||
messageId,
|
||||
showUnsubscribe = true,
|
||||
}: EmailFooterProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
const footerLinkStyle = {
|
||||
@@ -181,19 +188,20 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }:
|
||||
•{' '}
|
||||
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
•{' '}
|
||||
<a
|
||||
href={
|
||||
unsubscribe?.unsubscribeToken && unsubscribe?.email
|
||||
? `${baseUrl}/unsubscribe?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}`
|
||||
: `mailto:${brand.supportEmail}?subject=Unsubscribe%20Request&body=Please%20unsubscribe%20me%20from%20all%20emails.`
|
||||
}
|
||||
style={footerLinkStyle}
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Unsubscribe
|
||||
</a>
|
||||
{showUnsubscribe && (
|
||||
<>
|
||||
{' '}
|
||||
•{' '}
|
||||
<a
|
||||
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
|
||||
style={footerLinkStyle}
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Unsubscribe
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
|
||||
@@ -11,13 +11,23 @@ interface EmailLayoutProps {
|
||||
children: React.ReactNode
|
||||
/** Optional: hide footer for internal emails */
|
||||
hideFooter?: boolean
|
||||
/**
|
||||
* Whether to show unsubscribe link in footer.
|
||||
* Set to false for transactional emails where unsubscribe doesn't apply.
|
||||
*/
|
||||
showUnsubscribe: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared email layout wrapper providing consistent structure.
|
||||
* Includes Html, Head, Body, Container with logo header, and Footer.
|
||||
*/
|
||||
export function EmailLayout({ preview, children, hideFooter = false }: EmailLayoutProps) {
|
||||
export function EmailLayout({
|
||||
preview,
|
||||
children,
|
||||
hideFooter = false,
|
||||
showUnsubscribe,
|
||||
}: EmailLayoutProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
@@ -43,7 +53,7 @@ export function EmailLayout({ preview, children, hideFooter = false }: EmailLayo
|
||||
</Container>
|
||||
|
||||
{/* Footer in gray section */}
|
||||
{!hideFooter && <EmailFooter baseUrl={baseUrl} />}
|
||||
{!hideFooter && <EmailFooter baseUrl={baseUrl} showUnsubscribe={showUnsubscribe} />}
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@ export function BatchInvitationEmail({
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
|
||||
@@ -36,7 +36,10 @@ export function InvitationEmail({
|
||||
}
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`You've been invited to join ${organizationName} on ${brand.name}`}>
|
||||
<EmailLayout
|
||||
preview={`You've been invited to join ${organizationName} on ${brand.name}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
|
||||
|
||||
@@ -22,7 +22,10 @@ export function PollingGroupInvitationEmail({
|
||||
const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}>
|
||||
<EmailLayout
|
||||
preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> from <strong>{organizationName}</strong> has invited you to
|
||||
|
||||
@@ -41,6 +41,7 @@ export function WorkspaceInvitationEmail({
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function WorkflowNotificationEmail({
|
||||
: 'Your workflow completed successfully.'
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText}>
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>{message}</Text>
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ export function HelpConfirmationEmail({
|
||||
const typeLabel = getTypeLabel(type)
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`Your ${typeLabel.toLowerCase()} has been received`}>
|
||||
<EmailLayout
|
||||
preview={`Your ${typeLabel.toLowerCase()} has been received`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
|
||||
|
||||
@@ -152,15 +152,20 @@ function addUnsubscribeData(
|
||||
): UnsubscribeData {
|
||||
const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType)
|
||||
const baseUrl = getBaseUrl()
|
||||
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(recipientEmail)}`
|
||||
const encodedEmail = encodeURIComponent(recipientEmail)
|
||||
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodedEmail}`
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'List-Unsubscribe': `<${unsubscribeUrl}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
},
|
||||
html: html?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
|
||||
text: text?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
|
||||
html: html
|
||||
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
|
||||
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
|
||||
text: text
|
||||
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
|
||||
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,15 +366,15 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
|
||||
subject: email.subject,
|
||||
}
|
||||
|
||||
if (email.html) emailData.html = email.html
|
||||
if (email.text) emailData.text = email.text
|
||||
|
||||
if (includeUnsubscribe && emailType !== 'transactional') {
|
||||
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
|
||||
const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text)
|
||||
emailData.headers = unsubData.headers
|
||||
if (unsubData.html) emailData.html = unsubData.html
|
||||
if (unsubData.text) emailData.text = unsubData.text
|
||||
} else {
|
||||
if (email.html) emailData.html = email.html
|
||||
if (email.text) emailData.text = email.text
|
||||
}
|
||||
|
||||
batchEmails.push(emailData)
|
||||
|
||||
@@ -114,17 +114,15 @@ describe('unsubscribe utilities', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should handle legacy tokens (2 parts) and default to marketing', () => {
|
||||
// Generate a real legacy token using the actual hashing logic to ensure backward compatibility
|
||||
const salt = 'abc123'
|
||||
const secret = 'test-secret-key'
|
||||
const { createHash } = require('crypto')
|
||||
const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex')
|
||||
const legacyToken = `${salt}:${hash}`
|
||||
|
||||
// This should return valid since we're using the actual legacy format properly
|
||||
const result = verifyUnsubscribeToken(testEmail, legacyToken)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.emailType).toBe('marketing') // Should default to marketing for legacy tokens
|
||||
expect(result.emailType).toBe('marketing')
|
||||
})
|
||||
|
||||
it.concurrent('should reject malformed tokens', () => {
|
||||
@@ -226,7 +224,6 @@ describe('unsubscribe utilities', () => {
|
||||
it('should update email preferences for existing user', async () => {
|
||||
const userId = 'user-123'
|
||||
|
||||
// Mock finding the user
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
@@ -235,7 +232,6 @@ describe('unsubscribe utilities', () => {
|
||||
}),
|
||||
})
|
||||
|
||||
// Mock getting existing settings
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
@@ -244,7 +240,6 @@ describe('unsubscribe utilities', () => {
|
||||
}),
|
||||
})
|
||||
|
||||
// Mock insert with upsert
|
||||
mockDb.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -300,7 +295,6 @@ describe('unsubscribe utilities', () => {
|
||||
|
||||
await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
|
||||
|
||||
// Verify that the merged preferences are passed
|
||||
expect(mockInsertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
emailPreferences: {
|
||||
|
||||
@@ -38,7 +38,6 @@ export function verifyUnsubscribeToken(
|
||||
const parts = token.split(':')
|
||||
if (parts.length < 2) return { valid: false }
|
||||
|
||||
// Handle legacy tokens (without email type)
|
||||
if (parts.length === 2) {
|
||||
const [salt, expectedHash] = parts
|
||||
const hash = createHash('sha256')
|
||||
@@ -48,7 +47,6 @@ export function verifyUnsubscribeToken(
|
||||
return { valid: hash === expectedHash, emailType: 'marketing' }
|
||||
}
|
||||
|
||||
// Handle new tokens (with email type)
|
||||
const [salt, expectedHash, emailType] = parts
|
||||
if (!salt || !expectedHash || !emailType) return { valid: false }
|
||||
|
||||
@@ -101,7 +99,6 @@ export async function updateEmailPreferences(
|
||||
preferences: EmailPreferences
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// First, find the user
|
||||
const userResult = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
@@ -115,7 +112,6 @@ export async function updateEmailPreferences(
|
||||
|
||||
const userId = userResult[0].id
|
||||
|
||||
// Get existing email preferences
|
||||
const existingSettings = await db
|
||||
.select({ emailPreferences: settings.emailPreferences })
|
||||
.from(settings)
|
||||
@@ -127,13 +123,11 @@ export async function updateEmailPreferences(
|
||||
currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {}
|
||||
}
|
||||
|
||||
// Merge email preferences
|
||||
const updatedEmailPreferences = {
|
||||
...currentEmailPreferences,
|
||||
...preferences,
|
||||
}
|
||||
|
||||
// Upsert settings
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
@@ -168,10 +162,8 @@ export async function isUnsubscribed(
|
||||
const preferences = await getEmailPreferences(email)
|
||||
if (!preferences) return false
|
||||
|
||||
// Check unsubscribe all first
|
||||
if (preferences.unsubscribeAll) return true
|
||||
|
||||
// Check specific type
|
||||
switch (emailType) {
|
||||
case 'marketing':
|
||||
return preferences.unsubscribeMarketing || false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RequestParams, RequestResponse } from '@/tools/http/types'
|
||||
import { getDefaultHeaders, processUrl, transformTable } from '@/tools/http/utils'
|
||||
import { getDefaultHeaders, processUrl } from '@/tools/http/utils'
|
||||
import { transformTable } from '@/tools/shared/table'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isTest } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { transformTable } from '@/tools/shared/table'
|
||||
import type { TableRow } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('HTTPRequestUtils')
|
||||
@@ -119,28 +120,3 @@ export const shouldUseProxy = (url: string): boolean => {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a table from the store format to a key-value object
|
||||
* Local copy of the function to break circular dependencies
|
||||
* @param table Array of table rows from the store
|
||||
* @returns Record of key-value pairs
|
||||
*/
|
||||
export const transformTable = (table: TableRow[] | null): Record<string, any> => {
|
||||
if (!table) return {}
|
||||
|
||||
return table.reduce(
|
||||
(acc, row) => {
|
||||
if (row.cells?.Key && row.cells?.Value !== undefined) {
|
||||
// Extract the Value cell as is - it should already be properly resolved
|
||||
// by the InputResolver based on variable type (number, string, boolean etc.)
|
||||
const value = row.cells.Value
|
||||
|
||||
// Store the correctly typed value in the result object
|
||||
acc[row.cells.Key] = value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KnowledgeCreateDocumentResponse } from '@/tools/knowledge/types'
|
||||
import { formatDocumentTagsForAPI, parseDocumentTags } from '@/tools/params'
|
||||
import { enrichKBTagsSchema } from '@/tools/schema-enrichers'
|
||||
import { formatDocumentTagsForAPI, parseDocumentTags } from '@/tools/shared/tags'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const knowledgeCreateDocumentTool: ToolConfig<any, KnowledgeCreateDocumentResponse> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KnowledgeSearchResponse } from '@/tools/knowledge/types'
|
||||
import { parseTagFilters } from '@/tools/params'
|
||||
import { enrichKBTagFiltersSchema } from '@/tools/schema-enrichers'
|
||||
import { parseTagFilters } from '@/tools/shared/tags'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import {
|
||||
evaluateSubBlockCondition,
|
||||
type SubBlockCondition,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { isEmptyTagValue } from '@/tools/shared/tags'
|
||||
import type { ParameterVisibility, ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
@@ -23,194 +23,6 @@ export function isNonEmpty(value: unknown): boolean {
|
||||
// Tag/Value Parsing Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Document tag entry format used in create_document tool
|
||||
*/
|
||||
export interface DocumentTagEntry {
|
||||
tagName: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag filter entry format used in search tool
|
||||
*/
|
||||
export interface TagFilterEntry {
|
||||
tagName: string
|
||||
tagSlot?: string
|
||||
tagValue: string | number | boolean
|
||||
fieldType?: string
|
||||
operator?: string
|
||||
valueTo?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tag value is effectively empty (unfilled/default entry)
|
||||
*/
|
||||
function isEmptyTagEntry(entry: Record<string, unknown>): boolean {
|
||||
if (!entry.tagName || (typeof entry.tagName === 'string' && entry.tagName.trim() === '')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tag-based value is effectively empty (only contains default/unfilled entries).
|
||||
* Works for both documentTags and tagFilters parameters in various formats.
|
||||
*
|
||||
* @param value - The tag value to check (can be JSON string, array, or object)
|
||||
* @returns true if the value is empty or only contains unfilled entries
|
||||
*/
|
||||
export function isEmptyTagValue(value: unknown): boolean {
|
||||
if (!value) return true
|
||||
|
||||
// Handle JSON string format
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) return false
|
||||
if (parsed.length === 0) return true
|
||||
return parsed.every((entry: Record<string, unknown>) => isEmptyTagEntry(entry))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle array format directly
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return true
|
||||
return value.every((entry: Record<string, unknown>) => isEmptyTagEntry(entry))
|
||||
}
|
||||
|
||||
// Handle object format (LLM format: { "Category": "foo", "Priority": 5 })
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length === 0) return true
|
||||
return entries.every(([, val]) => val === undefined || val === null || val === '')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters valid document tags from an array, removing empty entries
|
||||
*/
|
||||
function filterValidDocumentTags(tags: unknown[]): DocumentTagEntry[] {
|
||||
return tags
|
||||
.filter((entry): entry is Record<string, unknown> => {
|
||||
if (typeof entry !== 'object' || entry === null) return false
|
||||
const e = entry as Record<string, unknown>
|
||||
if (!e.tagName || (typeof e.tagName === 'string' && e.tagName.trim() === '')) return false
|
||||
if (e.value === undefined || e.value === null || e.value === '') return false
|
||||
return true
|
||||
})
|
||||
.map((entry) => ({
|
||||
tagName: String(entry.tagName),
|
||||
value: String(entry.value),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses document tags from various formats into a normalized array format.
|
||||
* Used by create_document tool to handle tags from both UI and LLM sources.
|
||||
*
|
||||
* @param value - Document tags in object, array, or JSON string format
|
||||
* @returns Normalized array of document tag entries, or empty array if invalid
|
||||
*/
|
||||
export function parseDocumentTags(value: unknown): DocumentTagEntry[] {
|
||||
if (!value) return []
|
||||
|
||||
// Handle object format from LLM: { "Category": "foo", "Priority": 5 }
|
||||
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
|
||||
return Object.entries(value)
|
||||
.filter(([tagName, tagValue]) => {
|
||||
if (!tagName || tagName.trim() === '') return false
|
||||
if (tagValue === undefined || tagValue === null || tagValue === '') return false
|
||||
return true
|
||||
})
|
||||
.map(([tagName, tagValue]) => ({
|
||||
tagName,
|
||||
value: String(tagValue),
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle JSON string format from UI
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) {
|
||||
return filterValidDocumentTags(parsed)
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON, return empty
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Handle array format directly
|
||||
if (Array.isArray(value)) {
|
||||
return filterValidDocumentTags(value)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses tag filters from various formats into a normalized StructuredFilter array.
|
||||
* Used by search tool to handle tag filters from both UI and LLM sources.
|
||||
*
|
||||
* @param value - Tag filters in array or JSON string format
|
||||
* @returns Normalized array of structured filters, or empty array if invalid
|
||||
*/
|
||||
export function parseTagFilters(value: unknown): StructuredFilter[] {
|
||||
if (!value) return []
|
||||
|
||||
let tagFilters = value
|
||||
|
||||
// Handle JSON string format
|
||||
if (typeof tagFilters === 'string') {
|
||||
try {
|
||||
tagFilters = JSON.parse(tagFilters)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Must be an array at this point
|
||||
if (!Array.isArray(tagFilters)) return []
|
||||
|
||||
return tagFilters
|
||||
.filter((filter): filter is Record<string, unknown> => {
|
||||
if (typeof filter !== 'object' || filter === null) return false
|
||||
const f = filter as Record<string, unknown>
|
||||
if (!f.tagName || (typeof f.tagName === 'string' && f.tagName.trim() === '')) return false
|
||||
if (f.fieldType === 'boolean') {
|
||||
return f.tagValue !== undefined
|
||||
}
|
||||
if (f.tagValue === undefined || f.tagValue === null) return false
|
||||
if (typeof f.tagValue === 'string' && f.tagValue.trim().length === 0) return false
|
||||
return true
|
||||
})
|
||||
.map((filter) => ({
|
||||
tagName: filter.tagName as string,
|
||||
tagSlot: (filter.tagSlot as string) || '',
|
||||
fieldType: (filter.fieldType as string) || 'text',
|
||||
operator: (filter.operator as string) || 'eq',
|
||||
value: filter.tagValue as string | number | boolean,
|
||||
valueTo: filter.valueTo as string | number | undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts parsed document tags to the format expected by the create document API.
|
||||
* Returns the documentTagsData JSON string if there are valid tags.
|
||||
*/
|
||||
export function formatDocumentTagsForAPI(tags: DocumentTagEntry[]): { documentTagsData?: string } {
|
||||
if (tags.length === 0) return {}
|
||||
return {
|
||||
documentTagsData: JSON.stringify(tags),
|
||||
}
|
||||
}
|
||||
|
||||
export interface Option {
|
||||
label: string
|
||||
value: string
|
||||
|
||||
38
apps/sim/tools/shared/table.ts
Normal file
38
apps/sim/tools/shared/table.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { TableRow } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Transforms a table from the store format to a key-value object.
|
||||
*/
|
||||
export const transformTable = (
|
||||
table: TableRow[] | Record<string, any> | string | null
|
||||
): Record<string, any> => {
|
||||
if (!table) return {}
|
||||
|
||||
if (typeof table === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(table) as TableRow[] | Record<string, any>
|
||||
return transformTable(parsed)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(table)) {
|
||||
return table.reduce(
|
||||
(acc, row) => {
|
||||
if (row.cells?.Key && row.cells?.Value !== undefined) {
|
||||
const value = row.cells.Value
|
||||
acc[row.cells.Key] = value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof table === 'object') {
|
||||
return table
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
168
apps/sim/tools/shared/tags.ts
Normal file
168
apps/sim/tools/shared/tags.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||
|
||||
/**
|
||||
* Document tag entry format used in create_document tool.
|
||||
*/
|
||||
export interface DocumentTagEntry {
|
||||
tagName: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag filter entry format used in search tool.
|
||||
*/
|
||||
export interface TagFilterEntry {
|
||||
tagName: string
|
||||
tagSlot?: string
|
||||
tagValue: string | number | boolean
|
||||
fieldType?: string
|
||||
operator?: string
|
||||
valueTo?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tag value is effectively empty (unfilled/default entry).
|
||||
*/
|
||||
function isEmptyTagEntry(entry: Record<string, unknown>): boolean {
|
||||
if (!entry.tagName || (typeof entry.tagName === 'string' && entry.tagName.trim() === '')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tag-based value is effectively empty (only contains default/unfilled entries).
|
||||
*/
|
||||
export function isEmptyTagValue(value: unknown): boolean {
|
||||
if (!value) return true
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) return false
|
||||
if (parsed.length === 0) return true
|
||||
return parsed.every((entry: Record<string, unknown>) => isEmptyTagEntry(entry))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return true
|
||||
return value.every((entry: Record<string, unknown>) => isEmptyTagEntry(entry))
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length === 0) return true
|
||||
return entries.every(([, val]) => val === undefined || val === null || val === '')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters valid document tags from an array, removing empty entries.
|
||||
*/
|
||||
function filterValidDocumentTags(tags: unknown[]): DocumentTagEntry[] {
|
||||
return tags
|
||||
.filter((entry): entry is Record<string, unknown> => {
|
||||
if (typeof entry !== 'object' || entry === null) return false
|
||||
const e = entry as Record<string, unknown>
|
||||
if (!e.tagName || (typeof e.tagName === 'string' && e.tagName.trim() === '')) return false
|
||||
if (e.value === undefined || e.value === null || e.value === '') return false
|
||||
return true
|
||||
})
|
||||
.map((entry) => ({
|
||||
tagName: String(entry.tagName),
|
||||
value: String(entry.value),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses document tags from various formats into a normalized array format.
|
||||
*/
|
||||
export function parseDocumentTags(value: unknown): DocumentTagEntry[] {
|
||||
if (!value) return []
|
||||
|
||||
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
|
||||
return Object.entries(value)
|
||||
.filter(([tagName, tagValue]) => {
|
||||
if (!tagName || tagName.trim() === '') return false
|
||||
if (tagValue === undefined || tagValue === null || tagValue === '') return false
|
||||
return true
|
||||
})
|
||||
.map(([tagName, tagValue]) => ({
|
||||
tagName,
|
||||
value: String(tagValue),
|
||||
}))
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) {
|
||||
return filterValidDocumentTags(parsed)
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return filterValidDocumentTags(value)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses tag filters from various formats into a normalized StructuredFilter array.
|
||||
*/
|
||||
export function parseTagFilters(value: unknown): StructuredFilter[] {
|
||||
if (!value) return []
|
||||
|
||||
let tagFilters = value
|
||||
|
||||
if (typeof tagFilters === 'string') {
|
||||
try {
|
||||
tagFilters = JSON.parse(tagFilters)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(tagFilters)) return []
|
||||
|
||||
return tagFilters
|
||||
.filter((filter): filter is Record<string, unknown> => {
|
||||
if (typeof filter !== 'object' || filter === null) return false
|
||||
const f = filter as Record<string, unknown>
|
||||
if (!f.tagName || (typeof f.tagName === 'string' && f.tagName.trim() === '')) return false
|
||||
if (f.fieldType === 'boolean') {
|
||||
return f.tagValue !== undefined
|
||||
}
|
||||
if (f.tagValue === undefined || f.tagValue === null) return false
|
||||
if (typeof f.tagValue === 'string' && f.tagValue.trim().length === 0) return false
|
||||
return true
|
||||
})
|
||||
.map((filter) => ({
|
||||
tagName: filter.tagName as string,
|
||||
tagSlot: (filter.tagSlot as string) || '',
|
||||
fieldType: (filter.fieldType as string) || 'text',
|
||||
operator: (filter.operator as string) || 'eq',
|
||||
value: filter.tagValue as string | number | boolean,
|
||||
valueTo: filter.valueTo as string | number | undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts parsed document tags to the format expected by the create document API.
|
||||
*/
|
||||
export function formatDocumentTagsForAPI(tags: DocumentTagEntry[]): { documentTagsData?: string } {
|
||||
if (tags.length === 0) return {}
|
||||
return {
|
||||
documentTagsData: JSON.stringify(tags),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createMockFetch, loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { transformTable } from '@/tools/shared/table'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import {
|
||||
createCustomToolRequestBody,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
executeRequest,
|
||||
formatRequestParams,
|
||||
getClientEnvVars,
|
||||
transformTable,
|
||||
validateRequiredParametersAfterMerge,
|
||||
} from '@/tools/utils'
|
||||
|
||||
@@ -91,6 +91,25 @@ describe('transformTable', () => {
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should parse JSON string inputs and transform rows', () => {
|
||||
const table = [
|
||||
{ id: '1', cells: { Key: 'city', Value: 'SF' } },
|
||||
{ id: '2', cells: { Key: 'temp', Value: 64 } },
|
||||
]
|
||||
const result = transformTable(JSON.stringify(table))
|
||||
|
||||
expect(result).toEqual({
|
||||
city: 'SF',
|
||||
temp: 64,
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should parse JSON string object inputs', () => {
|
||||
const result = transformTable(JSON.stringify({ a: 1, b: 'two' }))
|
||||
|
||||
expect(result).toEqual({ a: 1, b: 'two' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRequestParams', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCustomToolsStore } from '@/stores/custom-tools'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { extractErrorMessage } from '@/tools/error-extractors'
|
||||
import { tools } from '@/tools/registry'
|
||||
import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('ToolsUtils')
|
||||
|
||||
@@ -70,30 +70,6 @@ export function resolveToolId(toolName: string): string {
|
||||
return toolName
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a table from the store format to a key-value object
|
||||
* @param table Array of table rows from the store
|
||||
* @returns Record of key-value pairs
|
||||
*/
|
||||
export const transformTable = (table: TableRow[] | null): Record<string, any> => {
|
||||
if (!table) return {}
|
||||
|
||||
return table.reduce(
|
||||
(acc, row) => {
|
||||
if (row.cells?.Key && row.cells?.Value !== undefined) {
|
||||
// Extract the Value cell as is - it should already be properly resolved
|
||||
// by the InputResolver based on variable type (number, string, boolean etc.)
|
||||
const value = row.cells.Value
|
||||
|
||||
// Store the correctly typed value in the result object
|
||||
acc[row.cells.Key] = value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
}
|
||||
|
||||
interface RequestParams {
|
||||
url: string
|
||||
method: string
|
||||
|
||||
Reference in New Issue
Block a user