diff --git a/package-lock.json b/package-lock.json index 0705718..03b2f4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@nestjs/terminus": "^11.0.0", "@nestjs/throttler": "^6.5.0", "@prisma/client": "^5.22.0", + "@types/uuid": "^10.0.0", "bcrypt": "^6.0.0", "bullmq": "^5.66.4", "cache-manager": "^7.2.7", @@ -43,6 +44,7 @@ "prisma": "^5.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "uuid": "^13.0.0", "zod": "^4.3.5" }, "devDependencies": { @@ -4942,6 +4944,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -12514,6 +12522,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 1babb95..1f4ee94 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@nestjs/terminus": "^11.0.0", "@nestjs/throttler": "^6.5.0", "@prisma/client": "^5.22.0", + "@types/uuid": "^10.0.0", "bcrypt": "^6.0.0", "bullmq": "^5.66.4", "cache-manager": "^7.2.7", @@ -53,6 +54,7 @@ "prisma": "^5.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "uuid": "^13.0.0", "zod": "^4.3.5" }, "devDependencies": { diff --git a/src/app.module.ts b/src/app.module.ts index 4acb805..a76c890 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, Logger as NestLogger } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; @@ -41,6 +41,7 @@ import { AdminModule } from './modules/admin/admin.module'; import { HealthModule } from './modules/health/health.module'; import { GeminiModule } from './modules/gemini/gemini.module'; import { CmsModule } from './modules/cms/cms.module'; +import { MailModule } from './modules/mail/mail.module'; // Guards import { @@ -71,7 +72,7 @@ import { LoggerModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: async (configService: ConfigService) => { + useFactory: (configService: ConfigService) => { return { pinoHttp: { level: configService.get('app.isDevelopment') ? 'debug' : 'info', @@ -121,6 +122,7 @@ import { isGlobal: true, imports: [ConfigModule], useFactory: async (configService: ConfigService) => { + const logger = new NestLogger('CacheModule'); const useRedis = configService.get('REDIS_ENABLED', 'false') === 'true'; if (useRedis) { @@ -132,18 +134,18 @@ import { }, ttl: 60 * 1000, // 1 minute default }); - console.log('✅ Redis cache connected'); + logger.log('✅ Redis cache connected'); return { store: store as unknown as any, ttl: 60 * 1000, }; } catch { - console.warn('⚠️ Redis connection failed, using in-memory cache'); + logger.warn('⚠️ Redis connection failed, using in-memory cache'); } } // Fallback to in-memory cache - console.log('📦 Using in-memory cache'); + logger.log('📦 Using in-memory cache'); return { ttl: 60 * 1000, }; @@ -166,6 +168,9 @@ import { // CMS Module CmsModule, + // Mail Module + MailModule, + // Serve uploaded files ServeStaticModule.forRoot({ rootPath: path.join(__dirname, '..', 'uploads'), @@ -210,4 +215,10 @@ import { }, ], }) -export class AppModule { } +export class AppModule { + private readonly logger = new NestLogger(AppModule.name); + + constructor() { + this.logger.log('AppModule initialized'); + } +} diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts index ebf3fe1..fdfa24b 100644 --- a/src/common/filters/global-exception.filter.ts +++ b/src/common/filters/global-exception.filter.ts @@ -80,7 +80,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { }); // Only update if translation exists (key is different from result) if (translatedMessage !== `errors.${message}`) { - message = translatedMessage as string; + message = translatedMessage; } } } catch { diff --git a/src/main.ts b/src/main.ts index 74a7844..edd6a3c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,10 +18,12 @@ async function bootstrap() { app.useGlobalInterceptors(new LoggerErrorInterceptor()); // Security Headers - app.use(helmet({ - contentSecurityPolicy: false, - crossOriginEmbedderPolicy: false, - })); + app.use( + helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, + }), + ); // Graceful Shutdown (Prisma & Docker) app.enableShutdownHooks(); diff --git a/src/modules/cms/cms.controller.ts b/src/modules/cms/cms.controller.ts index 19474f4..9f2793d 100644 --- a/src/modules/cms/cms.controller.ts +++ b/src/modules/cms/cms.controller.ts @@ -1,19 +1,17 @@ import { - Controller, - Get, - Post, - Put, - Patch, - Delete, - Body, - Param, - Query, - UseInterceptors, - UploadedFile, - ParseFilePipe, - MaxFileSizeValidator, - FileTypeValidator, - OnModuleInit, + Controller, + Get, + Post, + Put, + Patch, + Delete, + Body, + Param, + Query, + UseInterceptors, + UploadedFile, + BadRequestException, + OnModuleInit, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiBearerAuth, ApiConsumes, ApiBody } from '@nestjs/swagger'; @@ -21,212 +19,215 @@ import { diskStorage } from 'multer'; import { extname } from 'path'; import { CmsService } from './cms.service'; import { - CreateProjectDto, - UpdateProjectDto, - ReorderProjectsDto, - UpdateSiteContentDto, - CreateClientDto, - UpdateClientDto, + CreateProjectDto, + UpdateProjectDto, + ReorderProjectsDto, + UpdateSiteContentDto, + CreateClientDto, + UpdateClientDto, } from './dto'; import { Public, Roles } from '../../common/decorators'; // Generate unique filename const storage = diskStorage({ - destination: './uploads', - filename: (req, file, cb) => { - const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${extname(file.originalname)}`; - cb(null, uniqueName); - }, + destination: './uploads', + filename: (req, file, cb) => { + const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${extname(file.originalname)}`; + cb(null, uniqueName); + }, }); @ApiTags('CMS') @Controller('cms') export class CmsController implements OnModuleInit { - constructor(private readonly cmsService: CmsService) { } + constructor(private readonly cmsService: CmsService) { } - async onModuleInit() { - await this.cmsService.seedDefaultProjects(); - await this.cmsService.seedAdminUser(); + async onModuleInit() { + await this.cmsService.seedDefaultProjects(); + await this.cmsService.seedAdminUser(); + } + + // ── Public Endpoints ──────────────────────────── + + @Public() + @Get('projects') + findAllProjects() { + return this.cmsService.findAllProjects(); + } + + @Public() + @Get('content') + findAllContent(@Query('locale') locale?: string) { + return this.cmsService.findAllContent(locale); + } + + @Public() + @Get('content/:section') + findContentBySection( + @Param('section') section: string, + @Query('locale') locale?: string, + ) { + return this.cmsService.findContentBySection(section, locale ?? 'tr'); + } + + // ── Admin: Projects ───────────────────────────── + + @ApiBearerAuth() + @Roles('admin') + @Post('projects') + createProject(@Body() dto: CreateProjectDto) { + return this.cmsService.createProject(dto); + } + + @ApiBearerAuth() + @Roles('admin') + @Put('projects/:id') + updateProject(@Param('id') id: string, @Body() dto: UpdateProjectDto) { + return this.cmsService.updateProject(id, dto); + } + + @ApiBearerAuth() + @Roles('admin') + @Delete('projects/:id') + deleteProject(@Param('id') id: string) { + return this.cmsService.deleteProject(id); + } + + @ApiBearerAuth() + @Roles('admin') + @Post('projects/:id/restore') + restoreProject(@Param('id') id: string) { + return this.cmsService.restoreProject(id); + } + + @ApiBearerAuth() + @Roles('admin') + @Patch('projects/reorder') + reorderProjects(@Body() dto: ReorderProjectsDto) { + return this.cmsService.reorderProjects(dto); + } + + // ── Admin: Site Content ───────────────────────── + + @ApiBearerAuth() + @Roles('admin') + @Put('content/:section') + upsertContent( + @Param('section') section: string, + @Query('locale') locale: string = 'tr', + @Body() dto: UpdateSiteContentDto, + ) { + return this.cmsService.upsertContent(section, locale, dto); + } + + // ── Admin: Media Upload ───────────────────────── + + @ApiBearerAuth() + @Roles('admin') + @Post('upload') + @UseInterceptors(FileInterceptor('file', { storage })) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { file: { type: 'string', format: 'binary' } }, + }, + }) + async uploadFile( + @UploadedFile() file: Express.Multer.File, + ) { + try { + if (!file) { + throw new BadRequestException('Dosya bulunamadı'); + } + if (!file.mimetype.startsWith('image/')) { + throw new BadRequestException('Sadece görsel dosyaları yüklenebilir'); + } + const url = `/uploads/${file.filename}`; + const media = await this.cmsService.createMediaFile({ + filename: file.filename, + originalName: file.originalname, + mimetype: file.mimetype, + path: file.path, + url, + size: file.size, + }); + return media; + } catch (err) { + throw err; } + } - // ── Public Endpoints ──────────────────────────── + @Public() + @Get('media') + findAllMedia() { + return this.cmsService.findAllMedia(); + } - @Public() - @Get('projects') - findAllProjects() { - return this.cmsService.findAllProjects(); - } + @ApiBearerAuth() + @Roles('admin') + @Delete('media/:id') + deleteMedia(@Param('id') id: string) { + return this.cmsService.deleteMedia(id); + } - @Public() - @Get('content') - findAllContent(@Query('locale') locale?: string) { - return this.cmsService.findAllContent(locale); - } + // ── Public: Clients ───────────────────────── - @Public() - @Get('content/:section') - findContentBySection( - @Param('section') section: string, - @Query('locale') locale?: string, - ) { - return this.cmsService.findContentBySection(section, locale ?? 'tr'); - } + @Public() + @Get('clients') + findAllClients() { + return this.cmsService.findAllClients(); + } - // ── Admin: Projects ───────────────────────────── + // ── Admin: Clients ───────────────────────── - @ApiBearerAuth() - @Roles('admin') - @Post('projects') - createProject(@Body() dto: CreateProjectDto) { - return this.cmsService.createProject(dto); - } + @ApiBearerAuth() + @Roles('admin') + @Post('clients') + createClient(@Body() dto: CreateClientDto) { + return this.cmsService.createClient(dto); + } - @ApiBearerAuth() - @Roles('admin') - @Put('projects/:id') - updateProject(@Param('id') id: string, @Body() dto: UpdateProjectDto) { - return this.cmsService.updateProject(id, dto); - } + @ApiBearerAuth() + @Roles('admin') + @Put('clients/:id') + updateClient(@Param('id') id: string, @Body() dto: UpdateClientDto) { + return this.cmsService.updateClient(id, dto); + } - @ApiBearerAuth() - @Roles('admin') - @Delete('projects/:id') - deleteProject(@Param('id') id: string) { - return this.cmsService.deleteProject(id); - } + @ApiBearerAuth() + @Roles('admin') + @Delete('clients/:id') + deleteClient(@Param('id') id: string) { + return this.cmsService.deleteClient(id); + } - @ApiBearerAuth() - @Roles('admin') - @Post('projects/:id/restore') - restoreProject(@Param('id') id: string) { - return this.cmsService.restoreProject(id); - } + // ── Admin: Audit Logs ─────────────────────── - @ApiBearerAuth() - @Roles('admin') - @Patch('projects/reorder') - reorderProjects(@Body() dto: ReorderProjectsDto) { - return this.cmsService.reorderProjects(dto); - } + @ApiBearerAuth() + @Roles('admin') + @Get('audit-logs') + findAuditLogs( + @Query('entity') entity?: string, + @Query('action') action?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.cmsService.findAuditLogs({ + entity, + action, + limit: limit ? parseInt(limit, 10) : 50, + offset: offset ? parseInt(offset, 10) : 0, + }); + } - // ── Admin: Site Content ───────────────────────── - - @ApiBearerAuth() - @Roles('admin') - @Put('content/:section') - upsertContent( - @Param('section') section: string, - @Query('locale') locale: string = 'tr', - @Body() dto: UpdateSiteContentDto, - ) { - return this.cmsService.upsertContent(section, locale, dto); - } - - // ── Admin: Media Upload ───────────────────────── - - @ApiBearerAuth() - @Roles('admin') - @Post('upload') - @UseInterceptors(FileInterceptor('file', { storage })) - @ApiConsumes('multipart/form-data') - @ApiBody({ - schema: { - type: 'object', - properties: { file: { type: 'string', format: 'binary' } }, - }, - }) - async uploadFile( - @UploadedFile( - new ParseFilePipe({ - validators: [ - new FileTypeValidator({ fileType: /image\/(jpeg|jpg|png|webp|gif|svg\+xml)/ }), - ], - }), - ) - file: Express.Multer.File, - ) { - const url = `/uploads/${file.filename}`; - const media = await this.cmsService.createMediaFile({ - filename: file.filename, - originalName: file.originalname, - mimetype: file.mimetype, - path: file.path, - url, - size: file.size, - }); - return media; - } - - @Public() - @Get('media') - findAllMedia() { - return this.cmsService.findAllMedia(); - } - - @ApiBearerAuth() - @Roles('admin') - @Delete('media/:id') - deleteMedia(@Param('id') id: string) { - return this.cmsService.deleteMedia(id); - } - - // ── Public: Clients ───────────────────────── - - @Public() - @Get('clients') - findAllClients() { - return this.cmsService.findAllClients(); - } - - // ── Admin: Clients ───────────────────────── - - @ApiBearerAuth() - @Roles('admin') - @Post('clients') - createClient(@Body() dto: CreateClientDto) { - return this.cmsService.createClient(dto); - } - - @ApiBearerAuth() - @Roles('admin') - @Put('clients/:id') - updateClient(@Param('id') id: string, @Body() dto: UpdateClientDto) { - return this.cmsService.updateClient(id, dto); - } - - @ApiBearerAuth() - @Roles('admin') - @Delete('clients/:id') - deleteClient(@Param('id') id: string) { - return this.cmsService.deleteClient(id); - } - - // ── Admin: Audit Logs ─────────────────────── - - @ApiBearerAuth() - @Roles('admin') - @Get('audit-logs') - findAuditLogs( - @Query('entity') entity?: string, - @Query('action') action?: string, - @Query('limit') limit?: string, - @Query('offset') offset?: string, - ) { - return this.cmsService.findAuditLogs({ - entity, - action, - limit: limit ? parseInt(limit, 10) : 50, - offset: offset ? parseInt(offset, 10) : 0, - }); - } - - @ApiBearerAuth() - @Roles('admin') - @Get('audit-logs/:entity/:entityId') - findEntityAuditLogs( - @Param('entity') entity: string, - @Param('entityId') entityId: string, - ) { - return this.cmsService.findAuditLogsByEntity(entity, entityId); - } + @ApiBearerAuth() + @Roles('admin') + @Get('audit-logs/:entity/:entityId') + findEntityAuditLogs( + @Param('entity') entity: string, + @Param('entityId') entityId: string, + ) { + return this.cmsService.findAuditLogsByEntity(entity, entityId); + } } diff --git a/src/modules/cms/cms.module.ts b/src/modules/cms/cms.module.ts index da72a76..7a5330f 100644 --- a/src/modules/cms/cms.module.ts +++ b/src/modules/cms/cms.module.ts @@ -4,9 +4,9 @@ import { CmsService } from './cms.service'; import { DatabaseModule } from '../../database/database.module'; @Module({ - imports: [DatabaseModule], - controllers: [CmsController], - providers: [CmsService], - exports: [CmsService], + imports: [DatabaseModule], + controllers: [CmsController], + providers: [CmsService], + exports: [CmsService], }) -export class CmsModule { } +export class CmsModule {} diff --git a/src/modules/cms/cms.service.ts b/src/modules/cms/cms.service.ts index 789fa3b..d2d4f76 100644 --- a/src/modules/cms/cms.service.ts +++ b/src/modules/cms/cms.service.ts @@ -1,456 +1,493 @@ -import { Injectable, Logger, NotFoundException, InternalServerErrorException } from '@nestjs/common'; +import { + Injectable, + Logger, + NotFoundException, + InternalServerErrorException, +} from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../../database/prisma.service'; import { - CreateProjectDto, - UpdateProjectDto, - ReorderProjectsDto, - UpdateSiteContentDto, - CreateClientDto, - UpdateClientDto, + CreateProjectDto, + UpdateProjectDto, + ReorderProjectsDto, + UpdateSiteContentDto, + CreateClientDto, + UpdateClientDto, } from './dto'; @Injectable() export class CmsService { - private readonly logger = new Logger(CmsService.name); + private readonly logger = new Logger(CmsService.name); - constructor(private readonly prisma: PrismaService) { } + constructor(private readonly prisma: PrismaService) {} - // ── Audit Logging ────────────────────────────── + // ── Audit Logging ────────────────────────────── - private async audit( - entity: string, - entityId: string, - action: string, - before?: any, - after?: any, - ) { - try { - await this.prisma.auditLog.create({ - data: { - entity, - entityId, - action, - before: before ? JSON.stringify(before) : null, - after: after ? JSON.stringify(after) : null, - }, - }); - this.logger.log(`[AUDIT] ${action} ${entity}:${entityId}`); - } catch (err) { - // Audit failure should never break the main operation - this.logger.error(`[AUDIT] Failed to log ${action} ${entity}:${entityId}`, err.stack); - } + private async audit( + entity: string, + entityId: string, + action: string, + before?: any, + after?: any, + ) { + try { + await this.prisma.auditLog.create({ + data: { + entity, + entityId, + action, + before: before ? JSON.stringify(before) : null, + after: after ? JSON.stringify(after) : null, + }, + }); + this.logger.log(`[AUDIT] ${action} ${entity}:${entityId}`); + } catch (err) { + // Audit failure should never break the main operation + this.logger.error( + `[AUDIT] Failed to log ${action} ${entity}:${entityId}`, + err.stack, + ); + } + } + + // ── Write Verification ───────────────────────── + + private async verifyWrite( + model: string, + id: string, + operation: string, + ): Promise { + const record = await (this.prisma as any)[model].findUnique({ + where: { id }, + }); + if (!record) { + this.logger.error( + `[VERIFY] ${operation} failed — record ${model}:${id} not found after write`, + ); + throw new InternalServerErrorException( + `Veritabanı yazma doğrulaması başarısız: ${model}:${id} yazıldıktan sonra bulunamadı`, + ); + } + this.logger.debug(`[VERIFY] ${operation} ${model}:${id} — OK`); + return record as T; + } + + // ── Projects ────────────────────────────────── + + async findAllProjects() { + return this.prisma.project.findMany({ + where: { isActive: true, deletedAt: null }, + orderBy: { sortOrder: 'asc' }, + }); + } + + async findProjectById(id: string) { + const project = await this.prisma.project.findUnique({ where: { id } }); + if (!project || project.deletedAt) + throw new NotFoundException('Project not found'); + return project; + } + + async createProject(dto: CreateProjectDto) { + // Auto-set sortOrder to last position + const lastProject = await this.prisma.project.findFirst({ + where: { deletedAt: null }, + orderBy: { sortOrder: 'desc' }, + }); + const nextOrder = (lastProject?.sortOrder ?? -1) + 1; + + const created = await this.prisma.project.create({ + data: { + title: dto.title, + image: dto.image, + roles: JSON.stringify(dto.roles), + color: dto.color ?? '#FF5733', + sortOrder: dto.sortOrder ?? nextOrder, + }, + }); + + // Write verification + const verified = await this.verifyWrite( + 'project', + created.id, + 'CREATE Project', + ); + + // Audit log + await this.audit('project', created.id, 'CREATE', null, verified); + + return verified; + } + + async updateProject(id: string, dto: UpdateProjectDto) { + const before = await this.findProjectById(id); // throws if not found + + await this.prisma.project.update({ + where: { id }, + data: { + ...dto, + roles: dto.roles ? JSON.stringify(dto.roles) : undefined, + }, + }); + + // Write verification + const verified = await this.verifyWrite('project', id, 'UPDATE Project'); + + // Audit log + await this.audit('project', id, 'UPDATE', before, verified); + + return verified; + } + + async deleteProject(id: string) { + const before = await this.findProjectById(id); + + // Soft delete — projeyi silmek yerine deletedAt işaretle + const deleted = await this.prisma.project.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + // Write verification — deletedAt'ın set edildiğini doğrula + const verified = await (this.prisma as any).project.findUnique({ + where: { id }, + }); + if (!verified || !verified.deletedAt) { + this.logger.error( + `[VERIFY] Soft DELETE failed — project:${id} deletedAt is still null`, + ); + throw new InternalServerErrorException('Silme doğrulaması başarısız'); } - // ── Write Verification ───────────────────────── + // Audit log + await this.audit('project', id, 'DELETE', before, null); - private async verifyWrite( - model: string, - id: string, - operation: string, - ): Promise { - const record = await (this.prisma as any)[model].findUnique({ where: { id } }); - if (!record) { - this.logger.error(`[VERIFY] ${operation} failed — record ${model}:${id} not found after write`); - throw new InternalServerErrorException( - `Veritabanı yazma doğrulaması başarısız: ${model}:${id} yazıldıktan sonra bulunamadı`, - ); - } - this.logger.debug(`[VERIFY] ${operation} ${model}:${id} — OK`); - return record as T; + return deleted; + } + + async restoreProject(id: string) { + const project = await this.prisma.project.findUnique({ where: { id } }); + if (!project) throw new NotFoundException('Project not found'); + if (!project.deletedAt) + throw new NotFoundException('Project is not deleted'); + + await this.prisma.project.update({ + where: { id }, + data: { deletedAt: null }, + }); + + // Write verification + const verified = await this.verifyWrite('project', id, 'RESTORE Project'); + + // Audit log + await this.audit('project', id, 'RESTORE', null, verified); + + return verified; + } + + async reorderProjects(dto: ReorderProjectsDto) { + const updates = dto.items.map((item) => + this.prisma.project.update({ + where: { id: item.id }, + data: { sortOrder: item.sortOrder }, + }), + ); + await this.prisma.$transaction(updates); + return this.findAllProjects(); + } + + // ── Site Content ────────────────────────────── + + async findAllContent(locale?: string) { + const where = locale ? { locale } : {}; + const contents = await this.prisma.siteContent.findMany({ where }); + + // Transform into a section-keyed object + const result: Record = {}; + for (const c of contents) { + if (!result[c.locale]) result[c.locale] = {}; + try { + result[c.locale][c.section] = JSON.parse(c.content); + } catch { + result[c.locale][c.section] = c.content; + } + } + return result; + } + + async findContentBySection(section: string, locale: string = 'tr') { + const content = await this.prisma.siteContent.findUnique({ + where: { section_locale: { section, locale } }, + }); + if (!content) return null; + try { + return JSON.parse(content.content); + } catch { + return content.content; + } + } + + async upsertContent( + section: string, + locale: string, + dto: UpdateSiteContentDto, + ) { + const contentStr = JSON.stringify(dto.content); + + // Get before state for audit + const before = await this.prisma.siteContent.findUnique({ + where: { section_locale: { section, locale } }, + }); + + const result = await this.prisma.siteContent.upsert({ + where: { section_locale: { section, locale } }, + create: { + section, + locale, + content: contentStr, + }, + update: { + content: contentStr, + }, + }); + + // Audit log + await this.audit( + 'content', + result.id, + before ? 'UPDATE' : 'CREATE', + before, + result, + ); + + return result; + } + + // ── Media Files ────────────────────────────── + + async createMediaFile(file: { + filename: string; + originalName: string; + mimetype: string; + path: string; + url: string; + size: number; + }) { + const created = await this.prisma.mediaFile.create({ data: file }); + + // Audit log + await this.audit('media', created.id, 'CREATE', null, created); + + return created; + } + + async findAllMedia() { + return this.prisma.mediaFile.findMany({ + orderBy: { createdAt: 'desc' }, + }); + } + + async deleteMedia(id: string) { + const media = await this.prisma.mediaFile.findUnique({ where: { id } }); + if (!media) throw new NotFoundException('Media not found'); + + const result = await this.prisma.mediaFile.delete({ where: { id } }); + + // Audit log + await this.audit('media', id, 'DELETE', media, null); + + return result; + } + + // ── Seed ────────────────────────────────────── + + async seedDefaultProjects() { + const count = await this.prisma.project.count({ + where: { deletedAt: null }, + }); + if (count > 0) { + this.logger.log('Projects already seeded, skipping'); + return; } - // ── Projects ────────────────────────────────── + const defaults = [ + { + title: 'Deadpool', + image: 'https://picsum.photos/seed/deadpool/1920/1080?blur=2', + roles: JSON.stringify([ + 'Official Turkish Voice', + 'Character Voice Acting', + ]), + color: '#FF5733', + sortOrder: 0, + }, + { + title: 'Spider-Man', + image: 'https://picsum.photos/seed/spiderman/1920/1080?blur=2', + roles: JSON.stringify([ + 'Official Turkish Voice', + 'Character Voice Acting', + ]), + color: '#C70039', + sortOrder: 1, + }, + { + title: 'The Lion King', + image: 'https://picsum.photos/seed/lionking/1920/1080?blur=2', + roles: JSON.stringify(['Simba', 'Musical Performance', 'Vocals']), + color: '#900C3F', + sortOrder: 2, + }, + { + title: 'Monster Notebook', + image: 'https://picsum.photos/seed/monster/1920/1080?blur=2', + roles: JSON.stringify(['Brand Face', 'Corporate Voice']), + color: '#511845', + sortOrder: 3, + }, + ]; - async findAllProjects() { - return this.prisma.project.findMany({ - where: { isActive: true, deletedAt: null }, - orderBy: { sortOrder: 'asc' }, - }); + for (const p of defaults) { + const created = await this.prisma.project.create({ data: p }); + await this.audit('project', created.id, 'CREATE', null, created); + } + this.logger.log(`Seeded ${defaults.length} default projects`); + } + + async seedAdminUser() { + const adminExists = await this.prisma.user.findUnique({ + where: { email: 'admin@haruncan.com' }, + }); + if (adminExists) { + this.logger.log('Admin user already exists, skipping'); + return; } - async findProjectById(id: string) { - const project = await this.prisma.project.findUnique({ where: { id } }); - if (!project || project.deletedAt) throw new NotFoundException('Project not found'); - return project; - } + const hashedPassword = await bcrypt.hash('admin123', 10); - async createProject(dto: CreateProjectDto) { - // Auto-set sortOrder to last position - const lastProject = await this.prisma.project.findFirst({ - where: { deletedAt: null }, - orderBy: { sortOrder: 'desc' }, - }); - const nextOrder = (lastProject?.sortOrder ?? -1) + 1; - - const created = await this.prisma.project.create({ - data: { - title: dto.title, - image: dto.image, - roles: JSON.stringify(dto.roles), - color: dto.color ?? '#FF5733', - sortOrder: dto.sortOrder ?? nextOrder, + await this.prisma.user.create({ + data: { + email: 'admin@haruncan.com', + password: hashedPassword, + firstName: 'Harun', + lastName: 'CAN', + roles: { + create: { + role: { + connectOrCreate: { + where: { name: 'admin' }, + create: { name: 'admin', description: 'Administrator' }, + }, }, - }); - - // Write verification - const verified = await this.verifyWrite('project', created.id, 'CREATE Project'); - - // Audit log - await this.audit('project', created.id, 'CREATE', null, verified); - - return verified; - } - - async updateProject(id: string, dto: UpdateProjectDto) { - const before = await this.findProjectById(id); // throws if not found - - const updated = await this.prisma.project.update({ - where: { id }, - data: { - ...dto, - roles: dto.roles ? JSON.stringify(dto.roles) : undefined, - }, - }); - - // Write verification - const verified = await this.verifyWrite('project', id, 'UPDATE Project'); - - // Audit log - await this.audit('project', id, 'UPDATE', before, verified); - - return verified; - } - - async deleteProject(id: string) { - const before = await this.findProjectById(id); - - // Soft delete — projeyi silmek yerine deletedAt işaretle - const deleted = await this.prisma.project.update({ - where: { id }, - data: { deletedAt: new Date() }, - }); - - // Write verification — deletedAt'ın set edildiğini doğrula - const verified = await (this.prisma as any).project.findUnique({ where: { id } }); - if (!verified || !verified.deletedAt) { - this.logger.error(`[VERIFY] Soft DELETE failed — project:${id} deletedAt is still null`); - throw new InternalServerErrorException('Silme doğrulaması başarısız'); - } - - // Audit log - await this.audit('project', id, 'DELETE', before, null); - - return deleted; - } - - async restoreProject(id: string) { - const project = await this.prisma.project.findUnique({ where: { id } }); - if (!project) throw new NotFoundException('Project not found'); - if (!project.deletedAt) throw new NotFoundException('Project is not deleted'); - - const restored = await this.prisma.project.update({ - where: { id }, - data: { deletedAt: null }, - }); - - // Write verification - const verified = await this.verifyWrite('project', id, 'RESTORE Project'); - - // Audit log - await this.audit('project', id, 'RESTORE', null, verified); - - return verified; - } - - async reorderProjects(dto: ReorderProjectsDto) { - const updates = dto.items.map((item) => - this.prisma.project.update({ - where: { id: item.id }, - data: { sortOrder: item.sortOrder }, - }), - ); - await this.prisma.$transaction(updates); - return this.findAllProjects(); - } - - // ── Site Content ────────────────────────────── - - async findAllContent(locale?: string) { - const where = locale ? { locale } : {}; - const contents = await this.prisma.siteContent.findMany({ where }); - - // Transform into a section-keyed object - const result: Record = {}; - for (const c of contents) { - if (!result[c.locale]) result[c.locale] = {}; - try { - result[c.locale][c.section] = JSON.parse(c.content); - } catch { - result[c.locale][c.section] = c.content; - } - } - return result; - } - - async findContentBySection(section: string, locale: string = 'tr') { - const content = await this.prisma.siteContent.findUnique({ - where: { section_locale: { section, locale } }, - }); - if (!content) return null; - try { - return JSON.parse(content.content); - } catch { - return content.content; - } - } - - async upsertContent( - section: string, - locale: string, - dto: UpdateSiteContentDto, - ) { - const contentStr = JSON.stringify(dto.content); - - // Get before state for audit - const before = await this.prisma.siteContent.findUnique({ - where: { section_locale: { section, locale } }, - }); - - const result = await this.prisma.siteContent.upsert({ - where: { section_locale: { section, locale } }, - create: { - section, - locale, - content: contentStr, - }, - update: { - content: contentStr, - }, - }); - - // Audit log - await this.audit( - 'content', - result.id, - before ? 'UPDATE' : 'CREATE', - before, - result, - ); - - return result; - } - - // ── Media Files ────────────────────────────── - - async createMediaFile(file: { - filename: string; - originalName: string; - mimetype: string; - path: string; - url: string; - size: number; - }) { - const created = await this.prisma.mediaFile.create({ data: file }); - - // Audit log - await this.audit('media', created.id, 'CREATE', null, created); - - return created; - } - - async findAllMedia() { - return this.prisma.mediaFile.findMany({ - orderBy: { createdAt: 'desc' }, - }); - } - - async deleteMedia(id: string) { - const media = await this.prisma.mediaFile.findUnique({ where: { id } }); - if (!media) throw new NotFoundException('Media not found'); - - const result = await this.prisma.mediaFile.delete({ where: { id } }); - - // Audit log - await this.audit('media', id, 'DELETE', media, null); - - return result; - } - - // ── Seed ────────────────────────────────────── - - async seedDefaultProjects() { - const count = await this.prisma.project.count({ - where: { deletedAt: null }, - }); - if (count > 0) { - this.logger.log('Projects already seeded, skipping'); - return; - } - - const defaults = [ - { - title: 'Deadpool', - image: 'https://picsum.photos/seed/deadpool/1920/1080?blur=2', - roles: JSON.stringify(['Official Turkish Voice', 'Character Voice Acting']), - color: '#FF5733', - sortOrder: 0, - }, - { - title: 'Spider-Man', - image: 'https://picsum.photos/seed/spiderman/1920/1080?blur=2', - roles: JSON.stringify(['Official Turkish Voice', 'Character Voice Acting']), - color: '#C70039', - sortOrder: 1, - }, - { - title: 'The Lion King', - image: 'https://picsum.photos/seed/lionking/1920/1080?blur=2', - roles: JSON.stringify(['Simba', 'Musical Performance', 'Vocals']), - color: '#900C3F', - sortOrder: 2, - }, - { - title: 'Monster Notebook', - image: 'https://picsum.photos/seed/monster/1920/1080?blur=2', - roles: JSON.stringify(['Brand Face', 'Corporate Voice']), - color: '#511845', - sortOrder: 3, - }, - ]; - - for (const p of defaults) { - const created = await this.prisma.project.create({ data: p }); - await this.audit('project', created.id, 'CREATE', null, created); - } - this.logger.log(`Seeded ${defaults.length} default projects`); - } - - async seedAdminUser() { - const adminExists = await this.prisma.user.findUnique({ - where: { email: 'admin@haruncan.com' }, - }); - if (adminExists) { - this.logger.log('Admin user already exists, skipping'); - return; - } - - const hashedPassword = await bcrypt.hash('admin123', 10); - - await this.prisma.user.create({ - data: { - email: 'admin@haruncan.com', - password: hashedPassword, - firstName: 'Harun', - lastName: 'CAN', - roles: { - create: { - role: { - connectOrCreate: { - where: { name: 'admin' }, - create: { name: 'admin', description: 'Administrator' }, - }, - }, - }, - }, - }, - }); - - this.logger.log('✅ Admin user created: admin@haruncan.com / admin123'); - } - - // ── Clients ────────────────────────────────── - - async findAllClients() { - return this.prisma.client.findMany({ - where: { isActive: true }, - orderBy: { sortOrder: 'asc' }, - }); - } - - async createClient(dto: CreateClientDto) { - const lastClient = await this.prisma.client.findFirst({ - orderBy: { sortOrder: 'desc' }, - }); - const nextOrder = (lastClient?.sortOrder ?? -1) + 1; - - const created = await this.prisma.client.create({ - data: { - name: dto.name, - logo: dto.logo, - website: dto.website, - sortOrder: dto.sortOrder ?? nextOrder, - }, - }); - - // Write verification - const verified = await this.verifyWrite('client', created.id, 'CREATE Client'); - - // Audit log - await this.audit('client', created.id, 'CREATE', null, verified); - - return verified; - } - - async updateClient(id: string, dto: UpdateClientDto) { - const before = await this.prisma.client.findUniqueOrThrow({ where: { id } }).catch(() => { - throw new NotFoundException('Client not found'); - }); - - const updated = await this.prisma.client.update({ where: { id }, data: dto }); - - // Write verification - const verified = await this.verifyWrite('client', id, 'UPDATE Client'); - - // Audit log - await this.audit('client', id, 'UPDATE', before, verified); - - return verified; - } - - async deleteClient(id: string) { - const client = await this.prisma.client.findUnique({ where: { id } }); - if (!client) throw new NotFoundException('Client not found'); - - const result = await this.prisma.client.delete({ where: { id } }); - - // Audit log - await this.audit('client', id, 'DELETE', client, null); - - return result; - } - - // ── Audit Logs (Admin Queries) ─────────────── - - async findAuditLogs(options?: { - entity?: string; - entityId?: string; - action?: string; - limit?: number; - offset?: number; - }) { - const where: any = {}; - if (options?.entity) where.entity = options.entity; - if (options?.entityId) where.entityId = options.entityId; - if (options?.action) where.action = options.action; - - const [logs, total] = await Promise.all([ - this.prisma.auditLog.findMany({ - where, - orderBy: { createdAt: 'desc' }, - take: options?.limit ?? 50, - skip: options?.offset ?? 0, - }), - this.prisma.auditLog.count({ where }), - ]); - - return { logs, total }; - } - - async findAuditLogsByEntity(entity: string, entityId: string) { - return this.prisma.auditLog.findMany({ - where: { entity, entityId }, - orderBy: { createdAt: 'desc' }, - }); - } + }, + }, + }, + }); + + this.logger.log('✅ Admin user created: admin@haruncan.com / admin123'); + } + + // ── Clients ────────────────────────────────── + + async findAllClients() { + return this.prisma.client.findMany({ + where: { isActive: true }, + orderBy: { sortOrder: 'asc' }, + }); + } + + async createClient(dto: CreateClientDto) { + const lastClient = await this.prisma.client.findFirst({ + orderBy: { sortOrder: 'desc' }, + }); + const nextOrder = (lastClient?.sortOrder ?? -1) + 1; + + const created = await this.prisma.client.create({ + data: { + name: dto.name, + logo: dto.logo, + website: dto.website, + sortOrder: dto.sortOrder ?? nextOrder, + }, + }); + + // Write verification + const verified = await this.verifyWrite( + 'client', + created.id, + 'CREATE Client', + ); + + // Audit log + await this.audit('client', created.id, 'CREATE', null, verified); + + return verified; + } + + async updateClient(id: string, dto: UpdateClientDto) { + const before = await this.prisma.client + .findUniqueOrThrow({ where: { id } }) + .catch(() => { + throw new NotFoundException('Client not found'); + }); + + await this.prisma.client.update({ + where: { id }, + data: dto, + }); + + // Write verification + const verified = await this.verifyWrite('client', id, 'UPDATE Client'); + + // Audit log + await this.audit('client', id, 'UPDATE', before, verified); + + return verified; + } + + async deleteClient(id: string) { + const client = await this.prisma.client.findUnique({ where: { id } }); + if (!client) throw new NotFoundException('Client not found'); + + const result = await this.prisma.client.delete({ where: { id } }); + + // Audit log + await this.audit('client', id, 'DELETE', client, null); + + return result; + } + + // ── Audit Logs (Admin Queries) ─────────────── + + async findAuditLogs(options?: { + entity?: string; + entityId?: string; + action?: string; + limit?: number; + offset?: number; + }) { + const where: any = {}; + if (options?.entity) where.entity = options.entity; + if (options?.entityId) where.entityId = options.entityId; + if (options?.action) where.action = options.action; + + const [logs, total] = await Promise.all([ + this.prisma.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: options?.limit ?? 50, + skip: options?.offset ?? 0, + }), + this.prisma.auditLog.count({ where }), + ]); + + return { logs, total }; + } + + async findAuditLogsByEntity(entity: string, entityId: string) { + return this.prisma.auditLog.findMany({ + where: { entity, entityId }, + orderBy: { createdAt: 'desc' }, + }); + } } diff --git a/src/modules/cms/dto/cms.dto.ts b/src/modules/cms/dto/cms.dto.ts index a38e2ea..74e3b8b 100644 --- a/src/modules/cms/dto/cms.dto.ts +++ b/src/modules/cms/dto/cms.dto.ts @@ -1,142 +1,144 @@ import { - IsString, - IsOptional, - IsArray, - IsInt, - IsBoolean, - IsObject, - Min, + IsString, + IsOptional, + IsArray, + IsInt, + IsBoolean, + IsObject, + Min, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; // ── Projects ────────────────────────────────────── export class CreateProjectDto { - @ApiProperty({ example: 'Deadpool' }) - @IsString() - title: string; + @ApiProperty({ example: 'Deadpool' }) + @IsString() + title: string; - @ApiProperty({ example: 'https://example.com/deadpool.jpg' }) - @IsString() - image: string; + @ApiProperty({ example: 'https://example.com/deadpool.jpg' }) + @IsString() + image: string; - @ApiProperty({ example: ['Official Turkish Voice', 'Character Voice Acting'] }) - @IsArray() - @IsString({ each: true }) - roles: string[]; + @ApiProperty({ + example: ['Official Turkish Voice', 'Character Voice Acting'], + }) + @IsArray() + @IsString({ each: true }) + roles: string[]; - @ApiPropertyOptional({ example: '#FF5733' }) - @IsOptional() - @IsString() - color?: string; + @ApiPropertyOptional({ example: '#FF5733' }) + @IsOptional() + @IsString() + color?: string; - @ApiPropertyOptional({ example: 0 }) - @IsOptional() - @IsInt() - @Min(0) - sortOrder?: number; + @ApiPropertyOptional({ example: 0 }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; } export class UpdateProjectDto { - @ApiPropertyOptional({ example: 'Deadpool 3' }) - @IsOptional() - @IsString() - title?: string; + @ApiPropertyOptional({ example: 'Deadpool 3' }) + @IsOptional() + @IsString() + title?: string; - @ApiPropertyOptional({ example: 'https://example.com/deadpool3.jpg' }) - @IsOptional() - @IsString() - image?: string; + @ApiPropertyOptional({ example: 'https://example.com/deadpool3.jpg' }) + @IsOptional() + @IsString() + image?: string; - @ApiPropertyOptional({ example: ['Official Turkish Voice'] }) - @IsOptional() - @IsArray() - @IsString({ each: true }) - roles?: string[]; + @ApiPropertyOptional({ example: ['Official Turkish Voice'] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + roles?: string[]; - @ApiPropertyOptional({ example: '#C70039' }) - @IsOptional() - @IsString() - color?: string; + @ApiPropertyOptional({ example: '#C70039' }) + @IsOptional() + @IsString() + color?: string; - @ApiPropertyOptional({ example: 1 }) - @IsOptional() - @IsInt() - @Min(0) - sortOrder?: number; + @ApiPropertyOptional({ example: 1 }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; - @ApiPropertyOptional({ example: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean; + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; } export class ReorderProjectsDto { - @ApiProperty({ - example: [ - { id: 'uuid-1', sortOrder: 0 }, - { id: 'uuid-2', sortOrder: 1 }, - ], - }) - @IsArray() - items: { id: string; sortOrder: number }[]; + @ApiProperty({ + example: [ + { id: 'uuid-1', sortOrder: 0 }, + { id: 'uuid-2', sortOrder: 1 }, + ], + }) + @IsArray() + items: { id: string; sortOrder: number }[]; } // ── Site Content ────────────────────────────────── export class UpdateSiteContentDto { - @ApiProperty({ example: { title: 'Hello', desc: 'World' } }) - @IsObject() - content: Record; + @ApiProperty({ example: { title: 'Hello', desc: 'World' } }) + @IsObject() + content: Record; } // ── Clients ────────────────────────────────────── export class CreateClientDto { - @ApiProperty({ example: 'Netflix' }) - @IsString() - name: string; + @ApiProperty({ example: 'Netflix' }) + @IsString() + name: string; - @ApiProperty({ example: 'https://example.com/netflix-logo.png' }) - @IsString() - logo: string; + @ApiProperty({ example: 'https://example.com/netflix-logo.png' }) + @IsString() + logo: string; - @ApiPropertyOptional({ example: 'https://netflix.com' }) - @IsOptional() - @IsString() - website?: string; + @ApiPropertyOptional({ example: 'https://netflix.com' }) + @IsOptional() + @IsString() + website?: string; - @ApiPropertyOptional({ example: 0 }) - @IsOptional() - @IsInt() - @Min(0) - sortOrder?: number; + @ApiPropertyOptional({ example: 0 }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; } export class UpdateClientDto { - @ApiPropertyOptional({ example: 'Netflix' }) - @IsOptional() - @IsString() - name?: string; + @ApiPropertyOptional({ example: 'Netflix' }) + @IsOptional() + @IsString() + name?: string; - @ApiPropertyOptional({ example: 'https://example.com/netflix-logo.png' }) - @IsOptional() - @IsString() - logo?: string; + @ApiPropertyOptional({ example: 'https://example.com/netflix-logo.png' }) + @IsOptional() + @IsString() + logo?: string; - @ApiPropertyOptional({ example: 'https://netflix.com' }) - @IsOptional() - @IsString() - website?: string; + @ApiPropertyOptional({ example: 'https://netflix.com' }) + @IsOptional() + @IsString() + website?: string; - @ApiPropertyOptional({ example: 0 }) - @IsOptional() - @IsInt() - @Min(0) - sortOrder?: number; + @ApiPropertyOptional({ example: 0 }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; - @ApiPropertyOptional({ example: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean; + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; } diff --git a/src/modules/mail/mail.controller.ts b/src/modules/mail/mail.controller.ts new file mode 100644 index 0000000..8bcffb6 --- /dev/null +++ b/src/modules/mail/mail.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Post, Body, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { MailService, ContactMailData } from './mail.service'; +import { Public } from '../../common/decorators'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; + +class SendContactDto { + @IsNotEmpty() + @IsString() + name: string; + + @IsEmail() + email: string; + + @IsNotEmpty() + @IsString() + type: string; + + @IsNotEmpty() + @IsString() + details: string; + + @IsNotEmpty() + @IsString() + captchaId: string; + + @IsNotEmpty() + @IsString() + captchaAnswer: string; +} + +@ApiTags('Mail') +@Controller('mail') +export class MailController { + constructor(private readonly mailService: MailService) { } + + @Public() + @Post('send') + @ApiOperation({ summary: 'Send contact form message' }) + async sendContact(@Body() dto: SendContactDto) { + return this.mailService.sendContactMail(dto); + } + + @Public() + @Get('captcha') + @ApiOperation({ summary: 'Get a new math captcha' }) + async getCaptcha() { + return this.mailService.generateCaptcha(); + } +} diff --git a/src/modules/mail/mail.module.ts b/src/modules/mail/mail.module.ts new file mode 100644 index 0000000..e1c4611 --- /dev/null +++ b/src/modules/mail/mail.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { MailService } from './mail.service'; +import { MailController } from './mail.controller'; +import { CmsModule } from '../cms/cms.module'; + +@Module({ + imports: [CmsModule], + providers: [MailService], + controllers: [MailController], + exports: [MailService], +}) +export class MailModule { } diff --git a/src/modules/mail/mail.service.ts b/src/modules/mail/mail.service.ts new file mode 100644 index 0000000..06782f0 --- /dev/null +++ b/src/modules/mail/mail.service.ts @@ -0,0 +1,123 @@ +import { Injectable, Logger, Inject, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import type { Cache } from 'cache-manager'; +import * as nodemailer from 'nodemailer'; +import { CmsService } from '../cms/cms.service'; +import { v4 as uuidv4 } from 'uuid'; + +export interface ContactMailData { + name: string; + email: string; + type: string; + details: string; + captchaId: string; + captchaAnswer: string; +} + +@Injectable() +export class MailService { + private readonly logger = new Logger(MailService.name); + private transporter: nodemailer.Transporter; + + constructor( + private configService: ConfigService, + private cmsService: CmsService, + @Inject(CACHE_MANAGER) private cacheManager: Cache, + ) { + this.initTransporter(); + } + + async generateCaptcha() { + const num1 = Math.floor(Math.random() * 10) + 1; + const num2 = Math.floor(Math.random() * 10) + 1; + const answer = num1 + num2; + const id = uuidv4(); + + await this.cacheManager.set(`captcha:${id}`, answer.toString(), 300000); // 5 mins + + return { + id, + question: `${num1} + ${num2} = ?`, + }; + } + + async verifyCaptcha(id: string, answer: string) { + const cachedAnswer = await this.cacheManager.get(`captcha:${id}`); + if (!cachedAnswer) { + throw new BadRequestException('Captcha expired or invalid'); + } + + if (String(cachedAnswer) !== String(answer).trim()) { + throw new BadRequestException('Incorrect captcha answer'); + } + + await this.cacheManager.del(`captcha:${id}`); + return true; + } + + private initTransporter(customConfig?: any) { + this.transporter = nodemailer.createTransport({ + host: customConfig?.host || this.configService.get('MAIL_HOST'), + port: customConfig?.port || this.configService.get('MAIL_PORT', 587), + secure: customConfig?.secure === 'true' || customConfig?.secure === true, + auth: { + user: customConfig?.user || this.configService.get('MAIL_USER'), + pass: customConfig?.pass || this.configService.get('MAIL_PASSWORD'), + }, + }); + } + + async sendContactMail(data: ContactMailData) { + // Verify Captcha + await this.verifyCaptcha(data.captchaId, data.captchaAnswer); + + const isMailEnabled = this.configService.get('features.mail', false); + + // Alıcı mailini veritabanından veya varsayılandan al + const settings = await this.cmsService.findContentBySection('mail_settings', 'tr'); + const targetEmail = settings?.targetEmail || 'haruncanmedia@gmail.com'; + + if (!isMailEnabled) { + this.logger.warn('📧 Mail service is disabled. Message not sent, but logged:'); + this.logger.log(`TO: ${targetEmail}, FROM: ${data.name} <${data.email}>, TYPE: ${data.type}, MESSAGE: ${data.details}`); + return { success: true, message: 'Mail dev-mode: logged to console' }; + } + + // Eğer veritabanında SMTP ayarları varsa transporter'ı güncelle + if (settings && settings.host && settings.user && settings.pass) { + this.logger.log('📧 Using dynamic SMTP settings from database'); + this.initTransporter(settings); + } + + try { + const info = await this.transporter.sendMail({ + from: settings?.from || this.configService.get('MAIL_FROM', '"HarunCAN Studio" '), + to: targetEmail, + subject: `New Contact Form Message from ${data.name}`, + text: `Name: ${data.name}\nEmail: ${data.email}\nInquiry Type: ${data.type}\nDetails: ${data.details}`, + html: ` +
+

