Skip to content

Commit 4099904

Browse files
authored
Merge pull request #147 from Happybello365/feature/support-center-faq-captcha
feat: support center — FAQ accordion, CAPTCHA-protected contact form,…
2 parents b60c364 + 67f4c08 commit 4099904

15 files changed

Lines changed: 707 additions & 0 deletions

File tree

backend/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,13 @@ LOG_LEVEL=info
6161

6262
# Cache
6363
CACHE_TTL_SECONDS=60
64+
65+
# CAPTCHA (Turnstile or hCaptcha)
66+
# Provider: turnstile | hcaptcha
67+
CAPTCHA_PROVIDER=turnstile
68+
# Set to 'dev-skip' to bypass verification in development
69+
CAPTCHA_SECRET_KEY=dev-skip
70+
CAPTCHA_SITE_KEY=
71+
72+
# Support
73+
IP_HASH_SALT=change-me-in-production

backend/prisma/schema.prisma

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,36 @@ model AllowedAsset {
175175
176176
@@map("allowed_assets")
177177
}
178+
179+
/// Support ticket submitted via the /support contact form.
180+
model SupportTicket {
181+
id String @id @default(uuid())
182+
email String
183+
subject String
184+
message String
185+
/// One-way hash of submitter IP for spam detection (no raw IP stored).
186+
ipHash String?
187+
status TicketStatus @default(OPEN)
188+
createdAt DateTime @default(now())
189+
updatedAt DateTime @updatedAt
190+
191+
@@index([status])
192+
@@index([createdAt])
193+
@@map("support_tickets")
194+
}
195+
196+
/// Privacy-safe FAQ expansion counters.
197+
model FaqStat {
198+
faqId String @id
199+
expansions Int @default(0)
200+
updatedAt DateTime @updatedAt
201+
202+
@@map("faq_stats")
203+
}
204+
205+
enum TicketStatus {
206+
OPEN
207+
IN_PROGRESS
208+
RESOLVED
209+
CLOSED
210+
}

