Browse Source

Merge pull request #67 from vernu/improve-customer-support

improve customer support and account deletion request flows
pull/68/head
Israel Abebe 11 months ago
committed by GitHub
parent
commit
225d00de5b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      api/src/app.module.ts
  2. 6
      api/src/auth/auth.module.ts
  3. 57
      api/src/auth/guards/optional-auth.guard.ts
  4. 12
      api/src/billing/billing.service.ts
  5. 80
      api/src/mail/templates/account-deletion-request.hbs
  6. 61
      api/src/mail/templates/customer-support-confirmation.hbs
  7. 58
      api/src/support/support.controller.ts
  8. 26
      api/src/support/support.module.ts
  9. 141
      api/src/support/support.service.ts
  10. 6
      api/src/users/schemas/user.schema.ts
  11. 55
      web/app/(app)/dashboard/(components)/account-deletion-alert.tsx
  12. 7
      web/app/(app)/dashboard/(components)/account-settings.tsx
  13. 4
      web/app/(app)/dashboard/(components)/dashboard-layout.tsx
  14. 59
      web/app/api/customer-support/route.ts
  15. 103
      web/app/api/request-account-deletion/route.ts
  16. 84
      web/components/shared/customer-support.tsx
  17. 4
      web/config/api.ts

2
api/src/app.module.ts