New Contact Form Submission

+

Name: ${data.name}

+

Email: ${data.email}

+

Inquiry Type: ${data.type}

+
+

Message Details:

+

${data.details.replace(/\n/g, '
')}

+
+

+ Sent from HarunCAN Studio Contact Form +

+
+ `, + }); + + this.logger.log(`📧 Contact mail sent: ${info.messageId}`); + return { success: true, messageId: info.messageId }; + } catch (error) { + this.logger.error('❌ Failed to send contact mail:', error); + throw error; + } + } +} diff --git a/uploads/1773064602345-93585883.png b/uploads/1773064602345-93585883.png new file mode 100644 index 0000000..7ab94fe Binary files /dev/null and b/uploads/1773064602345-93585883.png differ diff --git a/uploads/1773064710998-3327682.png b/uploads/1773064710998-3327682.png new file mode 100644 index 0000000..e69c0c4 Binary files /dev/null and b/uploads/1773064710998-3327682.png differ diff --git a/uploads/1773064802421-246519346.png b/uploads/1773064802421-246519346.png new file mode 100644 index 0000000..8cc86fc Binary files /dev/null and b/uploads/1773064802421-246519346.png differ diff --git a/uploads/1773064868940-732271018.png b/uploads/1773064868940-732271018.png new file mode 100644 index 0000000..80da86c Binary files /dev/null and b/uploads/1773064868940-732271018.png differ diff --git a/uploads/1773064923678-146730379.png b/uploads/1773064923678-146730379.png new file mode 100644 index 0000000..c7277e3 Binary files /dev/null and b/uploads/1773064923678-146730379.png differ diff --git a/uploads/1773066611451-178319299.json b/uploads/1773066611451-178319299.json new file mode 100644 index 0000000..1babb95 --- /dev/null +++ b/uploads/1773066611451-178319299.json @@ -0,0 +1,105 @@ +{ + "name": "bbb", + "version": "0.0.1", + "description": "Generated by Antigravity CLI", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.964.0", + "@google/genai": "^1.35.0", + "@nestjs/bullmq": "^11.0.4", + "@nestjs/cache-manager": "^3.1.0", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.11", + "@nestjs/serve-static": "^5.0.4", + "@nestjs/swagger": "^11.2.4", + "@nestjs/terminus": "^11.0.0", + "@nestjs/throttler": "^6.5.0", + "@prisma/client": "^5.22.0", + "bcrypt": "^6.0.0", + "bullmq": "^5.66.4", + "cache-manager": "^7.2.7", + "cache-manager-redis-yet": "^5.1.5", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", + "helmet": "^8.1.0", + "ioredis": "^5.9.0", + "nestjs-i18n": "^10.6.0", + "nestjs-pino": "^4.5.0", + "nodemailer": "^7.0.12", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "pino": "^10.1.0", + "pino-http": "^11.0.0", + "prisma": "^5.22.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "zod": "^4.3.5" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", + "@types/node": "^22.10.7", + "@types/nodemailer": "^7.0.4", + "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^6.0.2", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^30.0.0", + "pino-pretty": "^13.1.3", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/uploads/1773067175411-781981796.png b/uploads/1773067175411-781981796.png new file mode 100644 index 0000000..1babb95 --- /dev/null +++ b/uploads/1773067175411-781981796.png @@ -0,0 +1,105 @@ +{ + "name": "bbb", + "version": "0.0.1", + "description": "Generated by Antigravity CLI", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.964.0", + "@google/genai": "^1.35.0", + "@nestjs/bullmq": "^11.0.4", + "@nestjs/cache-manager": "^3.1.0", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.11", + "@nestjs/serve-static": "^5.0.4", + "@nestjs/swagger": "^11.2.4", + "@nestjs/terminus": "^11.0.0", + "@nestjs/throttler": "^6.5.0", + "@prisma/client": "^5.22.0", + "bcrypt": "^6.0.0", + "bullmq": "^5.66.4", + "cache-manager": "^7.2.7", + "cache-manager-redis-yet": "^5.1.5", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", + "helmet": "^8.1.0", + "ioredis": "^5.9.0", + "nestjs-i18n": "^10.6.0", + "nestjs-pino": "^4.5.0", + "nodemailer": "^7.0.12", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "pino": "^10.1.0", + "pino-http": "^11.0.0", + "prisma": "^5.22.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "zod": "^4.3.5" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", + "@types/node": "^22.10.7", + "@types/nodemailer": "^7.0.4", + "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^6.0.2", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^30.0.0", + "pino-pretty": "^13.1.3", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/uploads/1773067234394-305603703.jpg b/uploads/1773067234394-305603703.jpg new file mode 100644 index 0000000..1babb95 --- /dev/null +++ b/uploads/1773067234394-305603703.jpg @@ -0,0 +1,105 @@ +{ + "name": "bbb", + "version": "0.0.1", + "description": "Generated by Antigravity CLI", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.964.0", + "@google/genai": "^1.35.0", + "@nestjs/bullmq": "^11.0.4", + "@nestjs/cache-manager": "^3.1.0", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.11", + "@nestjs/serve-static": "^5.0.4", + "@nestjs/swagger": "^11.2.4", + "@nestjs/terminus": "^11.0.0", + "@nestjs/throttler": "^6.5.0", + "@prisma/client": "^5.22.0", + "bcrypt": "^6.0.0", + "bullmq": "^5.66.4", + "cache-manager": "^7.2.7", + "cache-manager-redis-yet": "^5.1.5", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", + "helmet": "^8.1.0", + "ioredis": "^5.9.0", + "nestjs-i18n": "^10.6.0", + "nestjs-pino": "^4.5.0", + "nodemailer": "^7.0.12", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "pino": "^10.1.0", + "pino-http": "^11.0.0", + "prisma": "^5.22.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "zod": "^4.3.5" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", + "@types/node": "^22.10.7", + "@types/nodemailer": "^7.0.4", + "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^6.0.2", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^30.0.0", + "pino-pretty": "^13.1.3", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/uploads/1773071218522-54137057.png b/uploads/1773071218522-54137057.png new file mode 100644 index 0000000..e69c0c4 Binary files /dev/null and b/uploads/1773071218522-54137057.png differ diff --git a/uploads/1773071231136-538057302.png b/uploads/1773071231136-538057302.png new file mode 100644 index 0000000..80da86c Binary files /dev/null and b/uploads/1773071231136-538057302.png differ diff --git a/uploads/1773098189353-240031221.png b/uploads/1773098189353-240031221.png new file mode 100644 index 0000000..e69c0c4 Binary files /dev/null and b/uploads/1773098189353-240031221.png differ diff --git a/uploads/1773098196690-706527504.png b/uploads/1773098196690-706527504.png new file mode 100644 index 0000000..e69c0c4 Binary files /dev/null and b/uploads/1773098196690-706527504.png differ diff --git a/uploads/1773658627453-6202062.webp b/uploads/1773658627453-6202062.webp new file mode 100644 index 0000000..6dd7416 Binary files /dev/null and b/uploads/1773658627453-6202062.webp differ diff --git a/uploads/1773658652801-376000152.webp b/uploads/1773658652801-376000152.webp new file mode 100644 index 0000000..6dd7416 Binary files /dev/null and b/uploads/1773658652801-376000152.webp differ diff --git a/uploads/1773658935134-49699715.png b/uploads/1773658935134-49699715.png new file mode 100644 index 0000000..c7277e3 Binary files /dev/null and b/uploads/1773658935134-49699715.png differ diff --git a/uploads/1773659962590-143124373.png b/uploads/1773659962590-143124373.png new file mode 100644 index 0000000..c7277e3 Binary files /dev/null and b/uploads/1773659962590-143124373.png differ diff --git a/uploads/1773660004362-141903758.png b/uploads/1773660004362-141903758.png new file mode 100644 index 0000000..e69c0c4 Binary files /dev/null and b/uploads/1773660004362-141903758.png differ diff --git a/uploads/1773660025672-531339232.png b/uploads/1773660025672-531339232.png new file mode 100644 index 0000000..80da86c Binary files /dev/null and b/uploads/1773660025672-531339232.png differ diff --git a/uploads/1773660176624-509253881.png b/uploads/1773660176624-509253881.png new file mode 100644 index 0000000..5562342 Binary files /dev/null and b/uploads/1773660176624-509253881.png differ diff --git a/uploads/1773660217332-38983824.png b/uploads/1773660217332-38983824.png new file mode 100644 index 0000000..eb62057 Binary files /dev/null and b/uploads/1773660217332-38983824.png differ diff --git a/uploads/1773660242228-140851460.png b/uploads/1773660242228-140851460.png new file mode 100644 index 0000000..8cc86fc Binary files /dev/null and b/uploads/1773660242228-140851460.png differ diff --git a/uploads/1773660275096-653459971.png b/uploads/1773660275096-653459971.png new file mode 100644 index 0000000..7ab94fe Binary files /dev/null and b/uploads/1773660275096-653459971.png differ diff --git a/uploads/1773660472620-139173940.png b/uploads/1773660472620-139173940.png new file mode 100644 index 0000000..efe7bd7 Binary files /dev/null and b/uploads/1773660472620-139173940.png differ diff --git a/uploads/1773660536048-909194758.png b/uploads/1773660536048-909194758.png new file mode 100644 index 0000000..23f0827 Binary files /dev/null and b/uploads/1773660536048-909194758.png differ diff --git a/uploads/1773660645009-272744007.png b/uploads/1773660645009-272744007.png new file mode 100644 index 0000000..593bae2 Binary files /dev/null and b/uploads/1773660645009-272744007.png differ