generated from fahricansecer/boilerplate-be
@@ -181,6 +181,10 @@ model ScriptProject {
|
|||||||
highConcept String? @db.Text
|
highConcept String? @db.Text
|
||||||
includeInterviews Boolean @default(false)
|
includeInterviews Boolean @default(false)
|
||||||
|
|
||||||
|
// Project Status
|
||||||
|
status String @default("DRAFT") // DRAFT, RESEARCHING, SCRIPTING, ANALYZING, COMPLETED
|
||||||
|
currentVersionNumber Int @default(0)
|
||||||
|
|
||||||
// SEO Data (stored as JSON)
|
// SEO Data (stored as JSON)
|
||||||
seoTitle String?
|
seoTitle String?
|
||||||
seoDescription String? @db.Text
|
seoDescription String? @db.Text
|
||||||
@@ -205,9 +209,11 @@ model ScriptProject {
|
|||||||
characters CharacterProfile[]
|
characters CharacterProfile[]
|
||||||
briefItems BriefItem[]
|
briefItems BriefItem[]
|
||||||
visualAssets VisualAsset[]
|
visualAssets VisualAsset[]
|
||||||
|
versions ScriptVersion[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([topic])
|
@@index([topic])
|
||||||
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ScriptSegment {
|
model ScriptSegment {
|
||||||
@@ -312,3 +318,36 @@ model VisualAsset {
|
|||||||
|
|
||||||
@@index([projectId])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './projects.controller';
|
|||||||
export * from './scripts.controller';
|
export * from './scripts.controller';
|
||||||
export * from './research.controller';
|
export * from './research.controller';
|
||||||
export * from './analysis.controller';
|
export * from './analysis.controller';
|
||||||
|
export * from './versions.controller';
|
||||||
|
|||||||
@@ -99,4 +99,25 @@ export class ScriptsController {
|
|||||||
async generateSegmentImage(@Param('id') id: string) {
|
async generateSegmentImage(@Param('id') id: string) {
|
||||||
return this.scriptsService.generateSegmentImage(id);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
96
src/modules/skriptai/controllers/versions.controller.ts
Normal file
96
src/modules/skriptai/controllers/versions.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export * from './projects.service';
|
|||||||
export * from './scripts.service';
|
export * from './scripts.service';
|
||||||
export * from './research.service';
|
export * from './research.service';
|
||||||
export * from './analysis.service';
|
export * from './analysis.service';
|
||||||
|
export * from './versions.service';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../../../database/prisma.service';
|
|||||||
import { GeminiService } from '../../gemini/gemini.service';
|
import { GeminiService } from '../../gemini/gemini.service';
|
||||||
import { CreateSegmentDto, UpdateSegmentDto } from '../dto';
|
import { CreateSegmentDto, UpdateSegmentDto } from '../dto';
|
||||||
import { AnalysisService } from './analysis.service';
|
import { AnalysisService } from './analysis.service';
|
||||||
|
import { VersionsService } from './versions.service';
|
||||||
import {
|
import {
|
||||||
buildScriptOutlinePrompt,
|
buildScriptOutlinePrompt,
|
||||||
buildChapterSegmentPrompt,
|
buildChapterSegmentPrompt,
|
||||||
@@ -28,6 +29,7 @@ export class ScriptsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly gemini: GeminiService,
|
private readonly gemini: GeminiService,
|
||||||
private readonly analysisService: AnalysisService,
|
private readonly analysisService: AnalysisService,
|
||||||
|
private readonly versionsService: VersionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +115,20 @@ export class ScriptsService {
|
|||||||
async generateScript(projectId: string) {
|
async generateScript(projectId: string) {
|
||||||
this.logger.log(`Generating script for project: ${projectId}`);
|
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({
|
const project = await this.prisma.scriptProject.findUnique({
|
||||||
where: { id: projectId },
|
where: { id: projectId },
|
||||||
include: {
|
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<any[]>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
382
src/modules/skriptai/services/versions.service.ts
Normal file
382
src/modules/skriptai/services/versions.service.ts
Normal file
@@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ScriptsController,
|
ScriptsController,
|
||||||
ResearchController,
|
ResearchController,
|
||||||
AnalysisController,
|
AnalysisController,
|
||||||
|
VersionsController,
|
||||||
} from './controllers';
|
} from './controllers';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
ScriptsService,
|
ScriptsService,
|
||||||
ResearchService,
|
ResearchService,
|
||||||
AnalysisService,
|
AnalysisService,
|
||||||
|
VersionsService,
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
* - Neuro Marketing analysis
|
* - Neuro Marketing analysis
|
||||||
* - YouTube audit
|
* - YouTube audit
|
||||||
* - Commercial brief generation
|
* - Commercial brief generation
|
||||||
|
* - Version history & content management
|
||||||
*
|
*
|
||||||
* TR: SkriptAI ana modülü - AI destekli video script üretimi.
|
* TR: SkriptAI ana modülü - AI destekli video script üretimi.
|
||||||
* EN: Main module for the SkriptAI feature - AI-powered video script generation.
|
* EN: Main module for the SkriptAI feature - AI-powered video script generation.
|
||||||
@@ -41,13 +44,21 @@ import {
|
|||||||
ScriptsController,
|
ScriptsController,
|
||||||
ResearchController,
|
ResearchController,
|
||||||
AnalysisController,
|
AnalysisController,
|
||||||
|
VersionsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ProjectsService,
|
ProjectsService,
|
||||||
ScriptsService,
|
ScriptsService,
|
||||||
ResearchService,
|
ResearchService,
|
||||||
AnalysisService,
|
AnalysisService,
|
||||||
|
VersionsService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ProjectsService,
|
||||||
|
ScriptsService,
|
||||||
|
ResearchService,
|
||||||
|
AnalysisService,
|
||||||
|
VersionsService,
|
||||||
],
|
],
|
||||||
exports: [ProjectsService, ScriptsService, ResearchService, AnalysisService],
|
|
||||||
})
|
})
|
||||||
export class SkriptaiModule {}
|
export class SkriptaiModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user