mirror of
https://github.com/vernu/textbee.git
synced 2026-05-19 14:02:04 -04:00
chore: improve customer support and account deletion request flows
This commit is contained in:
@@ -18,6 +18,7 @@ import { ScheduleModule } from '@nestjs/schedule'
|
||||
import { BillingModule } from './billing/billing.module'
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||
import { BullModule } from '@nestjs/bull'
|
||||
import { SupportModule } from './support/support.module'
|
||||
|
||||
@Injectable()
|
||||
export class LoggerMiddleware implements NestMiddleware {
|
||||
@@ -56,6 +57,7 @@ export class LoggerMiddleware implements NestMiddleware {
|
||||
GatewayModule,
|
||||
WebhookModule,
|
||||
BillingModule,
|
||||
SupportModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from './schemas/password-reset.schema'
|
||||
import { AccessLog, AccessLogSchema } from './schemas/access-log.schema'
|
||||
import { EmailVerification, EmailVerificationSchema } from './schemas/email-verification.schema'
|
||||
import { AuthGuard } from './guards/auth.guard'
|
||||
import { OptionalAuthGuard } from './guards/optional-auth.guard'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -44,7 +46,7 @@ import { EmailVerification, EmailVerificationSchema } from './schemas/email-veri
|
||||
MailModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, MongooseModule],
|
||||
exports: [AuthService, JwtModule],
|
||||
providers: [AuthService, JwtStrategy, AuthGuard, OptionalAuthGuard, MongooseModule],
|
||||
exports: [AuthService, JwtModule, AuthGuard, OptionalAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
57
api/src/auth/guards/optional-auth.guard.ts
Normal file
57
api/src/auth/guards/optional-auth.guard.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -132,8 +132,8 @@ export class BillingService {
|
||||
|
||||
const customPlanSubscription = await this.subscriptionModel.findOne({
|
||||
user: user._id,
|
||||
plan: { $in: customPlans.map((plan) => plan._id) },
|
||||
isActive: true,
|
||||
plan: { $in: customPlans.map((plan) => plan._id) },
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
if (customPlanSubscription) {
|
||||
@@ -395,12 +395,18 @@ export class BillingService {
|
||||
bulkSendLimit: plan.bulkSendLimit,
|
||||
monthlyLimit: plan.monthlyLimit,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof HttpException &&
|
||||
error.getStatus() === HttpStatus.TOO_MANY_REQUESTS
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
console.error('canPerformAction: Exception in canPerformAction')
|
||||
console.error(JSON.stringify(error))
|
||||
return true
|
||||
|
||||
80
api/src/mail/templates/account-deletion-request.hbs
Normal file
80
api/src/mail/templates/account-deletion-request.hbs
Normal file
@@ -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>© {{currentYear}} textBee.dev.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
61
api/src/mail/templates/customer-support-confirmation.hbs
Normal file
61
api/src/mail/templates/customer-support-confirmation.hbs
Normal file
@@ -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>© {{currentYear}} textBee.dev.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
58
api/src/support/support.controller.ts
Normal file
58
api/src/support/support.controller.ts
Normal file
@@ -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
Normal file
26
api/src/support/support.module.ts
Normal file
@@ -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
Normal file
141
api/src/support/support.service.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,12 @@ export class User {
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
isBanned: boolean
|
||||
|
||||
@Prop({ type: Date })
|
||||
accountDeletionRequestedAt: Date
|
||||
|
||||
@Prop({ type: String })
|
||||
accountDeletionReason: string
|
||||
}
|
||||
|
||||
export const UserSchema = SchemaFactory.createForClass(User)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export default function AccountSettings() {
|
||||
isSuccess: isRequestAccountDeletionSuccess,
|
||||
} = useMutation({
|
||||
mutationFn: () =>
|
||||
axios.post('/api/request-account-deletion', {
|
||||
httpBrowserClient.post(ApiEndpoints.support.requestAccountDeletion(), {
|
||||
message: deleteReason,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -714,8 +714,9 @@ export default function AccountSettings() {
|
||||
|
||||
{requestAccountDeletionError && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { JoinCommunityModal } from '@/components/shared/join-community-modal'
|
||||
import { ContributeModal } from '@/components/shared/contribute-modal'
|
||||
import UpgradeToProAlert from './upgrade-to-pro-alert'
|
||||
import VerifyEmailAlert from './verify-email-alert'
|
||||
import AccountDeletionAlert from './account-deletion-alert'
|
||||
|
||||
export default function Dashboard({
|
||||
children,
|
||||
@@ -67,6 +68,7 @@ export default function Dashboard({
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='dashboard' className='space-y-4'>
|
||||
<AccountDeletionAlert />
|
||||
<CommunityAlert />
|
||||
<VerifyEmailAlert />
|
||||
<UpgradeToProAlert />
|
||||
@@ -74,12 +76,14 @@ export default function Dashboard({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='community' className='space-y-4'>
|
||||
<AccountDeletionAlert />
|
||||
<VerifyEmailAlert />
|
||||
<UpgradeToProAlert />
|
||||
<CommunityLinks />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='account' className='space-y-4'>
|
||||
<AccountDeletionAlert />
|
||||
<CommunityAlert />
|
||||
<VerifyEmailAlert />
|
||||
<UpgradeToProAlert />
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -31,13 +32,14 @@ import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import axios from 'axios'
|
||||
import httpBrowserClient from '@/lib/httpBrowserClient'
|
||||
import { ApiEndpoints } from '@/config/api'
|
||||
|
||||
const SupportFormSchema = z.object({
|
||||
name: z.string().min(1, { message: 'Name is required' }),
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
phone: z.string().optional(),
|
||||
category: z.enum(['general', 'technical'], {
|
||||
category: z.enum(['general', 'technical', 'billing-and-payments', 'other'], {
|
||||
message: 'Support category is required',
|
||||
}),
|
||||
message: z.string().min(1, { message: 'Message is required' }),
|
||||
@@ -45,6 +47,10 @@ const SupportFormSchema = z.object({
|
||||
|
||||
export default function SupportButton() {
|
||||
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({
|
||||
resolver: zodResolver(SupportFormSchema),
|
||||
defaultValues: {
|
||||
@@ -57,23 +63,41 @@ export default function SupportButton() {
|
||||
})
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
const response = await axios.post('/api/customer-support', data)
|
||||
setIsSubmitting(true)
|
||||
setErrorMessage(null)
|
||||
|
||||
const result = response.data
|
||||
try {
|
||||
// Use the existing httpBrowserClient to call the NestJS endpoint
|
||||
const response = await httpBrowserClient.post(
|
||||
ApiEndpoints.support.customerSupport(),
|
||||
data
|
||||
)
|
||||
|
||||
setIsSubmitSuccessful(true)
|
||||
|
||||
toast({
|
||||
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) {
|
||||
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({
|
||||
title: 'Error submitting support request',
|
||||
description: 'Please try again later',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +105,8 @@ export default function SupportButton() {
|
||||
setOpen(open)
|
||||
if (!open) {
|
||||
form.reset()
|
||||
setIsSubmitSuccessful(false)
|
||||
setErrorMessage(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,19 +124,23 @@ export default function SupportButton() {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Contact Support</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fill out the form below and we'll get back to you as soon as
|
||||
possible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='category'
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Support Category</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -123,6 +153,10 @@ export default function SupportButton() {
|
||||
<SelectItem value='technical'>
|
||||
Technical Support
|
||||
</SelectItem>
|
||||
<SelectItem value='billing-and-payments'>
|
||||
Billing and Payments
|
||||
</SelectItem>
|
||||
<SelectItem value='other'>Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -132,7 +166,7 @@ export default function SupportButton() {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
@@ -146,7 +180,7 @@ export default function SupportButton() {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
@@ -164,7 +198,7 @@ export default function SupportButton() {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='phone'
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone (Optional)</FormLabel>
|
||||
@@ -178,7 +212,7 @@ export default function SupportButton() {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='message'
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Message</FormLabel>
|
||||
@@ -193,27 +227,23 @@ export default function SupportButton() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.isSubmitSuccessful && (
|
||||
{isSubmitSuccessful && (
|
||||
<div className='flex items-center gap-2 text-green-500'>
|
||||
<Check className='h-4 w-4' /> We received your message, we will
|
||||
get back to you soon.
|
||||
</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'
|
||||
|
||||
@@ -37,4 +37,8 @@ export const ApiEndpoints = {
|
||||
checkout: () => '/billing/checkout',
|
||||
plans: () => '/billing/plans',
|
||||
},
|
||||
support: {
|
||||
customerSupport: () => '/support/customer-support',
|
||||
requestAccountDeletion: () => '/support/request-account-deletion',
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user