backend/src/config/env.validation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,11 @@ export const validationSchema = Joi.object({
4343
.valid('error', 'warn', 'log', 'verbose', 'debug'),
4444
// Cache
4545
CACHE_TTL_SECONDS: Joi.number().default(60).description('Cache TTL in seconds'),
46+
// CAPTCHA (Turnstile or hCaptcha)
47+
CAPTCHA_PROVIDER: Joi.string().valid('turnstile', 'hcaptcha').default('turnstile'),
48+
CAPTCHA_SECRET_KEY: Joi.string().allow('').default('dev-skip').description('Server-side CAPTCHA secret'),
49+
CAPTCHA_SITE_KEY: Joi.string().allow('').description('Client-side CAPTCHA site key (exposed to frontend)'),
50+
// Support
51+
IP_HASH_SALT: Joi.string().allow('').default('niff-salt').description('Salt for IP hashing'),
4652
});
4753

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import axios from 'axios';
4+
5+
@Injectable()
6+
export class CaptchaService {
7+
private readonly logger = new Logger(CaptchaService.name);
8+
9+
constructor(private readonly config: ConfigService) {}
10+
11+
async verify(token: string, remoteIp?: string): Promise<boolean> {
12+
const secret = this.config.get<string>('CAPTCHA_SECRET_KEY');
13+
14+
// In development/test with no secret configured, skip verification
15+
if (!secret || secret === 'dev-skip') {
16+
this.logger.warn('CAPTCHA verification skipped (no secret configured)');
17+
return true;
18+
}
19+
20+
const provider = this.config.get<string>('CAPTCHA_PROVIDER', 'turnstile');
21+
22+
try {
23+
const url =
24+
provider === 'hcaptcha'
25+
? 'https://hcaptcha.com/siteverify'
26+
: 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
27+
28+
const body: Record<string, string> = { secret, response: token };
29+
if (remoteIp) body['remoteip'] = remoteIp;
30+
31+
// Encode as application/x-www-form-urlencoded without relying on URLSearchParams
32+
const encoded = Object.entries(body)
33+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
34+
.join('&');
35+
36+
const { data } = await axios.post(url, encoded, {
37+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
38+
});
39+
40+
return data.success === true;
41+
} catch (err) {
42+
this.logger.error('CAPTCHA verification error', err);
43+
return false;
44+
}
45+
}
46+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class CreateTicketDto {
5+
@ApiProperty({ example: 'user@example.com' })
6+
@IsEmail()
7+
email: string;
8+
9+
@ApiProperty({ example: 'Issue with my policy' })
10+
@IsString()
11+
@MinLength(5)
12+
@MaxLength(120)
13+
subject: string;
14+
15+
@ApiProperty({ example: 'I cannot find my policy document...' })
16+
@IsString()
17+
@MinLength(20)
18+
@MaxLength(2000)
19+
message: string;
20+
21+
@ApiProperty({ description: 'CAPTCHA token from Turnstile/hCaptcha' })
22+
@IsString()
23+
@MinLength(1)
24+
captchaToken: string;
25+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {
2+
Body,
3+
Controller,
4+
HttpCode,
5+
HttpStatus,
6+
Ip,
7+
Param,
8+
Post,
9+
} from '@nestjs/common';
10+
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
11+
import { Throttle } from '@nestjs/throttler';
12+
import { SupportService } from './support.service';
13+
import { CreateTicketDto } from './dto/create-ticket.dto';
14+
15+
@ApiTags('Support')
16+
@Controller('support')
17+
export class SupportController {
18+
constructor(private readonly supportService: SupportService) {}
19+
20+
/**
21+
* POST /api/support/tickets
22+
* Submit a support ticket. CAPTCHA token required.
23+
* Rate-limited to 5 submissions per 10 minutes per IP.
24+
*/
25+
@Post('tickets')
26+
@HttpCode(HttpStatus.CREATED)
27+
@Throttle({ default: { limit: 5, ttl: 600_000 } })
28+
@ApiOperation({ summary: 'Submit a support ticket (CAPTCHA protected)' })
29+
@ApiResponse({ status: 201, description: 'Ticket received' })
30+
@ApiResponse({ status: 400, description: 'CAPTCHA failed or validation error' })
31+
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
32+
async submitTicket(@Body() dto: CreateTicketDto, @Ip() ip: string) {
33+
return this.supportService.submitTicket(dto, ip);
34+
}
35+
36+
/**
37+
* POST /api/support/faq/:faqId/expand
38+
* Privacy-safe FAQ expansion tracking.
39+
*/
40+
@Post('faq/:faqId/expand')
41+
@HttpCode(HttpStatus.NO_CONTENT)
42+
@Throttle({ default: { limit: 60, ttl: 60_000 } })
43+
@ApiOperation({ summary: 'Track FAQ entry expansion (privacy-safe)' })
44+
async trackExpansion(@Param('faqId') faqId: string) {
45+
await this.supportService.trackFaqExpansion(faqId);
46+
}
47+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Module } from '@nestjs/common';
2+
import { SupportController } from './support.controller';
3+
import { SupportService } from './support.service';
4+
import { CaptchaService } from './captcha.service';
5+
import { PrismaModule } from '../prisma/prisma.module';
6+
7+
@Module({
8+
imports: [PrismaModule],
9+
controllers: [SupportController],
10+
providers: [SupportService, CaptchaService],
11+
})
12+
export class SupportModule {}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
2+
import { PrismaService } from '../prisma/prisma.service';
3+
import { CaptchaService } from './captcha.service';
4+
import { CreateTicketDto } from './dto/create-ticket.dto';
5+
6+
@Injectable()
7+
export class SupportService {
8+
private readonly logger = new Logger(SupportService.name);
9+
10+
constructor(
11+
private readonly prisma: PrismaService,
12+
private readonly captcha: CaptchaService,
13+
) {}
14+
15+
async submitTicket(dto: CreateTicketDto, remoteIp?: string) {
16+
const valid = await this.captcha.verify(dto.captchaToken, remoteIp);
17+
if (!valid) {
18+
throw new BadRequestException('CAPTCHA verification failed');
19+
}
20+
21+
const ticket = await this.prisma.supportTicket.create({
22+
data: {
23+
email: dto.email,
24+
subject: dto.subject,
25+
message: dto.message,
26+
ipHash: remoteIp ? this.hashIp(remoteIp) : null,
27+
},
28+
});
29+
30+
this.logger.log(`Support ticket created: ${ticket.id}`);
31+
return { id: ticket.id, status: 'received' };
32+
}
33+
34+
async trackFaqExpansion(faqId: string) {
35+
// Privacy-safe: only increment a counter, no user data stored
36+
await this.prisma.faqStat.upsert({
37+
where: { faqId },
38+
update: { expansions: { increment: 1 } },
39+
create: { faqId, expansions: 1 },
40+
});
41+
}
42+
43+
/** One-way hash so we can detect duplicate IPs without storing raw IPs */
44+
private hashIp(ip: string): string {
45+
const crypto = require('crypto');
46+
return crypto.createHash('sha256').update(ip + process.env.IP_HASH_SALT ?? 'niff-salt').digest('hex');
47+
}
48+
}

frontend/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
NEXT_PUBLIC_API_URL=http://localhost:3000
2+
# CAPTCHA site key (public, safe to expose)
3+
# Use Cloudflare Turnstile or hCaptcha site key
4+
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
5+
# Provider: turnstile | hcaptcha
6+
NEXT_PUBLIC_CAPTCHA_PROVIDER=turnstile

frontend/src/app/support/page.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Metadata } from 'next';
2+
import { ExternalLink, MessageCircle, BookOpen } from 'lucide-react';
3+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
4+
import { FaqAccordion } from '@/components/support/faq-accordion';
5+
import { ContactForm } from '@/components/support/contact-form';
6+
import { FAQ_ITEMS } from '@/lib/faq-data';
7+
8+
export const metadata: Metadata = {
9+
title: 'Support — NiffyInsur',
10+
description: 'Get help with NiffyInsur. Browse FAQs or contact our support team.',
11+
};
12+
13+
export default function SupportPage() {
14+
return (
15+
<main className="mx-auto max-w-3xl px-4 py-16 space-y-16">
16+
{/* Header */}
17+
<div className="text-center space-y-2">
18+
<h1 className="text-3xl font-bold tracking-tight">Support Center</h1>
19+
<p className="text-muted-foreground">
20+
Find answers to common questions or reach out to our team.
21+
</p>
22+
</div>
23+
24+
{/* Quick links */}
25+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
26+
<a
27+
href="https://discord.gg/niffyinsur"
28+
target="_blank"
29+
rel="noopener noreferrer"
30+
className="flex items-center gap-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors"
31+
>
32+
<MessageCircle className="h-5 w-5 text-primary shrink-0" />
33+
<div>
34+
<p className="font-medium text-sm">Discord Community</p>
35+
<p className="text-xs text-muted-foreground">Chat with the community</p>
36+
</div>
37+
<ExternalLink className="ml-auto h-4 w-4 text-muted-foreground" />
38+
</a>
39+
<a
40+
href="https://docs.niffyinsur.com"
41+
target="_blank"
42+
rel="noopener noreferrer"
43+
className="flex items-center gap-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors"
44+
>
45+
<BookOpen className="h-5 w-5 text-primary shrink-0" />
46+
<div>
47+
<p className="font-medium text-sm">Documentation</p>
48+
<p className="text-xs text-muted-foreground">Guides and API reference</p>
49+
</div>
50+
<ExternalLink className="ml-auto h-4 w-4 text-muted-foreground" />
51+
</a>
52+
</div>
53+
54+
{/* FAQ */}
55+
<section aria-labelledby="faq-heading">
56+
<h2 id="faq-heading" className="text-xl font-semibold mb-4">Frequently Asked Questions</h2>
57+
<FaqAccordion items={FAQ_ITEMS} />
58+
</section>
59+
60+
{/* Contact form */}
61+
<section aria-labelledby="contact-heading">
62+
<Card>
63+
<CardHeader>
64+
<CardTitle id="contact-heading">Contact Support</CardTitle>
65+
<CardDescription>
66+
Can't find what you need? Send us a message and we'll respond within 1–2 business days.
67+
</CardDescription>
68+
</CardHeader>
69+
<CardContent>
70+
<ContactForm />
71+
</CardContent>
72+
</Card>
73+
</section>
74+
</main>
75+
);
76+
}

0 commit comments

Comments
 (0)