This commit is contained in:
Oscar
2026-06-22 09:31:44 +03:00
commit 3902d0dec4
48 changed files with 19218 additions and 0 deletions

22
backend/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Server
PORT=3001
NODE_ENV=development
# CORS
FRONTEND_URL=http://localhost:3000
# Mail (SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your@gmail.com
SMTP_PASS=your-app-password
MAIL_FROM=your@gmail.com
MAIL_TO=owner@gmail.com
# AI (Groq)
GROQ_API_KEY=your-groq-api-key
GROQ_MODEL=llama-3.3-70b-versatile
# Rate limiting
THROTTLE_TTL=60000
THROTTLE_LIMIT=5

16
backend/data/metrics.json Normal file
View File

@@ -0,0 +1,16 @@
{
"total": 2,
"byCategory": {
"question": 0,
"partnership": 0,
"job": 0,
"spam": 0,
"other": 2
},
"bySentiment": {
"positive": 0,
"neutral": 1,
"negative": 0
},
"lastUpdated": "2026-06-19T14:26:09.000Z"
}

39
backend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "dev-landing-backend",
"version": "1.0.0",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\""
},
"dependencies": {
"@fastify/static": "^9.1.3",
"@nestjs/common": "^11.1.27",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.27",
"@nestjs/platform-fastify": "^11.1.27",
"@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"fastify": "^5.8.5",
"nodemailer": "^9.0.1",
"openai": "^6.44.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"uuid": "^11.0.5",
"winston": "^3.17.0"
},
"devDependencies": {
"@antfu/eslint-config": "^3.14.0",
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.17",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}

6118
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
allowBuilds:
'@nestjs/core': true
'@scarf/scarf': true
unrs-resolver: true

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { LoggerModule } from '../logger/logger.module';
@Module({
imports: [LoggerModule],
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

View File

@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import OpenAI from 'openai';
import { LoggerService } from '../logger/logger.service';
export interface AiAnalysisResult {
sentiment: 'positive' | 'neutral' | 'negative';
category: 'question' | 'partnership' | 'job' | 'spam' | 'other';
autoReply: string;
}
@Injectable()
export class AiService {
private groq: OpenAI | null = null;
constructor(private readonly logger: LoggerService) {
const apiKey = process.env.GROQ_API_KEY;
if (apiKey) {
this.groq = new OpenAI({
apiKey,
baseURL: 'https://api.groq.com/openai/v1',
timeout: 10000,
});
}
}
async analyzeContact(dto: { name: string; email: string; comment?: string }): Promise<AiAnalysisResult | null> {
if (!this.groq) {
this.logger.warn('AiService: GROQ_API_KEY not set, skipping AI analysis');
return null;
}
try {
const model = process.env.GROQ_MODEL ?? 'llama-3.3-70b-versatile';
const completion = await this.groq.chat.completions.create({
model,
messages: [
{
role: 'system',
content: 'Ты — аналитик обращений. Отвечай ТОЛЬКО валидным JSON без markdown и пояснений.',
},
{
role: 'user',
content: `Проанализируй обращение:\nИмя: ${dto.name}\nEmail: ${dto.email}\nКомментарий: ${dto.comment ?? '(не указан)'}\n\nВерни JSON:\n{"sentiment":"positive"|"neutral"|"negative","category":"question"|"partnership"|"job"|"spam"|"other","autoReply":"текст автоответа на русском языке (2-3 предложения)"}`,
},
],
});
const text = completion.choices[0]?.message?.content ?? '';
const result = JSON.parse(text) as AiAnalysisResult;
return result;
} catch (e) {
this.logger.logError(e, 'AiService.analyzeContact');
return null;
}
}
}

30
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { ContactModule } from './contact/contact.module';
import { HealthModule } from './health/health.module';
import { MetricsModule } from './metrics/metrics.module';
import { LoggerModule } from './logger/logger.module';
import { StorageModule } from './storage/storage.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => [
{
ttl: parseInt(config.get('THROTTLE_TTL', '60000')),
limit: parseInt(config.get('THROTTLE_LIMIT', '5')),
},
],
}),
LoggerModule,
StorageModule,
ContactModule,
HealthModule,
MetricsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,23 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ContactService } from './contact.service';
import { CreateContactDto } from './dto/create-contact.dto';
@ApiTags('Contact')
@Controller('api/contact')
export class ContactController {
constructor(private readonly contactService: ContactService) {}
@Post()
@UseGuards(ThrottlerGuard)
@ApiOperation({ summary: 'Submit contact form' })
@ApiBody({ type: CreateContactDto })
@ApiResponse({ status: 201, description: 'Contact form submitted successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 429, description: 'Too many requests' })
@ApiResponse({ status: 500, description: 'Internal server error' })
async submit(@Body() dto: CreateContactDto) {
return this.contactService.handleContact(dto);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ContactController } from './contact.controller';
import { ContactService } from './contact.service';
import { AiModule } from '../ai/ai.module';
import { MailModule } from '../mail/mail.module';
import { MetricsModule } from '../metrics/metrics.module';
@Module({
imports: [AiModule, MailModule, MetricsModule],
controllers: [ContactController],
providers: [ContactService],
})
export class ContactModule {}

View File

@@ -0,0 +1,42 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { CreateContactDto } from './dto/create-contact.dto';
import { AiService } from '../ai/ai.service';
import { MailService } from '../mail/mail.service';
import { MetricsService } from '../metrics/metrics.service';
import { LoggerService } from '../logger/logger.service';
import { ContactResponse } from './interfaces/contact.interface';
@Injectable()
export class ContactService {
constructor(
private readonly aiService: AiService,
private readonly mailService: MailService,
private readonly metricsService: MetricsService,
private readonly logger: LoggerService,
) {}
async handleContact(dto: CreateContactDto): Promise<ContactResponse> {
this.logger.logRequest('POST', '/api/contact', dto, null, 0);
const aiResult = await this.aiService.analyzeContact(dto).catch((e) => {
this.logger.logError(e, 'ContactService.aiService.analyzeContact');
return null;
});
try {
await this.mailService.sendOwnerNotification(dto, aiResult);
await this.mailService.sendUserConfirmation(dto, aiResult);
} catch (e) {
this.logger.logError(e, 'ContactService.mailService');
throw new InternalServerErrorException('Не удалось отправить письмо. Попробуйте позже.');
}
this.metricsService.increment(aiResult?.category ?? null, aiResult?.sentiment ?? null);
return {
success: true,
message: 'Ваше обращение успешно отправлено!',
ai: aiResult,
};
}
}

View File

@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class CreateContactDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
@ApiProperty({ example: 'Иван Иванов' })
name: string;
@IsString()
@IsNotEmpty()
@Matches(/^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/)
@ApiProperty({ example: '+7 999 123-45-67' })
phone: string;
@IsEmail()
@ApiProperty({ example: 'ivan@example.com' })
email: string;
@IsString()
@IsOptional()
@MaxLength(1000)
@ApiProperty({ example: 'Хочу обсудить проект', required: false })
comment?: string;
}

View File

@@ -0,0 +1,18 @@
export interface Contact {
id: string;
name: string;
phone: string;
email: string;
comment?: string;
createdAt: string;
}
export interface ContactResponse {
success: boolean;
message: string;
ai: {
sentiment: string;
category: string;
autoReply: string;
} | null;
}

View File

@@ -0,0 +1,33 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { LoggerService } from '../logger/logger.service';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly logger: LoggerService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const reply = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const statusCode =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? (exception.getResponse() as any)?.message ?? exception.message
: 'Internal server error';
this.logger.logError(exception, `${request.method} ${request.url}`);
reply.status(statusCode).send({
statusCode,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@ApiTags('Health')
@Controller('api/health')
export class HealthController {
@Get()
@ApiOperation({ summary: 'Health check' })
@ApiResponse({ status: 200, description: 'Service is healthy' })
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
version: '1.0.0',
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { LoggerService } from './logger.service';
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import * as winston from 'winston';
import * as path from 'path';
import * as fs from 'fs';
@Injectable()
export class LoggerService {
private logger: winston.Logger;
constructor() {
const logsDir = path.join(process.cwd(), 'data', 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const today = new Date().toISOString().split('T')[0];
const logFile = path.join(logsDir, `requests-${today}.log`);
this.logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
),
}),
new winston.transports.File({
filename: logFile,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
}),
],
});
}
logRequest(method: string, path: string, body: any, response: any, duration: number) {
this.logger.info('Request', {
meta: { method, path, body, response, duration },
});
}
logError(error: any, context?: string) {
this.logger.error('Error', {
meta: { error: error?.message ?? String(error), stack: error?.stack, context },
});
}
log(message: string, meta?: any) {
this.logger.info(message, { meta });
}
warn(message: string, meta?: any) {
this.logger.warn(message, { meta });
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
import { LoggerModule } from '../logger/logger.module';
@Module({
imports: [LoggerModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { LoggerService } from '../logger/logger.service';
import { AiAnalysisResult } from '../ai/ai.service';
@Injectable()
export class MailService {
private transporter: nodemailer.Transporter | null = null;
private devMode: boolean;
constructor(private readonly logger: LoggerService) {
const smtpHost = process.env.SMTP_HOST;
this.devMode = !smtpHost;
if (!this.devMode) {
this.transporter = nodemailer.createTransport({
host: smtpHost,
port: parseInt(process.env.SMTP_PORT ?? '587'),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
}
async sendOwnerNotification(dto: { name: string; phone: string; email: string; comment?: string }, aiResult: AiAnalysisResult | null): Promise<void> {
const subject = `Новое обращение от ${dto.name}`;
const aiBlock = aiResult
? `<hr><h3>AI-анализ</h3><p><b>Тональность:</b> ${aiResult.sentiment}</p><p><b>Категория:</b> ${aiResult.category}</p><p><b>Автоответ:</b> ${aiResult.autoReply}</p>`
: '<p><i>AI-анализ недоступен</i></p>';
const html = `
<h2>Новое обращение с сайта</h2>
<p><b>Имя:</b> ${dto.name}</p>
<p><b>Телефон:</b> ${dto.phone}</p>
<p><b>Email:</b> ${dto.email}</p>
<p><b>Комментарий:</b> ${dto.comment ?? '—'}</p>
${aiBlock}
`;
await this.send(process.env.MAIL_TO ?? '', subject, html);
}
async sendUserConfirmation(dto: { name: string; email: string }, aiResult: AiAnalysisResult | null): Promise<void> {
const subject = `Спасибо за обращение, ${dto.name}!`;
const replyText = aiResult?.autoReply
? `<p>${aiResult.autoReply}</p>`
: '<p>Мы получили ваше сообщение и свяжемся с вами в ближайшее время.</p>';
const html = `
<h2>Спасибо за обращение!</h2>
<p>Здравствуйте, ${dto.name}!</p>
<p>Ваше сообщение успешно получено.</p>
${replyText}
<p>С уважением,<br>Команда разработки</p>
`;
await this.send(dto.email, subject, html);
}
private async send(to: string, subject: string, html: string): Promise<void> {
if (this.devMode || !this.transporter) {
this.logger.log(`[DEV MAIL] To: ${to} | Subject: ${subject}`, { html });
return;
}
await this.transporter.sendMail({
from: process.env.MAIL_FROM,
to,
subject,
html,
});
}
}

47
backend/src/main.ts Normal file
View File

@@ -0,0 +1,47 @@
import {NestFactory} from '@nestjs/core';
import {FastifyAdapter, NestFastifyApplication} from '@nestjs/platform-fastify';
import {ValidationPipe} from '@nestjs/common';
import {DocumentBuilder, SwaggerModule} from '@nestjs/swagger';
import {AppModule} from './app.module';
import {AllExceptionsFilter} from './filters/all-exceptions.filter';
import {LoggerService} from './logger/logger.service';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
app.enableCors({
origin: '*',//process.env.FRONTEND_URL ?? 'http://localhost:3000',
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
const logger = app.get(LoggerService);
app.useGlobalFilters(new AllExceptionsFilter(logger));
const config = new DocumentBuilder()
.setTitle('Dev Landing API')
.setDescription('Backend API for developer landing page with contact form, AI analysis, and metrics')
.setVersion('1.0.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.PORT ?? 3001;
await app.listen(port, '0.0.0.0');
console.log(`Application running on http://localhost:${port}`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@@ -0,0 +1,16 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { MetricsService } from './metrics.service';
@ApiTags('Metrics')
@Controller('api/metrics')
export class MetricsController {
constructor(private readonly metricsService: MetricsService) {}
@Get()
@ApiOperation({ summary: 'Get contact form metrics' })
@ApiResponse({ status: 200, description: 'Current metrics data' })
getMetrics() {
return this.metricsService.getMetrics();
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { MetricsService } from './metrics.service';
import { MetricsController } from './metrics.controller';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [StorageModule],
providers: [MetricsService],
controllers: [MetricsController],
exports: [MetricsService],
})
export class MetricsModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import * as path from 'path';
import { FileStorageService } from '../storage/file-storage.service';
export interface MetricsData {
total: number;
byCategory: Record<string, number>;
bySentiment: Record<string, number>;
lastUpdated: string;
}
const DEFAULT_METRICS: MetricsData = {
total: 0,
byCategory: { question: 0, partnership: 0, job: 0, spam: 0, other: 0 },
bySentiment: { positive: 0, neutral: 0, negative: 0 },
lastUpdated: new Date().toISOString(),
};
@Injectable()
export class MetricsService implements OnModuleInit {
private readonly filePath = path.join(process.cwd(), 'data', 'metrics.json');
constructor(private readonly storage: FileStorageService) {}
onModuleInit() {
const existing = this.storage.read<MetricsData>(this.filePath);
if (!existing) {
this.storage.write(this.filePath, DEFAULT_METRICS);
}
}
increment(category: string | null, sentiment: string | null) {
const metrics = this.storage.read<MetricsData>(this.filePath) ?? { ...DEFAULT_METRICS };
metrics.total += 1;
if (category && category in metrics.byCategory) {
metrics.byCategory[category] += 1;
} else {
metrics.byCategory['other'] = (metrics.byCategory['other'] ?? 0) + 1;
}
if (sentiment && sentiment in metrics.bySentiment) {
metrics.bySentiment[sentiment] += 1;
}
metrics.lastUpdated = new Date().toISOString();
this.storage.write(this.filePath, metrics);
}
getMetrics(): MetricsData {
return this.storage.read<MetricsData>(this.filePath) ?? { ...DEFAULT_METRICS };
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class FileStorageService {
read<T>(filepath: string): T | null {
try {
if (!fs.existsSync(filepath)) return null;
const content = fs.readFileSync(filepath, 'utf-8');
return JSON.parse(content) as T;
} catch {
return null;
}
}
write<T>(filepath: string, data: T): void {
const dir = path.dirname(filepath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf-8');
}
}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { FileStorageService } from './file-storage.service';
@Global()
@Module({
providers: [FileStorageService],
exports: [FileStorageService],
})
export class StorageModule {}

21
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}