Skip to content

Commit fc101c3

Browse files
feat(chat-deploy) (#277)
* added chatbot table with fk to workflows, added modal to deploy and delpoy to subdomain of *.simstudio.ai * fixed styling, added delete and edit routes for chatbot * use loading-agent animation for editing existing chatbot * add base_url so that we can delpoy in dev as well * fixed CORS issue, fixed password verification, can deploy chatbot and access it at subdomain. still need to fix the actual chat request to match the same format as the chat in the panel * fix: renamed chatbot to chat and changed chat to copilot * feat(chat-deploy): refactored api deploy flow * feat(chat-deploy): added chat to deploy flow * added output selector to chat deploy, deployment works and we can get a response from subdomain. need to fix UI + form submission of deploy modal but the core functionality works * add missing dependencies, fix build errors, remove old unused route * error disappeared for block output selection, need to update UI, add the ability to delete/view chat deployment, and test emails/email domain * added otp for email verification on chat deploy * feat(chat-deploy): ux improvements with chat-deploy modal * improvement(ui/ux): chat display improvement * improvement(ui/ux): deploy modal * added logging category for chat panel & chat deploy executions * improvement(ui/ux): finished chat-deploy flow * fix: deleted migrations --------- Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent f2e5d67 commit fc101c3

42 files changed

Lines changed: 6170 additions & 2124 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import { NextRequest } from 'next/server'
2+
import { eq } from 'drizzle-orm'
3+
import { z } from 'zod'
4+
import { createLogger } from '@/lib/logs/console-logger'
5+
import { db } from '@/db'
6+
import { chat } from '@/db/schema'
7+
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
8+
import { addCorsHeaders, setChatAuthCookie } from '../../utils'
9+
import { sendEmail } from '@/lib/mailer'
10+
import { render } from '@react-email/render'
11+
import OTPVerificationEmail from '@/components/emails/otp-verification-email'
12+
import { getRedisClient, markMessageAsProcessed, releaseLock } from '@/lib/redis'
13+
14+
const logger = createLogger('ChatOtpAPI')
15+
16+
function generateOTP() {
17+
return Math.floor(100000 + Math.random() * 900000).toString()
18+
}
19+
20+
// OTP storage utility functions using Redis
21+
// We use 15 minutes (900 seconds) expiry for OTPs
22+
const OTP_EXPIRY = 15 * 60
23+
24+
// Store OTP in Redis
25+
async function storeOTP(email: string, chatId: string, otp: string): Promise<void> {
26+
const key = `otp:${email}:${chatId}`
27+
const redis = getRedisClient()
28+
29+
if (redis) {
30+
// Use Redis if available
31+
await redis.set(key, otp, 'EX', OTP_EXPIRY)
32+
} else {
33+
// Use the existing function as fallback to mark that an OTP exists
34+
await markMessageAsProcessed(key, OTP_EXPIRY)
35+
36+
// For the fallback case, we need to handle storing the OTP value separately
37+
// since markMessageAsProcessed only stores "1"
38+
const valueKey = `${key}:value`
39+
try {
40+
// Access the in-memory cache directly - hacky but works for fallback
41+
const inMemoryCache = (global as any).inMemoryCache
42+
if (inMemoryCache) {
43+
const fullKey = `processed:${valueKey}`
44+
const expiry = OTP_EXPIRY ? Date.now() + OTP_EXPIRY * 1000 : null
45+
inMemoryCache.set(fullKey, { value: otp, expiry })
46+
}
47+
} catch (error) {
48+
logger.error('Error storing OTP in fallback cache:', error)
49+
}
50+
}
51+
}
52+
53+
// Get OTP from Redis
54+
async function getOTP(email: string, chatId: string): Promise<string | null> {
55+
const key = `otp:${email}:${chatId}`
56+
const redis = getRedisClient()
57+
58+
if (redis) {
59+
// Use Redis if available
60+
return await redis.get(key)
61+
} else {
62+
// Use the existing function as fallback - check if it exists
63+
const exists = await new Promise(resolve => {
64+
try {
65+
// Check the in-memory cache directly - hacky but works for fallback
66+
const inMemoryCache = (global as any).inMemoryCache
67+
const fullKey = `processed:${key}`
68+
const cacheEntry = inMemoryCache?.get(fullKey)
69+
resolve(!!cacheEntry)
70+
} catch {
71+
resolve(false)
72+
}
73+
})
74+
75+
if (!exists) return null
76+
77+
// Try to get the value key
78+
const valueKey = `${key}:value`
79+
try {
80+
const inMemoryCache = (global as any).inMemoryCache
81+
const fullKey = `processed:${valueKey}`
82+
const cacheEntry = inMemoryCache?.get(fullKey)
83+
return cacheEntry?.value || null
84+
} catch {
85+
return null
86+
}
87+
}
88+
}
89+
90+
// Delete OTP from Redis
91+
async function deleteOTP(email: string, chatId: string): Promise<void> {
92+
const key = `otp:${email}:${chatId}`
93+
const redis = getRedisClient()
94+
95+
if (redis) {
96+
// Use Redis if available
97+
await redis.del(key)
98+
} else {
99+
// Use the existing function as fallback
100+
await releaseLock(`processed:${key}`)
101+
await releaseLock(`processed:${key}:value`)
102+
}
103+
}
104+
105+
const otpRequestSchema = z.object({
106+
email: z.string().email('Invalid email address'),
107+
})
108+
109+
const otpVerifySchema = z.object({
110+
email: z.string().email('Invalid email address'),
111+
otp: z.string().length(6, 'OTP must be 6 digits'),
112+
})
113+
114+
// Send OTP endpoint
115+
export async function POST(
116+
request: NextRequest,
117+
{ params }: { params: Promise<{ subdomain: string }> }
118+
) {
119+
const { subdomain } = await params
120+
const requestId = crypto.randomUUID().slice(0, 8)
121+
122+
try {
123+
logger.debug(`[${requestId}] Processing OTP request for subdomain: ${subdomain}`)
124+
125+
// Parse request body
126+
let body
127+
try {
128+
body = await request.json()
129+
const { email } = otpRequestSchema.parse(body)
130+
131+
// Find the chat deployment
132+
const deploymentResult = await db
133+
.select({
134+
id: chat.id,
135+
authType: chat.authType,
136+
allowedEmails: chat.allowedEmails,
137+
title: chat.title,
138+
})
139+
.from(chat)
140+
.where(eq(chat.subdomain, subdomain))
141+
.limit(1)
142+
143+
if (deploymentResult.length === 0) {
144+
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
145+
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
146+
}
147+
148+
const deployment = deploymentResult[0]
149+
150+
// Verify this is an email-protected chat
151+
if (deployment.authType !== 'email') {
152+
return addCorsHeaders(
153+
createErrorResponse('This chat does not use email authentication', 400),
154+
request
155+
)
156+
}
157+
158+
const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
159+
? deployment.allowedEmails
160+
: []
161+
162+
// Check if the email is allowed
163+
const isEmailAllowed =
164+
allowedEmails.includes(email) ||
165+
allowedEmails.some((allowed: string) => {
166+
if (allowed.startsWith('@')) {
167+
const domain = email.split('@')[1]
168+
return domain && allowed === `@${domain}`
169+
}
170+
return false
171+
})
172+
173+
if (!isEmailAllowed) {
174+
return addCorsHeaders(
175+
createErrorResponse('Email not authorized for this chat', 403),
176+
request
177+
)
178+
}
179+
180+
// Generate OTP
181+
const otp = generateOTP()
182+
183+
// Store OTP in Redis - AWAIT THIS BEFORE RETURNING RESPONSE
184+
await storeOTP(email, deployment.id, otp)
185+
186+
// Create the email
187+
const emailContent = OTPVerificationEmail({
188+
otp,
189+
email,
190+
type: 'chat-access',
191+
chatTitle: deployment.title || 'Chat',
192+
})
193+
194+
// await the render function
195+
const emailHtml = await render(emailContent)
196+
197+
// MAKE SURE TO AWAIT THE EMAIL SENDING
198+
const emailResult = await sendEmail({
199+
to: email,
200+
subject: `Verification code for ${deployment.title || 'Chat'}`,
201+
html: emailHtml,
202+
})
203+
204+
if (!emailResult.success) {
205+
logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
206+
return addCorsHeaders(
207+
createErrorResponse('Failed to send verification email', 500),
208+
request
209+
)
210+
}
211+
212+
// Add a small delay to ensure Redis has fully processed the operation
213+
// This helps with eventual consistency in distributed systems
214+
await new Promise(resolve => setTimeout(resolve, 500))
215+
216+
logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`)
217+
return addCorsHeaders(
218+
createSuccessResponse({ message: 'Verification code sent' }),
219+
request
220+
)
221+
} catch (error: any) {
222+
if (error instanceof z.ZodError) {
223+
return addCorsHeaders(
224+
createErrorResponse(error.errors[0]?.message || 'Invalid request', 400),
225+
request
226+
)
227+
}
228+
throw error
229+
}
230+
} catch (error: any) {
231+
logger.error(`[${requestId}] Error processing OTP request:`, error)
232+
return addCorsHeaders(
233+
createErrorResponse(error.message || 'Failed to process request', 500),
234+
request
235+
)
236+
}
237+
}
238+
239+
// Verify OTP endpoint
240+
export async function PUT(
241+
request: NextRequest,
242+
{ params }: { params: Promise<{ subdomain: string }> }
243+
) {
244+
const { subdomain } = await params
245+
const requestId = crypto.randomUUID().slice(0, 8)
246+
247+
try {
248+
logger.debug(`[${requestId}] Verifying OTP for subdomain: ${subdomain}`)
249+
250+
// Parse request body
251+
let body
252+
try {
253+
body = await request.json()
254+
const { email, otp } = otpVerifySchema.parse(body)
255+
256+
// Find the chat deployment
257+
const deploymentResult = await db
258+
.select({
259+
id: chat.id,
260+
authType: chat.authType,
261+
})
262+
.from(chat)
263+
.where(eq(chat.subdomain, subdomain))
264+
.limit(1)
265+
266+
if (deploymentResult.length === 0) {
267+
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
268+
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
269+
}
270+
271+
const deployment = deploymentResult[0]
272+
273+
// Check if OTP exists and is valid
274+
const storedOTP = await getOTP(email, deployment.id)
275+
if (!storedOTP) {
276+
return addCorsHeaders(
277+
createErrorResponse('No verification code found, request a new one', 400),
278+
request
279+
)
280+
}
281+
282+
// Check if OTP matches
283+
if (storedOTP !== otp) {
284+
return addCorsHeaders(
285+
createErrorResponse('Invalid verification code', 400),
286+
request
287+
)
288+
}
289+
290+
// OTP is valid, clean up
291+
await deleteOTP(email, deployment.id)
292+
293+
// Create success response with auth cookie
294+
const response = addCorsHeaders(
295+
createSuccessResponse({ authenticated: true }),
296+
request
297+
)
298+
299+
// Set authentication cookie
300+
setChatAuthCookie(response, deployment.id, deployment.authType)
301+
302+
return response
303+
} catch (error: any) {
304+
if (error instanceof z.ZodError) {
305+
return addCorsHeaders(
306+
createErrorResponse(error.errors[0]?.message || 'Invalid request', 400),
307+
request
308+
)
309+
}
310+
throw error
311+
}
312+
} catch (error: any) {
313+
logger.error(`[${requestId}] Error verifying OTP:`, error)
314+
return addCorsHeaders(
315+
createErrorResponse(error.message || 'Failed to process request', 500),
316+
request
317+
)
318+
}
319+
}

0 commit comments

Comments
 (0)