@ -18,6 +18,7 @@ import { ScheduleModule } from '@nestjs/schedule'
import { BillingModule } from './billing/billing.module' import { BillingModule } from './billing/billing.module'
import { ConfigModule, ConfigService } from '@nestjs/config' import { ConfigModule, ConfigService } from '@nestjs/config'
import { BullModule } from '@nestjs/bull' import { BullModule } from '@nestjs/bull'
import { SupportModule } from './support/support.module'
@Injectable() @Injectable()
export class LoggerMiddleware implements NestMiddleware { export class LoggerMiddleware implements NestMiddleware {
@ -56,6 +57,7 @@ export class LoggerMiddleware implements NestMiddleware {
GatewayModule, GatewayModule,
WebhookModule, WebhookModule,
BillingModule, BillingModule,
SupportModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [

6
api/src/auth/auth.module.ts

@ -14,6 +14,8 @@ import {
} from './schemas/password-reset.schema' } from './schemas/password-reset.schema'
import { AccessLog, AccessLogSchema } from './schemas/access-log.schema' import { AccessLog, AccessLogSchema } from './schemas/access-log.schema'
import { EmailVerification, EmailVerificationSchema } from './schemas/email-verification.schema' import { EmailVerification, EmailVerificationSchema } from './schemas/email-verification.schema'
import { AuthGuard } from './guards/auth.guard'
import { OptionalAuthGuard } from './guards/optional-auth.guard'
@Module({ @Module({
imports: [ imports: [
@ -44,7 +46,7 @@ import { EmailVerification, EmailVerificationSchema } from './schemas/email-veri
MailModule, MailModule,
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy, MongooseModule],
exports: [AuthService, JwtModule],
providers: [AuthService, JwtStrategy, AuthGuard, OptionalAuthGuard, MongooseModule],
exports: [AuthService, JwtModule, AuthGuard, OptionalAuthGuard],
}) })
export class AuthModule {} export class AuthModule {}

57
api/src/auth/guards/optional-auth.guard.ts

@ -0,0 +1,57 @@
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { UsersService } from '../../users/users.service'
import { AuthService } from '../auth.service'
import * as bcrypt from 'bcryptjs'
@Injectable()
// Guard for optionally authenticating users by either jwt token or api key
export class OptionalAuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private usersService: UsersService,
private authService: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest()
let userId
const apiKeyString = request.headers['x-api-key'] || request.query.apiKey
if (request.headers.authorization?.startsWith('Bearer ')) {
const bearerToken = request.headers.authorization.split(' ')[1]
try {
const payload = this.jwtService.verify(bearerToken)
userId = payload.sub
} catch (e) {
// Ignore token verification errors
return true
}
} else if (apiKeyString) {
const regex = new RegExp(`^${apiKeyString.substr(0, 17)}`, 'g')
const apiKey = await this.authService.findApiKey({
apiKey: { $regex: regex },
$or: [{ revokedAt: null }, { revokedAt: { $exists: false } }],
})
if (apiKey && bcrypt.compareSync(apiKeyString, apiKey.hashedApiKey)) {
userId = apiKey.user
request.apiKey = apiKey
}
}
if (userId) {
const user = await this.usersService.findOne({ _id: userId })
if (user) {
request.user = user
this.authService.trackAccessLog({ request })
}
}
// Always return true as authentication is optional
return true
}
}

12
api/src/billing/billing.service.ts

@ -132,8 +132,8 @@ export class BillingService {
const customPlanSubscription = await this.subscriptionModel.findOne({ const customPlanSubscription = await this.subscriptionModel.findOne({
user: user._id, user: user._id,
plan: { $in: customPlans.map((plan) => plan._id) },
isActive: true,
plan: { $in: customPlans.map((plan) => plan._id) },
isActive: true,
}) })
if (customPlanSubscription) { if (customPlanSubscription) {
@ -395,12 +395,18 @@ export class BillingService {
bulkSendLimit: plan.bulkSendLimit, bulkSendLimit: plan.bulkSendLimit,
monthlyLimit: plan.monthlyLimit, monthlyLimit: plan.monthlyLimit,
}, },
HttpStatus.BAD_REQUEST,
HttpStatus.TOO_MANY_REQUESTS,
) )
} }
return true return true
} catch (error) { } catch (error) {
if (
error instanceof HttpException &&
error.getStatus() === HttpStatus.TOO_MANY_REQUESTS
) {
throw error
}
console.error('canPerformAction: Exception in canPerformAction') console.error('canPerformAction: Exception in canPerformAction')
console.error(JSON.stringify(error)) console.error(JSON.stringify(error))
return true return true

80
api/src/mail/templates/account-deletion-request.hbs

@ -0,0 +1,80 @@
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Account Deletion Request</title>
<style>
body { font-family: 'Helvetica Neue', Arial, sans-serif; line-height: 1.6;
color: #333; max-width: 600px; margin: 0 auto; padding: 20px; } .header {
text-align: center; margin-bottom: 20px; } .logo { max-width: 150px;
margin-bottom: 10px; } h1 { color: #dc2626; margin-bottom: 20px; }
.content { background-color: #f9fafb; border-radius: 8px; padding: 20px;
margin-bottom: 20px; } .message-details { background-color: #ffffff;
border-left: 4px solid #dc2626; padding: 15px; margin: 15px 0;
border-radius: 4px; } .contact-info { margin-top: 20px; padding-top: 15px;
border-top: 1px solid #e5e7eb; } .field-label { font-weight: bold; color:
#4b5563; margin-bottom: 5px; } .footer { text-align: center; font-size:
14px; color: #6b7280; margin-top: 30px; padding-top: 20px; border-top: 1px
solid #e5e7eb; } .important-notice { background-color: #fee2e2;
border-left: 4px solid #dc2626; padding: 15px; margin: 15px 0;
border-radius: 4px; } .cancel-notice { background-color: #e0f2fe; border:
2px solid #0284c7; padding: 15px; margin: 20px 0; border-radius: 4px;
text-align: center; } .cancel-notice h3 { color: #0284c7; margin-top: 0; }
.cancel-action { font-weight: bold; font-size: 16px; } .cancel-button {
display: inline-block; margin-top: 10px; padding: 8px 16px;
background-color: #0284c7; color: white; border-radius: 4px;
text-decoration: none; font-weight: bold; }
</style>
</head>
<body>
<div class='header'>
{{!-- <img src="{{appLogoUrl}}" alt="TextBee Logo" class="logo"> --}}
<h1>Account Deletion Request</h1>
</div>
<div class='content'>
<p>Hello {{name}},</p>
<p>We have received your request to delete your TextBee account. We're
sorry to see you go.</p>
<div class='important-notice'>
<p><strong>Important:</strong>
Your account has been marked for deletion and will be processed within
7 days. During this period:</p>
<ul>
<li>You can still log in and access your account until the deletion is
completed</li>
<li>After the deletion is complete, all your data will be permanently
removed</li>
<li>This action cannot be undone once processed</li>
</ul>
</div>
<div class='message-details'>
<div class='field-label'>Reason for deletion:</div>
<p>{{#if message}}{{message}}{{else}}No reason provided{{/if}}</p>
</div>
<div class='contact-info'>
<div class='field-label'>Account Information:</div>
<p>Name: {{name}}</p>
<p>Email: {{email}}</p>
</div>
<div class='cancel-notice'>
<h3>Changed Your Mind?</h3>
<p class='cancel-action'>If you didn't request this deletion or want to
keep your account, you can easily cancel this request!</p>
<p>Simply reply to this email as soon as possible and we'll immediately
stop the deletion process.</p>
<p>Your account and all your data will remain intact. No further action
will be needed.</p>
</div>
</div>
<div class='footer'>
<p>&copy; {{currentYear}} textBee.dev.</p>
</div>
</body>
</html>

61
api/src/mail/templates/customer-support-confirmation.hbs

@ -0,0 +1,61 @@
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Support Request Confirmation</title>
<style>
body { font-family: 'Helvetica Neue', Arial, sans-serif; line-height: 1.6;
color: #333; max-width: 600px; margin: 0 auto; padding: 20px; } .header {
text-align: center; margin-bottom: 20px; } .logo { max-width: 150px;
margin-bottom: 10px; } h1 { color: #2563eb; margin-bottom: 20px; }
.content { background-color: #f9fafb; border-radius: 8px; padding: 20px;
margin-bottom: 20px; } .message-details { background-color: #ffffff;
border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0;
border-radius: 4px; } .contact-info { margin-top: 20px; padding-top: 15px;
border-top: 1px solid #e5e7eb; } .field-label { font-weight: bold; color:
#4b5563; margin-bottom: 5px; } .footer { text-align: center; font-size:
14px; color: #6b7280; margin-top: 30px; padding-top: 20px; border-top: 1px
solid #e5e7eb; }
</style>
</head>
<body>
<div class='header'>
{{!-- <img src="{{appLogoUrl}}" alt="TextBee Logo" class="logo"> --}}
<h1>Support Request Submitted</h1>
</div>
<div class='content'>
<p>Hello {{name}},</p>
<p>Thank you for contacting our support team. We have received your
message and will get back to you as soon as possible.</p>
<div class='message-details'>
<div class='field-label'>Category:</div>
<p>{{category}}</p>
<div class='field-label'>Your Message:</div>
<p>{{message}}</p>
</div>
<div class='contact-info'>
<div class='field-label'>Your Contact Information:</div>
<p>Name: {{name}}</p>
<p>Email: {{email}}</p>
{{#if phone}}
<p>Phone: {{phone}}</p>
{{else}}
<p>Phone: Not provided</p>
{{/if}}
</div>
<p>we will review your request and respond to the email address you
provided. If you have any additional information to share, please reply
to this email.</p>
</div>
<div class='footer'>
<p>&copy; {{currentYear}} textBee.dev.</p>
</div>
</body>
</html>

58
api/src/support/support.controller.ts

@ -0,0 +1,58 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'
import {
CreateSupportMessageDto,
SupportCategory,
} from './dto/create-support-message.dto'
import { SupportService } from './support.service'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Request } from 'express'
import { OptionalAuthGuard } from '../auth/guards/optional-auth.guard'
@Controller('support')
export class SupportController {
constructor(private readonly supportService: SupportService) {}
@UseGuards(OptionalAuthGuard)
@Post('customer-support')
async createSupportMessage(
@Body() createSupportMessageDto: CreateSupportMessageDto,
@Req() req: Request,
) {
const ip = req.ip || (req.headers['x-forwarded-for'] as string)
const userAgent = req.headers['user-agent'] as string
// Add request metadata
createSupportMessageDto.ip = ip
createSupportMessageDto.userAgent = userAgent
// If user is authenticated, associate the support request with the user
if (req.user) {
createSupportMessageDto.user = req.user['_id']
}
return this.supportService.createSupportMessage(createSupportMessageDto)
}
@UseGuards(JwtAuthGuard)
@Post('request-account-deletion')
async requestAccountDeletion(
@Body() body: { message: string },
@Req() req: Request,
) {
const ip = req.ip || (req.headers['x-forwarded-for'] as string)
const userAgent = req.headers['user-agent'] as string
const user = req.user
const createSupportMessageDto: CreateSupportMessageDto = {
user: user['_id'],
name: user['name'],
email: user['email'],
category: SupportCategory.ACCOUNT_DELETION,
message: body.message,
ip,
userAgent,
}
return this.supportService.requestAccountDeletion(createSupportMessageDto)
}
}

26
api/src/support/support.module.ts

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import {
SupportMessage,
SupportMessageSchema,
} from './schemas/support-message.schema'
import { SupportService } from './support.service'
import { SupportController } from './support.controller'
import { UsersModule } from 'src/users/users.module'
import { BillingModule } from 'src/billing/billing.module'
import { MailModule } from 'src/mail/mail.module'
import { AuthModule } from 'src/auth/auth.module'
@Module({
imports: [
MongooseModule.forFeature([
{ name: SupportMessage.name, schema: SupportMessageSchema },
]),
UsersModule,
BillingModule,
MailModule,
AuthModule,
],
controllers: [SupportController],
providers: [SupportService],
})
export class SupportModule {}

141
api/src/support/support.service.ts

@ -0,0 +1,141 @@
import {
Injectable,
ConflictException,
NotFoundException,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { isValidObjectId, Model, Types } from 'mongoose'
import {
SupportMessage,
SupportMessageDocument,
} from './schemas/support-message.schema'
import { User, UserDocument } from '../users/schemas/user.schema'
import {
CreateSupportMessageDto,
SupportCategory,
} from './dto/create-support-message.dto'
import { MailService } from '../mail/mail.service'
@Injectable()
export class SupportService {
constructor(
@InjectModel(SupportMessage.name)
private supportMessageModel: Model<SupportMessageDocument>,
@InjectModel(User.name) private userModel: Model<UserDocument>,
private readonly mailService: MailService,
) {}
async createSupportMessage(
createSupportMessageDto: CreateSupportMessageDto,
): Promise<{ message: string }> {
try {
// Create and save the support message
const createdMessage = new this.supportMessageModel(
createSupportMessageDto,
)
const savedMessage = await createdMessage.save()
// Determine if the user is registered
let user = null
if (
createSupportMessageDto.user &&
isValidObjectId(createSupportMessageDto.user)
) {
user = await this.userModel.findById(createSupportMessageDto.user)
}
// Send confirmation email to user
await this.mailService.sendEmailFromTemplate({
to: createSupportMessageDto.email,
cc: process.env.ADMIN_EMAIL,
subject: `Support Request Submitted: ${createSupportMessageDto.category}-${savedMessage._id}`,
template: 'customer-support-confirmation',
context: {
name: createSupportMessageDto.name,
email: createSupportMessageDto.email,
phone: createSupportMessageDto.phone || 'Not provided',
category: createSupportMessageDto.category,
message: createSupportMessageDto.message,
appLogoUrl:
process.env.APP_LOGO_URL || 'https://textbee.dev/logo.png',
currentYear: new Date().getFullYear(),
},
})
return { message: 'Support request submitted successfully' }
} catch (error) {
console.error('Error creating support message:', error)
throw error
}
}
// Method for account deletion requests
async requestAccountDeletion(
createSupportMessageDto: CreateSupportMessageDto,
): Promise<{ message: string }> {
try {
// Check if user exists
if (
!createSupportMessageDto.user ||
!isValidObjectId(createSupportMessageDto.user)
) {
throw new NotFoundException('User not found')
}
const userId = new Types.ObjectId(createSupportMessageDto.user.toString())
const user = await this.userModel.findById(userId)
if (!user) {
throw new NotFoundException('User not found')
}
// Check if user has already requested deletion
const existingRequest = await this.supportMessageModel.findOne({
user: userId,
category: SupportCategory.ACCOUNT_DELETION,
})
if (existingRequest) {
throw new ConflictException(
'Account deletion has already been requested',
)
}
// Create and save the support message
const createdMessage = new this.supportMessageModel(
createSupportMessageDto,
)
const savedMessage = await createdMessage.save()
// Update user's account deletion requested timestamp
await this.userModel.updateOne(
{ _id: userId },
{
accountDeletionRequestedAt: new Date(),
accountDeletionReason: createSupportMessageDto.message || null,
},
)
// Send confirmation email
await this.mailService.sendEmailFromTemplate({
to: user.email,
cc: process.env.ADMIN_EMAIL,
subject: `Account Deletion Request: ${savedMessage._id}`,
template: 'account-deletion-request',
context: {
name: user.name,
email: user.email,
message: createSupportMessageDto.message || 'No reason provided',
appLogoUrl:
process.env.APP_LOGO_URL || 'https://textbee.dev/logo.png',
currentYear: new Date().getFullYear(),
},
})
return { message: 'Account deletion request submitted successfully' }
} catch (error) {
console.error('Error requesting account deletion:', error)
throw error
}
}
}

6
api/src/users/schemas/user.schema.ts

@ -37,6 +37,12 @@ export class User {
@Prop({ type: Boolean, default: false }) @Prop({ type: Boolean, default: false })
isBanned: boolean isBanned: boolean
@Prop({ type: Date })
accountDeletionRequestedAt: Date
@Prop({ type: String })
accountDeletionReason: string
} }
export const UserSchema = SchemaFactory.createForClass(User) export const UserSchema = SchemaFactory.createForClass(User)

55
web/app/(app)/dashboard/(components)/account-deletion-alert.tsx

@ -0,0 +1,55 @@
import { Alert, AlertDescription } from '@/components/ui/alert'
import { ApiEndpoints } from '@/config/api'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
export default function AccountDeletionAlert() {
const {
data: userData,
isLoading: isLoadingUserData,
error: userDataError,
} = useQuery({
queryKey: ['whoAmI'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.whoAmI())
.then((res) => res.data.data),
})
if (isLoadingUserData || !userData || userDataError) {
return null
}
// Only show the alert if the user has requested account deletion
if (!userData.accountDeletionRequestedAt) {
return null
}
// Calculate days remaining until deletion (assuming 7-day window)
const deletionDate = new Date(userData.accountDeletionRequestedAt)
deletionDate.setDate(deletionDate.getDate() + 7)
const daysRemaining = Math.max(
0,
Math.ceil(
(deletionDate.getTime() - new Date().getTime()) / (1000 * 3600 * 24)
)
)
return (
<Alert className='bg-gradient-to-r from-amber-600 to-red-600 text-white'>
<AlertDescription className='flex items-center gap-2'>
<AlertTriangle className='h-5 w-5 flex-shrink-0' />
<div className='text-sm md:text-base'>
<span className='font-medium'>Your account is pending deletion.</span>{' '}
Your data will be permanently deleted{' '}
{daysRemaining > 0
? `in ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''}.`
: 'very soon.'}{' '}
If you would like to cancel this request, please email{' '}
<span className='font-medium'>support@textbee.dev</span>.
</div>
</AlertDescription>
</Alert>
)
}

7
web/app/(app)/dashboard/(components)/account-settings.tsx

@ -188,7 +188,7 @@ export default function AccountSettings() {
isSuccess: isRequestAccountDeletionSuccess, isSuccess: isRequestAccountDeletionSuccess,
} = useMutation({ } = useMutation({
mutationFn: () => mutationFn: () =>
axios.post('/api/request-account-deletion', {
httpBrowserClient.post(ApiEndpoints.support.requestAccountDeletion(), {
message: deleteReason, message: deleteReason,
}), }),
onSuccess: () => { onSuccess: () => {
@ -714,8 +714,9 @@ export default function AccountSettings() {
{requestAccountDeletionError && ( {requestAccountDeletionError && (
<p className='text-sm text-destructive'> <p className='text-sm text-destructive'>
{requestAccountDeletionError.message ||
'Failed to submit account deletion request'}
{(requestAccountDeletionError as any).response?.data?.message ||
requestAccountDeletionError.message ||
'Failed to submit account deletion request'}
</p> </p>
)} )}

4
web/app/(app)/dashboard/(components)/dashboard-layout.tsx

@ -15,6 +15,7 @@ import { JoinCommunityModal } from '@/components/shared/join-community-modal'
import { ContributeModal } from '@/components/shared/contribute-modal' import { ContributeModal } from '@/components/shared/contribute-modal'
import UpgradeToProAlert from './upgrade-to-pro-alert' import UpgradeToProAlert from './upgrade-to-pro-alert'
import VerifyEmailAlert from './verify-email-alert' import VerifyEmailAlert from './verify-email-alert'
import AccountDeletionAlert from './account-deletion-alert'
export default function Dashboard({ export default function Dashboard({
children, children,
@ -67,6 +68,7 @@ export default function Dashboard({
</TabsList> </TabsList>
<TabsContent value='dashboard' className='space-y-4'> <TabsContent value='dashboard' className='space-y-4'>
<AccountDeletionAlert />
<CommunityAlert /> <CommunityAlert />
<VerifyEmailAlert /> <VerifyEmailAlert />
<UpgradeToProAlert /> <UpgradeToProAlert />
@ -74,12 +76,14 @@ export default function Dashboard({
</TabsContent> </TabsContent>
<TabsContent value='community' className='space-y-4'> <TabsContent value='community' className='space-y-4'>
<AccountDeletionAlert />
<VerifyEmailAlert /> <VerifyEmailAlert />
<UpgradeToProAlert /> <UpgradeToProAlert />
<CommunityLinks /> <CommunityLinks />
</TabsContent> </TabsContent>
<TabsContent value='account' className='space-y-4'> <TabsContent value='account' className='space-y-4'>
<AccountDeletionAlert />
<CommunityAlert /> <CommunityAlert />
<VerifyEmailAlert /> <VerifyEmailAlert />
<UpgradeToProAlert /> <UpgradeToProAlert />

59
web/app/api/customer-support/route.ts

@ -1,59 +0,0 @@
import {
NextRequest,
NextResponse,
userAgent,
userAgentFromString,
} from 'next/server'
import prismaClient from '@/lib/prismaClient'
import { sendMail } from '@/lib/mail'
export async function POST(req: NextRequest) {
const ip = req.ip || req.headers.get('x-forwarded-for')
const { browser, device, os, isBot, ua } = userAgent(req)
// const userAgentString = userAgentFromString(ua)
const body = await req.json()
try {
const result = await prismaClient.supportMessage.create({
data: {
...body,
ip,
userAgent: ua,
},
})
// send email to user
await sendMail({
to: body.email,
cc: process.env.ADMIN_EMAIL,
subject: `Support request submitted: ${body.category}-${result.id}`,
html: `<pre>
<h1>Support request submitted</h1>
<p>Thank you for contacting us. We will get back to you soon.</p>
<p>Here is a copy of your message:</p>
<hr/>
<h2>Category</h2><br/>${body.category}
<h2>Message</h2><br/>${body.message}
<h2>Contact Information</h2>
<p>Name: ${body.name}</p>
<p>Email: ${body.email}</p>
<p>Phone: ${body.phone || 'N/A'}</p>
</pre>`,
})
return NextResponse.json({
message: 'Support request submitted',
})
} catch (error) {
console.error(error)
return NextResponse.json(
{
message: `Support request failed to submit : ${error.message}`,
},
{ status: 400 }
)
}
}

103
web/app/api/request-account-deletion/route.ts

@ -1,103 +0,0 @@
import {
NextRequest,
NextResponse,
userAgent,
userAgentFromString,
} from 'next/server'
import prismaClient from '@/lib/prismaClient'
import { sendMail } from '@/lib/mail'
import { getServerSession, User } from 'next-auth'
import { authOptions } from '@/lib/auth'
export async function POST(req: NextRequest) {
const ip = req.ip || req.headers.get('x-forwarded-for')
const { browser, device, os, isBot, ua } = userAgent(req)
// const userAgentString = userAgentFromString(ua)
const body = await req.json()
const session = await getServerSession(authOptions as any)
if (!session) {
return NextResponse.json(
{
message: 'You must be logged in to request account deletion',
},
{ status: 401 }
)
}
// @ts-ignore
const currentUser = session?.user as User
if (!currentUser) {
return NextResponse.json(
{
message: 'You must be logged in to request account deletion',
},
{ status: 401 }
)
}
const category = 'account-deletion'
const message = body.message ?? 'No message provided'
try {
// check if the user has already requested account deletion
const existingRequest = await prismaClient.supportMessage.findFirst({
where: {
user: currentUser.id,
category: 'account-deletion',
},
})
if (existingRequest) {
return NextResponse.json(
{
message: 'You have already requested account deletion',
},
{ status: 400 }
)
}
const result = await prismaClient.supportMessage.create({
data: {
user: currentUser.id,
category,
message,
ip,
userAgent: ua,
},
})
// send email to user
await sendMail({
to: currentUser.email,
cc: process.env.ADMIN_EMAIL,
subject: `Account deletion request submitted: ${category}-${result.id}`,
html: `<pre>
<h1>Account deletion request submitted</h1>
<p>Thank you for contacting us. We will get back to you soon.</p>
<p>Here is a copy of your message:</p>
<hr/>
<h2>Category</h2><br/>${category}
<h2>Message</h2><br/>${message}
<h2>Contact Information</h2>
<p>Name: ${currentUser.name}</p>
<p>Email: ${currentUser.email}</p>
</pre>`,
})
return NextResponse.json({
message: 'Support request submitted',
})
} catch (error) {
console.error(error)
return NextResponse.json(
{
message: `Support request failed to submit : ${error.message}`,
},
{ status: 400 }
)
}
}

84
web/components/shared/customer-support.tsx

@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@ -31,13 +32,14 @@ import { useForm } from 'react-hook-form'
import { z } from 'zod' import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import axios from 'axios'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
const SupportFormSchema = z.object({ const SupportFormSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }), name: z.string().min(1, { message: 'Name is required' }),
email: z.string().email({ message: 'Invalid email address' }), email: z.string().email({ message: 'Invalid email address' }),
phone: z.string().optional(), phone: z.string().optional(),
category: z.enum(['general', 'technical'], {
category: z.enum(['general', 'technical', 'billing-and-payments', 'other'], {
message: 'Support category is required', message: 'Support category is required',
}), }),
message: z.string().min(1, { message: 'Message is required' }), message: z.string().min(1, { message: 'Message is required' }),
@ -45,6 +47,10 @@ const SupportFormSchema = z.object({
export default function SupportButton() { export default function SupportButton() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitSuccessful, setIsSubmitSuccessful] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const form = useForm({ const form = useForm({
resolver: zodResolver(SupportFormSchema), resolver: zodResolver(SupportFormSchema),
defaultValues: { defaultValues: {
@ -57,23 +63,41 @@ export default function SupportButton() {
}) })
const onSubmit = async (data: any) => { const onSubmit = async (data: any) => {
setIsSubmitting(true)
setErrorMessage(null)
try { try {
const response = await axios.post('/api/customer-support', data)
// Use the existing httpBrowserClient to call the NestJS endpoint
const response = await httpBrowserClient.post(
ApiEndpoints.support.customerSupport(),
data
)
const result = response.data
setIsSubmitSuccessful(true)
toast({ toast({
title: 'Support request submitted', title: 'Support request submitted',
description: result.message,
description: response.data.message || 'We will get back to you soon.',
}) })
// Wait 3 seconds before closing the dialog
setTimeout(() => {
setOpen(false)
}, 3000)
} catch (error) { } catch (error) {
form.setError('root.serverError', {
message: 'Error submitting support request',
})
console.error('Error submitting support request:', error)
setErrorMessage(
'Error submitting support request. Please try again later.'
)
toast({ toast({
title: 'Error submitting support request', title: 'Error submitting support request',
description: 'Please try again later', description: 'Please try again later',
variant: 'destructive',
}) })
} finally {
setIsSubmitting(false)
} }
} }
@ -81,6 +105,8 @@ export default function SupportButton() {
setOpen(open) setOpen(open)
if (!open) { if (!open) {
form.reset() form.reset()
setIsSubmitSuccessful(false)
setErrorMessage(null)
} }
} }
@ -98,19 +124,23 @@ export default function SupportButton() {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Contact Support</DialogTitle> <DialogTitle>Contact Support</DialogTitle>
<DialogDescription>
Fill out the form below and we'll get back to you as soon as
possible.
</DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'> <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField <FormField
control={form.control} control={form.control}
name='category' name='category'
disabled={form.formState.isSubmitting}
disabled={isSubmitting}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Support Category</FormLabel> <FormLabel>Support Category</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
disabled={form.formState.isSubmitting}
disabled={isSubmitting}
defaultValue={field.value} defaultValue={field.value}
> >
<FormControl> <FormControl>
@ -123,6 +153,10 @@ export default function SupportButton() {
<SelectItem value='technical'> <SelectItem value='technical'>
Technical Support Technical Support
</SelectItem> </SelectItem>
<SelectItem value='billing-and-payments'>
Billing and Payments
</SelectItem>
<SelectItem value='other'>Other</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@ -132,7 +166,7 @@ export default function SupportButton() {
<FormField <FormField
control={form.control} control={form.control}
name='name' name='name'
disabled={form.formState.isSubmitting}
disabled={isSubmitting}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
@ -146,7 +180,7 @@ export default function SupportButton() {
<FormField <FormField
control={form.control} control={form.control}
name='email' name='email'
disabled={form.formState.isSubmitting}
disabled={isSubmitting}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
@ -164,7 +198,7 @@ export default function SupportButton() {
<FormField <FormField
control={form.control} control={form.control}
name='phone' name='phone'
disabled={form.formState.isSubmitting}
disabled={isSubmitting}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Phone (Optional)</FormLabel> <FormLabel>Phone (Optional)</FormLabel>
@ -178,7 +212,7 @@ export default function SupportButton() {
<FormField <FormField
control={form.control} control={form.control}
name='message' name='message'
disabled={form.formState.isSubmitting}
disabled={isSubmitting}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Message</FormLabel> <FormLabel>Message</FormLabel>
@ -193,27 +227,23 @@ export default function SupportButton() {
</FormItem> </FormItem>
)} )}
/> />
{form.formState.isSubmitSuccessful && (
{isSubmitSuccessful && (
<div className='flex items-center gap-2 text-green-500'> <div className='flex items-center gap-2 text-green-500'>
<Check className='h-4 w-4' /> We received your message, we will <Check className='h-4 w-4' /> We received your message, we will
get back to you soon. get back to you soon.
</div> </div>
)} )}
{form.formState.errors.root?.serverError && (
<>
<AlertTriangle className='h-4 w-4' />{' '}
{form.formState.errors.root.serverError.message}
</>
{errorMessage && (
<div className='flex items-center gap-2 text-red-500'>
<AlertTriangle className='h-4 w-4' /> {errorMessage}
</div>
)} )}
<Button
type='submit'
disabled={form.formState.isSubmitting}
className='w-full'
>
{form.formState.isSubmitting ? (
<Button type='submit' disabled={isSubmitting} className='w-full'>
{isSubmitting ? (
<> <>
<Loader2 className='h-4 w-4 animate-spin' /> Submitting ...{' '}
<Loader2 className='h-4 w-4 animate-spin mr-2' />{' '}
Submitting...
</> </>
) : ( ) : (
'Submit' 'Submit'

4
web/config/api.ts

@ -37,4 +37,8 @@ export const ApiEndpoints = {
checkout: () => '/billing/checkout', checkout: () => '/billing/checkout',
plans: () => '/billing/plans', plans: () => '/billing/plans',
}, },
support: {
customerSupport: () => '/support/customer-support',
requestAccountDeletion: () => '/support/request-account-deletion',
},
} }
Loading…
Cancel
Save