generated from fahricansecer/boilerplate-be
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
QueryResolver,
|
||||
} from 'nestjs-i18n';
|
||||
import * as path from 'path';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
|
||||
// Config
|
||||
import {
|
||||
@@ -39,6 +40,7 @@ import { UsersModule } from './modules/users/users.module';
|
||||
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';
|
||||
|
||||
// Guards
|
||||
import {
|
||||
@@ -75,11 +77,11 @@ import {
|
||||
level: configService.get('app.isDevelopment') ? 'debug' : 'info',
|
||||
transport: configService.get('app.isDevelopment')
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
},
|
||||
}
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
@@ -160,6 +162,15 @@ import {
|
||||
// Optional Modules (controlled by env variables)
|
||||
GeminiModule,
|
||||
HealthModule,
|
||||
|
||||
// CMS Module
|
||||
CmsModule,
|
||||
|
||||
// Serve uploaded files
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: path.join(__dirname, '..', 'uploads'),
|
||||
serveRoot: '/uploads',
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
// Global Exception Filter
|
||||
@@ -199,4 +210,4 @@ import {
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule { }
|
||||
|
||||
@@ -75,8 +75,13 @@ async function bootstrap() {
|
||||
});
|
||||
logger.log('Swagger initialized');
|
||||
|
||||
logger.log(`Attempting to listen on port ${port}...`);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
try {
|
||||
logger.log(`Attempting to listen on port ${port}...`);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
} catch (err: any) {
|
||||
logger.error('Failed to bind to port:', err.message, err.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.log('═══════════════════════════════════════════════════════════');
|
||||
logger.log(`🚀 Server is running on: http://localhost:${port}/api`);
|
||||
|
||||
232
src/modules/cms/cms.controller.ts
Normal file
232
src/modules/cms/cms.controller.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
ParseFilePipe,
|
||||
MaxFileSizeValidator,
|
||||
FileTypeValidator,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth, ApiConsumes, ApiBody } from '@nestjs/swagger';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { CmsService } from './cms.service';
|
||||
import {
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@ApiTags('CMS')
|
||||
@Controller('cms')
|
||||
export class CmsController implements OnModuleInit {
|
||||
constructor(private readonly cmsService: CmsService) { }
|
||||
|
||||
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(
|
||||
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);
|
||||
}
|
||||
}
|
||||
12
src/modules/cms/cms.module.ts
Normal file
12
src/modules/cms/cms.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CmsController } from './cms.controller';
|
||||
import { CmsService } from './cms.service';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [CmsController],
|
||||
providers: [CmsService],
|
||||
exports: [CmsService],
|
||||
})
|
||||
export class CmsModule { }
|
||||
456
src/modules/cms/cms.service.ts
Normal file
456
src/modules/cms/cms.service.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
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,
|
||||
} from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class CmsService {
|
||||
private readonly logger = new Logger(CmsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write Verification ─────────────────────────
|
||||
|
||||
private async verifyWrite<T extends { id: string }>(
|
||||
model: string,
|
||||
id: string,
|
||||
operation: string,
|
||||
): Promise<T> {
|
||||
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
|
||||
|
||||
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<string, any> = {};
|
||||
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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
142
src/modules/cms/dto/cms.dto.ts
Normal file
142
src/modules/cms/dto/cms.dto.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
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: 'https://example.com/deadpool.jpg' })
|
||||
@IsString()
|
||||
image: string;
|
||||
|
||||
@ApiProperty({ example: ['Official Turkish Voice', 'Character Voice Acting'] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
roles: string[];
|
||||
|
||||
@ApiPropertyOptional({ example: '#FF5733' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateProjectDto {
|
||||
@ApiPropertyOptional({ example: 'Deadpool 3' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: 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: '#C70039' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@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 }[];
|
||||
}
|
||||
|
||||
// ── Site Content ──────────────────────────────────
|
||||
|
||||
export class UpdateSiteContentDto {
|
||||
@ApiProperty({ example: { title: 'Hello', desc: 'World' } })
|
||||
@IsObject()
|
||||
content: Record<string, any>;
|
||||
}
|
||||
|
||||
// ── Clients ──────────────────────────────────────
|
||||
|
||||
export class CreateClientDto {
|
||||
@ApiProperty({ example: 'Netflix' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: 'https://example.com/netflix-logo.png' })
|
||||
@IsString()
|
||||
logo: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://netflix.com' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
website?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateClientDto {
|
||||
@ApiPropertyOptional({ example: 'Netflix' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/netflix-logo.png' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logo?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://netflix.com' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
website?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
1
src/modules/cms/dto/index.ts
Normal file
1
src/modules/cms/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './cms.dto';
|
||||
Reference in New Issue
Block a user