init
This commit is contained in:
22
backend/.env.example
Normal file
22
backend/.env.example
Normal 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
16
backend/data/metrics.json
Normal 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
39
backend/package.json
Normal 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
6118
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
backend/pnpm-workspace.yaml
Normal file
4
backend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
'@nestjs/core': true
|
||||
'@scarf/scarf': true
|
||||
unrs-resolver: true
|
||||
10
backend/src/ai/ai.module.ts
Normal file
10
backend/src/ai/ai.module.ts
Normal 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 {}
|
||||
56
backend/src/ai/ai.service.ts
Normal file
56
backend/src/ai/ai.service.ts
Normal 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
30
backend/src/app.module.ts
Normal 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 {}
|
||||
23
backend/src/contact/contact.controller.ts
Normal file
23
backend/src/contact/contact.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
backend/src/contact/contact.module.ts
Normal file
13
backend/src/contact/contact.module.ts
Normal 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 {}
|
||||
42
backend/src/contact/contact.service.ts
Normal file
42
backend/src/contact/contact.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
26
backend/src/contact/dto/create-contact.dto.ts
Normal file
26
backend/src/contact/dto/create-contact.dto.ts
Normal 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;
|
||||
}
|
||||
18
backend/src/contact/interfaces/contact.interface.ts
Normal file
18
backend/src/contact/interfaces/contact.interface.ts
Normal 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;
|
||||
}
|
||||
33
backend/src/filters/all-exceptions.filter.ts
Normal file
33
backend/src/filters/all-exceptions.filter.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
18
backend/src/health/health.controller.ts
Normal file
18
backend/src/health/health.controller.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
backend/src/health/health.module.ts
Normal file
7
backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
9
backend/src/logger/logger.module.ts
Normal file
9
backend/src/logger/logger.module.ts
Normal 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 {}
|
||||
61
backend/src/logger/logger.service.ts
Normal file
61
backend/src/logger/logger.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
10
backend/src/mail/mail.module.ts
Normal file
10
backend/src/mail/mail.module.ts
Normal 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 {}
|
||||
76
backend/src/mail/mail.service.ts
Normal file
76
backend/src/mail/mail.service.ts
Normal 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
47
backend/src/main.ts
Normal 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();
|
||||
16
backend/src/metrics/metrics.controller.ts
Normal file
16
backend/src/metrics/metrics.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
12
backend/src/metrics/metrics.module.ts
Normal file
12
backend/src/metrics/metrics.module.ts
Normal 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 {}
|
||||
50
backend/src/metrics/metrics.service.ts
Normal file
50
backend/src/metrics/metrics.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
24
backend/src/storage/file-storage.service.ts
Normal file
24
backend/src/storage/file-storage.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
9
backend/src/storage/storage.module.ts
Normal file
9
backend/src/storage/storage.module.ts
Normal 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
21
backend/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user