diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3d18c6f..7fac132 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -181,6 +181,10 @@ model ScriptProject { highConcept String? @db.Text includeInterviews Boolean @default(false) + // Project Status + status String @default("DRAFT") // DRAFT, RESEARCHING, SCRIPTING, ANALYZING, COMPLETED + currentVersionNumber Int @default(0) + // SEO Data (stored as JSON) seoTitle String? seoDescription String? @db.Text @@ -205,9 +209,11 @@ model ScriptProject { characters CharacterProfile[] briefItems BriefItem[] visualAssets VisualAsset[] + versions ScriptVersion[] @@index([userId]) @@index([topic]) + @@index([status]) } model ScriptSegment { @@ -312,3 +318,36 @@ model VisualAsset { @@index([projectId]) } + +// ============================================ +// Version History +// ============================================ + +model ScriptVersion { + id String @id @default(uuid()) + projectId String + versionNumber Int + label String? // User-defined label, e.g. "Final Draft", "Before Rewrite" + generatedBy String @default("AI") // AI | USER | AUTO_SAVE + + // Snapshot data: complete segments at this point in time + snapshotData Json // Array of segment objects + + // Optional: SEO snapshot + seoSnapshot Json? // { seoTitle, seoDescription, seoTags, thumbnailIdeas } + + // Metadata + segmentCount Int @default(0) + totalWords Int @default(0) + changeNote String? @db.Text // What changed in this version + + // Timestamps + createdAt DateTime @default(now()) + + // Relations + project ScriptProject @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, versionNumber]) + @@index([projectId]) + @@index([createdAt]) +} diff --git a/src/modules/skriptai/controllers/index.ts b/src/modules/skriptai/controllers/index.ts index f71717f..92164db 100644 --- a/src/modules/skriptai/controllers/index.ts +++ b/src/modules/skriptai/controllers/index.ts @@ -2,3 +2,4 @@ export * from './projects.controller'; export * from './scripts.controller'; export * from './research.controller'; export * from './analysis.controller'; +export * from './versions.controller'; diff --git a/src/modules/skriptai/controllers/scripts.controller.ts b/src/modules/skriptai/controllers/scripts.controller.ts index 04a4f70..bb48aa9 100644 --- a/src/modules/skriptai/controllers/scripts.controller.ts +++ b/src/modules/skriptai/controllers/scripts.controller.ts @@ -99,4 +99,25 @@ export class ScriptsController { async generateSegmentImage(@Param('id') id: string) { return this.scriptsService.generateSegmentImage(id); } + + @Post('segments/:id/regenerate') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Regenerate a single segment with AI' }) + async regenerateSegment(@Param('id') id: string) { + return this.scriptsService.regenerateSegment(id); + } + + @Post('regenerate-partial') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Regenerate selected segments with AI' }) + async regeneratePartial( + @Body() body: { projectId: string; segmentIds: string[] }, + ) { + return this.scriptsService.regeneratePartial( + body.projectId, + body.segmentIds, + ); + } } diff --git a/src/modules/skriptai/controllers/versions.controller.ts b/src/modules/skriptai/controllers/versions.controller.ts new file mode 100644 index 0000000..4794660 --- /dev/null +++ b/src/modules/skriptai/controllers/versions.controller.ts @@ -0,0 +1,96 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + Query, + Logger, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiParam, ApiBearerAuth } from '@nestjs/swagger'; +import { VersionsService } from '../services/versions.service'; + +/** + * VersionsController + * + * REST API for managing script version history. + * + * Endpoints: + * - GET /projects/:projectId/versions — List all versions + * - GET /projects/:projectId/versions/:id — Get version details + * - POST /projects/:projectId/versions — Manual save (create snapshot) + * - POST /projects/:projectId/versions/:id/restore — Restore to version + * - DELETE /projects/:projectId/versions/:id — Delete version + * - GET /projects/:projectId/versions/compare — Compare two versions + */ +@ApiTags('SkriptAI - Versions') +@ApiBearerAuth() +@Controller('skriptai/projects/:projectId/versions') +export class VersionsController { + private readonly logger = new Logger(VersionsController.name); + + constructor(private readonly versionsService: VersionsService) {} + + @Get() + @ApiOperation({ summary: 'List all versions for a project' }) + @ApiParam({ name: 'projectId', description: 'Project ID' }) + async listVersions(@Param('projectId') projectId: string) { + return this.versionsService.listVersions(projectId); + } + + @Get('compare') + @ApiOperation({ summary: 'Compare two versions' }) + async compareVersions( + @Param('projectId') projectId: string, + @Query('versionA') versionAId: string, + @Query('versionB') versionBId: string, + ) { + return this.versionsService.compareVersions( + projectId, + versionAId, + versionBId, + ); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a specific version with full snapshot data' }) + async getVersion( + @Param('projectId') projectId: string, + @Param('id') versionId: string, + ) { + return this.versionsService.getVersion(projectId, versionId); + } + + @Post() + @ApiOperation({ summary: 'Manually save current state as a new version' }) + async createSnapshot( + @Param('projectId') projectId: string, + @Body() body: { label?: string; changeNote?: string }, + ) { + return this.versionsService.createSnapshot( + projectId, + 'USER', + body.label, + body.changeNote, + ); + } + + @Post(':id/restore') + @ApiOperation({ summary: 'Restore project to a specific version' }) + async restoreVersion( + @Param('projectId') projectId: string, + @Param('id') versionId: string, + ) { + return this.versionsService.restoreVersion(projectId, versionId); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a specific version' }) + async deleteVersion( + @Param('projectId') projectId: string, + @Param('id') versionId: string, + ) { + return this.versionsService.deleteVersion(projectId, versionId); + } +} diff --git a/src/modules/skriptai/services/index.ts b/src/modules/skriptai/services/index.ts index 7bee0db..b2aa510 100644 --- a/src/modules/skriptai/services/index.ts +++ b/src/modules/skriptai/services/index.ts @@ -2,3 +2,4 @@ export * from './projects.service'; export * from './scripts.service'; export * from './research.service'; export * from './analysis.service'; +export * from './versions.service'; diff --git a/src/modules/skriptai/services/scripts.service.ts b/src/modules/skriptai/services/scripts.service.ts index 9d2b1f0..cd57c10 100644 --- a/src/modules/skriptai/services/scripts.service.ts +++ b/src/modules/skriptai/services/scripts.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../../../database/prisma.service'; import { GeminiService } from '../../gemini/gemini.service'; import { CreateSegmentDto, UpdateSegmentDto } from '../dto'; import { AnalysisService } from './analysis.service'; +import { VersionsService } from './versions.service'; import { buildScriptOutlinePrompt, buildChapterSegmentPrompt, @@ -28,6 +29,7 @@ export class ScriptsService { private readonly prisma: PrismaService, private readonly gemini: GeminiService, private readonly analysisService: AnalysisService, + private readonly versionsService: VersionsService, ) {} /** @@ -113,6 +115,20 @@ export class ScriptsService { async generateScript(projectId: string) { this.logger.log(`Generating script for project: ${projectId}`); + // Auto-snapshot current state before regeneration + await this.versionsService.createSnapshot( + projectId, + 'AUTO_SAVE', + undefined, + 'Auto-save before script generation', + ).catch(() => { /* ignore if no segments yet */ }); + + // Update status + await this.prisma.scriptProject.update({ + where: { id: projectId }, + data: { status: 'SCRIPTING' }, + }); + const project = await this.prisma.scriptProject.findUnique({ where: { id: projectId }, include: { @@ -364,4 +380,103 @@ export class ScriptsService { }, }); } + + // ========== REGENERATE (Tek segment / Partial) ========== + + /** + * Regenerate a single segment with AI. + * Auto-snapshots current state before regeneration. + * + * @param segmentId - Segment ID to regenerate + * @returns Updated segment + */ + async regenerateSegment(segmentId: string) { + const segment = await this.prisma.scriptSegment.findUnique({ + where: { id: segmentId }, + include: { project: { include: { characters: true } } }, + }); + + if (!segment) { + throw new NotFoundException(`Segment with ID ${segmentId} not found`); + } + + // Auto-snapshot before regeneration + await this.versionsService.createSnapshot( + segment.projectId, + 'AUTO_SAVE', + undefined, + `Auto-save before regenerating segment #${segment.sortOrder + 1}`, + ).catch(() => {}); + + const characterContext = segment.project.characters + .map((c) => `${c.name} (${c.role}): Values[${c.values}] Traits[${c.traits}]`) + .join('\n'); + + const chapterPromptData = buildChapterSegmentPrompt({ + chapterIndex: segment.sortOrder, + totalChapters: 1, + chapterTitle: segment.segmentType, + chapterFocus: segment.visualDescription || segment.segmentType, + chapterType: segment.segmentType, + speechStyles: segment.project.speechStyle, + targetAudience: segment.project.targetAudience, + characterContext, + language: segment.project.language, + }); + + const resp = await this.gemini.generateJSON( + chapterPromptData.prompt, + chapterPromptData.schema, + { temperature: chapterPromptData.temperature }, + ); + + const newSeg = resp.data[0]; + if (!newSeg) return segment; + + const words = newSeg.narratorScript ? newSeg.narratorScript.split(' ').length : 0; + const dur = Math.max(5, Math.ceil(words / (140 / 60))); + + return this.prisma.scriptSegment.update({ + where: { id: segmentId }, + data: { + narratorScript: newSeg.narratorScript, + visualDescription: newSeg.visualDescription, + videoPrompt: newSeg.videoPrompt, + imagePrompt: newSeg.imagePrompt, + onScreenText: newSeg.onScreenText, + audioCues: newSeg.audioCues, + stockQuery: newSeg.stockQuery, + duration: `${dur}s`, + }, + }); + } + + /** + * Regenerate multiple selected segments. + * + * @param projectId - Project ID + * @param segmentIds - Array of segment IDs to regenerate + * @returns Updated segments + */ + async regeneratePartial(projectId: string, segmentIds: string[]) { + // Auto-snapshot + await this.versionsService.createSnapshot( + projectId, + 'AUTO_SAVE', + undefined, + `Auto-save before partial regeneration (${segmentIds.length} segments)`, + ).catch(() => {}); + + const results: any[] = []; + for (const segId of segmentIds) { + try { + const result = await this.regenerateSegment(segId); + results.push(result); + } catch (error) { + this.logger.warn(`Failed to regenerate segment ${segId}: ${error}`); + } + } + + return results; + } } diff --git a/src/modules/skriptai/services/versions.service.ts b/src/modules/skriptai/services/versions.service.ts new file mode 100644 index 0000000..b583428 --- /dev/null +++ b/src/modules/skriptai/services/versions.service.ts @@ -0,0 +1,382 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../../database/prisma.service'; + +/** + * VersionsService + * + * Manages script version history — automatic snapshots, manual saves, + * version restoration, and diff comparison. + * + * TR: Script versiyon geçmişi yönetimi — otomatik snapshot, manuel kayıt, + * versiyona geri dönüş ve karşılaştırma. + */ +@Injectable() +export class VersionsService { + private readonly logger = new Logger(VersionsService.name); + + /** Maximum number of versions to keep per project */ + private readonly MAX_VERSIONS = 20; + + constructor(private readonly prisma: PrismaService) {} + + // ========== VERSION LISTING ========== + + /** + * List all versions for a project + */ + async listVersions(projectId: string) { + await this.ensureProjectExists(projectId); + + return this.prisma.scriptVersion.findMany({ + where: { projectId }, + orderBy: { versionNumber: 'desc' }, + select: { + id: true, + versionNumber: true, + label: true, + generatedBy: true, + segmentCount: true, + totalWords: true, + changeNote: true, + createdAt: true, + }, + }); + } + + /** + * Get a specific version with full snapshot data + */ + async getVersion(projectId: string, versionId: string) { + const version = await this.prisma.scriptVersion.findFirst({ + where: { id: versionId, projectId }, + }); + + if (!version) { + throw new NotFoundException( + `Version ${versionId} not found for project ${projectId}`, + ); + } + + return version; + } + + // ========== VERSION CREATION ========== + + /** + * Create a snapshot of the current script state. + * Called automatically before AI generation or manually by user. + * + * @param projectId - Project ID + * @param generatedBy - Who created this version: 'AI' | 'USER' | 'AUTO_SAVE' + * @param label - Optional label for the version + * @param changeNote - Optional note about what changed + * @returns Created version + */ + async createSnapshot( + projectId: string, + generatedBy: string = 'AUTO_SAVE', + label?: string, + changeNote?: string, + ) { + const project = await this.prisma.scriptProject.findUnique({ + where: { id: projectId }, + include: { + segments: { orderBy: { sortOrder: 'asc' } }, + }, + }); + + if (!project) { + throw new NotFoundException(`Project with ID ${projectId} not found`); + } + + // Don't create empty snapshots + if (!project.segments || project.segments.length === 0) { + this.logger.warn( + `No segments to snapshot for project ${projectId}. Skipping.`, + ); + return null; + } + + // Calculate next version number + const nextVersion = project.currentVersionNumber + 1; + + // Build snapshot data (strip relation fields, keep only data) + const snapshotData = project.segments.map((seg) => ({ + segmentType: seg.segmentType, + timeStart: seg.timeStart, + duration: seg.duration, + narratorScript: seg.narratorScript, + visualDescription: seg.visualDescription, + videoPrompt: seg.videoPrompt, + imagePrompt: seg.imagePrompt, + onScreenText: seg.onScreenText, + editorNotes: seg.editorNotes, + generalNotes: seg.generalNotes, + audioCues: seg.audioCues, + stockQuery: seg.stockQuery, + generatedImageUrl: seg.generatedImageUrl, + sortOrder: seg.sortOrder, + citationIndexes: seg.citationIndexes, + })); + + // Build SEO snapshot + const seoSnapshot = { + seoTitle: project.seoTitle, + seoDescription: project.seoDescription, + seoTags: project.seoTags, + thumbnailIdeas: project.thumbnailIdeas, + }; + + // Calculate total words + const totalWords = project.segments.reduce((acc, seg) => { + return acc + (seg.narratorScript?.split(' ').length || 0); + }, 0); + + // Create version + const version = await this.prisma.scriptVersion.create({ + data: { + projectId, + versionNumber: nextVersion, + label: + label || + `v${nextVersion} — ${generatedBy === 'AI' ? 'AI Generated' : generatedBy === 'USER' ? 'Manual Save' : 'Auto Save'}`, + generatedBy, + snapshotData: snapshotData as any, + seoSnapshot: seoSnapshot as any, + segmentCount: project.segments.length, + totalWords, + changeNote, + }, + }); + + // Update project's current version number + await this.prisma.scriptProject.update({ + where: { id: projectId }, + data: { currentVersionNumber: nextVersion }, + }); + + // Cleanup old versions (keep only MAX_VERSIONS) + await this.cleanupOldVersions(projectId); + + this.logger.log( + `Created version v${nextVersion} for project ${projectId} (${generatedBy})`, + ); + + return version; + } + + // ========== VERSION RESTORE ========== + + /** + * Restore a project to a specific version. + * Creates a snapshot of current state before restoring. + * + * @param projectId - Project ID + * @param versionId - Version ID to restore to + * @returns Restored segments + */ + async restoreVersion(projectId: string, versionId: string) { + const version = await this.getVersion(projectId, versionId); + + // First, snapshot current state before restoring + await this.createSnapshot( + projectId, + 'AUTO_SAVE', + undefined, + `Auto-save before restoring to v${version.versionNumber}`, + ); + + // Parse snapshot data + const segments = version.snapshotData as any[]; + + if (!segments || segments.length === 0) { + throw new Error('Version snapshot has no segment data'); + } + + // Delete current segments + await this.prisma.scriptSegment.deleteMany({ where: { projectId } }); + + // Restore segments from snapshot + await this.prisma.scriptSegment.createMany({ + data: segments.map((seg) => ({ + projectId, + segmentType: seg.segmentType || 'Body', + timeStart: seg.timeStart || '00:00', + duration: seg.duration || '0s', + narratorScript: seg.narratorScript, + visualDescription: seg.visualDescription, + videoPrompt: seg.videoPrompt, + imagePrompt: seg.imagePrompt, + onScreenText: seg.onScreenText, + editorNotes: seg.editorNotes, + generalNotes: seg.generalNotes, + audioCues: seg.audioCues, + stockQuery: seg.stockQuery, + generatedImageUrl: seg.generatedImageUrl, + sortOrder: seg.sortOrder ?? 0, + citationIndexes: seg.citationIndexes || [], + })), + }); + + // Restore SEO data if present + const seoData = version.seoSnapshot as any; + if (seoData) { + await this.prisma.scriptProject.update({ + where: { id: projectId }, + data: { + seoTitle: seoData.seoTitle, + seoDescription: seoData.seoDescription, + seoTags: seoData.seoTags || [], + thumbnailIdeas: seoData.thumbnailIdeas || [], + }, + }); + } + + this.logger.log( + `Restored project ${projectId} to version v${version.versionNumber}`, + ); + + // Return the restored project data + return this.prisma.scriptProject.findUnique({ + where: { id: projectId }, + include: { + segments: { orderBy: { sortOrder: 'asc' } }, + }, + }); + } + + // ========== VERSION DELETION ========== + + /** + * Delete a specific version + */ + async deleteVersion(projectId: string, versionId: string) { + const version = await this.getVersion(projectId, versionId); + + await this.prisma.scriptVersion.delete({ + where: { id: version.id }, + }); + + return { deleted: true, versionNumber: version.versionNumber }; + } + + // ========== VERSION COMPARISON ========== + + /** + * Compare two versions and return differences + */ + async compareVersions( + projectId: string, + versionAId: string, + versionBId: string, + ) { + const [versionA, versionB] = await Promise.all([ + this.getVersion(projectId, versionAId), + this.getVersion(projectId, versionBId), + ]); + + const segmentsA = (versionA.snapshotData as any[]) || []; + const segmentsB = (versionB.snapshotData as any[]) || []; + + const maxLen = Math.max(segmentsA.length, segmentsB.length); + const diffs: any[] = []; + + for (let i = 0; i < maxLen; i++) { + const segA = segmentsA[i] || null; + const segB = segmentsB[i] || null; + + if (!segA && segB) { + diffs.push({ + index: i, + type: 'added', + segmentType: segB.segmentType, + narratorScript: { before: null, after: segB.narratorScript }, + }); + } else if (segA && !segB) { + diffs.push({ + index: i, + type: 'removed', + segmentType: segA.segmentType, + narratorScript: { before: segA.narratorScript, after: null }, + }); + } else if (segA && segB) { + const changed = + segA.narratorScript !== segB.narratorScript || + segA.visualDescription !== segB.visualDescription || + segA.onScreenText !== segB.onScreenText; + + if (changed) { + diffs.push({ + index: i, + type: 'modified', + segmentType: segB.segmentType, + narratorScript: { + before: segA.narratorScript, + after: segB.narratorScript, + }, + visualDescription: { + before: segA.visualDescription, + after: segB.visualDescription, + }, + }); + } + } + } + + return { + versionA: { + id: versionA.id, + versionNumber: versionA.versionNumber, + label: versionA.label, + createdAt: versionA.createdAt, + }, + versionB: { + id: versionB.id, + versionNumber: versionB.versionNumber, + label: versionB.label, + createdAt: versionB.createdAt, + }, + totalDiffs: diffs.length, + diffs, + }; + } + + // ========== HELPERS ========== + + /** + * Verify project exists or throw + */ + private async ensureProjectExists(projectId: string) { + const exists = await this.prisma.scriptProject.findUnique({ + where: { id: projectId }, + select: { id: true }, + }); + if (!exists) { + throw new NotFoundException(`Project with ID ${projectId} not found`); + } + } + + /** + * Remove old versions beyond the max limit per project + */ + private async cleanupOldVersions(projectId: string) { + const versions = await this.prisma.scriptVersion.findMany({ + where: { projectId }, + orderBy: { versionNumber: 'desc' }, + select: { id: true, versionNumber: true }, + }); + + if (versions.length > this.MAX_VERSIONS) { + const toDelete = versions.slice(this.MAX_VERSIONS); + await this.prisma.scriptVersion.deleteMany({ + where: { + id: { in: toDelete.map((v) => v.id) }, + }, + }); + + this.logger.log( + `Cleaned up ${toDelete.length} old versions for project ${projectId}`, + ); + } + } +} diff --git a/src/modules/skriptai/skriptai.module.ts b/src/modules/skriptai/skriptai.module.ts index 6cb780b..419a2d9 100644 --- a/src/modules/skriptai/skriptai.module.ts +++ b/src/modules/skriptai/skriptai.module.ts @@ -8,6 +8,7 @@ import { ScriptsController, ResearchController, AnalysisController, + VersionsController, } from './controllers'; // Services @@ -16,6 +17,7 @@ import { ScriptsService, ResearchService, AnalysisService, + VersionsService, } from './services'; /** @@ -30,6 +32,7 @@ import { * - Neuro Marketing analysis * - YouTube audit * - Commercial brief generation + * - Version history & content management * * TR: SkriptAI ana modülü - AI destekli video script üretimi. * EN: Main module for the SkriptAI feature - AI-powered video script generation. @@ -41,13 +44,21 @@ import { ScriptsController, ResearchController, AnalysisController, + VersionsController, ], providers: [ ProjectsService, ScriptsService, ResearchService, AnalysisService, + VersionsService, + ], + exports: [ + ProjectsService, + ScriptsService, + ResearchService, + AnalysisService, + VersionsService, ], - exports: [ProjectsService, ScriptsService, ResearchService, AnalysisService], }) export class SkriptaiModule {}