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