Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)

This commit is contained in:
2026-08-11 12:27:44 +02:00
commit d5af006443
304 changed files with 74604 additions and 0 deletions
@@ -0,0 +1,79 @@
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PrismaService } from '../../shared/prisma/prisma.service';
@ApiTags('audit-logs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('audit-logs')
export class AuditLogsController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@ApiOperation({ summary: 'Team audit trail (paginated, filtered)' })
async findAll(
@Req() req: any,
@Query('page') page = '1',
@Query('limit') limit = '50',
@Query('entityType') entityType?: string,
@Query('action') action?: string,
) {
const members = await this.prisma.teamMember.findMany({
where: { teamId: req.user.teamId },
select: { userId: true },
});
const actorIds = members.map((m) => m.userId);
const where = {
actorId: { in: actorIds },
...(entityType ? { entityType } : {}),
...(action ? { action } : {}),
};
const [logs, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
}),
this.prisma.auditLog.count({ where }),
]);
const uniqueActorIds = [...new Set(logs.map((l) => l.actorId))];
const users = await this.prisma.user.findMany({
where: { id: { in: uniqueActorIds } },
select: { id: true, name: true, email: true },
});
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
return {
data: logs.map((log) => ({ ...log, actor: userMap[log.actorId] ?? null })),
total,
page: Number(page),
limit: Number(limit),
};
}
@Get(':entityType/:entityId')
@ApiOperation({ summary: 'Audit history for a specific entity' })
async findForEntity(
@Param('entityType') entityType: string,
@Param('entityId') entityId: string,
) {
const logs = await this.prisma.auditLog.findMany({
where: { entityType, entityId },
orderBy: { createdAt: 'desc' },
});
const uniqueActorIds = [...new Set(logs.map((l) => l.actorId))];
const users = await this.prisma.user.findMany({
where: { id: { in: uniqueActorIds } },
select: { id: true, name: true, email: true },
});
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
return logs.map((log) => ({ ...log, actor: userMap[log.actorId] ?? null }));
}
}
@@ -0,0 +1,91 @@
import { Controller, Get, Patch, Post, Req, Res, UseGuards, Body } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Response, Request } from 'express';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Get('google')
@ApiOperation({ summary: 'Redirect to Google OAuth' })
@UseGuards(AuthGuard('google'))
googleAuth() {}
@Get('google/callback')
@ApiOperation({ summary: 'Google OAuth callback' })
@UseGuards(AuthGuard('google'))
async googleCallback(@Req() req: Request, @Res() res: Response) {
const { user, teamId, teamRole } = req.user as any;
const accessToken = this.authService.issueJwt(user, teamId, teamRole);
const refreshToken = this.authService.issueRefreshToken(user);
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
const frontendUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
res.redirect(`${frontendUrl}/auth/callback?token=${accessToken}`);
}
@Get('me')
@ApiOperation({ summary: 'Get current user with team context' })
@UseGuards(JwtAuthGuard)
me(@Req() req: Request) {
const user = req.user as any;
return {
id: user.id,
email: user.email,
name: user.name,
isAppAdmin: user.isAppAdmin,
teamId: user.teamId,
teamRole: user.teamRole,
};
}
@Post('refresh')
@ApiOperation({ summary: 'Refresh access token' })
async refresh(@Req() req: Request) {
const token = req.cookies?.['refresh_token'];
return this.authService.refreshAccessToken(token);
}
@Get('me/preferences')
@ApiOperation({ summary: 'Get current user preferences' })
@UseGuards(JwtAuthGuard)
getPreferences(@Req() req: Request) {
return this.authService.getPreferences((req.user as any).id);
}
@Patch('me/preferences')
@ApiOperation({ summary: 'Patch current user preferences (shallow merge)' })
@UseGuards(JwtAuthGuard)
updatePreferences(@Req() req: Request, @Body() body: Record<string, unknown>) {
return this.authService.updatePreferences((req.user as any).id, body);
}
@Post('switch-team')
@ApiOperation({ summary: 'Switch active team — returns a new access token' })
@UseGuards(JwtAuthGuard)
async switchTeam(@Req() req: Request, @Body('teamId') teamId: string) {
const user = req.user as any;
return this.authService.switchTeam(user.id, teamId);
}
@Post('logout')
@ApiOperation({ summary: 'Clear refresh token cookie' })
logout(@Res() res: Response) {
(res as any).clearCookie('refresh_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
(res as any).json({ message: 'Logged out' });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { GoogleStrategy } from './strategies/google.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '15m' },
}),
inject: [ConfigService],
}),
],
providers: [AuthService, GoogleStrategy, JwtStrategy],
controllers: [AuthController],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+218
View File
@@ -0,0 +1,218 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
import { google } from 'googleapis';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { User, TeamRole } from '@prisma/client';
export interface AuthResult {
user: User;
teamId: string;
teamRole: TeamRole;
}
@Injectable()
export class AuthService {
private readonly encKey: Buffer;
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {
const raw = this.config.get<string>('TOKEN_ENCRYPTION_KEY') ?? 'fallback_dev_key_32chars_padded!!';
this.encKey = scryptSync(raw, 'studioflow-salt', 32);
}
async upsertGoogleUser(
profile: any,
accessToken: string,
refreshToken: string,
): Promise<AuthResult> {
const email: string = profile.emails?.[0]?.value;
const googleId: string = profile.id;
const name: string = profile.displayName;
const user = await this.prisma.user.upsert({
where: { googleId },
create: { email, name, googleId },
update: { email, name },
});
const memberships = await this.prisma.teamMember.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'asc' },
});
let teamId: string;
let teamRole: TeamRole;
if (memberships.length === 0) {
const { youtubeChannelId, channelName, uploadsPlaylistId } =
await this.fetchYouTubeChannelInfo(accessToken, refreshToken);
const slug = this.slugify(name || email.split('@')[0]);
const uniqueSlug = await this.ensureUniqueSlug(slug);
const team = await this.prisma.team.create({
data: {
name: name || email.split('@')[0],
slug: uniqueSlug,
members: {
create: { userId: user.id, role: TeamRole.OWNER },
},
channels: {
create: {
youtubeChannelId,
name: channelName,
uploadsPlaylistId,
youtubeAccessToken: this.encrypt(accessToken),
youtubeRefreshToken: refreshToken ? this.encrypt(refreshToken) : undefined,
youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
connectedBy: user.id,
},
},
},
});
teamId = team.id;
teamRole = TeamRole.OWNER;
} else {
const primary = memberships[0];
teamId = primary.teamId;
teamRole = primary.role;
// Refresh tokens on channels this user connected.
// Only overwrite youtubeRefreshToken when Google actually returns one
// (it only does on first auth or when prompt=consent forces re-consent).
await this.prisma.channel.updateMany({
where: { teamId, connectedBy: user.id },
data: {
youtubeAccessToken: this.encrypt(accessToken),
...(refreshToken && { youtubeRefreshToken: this.encrypt(refreshToken) }),
youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
},
});
}
return { user, teamId, teamRole };
}
issueJwt(user: User, teamId: string, teamRole: TeamRole): string {
return this.jwt.sign(
{ sub: user.id, email: user.email, teamId, teamRole },
{ expiresIn: '15m' },
);
}
issueRefreshToken(user: User): string {
return this.jwt.sign(
{ sub: user.id },
{
secret: this.config.get<string>('JWT_REFRESH_SECRET') ?? this.config.get<string>('JWT_SECRET'),
expiresIn: '7d',
},
);
}
async refreshAccessToken(refreshToken: string | undefined): Promise<{ accessToken: string }> {
if (!refreshToken) throw new Error('No refresh token');
const payload = this.jwt.verify<{ sub: string }>(refreshToken, {
secret: this.config.get<string>('JWT_REFRESH_SECRET') ?? this.config.get<string>('JWT_SECRET'),
});
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: payload.sub } });
const membership = await this.prisma.teamMember.findFirst({
where: { userId: user.id },
orderBy: { createdAt: 'asc' },
});
if (!membership) throw new Error('User has no team');
return { accessToken: this.issueJwt(user, membership.teamId, membership.role) };
}
async getPreferences(userId: string): Promise<Record<string, unknown>> {
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });
return (user.preferences as Record<string, unknown>) ?? {};
}
async updatePreferences(userId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });
const merged = { ...((user.preferences as Record<string, unknown>) ?? {}), ...patch };
await this.prisma.user.update({ where: { id: userId }, data: { preferences: merged as any } });
return merged;
}
async switchTeam(userId: string, teamId: string): Promise<{ accessToken: string }> {
const membership = await this.prisma.teamMember.findUnique({
where: { userId_teamId: { userId, teamId } },
});
if (!membership) throw new Error('Not a member of this team');
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });
return { accessToken: this.issueJwt(user, membership.teamId, membership.role) };
}
decryptToken(encrypted: string): string {
return this.decrypt(encrypted);
}
encryptToken(plain: string): string {
return this.encrypt(plain);
}
private async fetchYouTubeChannelInfo(accessToken: string, refreshToken: string) {
const oauth2 = new google.auth.OAuth2(
this.config.get<string>('GOOGLE_CLIENT_ID'),
this.config.get<string>('GOOGLE_CLIENT_SECRET'),
);
oauth2.setCredentials({ access_token: accessToken, refresh_token: refreshToken });
const yt = google.youtube({ version: 'v3', auth: oauth2 });
try {
const res = await yt.channels.list({ part: ['snippet', 'contentDetails'], mine: true });
const ch = res.data.items?.[0];
return {
youtubeChannelId: ch?.id ?? `unknown-${Date.now()}`,
channelName: ch?.snippet?.title ?? 'My Channel',
uploadsPlaylistId: ch?.contentDetails?.relatedPlaylists?.uploads ?? undefined,
};
} catch {
return {
youtubeChannelId: `unknown-${Date.now()}`,
channelName: 'My Channel',
uploadsPlaylistId: undefined,
};
}
}
private slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50) || 'team';
}
private async ensureUniqueSlug(base: string): Promise<string> {
let slug = base;
let i = 2;
while (await this.prisma.team.findUnique({ where: { slug } })) {
slug = `${base}-${i++}`;
}
return slug;
}
private encrypt(text: string): string {
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-cbc', this.encKey, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}
private decrypt(text: string): string {
const [ivHex, encHex] = text.split(':');
const iv = Buffer.from(ivHex, 'hex');
const encrypted = Buffer.from(encHex, 'hex');
const decipher = createDecipheriv('aes-256-cbc', this.encKey, iv);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
}
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { TeamRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: TeamRole[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
@@ -0,0 +1,35 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { TeamRole } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
const ROLE_PRIORITY: Record<TeamRole, number> = {
[TeamRole.OWNER]: 5,
[TeamRole.ADMIN]: 4,
[TeamRole.EDITOR]: 3,
[TeamRole.REVIEWER]: 2,
[TeamRole.READONLY]: 1,
};
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<TeamRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) return true;
const { user } = context.switchToHttp().getRequest();
if (!user) throw new ForbiddenException();
const userPriority = ROLE_PRIORITY[user.teamRole as TeamRole] ?? 0;
const minRequired = Math.min(...required.map((r) => ROLE_PRIORITY[r]));
if (userPriority < minRequired) throw new ForbiddenException('Insufficient role');
return true;
}
}
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from '../auth.service';
import { PrismaService } from '../../../shared/prisma/prisma.service';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
private readonly config: ConfigService,
private readonly authService: AuthService,
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {
super({
clientID: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
callbackURL: config.get<string>('GOOGLE_CALLBACK_URL') ?? 'http://localhost:3001/api/v1/auth/google/callback',
scope: ['email', 'profile', 'https://www.googleapis.com/auth/youtube'],
});
}
override authorizationParams(options: any): Record<string, string> {
const params: Record<string, string> = { access_type: 'offline' };
if (options?.prompt) params.prompt = options.prompt;
return params;
}
override authenticate(req: any, options?: any) {
this.resolvePrompt(req)
.then((prompt) => super.authenticate(req, { ...options, ...(prompt ? { prompt } : {}) }))
.catch(() => super.authenticate(req, options));
}
private async resolvePrompt(req: any): Promise<string | null> {
// Extract bearer token from Authorization header (sent by the frontend before redirect)
const authHeader: string | undefined = req.headers?.authorization;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) return null; // new user — access_type=offline alone gives a refresh token on first auth
try {
const payload = this.jwt.verify<{ sub: string; teamId: string }>(token, {
secret: this.config.getOrThrow<string>('JWT_SECRET'),
});
const channel = await this.prisma.channel.findFirst({
where: { teamId: payload.teamId, connectedBy: payload.sub },
select: { youtubeRefreshToken: true },
});
// Only force consent when the refresh token is genuinely missing
return channel && !channel.youtubeRefreshToken ? 'consent' : null;
} catch {
return null;
}
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
) {
// authResult contains { user, teamId, teamRole } — passed as req.user to the controller
const authResult = await this.authService.upsertGoogleUser(profile, accessToken, refreshToken);
done(null, authResult as any);
}
}
@@ -0,0 +1,39 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { TeamRole } from '@prisma/client';
interface JwtPayload {
sub: string;
email: string;
teamId: string;
teamRole: TeamRole;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
});
}
async validate(payload: JwtPayload) {
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user) throw new UnauthorizedException();
const membership = await this.prisma.teamMember.findUnique({
where: { userId_teamId: { userId: user.id, teamId: payload.teamId } },
});
if (!membership) throw new UnauthorizedException('Not a member of this team');
return { ...user, teamId: payload.teamId, teamRole: payload.teamRole };
}
}
@@ -0,0 +1,35 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { BlocksService } from './blocks.service';
import { CreateBlockDto } from './dto/create-block.dto';
import { UpdateBlockDto } from './dto/update-block.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('blocks')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('blocks')
export class BlocksController {
constructor(private readonly service: BlocksService) {}
@Get() @ApiOperation({ summary: 'List all blocks' })
findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); }
@Post() @ApiOperation({ summary: 'Create block' }) @Roles(TeamRole.EDITOR)
create(@Body() dto: CreateBlockDto, @Req() req: any) { return this.service.create(dto, req.user.id, req.user.teamId); }
@Patch(':id') @ApiOperation({ summary: 'Update block (creates version snapshot)' }) @Roles(TeamRole.EDITOR)
update(@Param('id') id: string, @Body() dto: UpdateBlockDto, @Req() req: any) { return this.service.update(id, dto, req.user.id, req.user.teamId); }
@Delete(':id') @ApiOperation({ summary: 'Delete block (only if not in use)' }) @Roles(TeamRole.EDITOR)
remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); }
@Get(':id/versions') @ApiOperation({ summary: 'Block version history' })
versions(@Param('id') id: string) { return this.service.getVersions(id); }
@Get(':id/usage') @ApiOperation({ summary: 'Videos and templates using this block' })
usage(@Param('id') id: string, @Req() req: any) { return this.service.getUsage(id, req.user.teamId); }
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { BlocksService } from './blocks.service';
import { BlocksController } from './blocks.controller';
import { AuditModule } from '../../shared/audit/audit.module';
import { QUEUES } from '../../queues/queues.constants';
@Module({
imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.RENDER })],
providers: [BlocksService],
controllers: [BlocksController],
exports: [BlocksService],
})
export class BlocksModule {}
@@ -0,0 +1,107 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AuditService } from '../../shared/audit/audit.service';
import { CreateBlockDto } from './dto/create-block.dto';
import { UpdateBlockDto } from './dto/update-block.dto';
import { QUEUES } from '../../queues/queues.constants';
@Injectable()
export class BlocksService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
@InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue,
) {}
findAll(teamId: string) {
return this.prisma.descriptionBlock.findMany({ where: { teamId }, orderBy: { name: 'asc' } });
}
async create(dto: CreateBlockDto, actorId: string, teamId: string) {
const block = await this.prisma.descriptionBlock.create({
data: {
...dto,
variableDefinitions: (dto.variableDefinitions ?? []) as any,
condition: dto.condition === null ? Prisma.JsonNull : (dto.condition as any),
teamId,
},
});
await this.audit.log(actorId, 'DescriptionBlock', block.id, 'create', null, block);
return block;
}
async update(id: string, dto: UpdateBlockDto, actorId: string, teamId: string) {
const before = await this.prisma.descriptionBlock.findFirst({ where: { id, teamId } });
if (!before) throw new NotFoundException(`Block ${id} not found`);
await this.prisma.blockVersion.create({
data: { blockId: id, version: before.version, contentSnapshot: before as any, createdBy: actorId },
});
const updated = await this.prisma.descriptionBlock.update({
where: { id },
data: {
...dto,
variableDefinitions: dto.variableDefinitions !== undefined ? (dto.variableDefinitions as any) : undefined,
condition: dto.condition === null ? Prisma.JsonNull : dto.condition !== undefined ? (dto.condition as any) : undefined,
version: { increment: 1 },
},
});
await this.audit.log(actorId, 'DescriptionBlock', id, 'update', before, updated);
await this.enqueueBlockRenders(id, teamId);
return updated;
}
private async enqueueBlockRenders(blockId: string, teamId: string) {
const configs = await this.prisma.videoConfig.findMany({
where: {
blockOrder: { array_contains: blockId } as any,
video: { channel: { teamId }, youtubeDeletedAt: null },
},
select: { videoId: true },
});
await Promise.all(
configs.map((c) => this.renderQueue.add('render', { videoId: c.videoId }, { jobId: `render-${c.videoId}` })),
);
}
getVersions(id: string) {
return this.prisma.blockVersion.findMany({ where: { blockId: id }, orderBy: { version: 'desc' } });
}
async delete(id: string, teamId: string, actorId: string) {
const block = await this.prisma.descriptionBlock.findFirst({ where: { id, teamId } });
if (!block) throw new NotFoundException(`Block ${id} not found`);
const usage = await this.getUsage(id, teamId);
if (usage.videos.length > 0 || usage.templates.length > 0) {
throw new ConflictException(
`Block is used by ${usage.videos.length} video config(s) and ${usage.templates.length} template(s) and cannot be deleted`,
);
}
await this.prisma.blockVersion.deleteMany({ where: { blockId: id } });
await this.prisma.descriptionBlock.delete({ where: { id } });
await this.audit.log(actorId, 'DescriptionBlock', id, 'delete', block, null);
return { deleted: true };
}
async getUsage(id: string, teamId: string) {
const [videoConfigs, templates] = await Promise.all([
this.prisma.videoConfig.findMany({
where: { blockOrder: { array_contains: id }, video: { channel: { teamId } } },
select: { videoId: true, video: { select: { title: true } } },
}),
this.prisma.template.findMany({
where: { defaultBlocks: { array_contains: id }, teamId },
select: { id: true, name: true },
}),
]);
return {
videos: videoConfigs.map((c) => ({ id: c.videoId, title: c.video.title })),
templates,
};
}
}
@@ -0,0 +1,22 @@
import { IsString, IsEnum, IsOptional, IsBoolean, IsArray } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BlockType } from '@prisma/client';
export class BlockVariableDefinition {
name: string;
label: string;
description?: string;
defaultValue?: string;
}
export class CreateBlockDto {
@ApiProperty() @IsString() name: string;
@ApiProperty({ enum: BlockType }) @IsEnum(BlockType) type: BlockType;
@ApiProperty() @IsString() content: string;
@ApiPropertyOptional() @IsOptional() @IsString() language?: string;
@ApiPropertyOptional() @IsOptional() @IsBoolean() compact?: boolean;
@ApiPropertyOptional() @IsOptional() @IsString() campaignId?: string;
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
@ApiPropertyOptional({ type: [BlockVariableDefinition] }) @IsOptional() @IsArray() variableDefinitions?: BlockVariableDefinition[];
@ApiPropertyOptional() @IsOptional() condition?: Record<string, any> | null;
}
@@ -0,0 +1,14 @@
import { IsString, IsOptional, IsBoolean, IsArray } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BlockVariableDefinition } from './create-block.dto';
export class UpdateBlockDto {
@ApiPropertyOptional() @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() content?: string;
@ApiPropertyOptional() @IsOptional() @IsString() language?: string;
@ApiPropertyOptional() @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional() @IsOptional() @IsBoolean() compact?: boolean;
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
@ApiPropertyOptional({ type: [BlockVariableDefinition] }) @IsOptional() @IsArray() variableDefinitions?: BlockVariableDefinition[];
@ApiPropertyOptional() @IsOptional() condition?: Record<string, any> | null;
}
@@ -0,0 +1,38 @@
import { Controller, Get, Post, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { BulkJobsService } from './bulk-jobs.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('bulk-jobs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('bulk-jobs')
export class BulkJobsController {
constructor(private readonly service: BulkJobsService) {}
@Get() @ApiOperation({ summary: 'List bulk jobs for active team' })
findAll(@Req() req: any, @Query('status') status?: string) {
return this.service.findAll(req.user.teamId, status);
}
@Get(':id') @ApiOperation({ summary: 'Get bulk job with items' })
findOne(@Param('id') id: string, @Req() req: any) { return this.service.findOne(id, req.user.teamId); }
@Post(':id/rollback') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Rollback a completed bulk job' })
rollback(@Param('id') id: string, @Req() req: any) { return this.service.rollback(id, req.user.id, req.user.teamId); }
@Get('push-pending/preview') @ApiOperation({ summary: 'Preview all push-pending videos with field-level diffs' })
previewPushPending(
@Req() req: any,
@Query('sort') sort?: string,
@Query('order') order?: string,
) { return this.service.previewPushPending(req.user.teamId, sort, order as 'asc' | 'desc' | undefined); }
@Post('push-pending') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Create a bulk push job for selected pending videos' })
createPushPendingJob(@Req() req: any, @Body('videoIds') videoIds: string[]) {
return this.service.createPushPendingJob(req.user.teamId, req.user.id, videoIds);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { BulkJobsService } from './bulk-jobs.service';
import { BulkJobsController } from './bulk-jobs.controller';
import { AuditModule } from '../../shared/audit/audit.module';
import { QUEUES } from '../../queues/queues.constants';
@Module({
imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.BULK_METADATA }), BullModule.registerQueue({ name: QUEUES.YOUTUBE_SYNC })],
providers: [BulkJobsService],
controllers: [BulkJobsController],
exports: [BulkJobsService],
})
export class BulkJobsModule {}
@@ -0,0 +1,237 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AuditService } from '../../shared/audit/audit.service';
import { QUEUES } from '../../queues/queues.constants';
import { computePendingChanges } from '../../shared/render-engine/pending-changes';
export type FieldDiff = { before: string | null; after: string | null };
export type TagsDiff = { before: string[]; after: string[] };
export type BoolDiff = { before: boolean; after: boolean };
export interface PushPendingPreviewItem {
videoId: string;
title: string;
thumbnailUrl: string | null;
firstSync: boolean;
changedFields: string[];
diff: {
title?: FieldDiff;
description?: FieldDiff;
privacyStatus?: FieldDiff;
tags?: TagsDiff;
categoryId?: FieldDiff;
defaultLanguage?: FieldDiff;
defaultAudioLanguage?: FieldDiff;
selfDeclaredMadeForKids?: BoolDiff;
embeddable?: BoolDiff;
license?: FieldDiff;
recordingDate?: FieldDiff;
};
}
@Injectable()
export class BulkJobsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
@InjectQueue(QUEUES.BULK_METADATA) private readonly bulkQueue: Queue,
@InjectQueue(QUEUES.YOUTUBE_SYNC) private readonly syncQueue: Queue,
) {}
findAll(teamId: string, status?: string) {
return this.prisma.bulkJob.findMany({
where: { teamId, ...(status ? { status: status as any } : {}) },
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string, teamId: string) {
const job = await this.prisma.bulkJob.findFirst({
where: { id, teamId },
include: { items: { include: { video: { select: { id: true, title: true } } } } },
});
if (!job) throw new NotFoundException(`BulkJob ${id} not found`);
return job;
}
async rollback(id: string, actorId: string, teamId: string) {
const job = await this.findOne(id, teamId);
const doneItems = await this.prisma.bulkJobItem.findMany({
where: { bulkJobId: id, status: 'done' },
});
for (const item of doneItems) {
if (!item.beforeSnapshot) continue;
await this.prisma.video.update({ where: { id: item.videoId }, data: item.beforeSnapshot as any });
await this.prisma.bulkJobItem.update({ where: { id: item.id }, data: { status: 'rolled_back' } });
}
const updated = await this.prisma.bulkJob.update({ where: { id }, data: { status: 'ROLLED_BACK' } });
await this.audit.log(actorId, 'BulkJob', id, 'rollback', { status: job.status }, updated);
return updated;
}
async enqueueItems(bulkJobId: string, teamId: string) {
const job = await this.findOne(bulkJobId, teamId);
const items = await this.prisma.bulkJobItem.findMany({
where: { bulkJobId, status: 'pending' },
});
await this.prisma.bulkJob.update({ where: { id: bulkJobId }, data: { status: 'RUNNING' } });
await Promise.all(
items.map((item) => this.bulkQueue.add('process-item', { bulkJobId, itemId: item.id })),
);
return { enqueued: items.length };
}
async previewPushPending(teamId: string, sort = 'publishedAt', order: 'asc' | 'desc' = 'desc'): Promise<PushPendingPreviewItem[]> {
const ALLOWED = new Set(['title', 'publishedAt', 'privacyStatus', 'lintStatus', 'lastSyncedAt']);
const sortField = ALLOWED.has(sort) ? sort : 'publishedAt';
const videos = await this.prisma.video.findMany({
where: { channel: { teamId }, youtubeDeletedAt: null },
select: {
id: true, title: true, thumbnailUrl: true, tags: true, categoryId: true,
privacyStatus: true, defaultLanguage: true, defaultAudioLanguage: true,
selfDeclaredMadeForKids: true, embeddable: true, license: true, recordingDate: true,
youtubeDescription: true, renderedDescription: true,
lastSyncedHash: true, youtubeSnapshot: true,
publishedAt: true, scheduledAt: true, createdAt: true, lintStatus: true, lastSyncedAt: true,
},
});
const pending = videos.filter((v) => computePendingChanges(v));
// Mirror the same sort logic used by findAll so the modal order matches the tab order.
if (sortField === 'publishedAt') {
const now = new Date();
pending.sort((a, b) => {
const aDate = (a.scheduledAt && a.scheduledAt > now ? a.scheduledAt : null) ?? a.publishedAt ?? a.createdAt;
const bDate = (b.scheduledAt && b.scheduledAt > now ? b.scheduledAt : null) ?? b.publishedAt ?? b.createdAt;
return order === 'desc' ? bDate.getTime() - aDate.getTime() : aDate.getTime() - bDate.getTime();
});
} else {
const dir = order === 'asc' ? 1 : -1;
pending.sort((a, b) => {
const av = (a as any)[sortField] ?? '';
const bv = (b as any)[sortField] ?? '';
if (av < bv) return -dir;
if (av > bv) return dir;
return 0;
});
}
return pending.map((v) => {
const snap = v.youtubeSnapshot as Record<string, any> | null;
const allFields: string[] = ['title', 'description', 'privacyStatus', 'tags', 'categoryId',
'defaultLanguage', 'defaultAudioLanguage', 'selfDeclaredMadeForKids', 'embeddable', 'license', 'recordingDate'];
if (!snap) {
return {
videoId: v.id, title: v.title, thumbnailUrl: v.thumbnailUrl,
firstSync: true, changedFields: allFields,
diff: {
title: { before: null, after: v.title },
description: { before: null, after: v.renderedDescription ?? '' },
privacyStatus: { before: null, after: String(v.privacyStatus) },
tags: { before: [], after: v.tags },
categoryId: { before: null, after: v.categoryId ?? null },
defaultLanguage: { before: null, after: v.defaultLanguage ?? null },
defaultAudioLanguage: { before: null, after: v.defaultAudioLanguage ?? null },
selfDeclaredMadeForKids: { before: false, after: v.selfDeclaredMadeForKids ?? false },
embeddable: { before: true, after: v.embeddable ?? true },
license: { before: null, after: v.license ?? null },
recordingDate: { before: null, after: v.recordingDate ? v.recordingDate.toISOString().slice(0, 10) : null },
},
};
}
const diff: PushPendingPreviewItem['diff'] = {};
const changedFields: string[] = [];
if (snap.title !== v.title) {
changedFields.push('title');
diff.title = { before: snap.title ?? null, after: v.title };
}
const descBefore = v.youtubeDescription ?? '';
const descAfter = v.renderedDescription ?? '';
if (descBefore !== descAfter) {
changedFields.push('description');
diff.description = { before: descBefore, after: descAfter };
}
const snapPrivacy = String(snap.privacyStatus ?? '').toUpperCase();
if (snapPrivacy !== String(v.privacyStatus)) {
changedFields.push('privacyStatus');
diff.privacyStatus = { before: snap.privacyStatus ?? null, after: String(v.privacyStatus) };
}
if (JSON.stringify([...(snap.tags ?? [])].sort()) !== JSON.stringify([...v.tags].sort())) {
changedFields.push('tags');
diff.tags = { before: snap.tags ?? [], after: v.tags };
}
if ((snap.categoryId ?? null) !== (v.categoryId ?? null)) {
changedFields.push('categoryId');
diff.categoryId = { before: snap.categoryId ?? null, after: v.categoryId ?? null };
}
if ((snap.defaultLanguage ?? null) !== (v.defaultLanguage ?? null)) {
changedFields.push('defaultLanguage');
diff.defaultLanguage = { before: snap.defaultLanguage ?? null, after: v.defaultLanguage ?? null };
}
if ((snap.defaultAudioLanguage ?? null) !== (v.defaultAudioLanguage ?? null)) {
changedFields.push('defaultAudioLanguage');
diff.defaultAudioLanguage = { before: snap.defaultAudioLanguage ?? null, after: v.defaultAudioLanguage ?? null };
}
if ((snap.selfDeclaredMadeForKids ?? false) !== (v.selfDeclaredMadeForKids ?? false)) {
changedFields.push('selfDeclaredMadeForKids');
diff.selfDeclaredMadeForKids = { before: snap.selfDeclaredMadeForKids ?? false, after: v.selfDeclaredMadeForKids ?? false };
}
if ((snap.embeddable ?? true) !== (v.embeddable ?? true)) {
changedFields.push('embeddable');
diff.embeddable = { before: snap.embeddable ?? true, after: v.embeddable ?? true };
}
if ((snap.license ?? null) !== (v.license ?? null)) {
changedFields.push('license');
diff.license = { before: snap.license ?? null, after: v.license ?? null };
}
const snapRd = snap.recordingDate ?? null;
const curRd = v.recordingDate ? v.recordingDate.toISOString().slice(0, 10) : null;
if (snapRd !== curRd) {
changedFields.push('recordingDate');
diff.recordingDate = { before: snapRd, after: curRd };
}
return { videoId: v.id, title: v.title, thumbnailUrl: v.thumbnailUrl, firstSync: false, changedFields, diff };
});
}
async createPushPendingJob(teamId: string, userId: string, videoIds: string[]): Promise<{ bulkJobId: string; count: number }> {
// Verify all requested videoIds belong to this team
const owned = await this.prisma.video.findMany({
where: { id: { in: videoIds }, channel: { teamId } },
select: { id: true },
});
const ownedIds = owned.map((v) => v.id);
const job = await this.prisma.bulkJob.create({
data: {
teamId, type: 'SYNC_PUSH', initiatedBy: userId,
filterSnapshot: { type: 'SYNC_PUSH', videoIds: ownedIds },
targetIds: ownedIds,
totalCount: ownedIds.length,
status: 'CONFIRMED',
items: { create: ownedIds.map((id) => ({ videoId: id, status: 'pending' })) },
},
include: { items: { select: { id: true, videoId: true } } },
});
await this.prisma.bulkJob.update({ where: { id: job.id }, data: { status: 'RUNNING' } });
for (const item of job.items) {
await this.syncQueue.add('sync', { videoId: item.videoId, bulkJobItemId: item.id });
}
return { bulkJobId: job.id, count: ownedIds.length };
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { CalendarService } from './calendar.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('calendar')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('calendar')
export class CalendarController {
constructor(private readonly service: CalendarService) {}
@Get()
@ApiOperation({ summary: 'Get calendar entries' })
@ApiQuery({ name: 'view', enum: ['month', 'week', 'agenda'] })
@ApiQuery({ name: 'date', example: '2026-04' })
getEntries(@Query('view') view: string = 'month', @Query('date') date: string) {
return this.service.getEntries(view, date ?? new Date().toISOString().slice(0, 7));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CalendarService } from './calendar.service';
import { CalendarController } from './calendar.controller';
@Module({
providers: [CalendarService],
controllers: [CalendarController],
})
export class CalendarModule {}
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
@Injectable()
export class CalendarService {
constructor(private readonly prisma: PrismaService) {}
async getEntries(view: string, date: string) {
const { start, end } = this.parseRange(view, date);
const videos = await this.prisma.video.findMany({
where: {
OR: [
{ scheduledAt: { gte: start, lte: end } },
{ publishedAt: { gte: start, lte: end } },
],
},
include: {
template: { select: { id: true, name: true } },
},
orderBy: { scheduledAt: 'asc' },
});
const allCollabIds = [...new Set(videos.flatMap((v) => (v.collaboratorIds as string[]) ?? []))];
const collabs = allCollabIds.length > 0
? await this.prisma.collaborator.findMany({
where: { id: { in: allCollabIds } },
select: { id: true, name: true, youtubeLink: true },
})
: [];
const collabMap = new Map(collabs.map((c) => [c.id, c]));
return videos.map((v) => ({
videoId: v.id,
title: v.title,
date: v.scheduledAt ?? v.publishedAt,
templateName: v.template?.name,
collaborators: ((v.collaboratorIds as string[]) ?? []).map((id) => collabMap.get(id)).filter(Boolean),
lintStatus: v.lintStatus,
channelId: v.channelId,
privacyStatus: v.privacyStatus,
}));
}
private parseRange(view: string, date: string): { start: Date; end: Date } {
const [year, month] = date.split('-').map(Number);
if (view === 'month') {
const start = new Date(year, month - 1, 1);
const end = new Date(year, month, 0, 23, 59, 59);
return { start, end };
}
if (view === 'week') {
const base = new Date(year, month - 1, 1);
const start = new Date(base);
start.setDate(base.getDate() - base.getDay());
const end = new Date(start);
end.setDate(start.getDate() + 6);
end.setHours(23, 59, 59);
return { start, end };
}
// agenda: next 30 days from date
const start = new Date(date);
const end = new Date(start);
end.setDate(start.getDate() + 30);
return { start, end };
}
}
@@ -0,0 +1,22 @@
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { PrismaService } from '../../shared/prisma/prisma.service';
@ApiTags('campaigns')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('campaigns')
export class CampaignsController {
constructor(private readonly prisma: PrismaService) {}
@Get()
findAll(@Req() req: any) {
return this.prisma.campaign.findMany({
where: { teamId: req.user.teamId },
orderBy: { startAt: 'desc' },
select: { id: true, name: true, startAt: true, endAt: true, status: true },
});
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CampaignsController } from './campaigns.controller';
import { PrismaModule } from '../../shared/prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [CampaignsController],
})
export class CampaignsModule {}
@@ -0,0 +1,32 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { CollaboratorsService } from './collaborators.service';
import { CreateCollaboratorDto } from './dto/create-collaborator.dto';
import { UpdateCollaboratorDto } from './dto/update-collaborator.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('collaborators')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('collaborators')
export class CollaboratorsController {
constructor(private readonly service: CollaboratorsService) {}
@Get()
findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); }
@Post() @Roles(TeamRole.EDITOR)
create(@Body() dto: CreateCollaboratorDto, @Req() req: any) { return this.service.create(dto, req.user.id, req.user.teamId); }
@Patch(':id') @Roles(TeamRole.EDITOR)
update(@Param('id') id: string, @Body() dto: UpdateCollaboratorDto, @Req() req: any) { return this.service.update(id, dto, req.user.id, req.user.teamId); }
@Delete(':id') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Delete collaborator (only if not in use)' })
remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); }
@Get(':id/videos') @ApiOperation({ summary: 'Videos linked to this collaborator' })
videos(@Param('id') id: string, @Req() req: any) { return this.service.getVideos(id, req.user.teamId); }
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { CollaboratorsService } from './collaborators.service';
import { CollaboratorsController } from './collaborators.controller';
import { AuditModule } from '../../shared/audit/audit.module';
import { QUEUES } from '../../queues/queues.constants';
@Module({
imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.RENDER })],
providers: [CollaboratorsService],
controllers: [CollaboratorsController],
exports: [CollaboratorsService],
})
export class CollaboratorsModule {}
@@ -0,0 +1,69 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AuditService } from '../../shared/audit/audit.service';
import { CreateCollaboratorDto } from './dto/create-collaborator.dto';
import { UpdateCollaboratorDto } from './dto/update-collaborator.dto';
import { QUEUES } from '../../queues/queues.constants';
@Injectable()
export class CollaboratorsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
@InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue,
) {}
findAll(teamId: string) {
return this.prisma.collaborator.findMany({ where: { teamId }, orderBy: { name: 'asc' } });
}
async create(dto: CreateCollaboratorDto, actorId: string, teamId: string) {
const c = await this.prisma.collaborator.create({ data: { ...dto, teamId } });
await this.audit.log(actorId, 'Collaborator', c.id, 'create', null, c);
return c;
}
async update(id: string, dto: UpdateCollaboratorDto, actorId: string, teamId: string) {
const before = await this.prisma.collaborator.findFirst({ where: { id, teamId } });
if (!before) throw new NotFoundException(`Collaborator ${id} not found`);
const updated = await this.prisma.collaborator.update({ where: { id }, data: dto });
await this.audit.log(actorId, 'Collaborator', id, 'update', before, updated);
await this.enqueueCollaboratorRenders(id, teamId);
return updated;
}
private async enqueueCollaboratorRenders(collaboratorId: string, teamId: string) {
const videos = await this.prisma.video.findMany({
where: {
collaboratorIds: { array_contains: collaboratorId } as any,
channel: { teamId },
youtubeDeletedAt: null,
},
select: { id: true },
});
await Promise.all(
videos.map((v) => this.renderQueue.add('render', { videoId: v.id }, { jobId: `render-${v.id}` })),
);
}
async getVideos(id: string, teamId: string) {
return this.prisma.video.findMany({
where: { collaboratorIds: { array_contains: id }, channel: { teamId } } as any,
select: { id: true, title: true },
});
}
async delete(id: string, teamId: string, actorId: string) {
const collab = await this.prisma.collaborator.findFirst({ where: { id, teamId } });
if (!collab) throw new NotFoundException(`Collaborator ${id} not found`);
const usageCount = await this.prisma.video.count({
where: { collaboratorIds: { array_contains: id } } as any,
});
if (usageCount > 0) throw new ConflictException(`Collaborator is used in ${usageCount} video(s) and cannot be deleted`);
await this.prisma.collaborator.delete({ where: { id } });
await this.audit.log(actorId, 'Collaborator', id, 'delete', collab, null);
return { deleted: true };
}
}
@@ -0,0 +1,15 @@
import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCollaboratorDto {
@ApiProperty() @IsString() name: string;
@ApiPropertyOptional() @IsOptional() @IsString() youtubeLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() twitchLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() instagramLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() tiktokLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() twitterLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() blueskyLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() discordHandle?: string;
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) aliases?: string[];
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
@@ -0,0 +1,16 @@
import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateCollaboratorDto {
@ApiPropertyOptional() @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() youtubeLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() twitchLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() instagramLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() tiktokLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() twitterLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() blueskyLink?: string;
@ApiPropertyOptional() @IsOptional() @IsString() discordHandle?: string;
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) aliases?: string[];
@ApiPropertyOptional() @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
@@ -0,0 +1,28 @@
import { Controller, Post, Body, Res, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { Response } from 'express';
import { ExportsService } from './exports.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('exports')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('exports')
export class ExportsController {
constructor(private readonly service: ExportsService) {}
@Post('csv')
@ApiOperation({ summary: 'Export videos as CSV' })
async exportCsv(@Body() body: { videoIds?: string[]; savedViewId?: string }, @Res() res: Response) {
const csv = await this.service.exportCsv(body.videoIds, body.savedViewId);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="studioflow-export-${Date.now()}.csv"`);
res.send(csv);
}
@Post('json')
@ApiOperation({ summary: 'Export full workspace as JSON' })
exportJson() {
return this.service.exportJson();
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ExportsService } from './exports.service';
import { ExportsController } from './exports.controller';
@Module({
providers: [ExportsService],
controllers: [ExportsController],
})
export class ExportsModule {}
@@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { stringify } from 'csv-stringify/sync';
import { PrismaService } from '../../shared/prisma/prisma.service';
@Injectable()
export class ExportsService {
constructor(private readonly prisma: PrismaService) {}
async exportCsv(videoIds?: string[], savedViewId?: string): Promise<string> {
let ids = videoIds ?? [];
if (!ids.length && savedViewId) {
const view = await this.prisma.savedView.findUniqueOrThrow({ where: { id: savedViewId } });
const videos = await this.prisma.video.findMany({
where: view.queryJson as any,
select: { id: true },
});
ids = videos.map((v) => v.id);
}
const where = ids.length > 0 ? { id: { in: ids } } : undefined;
const videos = await this.prisma.video.findMany({
where,
include: { template: { select: { name: true } } },
orderBy: { publishedAt: 'desc' },
});
const rows = videos.map((v) => ({
youtube_video_id: v.youtubeVideoId,
title: v.title,
tags: v.tags.join(','),
category_id: v.categoryId ?? '',
privacy_status: v.privacyStatus,
published_at: v.publishedAt?.toISOString() ?? '',
scheduled_at: v.scheduledAt?.toISOString() ?? '',
template: v.template?.name ?? '',
lint_status: v.lintStatus,
}));
return stringify(rows, { header: true });
}
async exportJson(): Promise<object> {
const [videos, videoConfigs, blocks, templates, collaborators, savedViews] = await Promise.all([
this.prisma.video.findMany(),
this.prisma.videoConfig.findMany(),
this.prisma.descriptionBlock.findMany(),
this.prisma.template.findMany(),
this.prisma.collaborator.findMany(),
this.prisma.savedView.findMany(),
]);
return {
version: '1.0',
exportedAt: new Date().toISOString(),
videos,
videoConfigs,
blocks,
templates,
collaborators,
savedViews,
};
}
}
@@ -0,0 +1,46 @@
import { Controller, Post, Body, UploadedFile, UseInterceptors, UseGuards, Req } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiConsumes } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { ImportsService } from './imports.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('imports')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('imports')
export class ImportsController {
constructor(private readonly service: ImportsService) {}
@Post('csv/preview')
@Roles(TeamRole.EDITOR)
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload CSV and get validation report' })
@UseInterceptors(FileInterceptor('file'))
previewCsv(@UploadedFile() file: Express.Multer.File, @Body() body: any, @Req() req: any) {
return this.service.previewCsv(file.buffer, body.mapping ? JSON.parse(body.mapping) : null, req.user.id, req.user.teamId);
}
@Post('csv/commit')
@Roles(TeamRole.EDITOR)
@ApiOperation({ summary: 'Commit a previewed CSV import' })
commitCsv(@Body() body: { importJobId: string }, @Req() req: any) {
return this.service.commitCsv(body.importJobId, req.user.teamId);
}
@Post('json/preview')
@Roles(TeamRole.EDITOR)
@ApiOperation({ summary: 'Validate JSON workspace payload' })
previewJson(@Body() body: any, @Req() req: any) {
return this.service.previewJson(body, req.user.id, req.user.teamId);
}
@Post('json/commit')
@Roles(TeamRole.EDITOR)
@ApiOperation({ summary: 'Commit JSON workspace import' })
commitJson(@Body() body: { importJobId: string; payload: any }, @Req() req: any) {
return this.service.commitJson(body.importJobId, body.payload, req.user.teamId);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ImportsService } from './imports.service';
import { ImportsController } from './imports.controller';
import { AuditModule } from '../../shared/audit/audit.module';
import { QUEUES } from '../../queues/queues.constants';
@Module({
imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.IMPORT })],
providers: [ImportsService],
controllers: [ImportsController],
exports: [ImportsService],
})
export class ImportsModule {}
@@ -0,0 +1,143 @@
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { parse } from 'csv-parse/sync';
import { z } from 'zod';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AuditService } from '../../shared/audit/audit.service';
import { QUEUES } from '../../queues/queues.constants';
const CsvRowSchema = z.object({
youtube_video_id: z.string().min(1),
title: z.string().optional(),
tags: z.string().optional(),
category_id: z.string().optional(),
privacy_status: z.enum(['PUBLIC', 'PRIVATE', 'UNLISTED']).optional(),
scheduled_at: z.string().optional(),
template_name: z.string().optional(),
collaborators: z.string().optional(),
});
@Injectable()
export class ImportsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
@InjectQueue(QUEUES.IMPORT) private readonly importQueue: Queue,
) {}
async previewCsv(fileBuffer: Buffer, mappingJson: any, createdBy: string, teamId: string) {
const rows: any[] = parse(fileBuffer, { columns: true, skip_empty_lines: true, trim: true });
const validRows: any[] = [];
const errors: { row: number; field: string; message: string }[] = [];
rows.forEach((row, i) => {
const result = CsvRowSchema.safeParse(row);
if (result.success) {
validRows.push(result.data);
} else {
result.error.errors.forEach((e) => {
errors.push({ row: i + 1, field: e.path.join('.'), message: e.message });
});
}
});
const importJob = await this.prisma.importJob.create({
data: {
teamId,
type: 'csv',
sourceName: 'upload',
mappingJson: mappingJson ?? null,
validationReport: { validCount: validRows.length, errorCount: errors.length, errors },
commitStatus: 'pending',
createdBy,
},
});
return { importJobId: importJob.id, validRows: validRows.length, errors };
}
async commitCsv(importJobId: string, teamId: string) {
const job = await this.prisma.importJob.findFirstOrThrow({ where: { id: importJobId, teamId } });
if (job.commitStatus !== 'pending') throw new BadRequestException('Already committed');
await this.importQueue.add('import', { importJobId });
return { queued: true, importJobId };
}
async executeCommit(importJobId: string) {
const job = await this.prisma.importJob.findUniqueOrThrow({ where: { id: importJobId } });
await this.prisma.importJob.update({
where: { id: importJobId },
data: { commitStatus: 'committed', committedAt: new Date() },
});
await this.audit.log(job.createdBy, 'ImportJob', importJobId, 'commit', null, { status: 'committed' });
}
async previewJson(payload: any, createdBy: string, teamId: string) {
const WorkspaceSchema = z.object({
version: z.string(),
videos: z.array(z.any()).optional(),
blocks: z.array(z.any()).optional(),
templates: z.array(z.any()).optional(),
collaborators: z.array(z.any()).optional(),
});
const result = WorkspaceSchema.safeParse(payload);
if (!result.success) throw new BadRequestException(result.error.message);
const importJob = await this.prisma.importJob.create({
data: {
teamId,
type: 'json',
sourceName: 'upload',
validationReport: { valid: true },
commitStatus: 'pending',
createdBy,
},
});
return { importJobId: importJob.id, valid: true };
}
async commitJson(importJobId: string, payload: any, teamId: string) {
const job = await this.prisma.importJob.findFirstOrThrow({ where: { id: importJobId, teamId } });
if (job.commitStatus !== 'pending') throw new BadRequestException('Already committed');
if (payload.collaborators) {
for (const c of payload.collaborators) {
await this.prisma.collaborator.upsert({
where: { id: c.id },
create: { ...c, teamId },
update: c,
});
}
}
if (payload.blocks) {
for (const b of payload.blocks) {
await this.prisma.descriptionBlock.upsert({
where: { id: b.id },
create: { ...b, teamId },
update: b,
});
}
}
if (payload.templates) {
for (const t of payload.templates) {
await this.prisma.template.upsert({
where: { id: t.id },
create: { ...t, teamId },
update: t,
});
}
}
await this.prisma.importJob.update({
where: { id: importJobId },
data: { commitStatus: 'committed', committedAt: new Date() },
});
return { committed: true };
}
}
@@ -0,0 +1,194 @@
import { Controller, Post, Get, Patch, Param, Body, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { IsArray, IsString, IsOptional, IsEnum } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { LintSeverity } from '@prisma/client';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { LintingService } from './linting.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { QUEUES } from '../../queues/queues.constants';
class BulkLintDto {
@IsArray() @IsString({ each: true }) videoIds: string[];
}
class BulkResolveDto {
@IsArray() @IsString({ each: true }) ids: string[];
}
class QueryLintResultsDto {
@ApiPropertyOptional({ enum: LintSeverity }) @IsOptional() @IsEnum(LintSeverity) severity?: LintSeverity;
@ApiPropertyOptional() @IsOptional() @IsString() ruleCode?: string;
@ApiPropertyOptional() @IsOptional() @IsString() videoId?: string;
}
@ApiTags('lint')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('lint')
export class LintingController {
constructor(
private readonly service: LintingService,
private readonly prisma: PrismaService,
@InjectQueue(QUEUES.LINT) private readonly lintQueue: Queue,
) {}
@Post('videos/:id')
@ApiOperation({ summary: 'Lint a single video' })
lintOne(@Param('id') id: string) {
return this.service.lintVideo(id);
}
@Post('bulk')
@ApiOperation({ summary: 'Enqueue lint jobs for multiple videos' })
async lintBulk(@Body() dto: BulkLintDto) {
const jobs = dto.videoIds.map((videoId) =>
this.lintQueue.add('lint', { videoId }, { jobId: `lint-${videoId}` }),
);
await Promise.all(jobs);
return { queued: dto.videoIds.length };
}
@Get('results')
@ApiOperation({ summary: 'Get open lint results scoped to the current user\'s team' })
async getResults(@Query() query: QueryLintResultsDto, @Req() req: any) {
const teamId = await this.resolveTeamId(req.user.id);
return this.prisma.lintResult.findMany({
where: {
resolvedAt: null,
video: { channel: { teamId } },
...(query.severity ? { severity: query.severity } : {}),
...(query.ruleCode ? { ruleCode: query.ruleCode } : {}),
...(query.videoId ? { videoId: query.videoId } : {}),
},
include: { video: { select: { id: true, title: true } } },
orderBy: { createdAt: 'desc' },
});
}
@Patch('results/:id/resolve')
@ApiOperation({ summary: 'Mark a lint result as resolved' })
async resolveOne(@Param('id') id: string, @Req() req: any) {
const result = await this.prisma.lintResult.update({
where: { id },
data: { resolvedAt: new Date() },
include: { video: { select: { id: true } } },
});
// Recompute video lintStatus
await this.recomputeVideoStatus(result.video.id);
return result;
}
@Post('results/bulk-resolve')
@ApiOperation({ summary: 'Mark multiple lint results as resolved' })
async bulkResolve(@Body() dto: BulkResolveDto) {
await this.prisma.lintResult.updateMany({
where: { id: { in: dto.ids } },
data: { resolvedAt: new Date() },
});
// Recompute lintStatus for all affected videos
const affected = await this.prisma.lintResult.findMany({
where: { id: { in: dto.ids } },
select: { videoId: true },
distinct: ['videoId'],
});
await Promise.all(affected.map((r) => this.recomputeVideoStatus(r.videoId)));
return { resolved: dto.ids.length };
}
@Post('channel/:channelId')
@ApiOperation({ summary: 'Enqueue lint jobs for all videos in a channel' })
async lintChannel(@Param('channelId') channelId: string, @Req() req: any) {
const teamId = await this.resolveTeamId(req.user.id);
const videoIds = await this.service.getChannelVideoIds(channelId, teamId);
await this.enqueueRerun(videoIds);
return { queued: videoIds.length };
}
@Post('team')
@ApiOperation({ summary: 'Enqueue lint jobs for all videos in the current team' })
async lintTeam(@Req() req: any) {
const teamId = await this.resolveTeamId(req.user.id);
const videoIds = await this.service.getTeamVideoIds(teamId);
await this.enqueueRerun(videoIds);
return { queued: videoIds.length };
}
@Post('team/recompute-status')
@ApiOperation({ summary: 'Recompute lintStatus for all team videos from actual open results (heals stale status)' })
async recomputeTeamStatus(@Req() req: any) {
const teamId = await this.resolveTeamId(req.user.id);
// Aggregate open result severities per video in one query
const openResults = await this.prisma.lintResult.groupBy({
by: ['videoId'],
where: { resolvedAt: null, video: { channel: { teamId } } },
_max: { severity: true },
});
// Build a map: videoId → worst severity
const severityMap = new Map(openResults.map((r) => [r.videoId, r._max.severity]));
// All video IDs for the team
const allVideos = await this.prisma.video.findMany({
where: { channel: { teamId } },
select: { id: true, lintStatus: true },
});
const updates = allVideos
.map((v) => {
const worst = severityMap.get(v.id) ?? null;
const status = worst === 'ERROR' ? 'ERROR' : worst === 'WARNING' ? 'WARNING' : 'OK';
return status !== v.lintStatus ? { id: v.id, status } : null;
})
.filter(Boolean) as { id: string; status: string }[];
await Promise.all(
updates.map((u) =>
this.prisma.video.update({ where: { id: u.id }, data: { lintStatus: u.status as any } }),
),
);
return { checked: allVideos.length, updated: updates.length };
}
// ── Helpers ────────────────────────────────────────────────────────────────
/** Enqueues re-run jobs with unique IDs so BullMQ never deduplicates them. */
private enqueueRerun(videoIds: string[]) {
const ts = Date.now();
return Promise.all(
videoIds.map((videoId) =>
this.lintQueue.add('lint', { videoId }, { jobId: `lint-${videoId}-${ts}` }),
),
);
}
private async resolveTeamId(userId: string): Promise<string> {
const membership = await this.prisma.teamMember.findFirst({
where: { userId },
orderBy: { createdAt: 'asc' },
});
return membership?.teamId ?? '';
}
private async recomputeVideoStatus(videoId: string) {
const open = await this.prisma.lintResult.findMany({
where: { videoId, resolvedAt: null },
select: { severity: true },
});
const severities = open.map((r) => r.severity);
const status = severities.includes(LintSeverity.ERROR)
? 'ERROR'
: severities.includes(LintSeverity.WARNING)
? 'WARNING'
: 'OK';
await this.prisma.video.update({ where: { id: videoId }, data: { lintStatus: status as any } });
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { LintingService } from './linting.service';
import { LintingController } from './linting.controller';
import { QUEUES } from '../../queues/queues.constants';
@Module({
imports: [BullModule.registerQueue({ name: QUEUES.LINT })],
providers: [LintingService],
controllers: [LintingController],
exports: [LintingService],
})
export class LintingModule {}
@@ -0,0 +1,112 @@
import { Injectable, ForbiddenException } from '@nestjs/common';
import { LintSeverity, LintStatus } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { LintRule } from './rules/base.rule';
import { TitleWeakRule } from './rules/title-weak.rule';
import { TitleTooLongRule } from './rules/title-too-long.rule';
import { DescMissingCtaRule } from './rules/desc-missing-cta.rule';
import { DescMissingChaptersRule } from './rules/desc-missing-chapters.rule';
import { DescEmptyPlaceholderRule } from './rules/desc-empty-placeholder.rule';
import { DescDuplicateHashtagRule } from './rules/desc-duplicate-hashtag.rule';
import { DescRequiredLinkMissingRule } from './rules/desc-required-link-missing.rule';
import { DescOutdatedSponsorRule } from './rules/desc-outdated-sponsor.rule';
import { RemoteConflictRule } from './rules/remote-conflict.rule';
@Injectable()
export class LintingService {
private readonly rules: LintRule[] = [
new TitleWeakRule(),
new TitleTooLongRule(),
new DescMissingCtaRule(),
new DescMissingChaptersRule(),
new DescEmptyPlaceholderRule(),
new DescDuplicateHashtagRule(),
new DescRequiredLinkMissingRule(),
new DescOutdatedSponsorRule(),
new RemoteConflictRule(),
];
constructor(private readonly prisma: PrismaService) {}
async lintVideo(videoId: string) {
const video = await this.prisma.video.findUniqueOrThrow({
where: { id: videoId },
include: {
config: true,
template: true,
lintResults: { where: { resolvedAt: null } },
channel: { include: { team: { select: { disabledLintRules: true } } } },
},
});
const disabledRules: string[] = (video as any).channel?.team?.disabledLintRules ?? [];
// Attach campaign blocks for the outdated sponsor check
const blockIds: string[] = (video.config?.blockOrder as string[]) ?? [];
const campaignBlocks = await this.prisma.descriptionBlock.findMany({
where: { id: { in: blockIds }, campaignId: { not: null } },
include: { campaign: true },
});
(video as any)._campaignBlocks = campaignBlocks;
const activeRules = disabledRules.length > 0
? this.rules.filter((r) => !disabledRules.includes(r.code))
: this.rules;
const issues = activeRules
.map((rule) => {
const issue = rule.check(video);
if (!issue) return null;
return {
videoId,
ruleCode: rule.code,
severity: rule.severity,
targetField: issue.targetField,
message: issue.message,
fixSuggestion: issue.fixSuggestion,
};
})
.filter(Boolean) as any[];
// Replace all unresolved lint results for this video
await this.prisma.$transaction([
this.prisma.lintResult.deleteMany({ where: { videoId, resolvedAt: null } }),
...(issues.length > 0
? [this.prisma.lintResult.createMany({ data: issues })]
: []),
this.prisma.video.update({
where: { id: videoId },
data: { lintStatus: this.computeStatus(issues.map((i) => i.severity)) },
}),
]);
return issues;
}
/** Returns all video IDs for a channel so the controller can enqueue them. */
async getChannelVideoIds(channelId: string, teamId: string): Promise<string[]> {
const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } });
if (!channel) throw new ForbiddenException('Channel not found in this team');
const videos = await this.prisma.video.findMany({
where: { channelId },
select: { id: true },
});
return videos.map((v) => v.id);
}
/** Returns all video IDs across all channels in a team. */
async getTeamVideoIds(teamId: string): Promise<string[]> {
const videos = await this.prisma.video.findMany({
where: { channel: { teamId } },
select: { id: true },
});
return videos.map((v) => v.id);
}
private computeStatus(severities: LintSeverity[]): LintStatus {
if (severities.includes(LintSeverity.ERROR)) return LintStatus.ERROR;
if (severities.includes(LintSeverity.WARNING)) return LintStatus.WARNING;
return LintStatus.OK;
}
}
@@ -0,0 +1,13 @@
import { LintSeverity } from '@prisma/client';
export interface LintIssue {
message: string;
targetField?: string;
fixSuggestion?: string;
}
export interface LintRule {
code: string;
severity: LintSeverity;
check(video: any): LintIssue | null;
}
@@ -0,0 +1,26 @@
import { LintSeverity } from '@prisma/client';
import { LintRule, LintIssue } from './base.rule';
export class DescDuplicateHashtagRule implements LintRule {
code = 'DESC_DUPLICATE_HASHTAG';
severity = LintSeverity.WARNING;
check(video: any): LintIssue | null {
const desc: string = video.renderedDescription ?? '';
const hashtags = desc.match(/#\w+/g)?.map((h) => h.toLowerCase()) ?? [];
const seen = new Set<string>();
const duplicates: string[] = [];
for (const tag of hashtags) {
if (seen.has(tag)) duplicates.push(tag);
else seen.add(tag);
}
if (duplicates.length > 0) {
return {
message: `Duplicate hashtags found: ${[...new Set(duplicates)].join(', ')}`,
targetField: 'description',
fixSuggestion: 'Remove duplicate hashtags from the description.',
};
}
return null;
}
}
@@ -0,0 +1,22 @@
import { LintSeverity } from '@prisma/client';
import { LintRule, LintIssue } from './base.rule';
const PLACEHOLDER_REGEX = /\{[a-z_][a-z0-9_]*\}/g;
export class DescEmptyPlaceholderRule implements LintRule {
code = 'DESC_EMPTY_PLACEHOLDER';
severity = LintSeverity.ERROR;
check(video: any): LintIssue | null {
const desc: string = video.renderedDescription ?? '';
const unresolved = desc.match(PLACEHOLDER_REGEX);
if (unresolved?.length) {
return {
message: `Unresolved placeholders in description: ${unresolved.join(', ')}`,
targetField: 'description',
fixSuggestion: 'Provide values for all variable placeholders before publishing.',
};
}
return null;
}
}
@@ -0,0 +1,23 @@
import { LintSeverity } from '@prisma/client';
import { LintRule, LintIssue } from './base.rule';
// Timestamps follow pattern 0:00 or 00:00 or 0:00:00
const TIMESTAMP_REGEX = /\d{1,2}:\d{2}(:\d{2})?/g;
export class DescMissingChaptersRule implements LintRule {
code = 'DESC_MISSING_CHAPTERS';
severity = LintSeverity.WARNING;
check(video: any): LintIssue | null {
const desc: string = video.renderedDescription ?? '';
const matches = desc.match(TIMESTAMP_REGEX);
if (!matches || matches.length < 2) {
return {
message: 'No chapter timestamps found in description',
targetField: 'description',
fixSuggestion: 'Add a chapters block with at least two timestamps (e.g., 0:00 Intro).',
};
}
return null;
}
}
@@ -0,0 +1,22 @@
import { LintSeverity } from '@prisma/client';
import { LintRule, LintIssue } from './base.rule';
const CTA_KEYWORDS = ['subscribe', 'abonnieren', 'follow', 'like', 'comment', 'cta'];
export class DescMissingCtaRule implements LintRule {
code = 'DESC_MISSING_CTA';
severity = LintSeverity.WARNING;
check(video: any): LintIssue | null {
const desc: string = (video.renderedDescription ?? '').toLowerCase();
const hasCta = CTA_KEYWORDS.some((k) => desc.includes(k));
if (!hasCta) {
return {
message: 'No call-to-action found in description',
targetField: 'description',
fixSuggestion: 'Add a CTA block (subscribe, follow, etc.)',
};
}
return null;
}
}
@@ -0,0 +1,30 @@
import { LintSeverity } from '@prisma/client';
import { LintRule, LintIssue } from './base.rule';
export class DescOutdatedSponsorRule implements LintRule {
code = 'DESC_OUTDATED_SPONSOR_COPY';
severity = LintSeverity.ERROR;
check(video: any): LintIssue | null {
const config = video.config;
if (!config) return null;
const blockOrder: string[] = (config.blockOrder as string[]) ?? [];
// blocks that come with a campaign reference
const campaignBlocks = (video._campaignBlocks ?? []) as Array<{ campaignId?: string; campaign?: { endAt?: Date; status: string } }>;
for (const block of campaignBlocks) {
if (!blockOrder.includes((block as any).id)) continue;
if (!block.campaign) continue;
const { endAt, status } = block.campaign;
if (status !== 'active' || (endAt && new Date(endAt) < new Date())) {
return {
message: 'Sponsor/campaign block references an expired or inactive campaign',
targetField: 'description',
fixSuggestion: 'Update or remove the outdated sponsor block.',
};
}
}
return null;
}
}
@@ -0,0 +1,28 @@
import { LintSeverity } from '@prisma/client';
import { LintRule, LintIssue } from './base.rule';
export class DescRequiredLinkMissingRule implements LintRule {
code = 'DESC_REQUIRED_LINK_MISSING';
severity = LintSeverity.ERROR;
check(video: any): LintIssue | null {
const template = video.template;
if (!template) return null;
const rules = (template.rules as any) ?? {};
const requiredLinks: string[] = rules.requiredLinks ?? [];
if (requiredLinks.length === 0) return null;
const desc: string = video.renderedDescription ?? '';
const missing = requiredLinks.filter((link: string) => !desc.includes(link));
if (missing.length > 0) {
return {
message: `Required links missing from description: ${missing.join(', ')}`,
targetField: 'description',
fixSuggestion: 'Add the required links defined in the template rules.',
};
}
return null;
}
}
@@ -0,0 +1,18 @@
import { LintSeverity } from '@prisma/client';
import { LintRule, LintIssue } from './base.rule';
export class RemoteConflictRule implements LintRule {
code = 'REMOTE_CONFLICT';
severity = LintSeverity.ERROR;
check(video: any): LintIssue | null {
if (video.remoteConflict) {
return {
message: 'Remote YouTube metadata has changed since last sync — local and remote are out of sync',
targetField: 'sync',
fixSuggestion: 'Review the remote changes and re-sync to resolve the conflict.',
};
}
return null;
}
}
@@ -0,0 +1,19 @@
import { LintSeverity } from '@prisma/client';
import { LintRule, LintIssue } from './base.rule';
export class TitleTooLongRule implements LintRule {
code = 'TITLE_TOO_LONG';
severity = LintSeverity.WARNING;
check(video: any): LintIssue | null {
const title: string = video.title ?? '';
if (title.length > 100) {
return {
message: `Title exceeds 100 characters (${title.length})`,
targetField: 'title',
fixSuggestion: 'Shorten the title to under 100 characters.',
};
}
return null;
}
}
@@ -0,0 +1,21 @@
import { LintSeverity } from '@prisma/client';
import { LintRule, LintIssue } from './base.rule';
const GENERIC_WORDS = ['video', 'test', 'untitled', 'new video', 'upload'];
export class TitleWeakRule implements LintRule {
code = 'TITLE_WEAK';
severity = LintSeverity.WARNING;
check(video: any): LintIssue | null {
const title: string = video.title ?? '';
if (title.length < 20 || GENERIC_WORDS.some((w) => title.toLowerCase().includes(w))) {
return {
message: `Title is too short or uses generic words: "${title}"`,
targetField: 'title',
fixSuggestion: 'Use a descriptive, specific title with at least 20 characters.',
};
}
return null;
}
}
@@ -0,0 +1,48 @@
import { Controller, Get, Post, Delete, Param, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { PlaylistsService } from './playlists.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { TeamRole } from '@prisma/client';
@ApiTags('playlists')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('playlists')
export class PlaylistsController {
constructor(private readonly service: PlaylistsService) {}
@Get('channel/:channelId')
@ApiOperation({ summary: 'List playlists for a channel (from DB)' })
listForChannel(@Param('channelId') channelId: string, @Req() req: any) {
return this.service.listForChannel(channelId, req.user.teamId);
}
@Post('channel/:channelId/sync')
@ApiOperation({ summary: 'Sync playlists from YouTube for a channel' })
@Roles(TeamRole.EDITOR)
sync(@Param('channelId') channelId: string, @Req() req: any) {
return this.service.syncChannelPlaylists(channelId, req.user.teamId);
}
@Get('video/:videoId')
@ApiOperation({ summary: 'List playlists a video belongs to' })
listForVideo(@Param('videoId') videoId: string, @Req() req: any) {
return this.service.listForVideo(videoId, req.user.teamId);
}
@Post('video/:videoId/add/:playlistId')
@ApiOperation({ summary: 'Add video to playlist' })
@Roles(TeamRole.EDITOR)
add(@Param('videoId') videoId: string, @Param('playlistId') playlistId: string, @Req() req: any) {
return this.service.addVideoToPlaylist(videoId, playlistId, req.user.teamId);
}
@Delete('video/:videoId/remove/:playlistId')
@ApiOperation({ summary: 'Remove video from playlist' })
@Roles(TeamRole.EDITOR)
remove(@Param('videoId') videoId: string, @Param('playlistId') playlistId: string, @Req() req: any) {
return this.service.removeVideoFromPlaylist(videoId, playlistId, req.user.teamId);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PlaylistsService } from './playlists.service';
import { PlaylistsController } from './playlists.controller';
import { YouTubeSyncModule } from '../youtube-sync/youtube-sync.module';
@Module({
imports: [YouTubeSyncModule],
controllers: [PlaylistsController],
providers: [PlaylistsService],
})
export class PlaylistsModule {}
@@ -0,0 +1,120 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { YouTubeApiClient } from '../youtube-sync/youtube-api.client';
@Injectable()
export class PlaylistsService {
constructor(
private readonly prisma: PrismaService,
private readonly ytApi: YouTubeApiClient,
) {}
async syncChannelPlaylists(channelId: string, teamId: string) {
const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } });
if (!channel) throw new NotFoundException('Channel not found');
const ctx = { actionId: randomUUID(), actionType: 'playlist_sync' };
const existingPlaylists = await this.prisma.playlist.findMany({
where: { channelId },
select: { id: true, youtubePlaylistId: true, itemCount: true },
});
const existingMap = new Map(existingPlaylists.map((pl) => [pl.youtubePlaylistId, pl]));
const ytPlaylists = await this.ytApi.listChannelPlaylists(channelId, ctx);
// Upsert playlist metadata
for (const pl of ytPlaylists) {
await this.prisma.playlist.upsert({
where: { youtubePlaylistId: pl.youtubePlaylistId },
create: { channelId, ...pl },
update: { title: pl.title, description: pl.description, itemCount: pl.itemCount, privacyStatus: pl.privacyStatus },
});
}
const dbPlaylists = await this.prisma.playlist.findMany({
where: { channelId },
select: { id: true, youtubePlaylistId: true },
});
const playlistIdMap = new Map(dbPlaylists.map((pl) => [pl.youtubePlaylistId, pl.id]));
for (const pl of ytPlaylists) {
const dbPlaylistId = playlistIdMap.get(pl.youtubePlaylistId);
if (!dbPlaylistId) continue;
const existing = existingMap.get(pl.youtubePlaylistId);
if (existing && existing.itemCount === pl.itemCount) continue;
const { ids: ytVideoIds } = await this.ytApi.listPlaylistVideoIds(channelId, pl.youtubePlaylistId, ctx);
const matchingVideos = await this.prisma.video.findMany({
where: { youtubeVideoId: { in: ytVideoIds }, channelId },
select: { id: true },
});
await this.prisma.videoPlaylist.deleteMany({ where: { playlistId: dbPlaylistId } });
if (matchingVideos.length > 0) {
await this.prisma.videoPlaylist.createMany({
data: matchingVideos.map((v) => ({ videoId: v.id, playlistId: dbPlaylistId })),
skipDuplicates: true,
});
}
}
return this.prisma.playlist.findMany({ where: { channelId } });
}
async listForChannel(channelId: string, teamId: string) {
const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } });
if (!channel) throw new NotFoundException('Channel not found');
return this.prisma.playlist.findMany({ where: { channelId }, orderBy: { title: 'asc' } });
}
async listForVideo(videoId: string, teamId: string) {
const video = await this.prisma.video.findFirst({
where: { id: videoId, channel: { teamId } },
include: { playlists: { include: { playlist: true } } },
});
if (!video) throw new NotFoundException('Video not found');
return video.playlists.map((vp) => vp.playlist);
}
async addVideoToPlaylist(videoId: string, playlistId: string, teamId: string) {
const video = await this.prisma.video.findFirst({
where: { id: videoId, channel: { teamId } },
include: { channel: true },
});
if (!video) throw new NotFoundException('Video not found');
const playlist = await this.prisma.playlist.findFirst({ where: { id: playlistId, channel: { teamId } } });
if (!playlist) throw new NotFoundException('Playlist not found');
await this.ytApi.addVideoToPlaylist(video.youtubeVideoId, playlist.youtubePlaylistId, video.channelId, { actionId: randomUUID(), actionType: 'playlist_add' });
await this.prisma.videoPlaylist.upsert({
where: { videoId_playlistId: { videoId, playlistId } },
create: { videoId, playlistId },
update: {},
});
return { videoId, playlistId };
}
async removeVideoFromPlaylist(videoId: string, playlistId: string, teamId: string) {
const video = await this.prisma.video.findFirst({
where: { id: videoId, channel: { teamId } },
include: { channel: true },
});
if (!video) throw new NotFoundException('Video not found');
const playlist = await this.prisma.playlist.findFirst({ where: { id: playlistId, channel: { teamId } } });
if (!playlist) throw new NotFoundException('Playlist not found');
await this.ytApi.removeVideoFromPlaylist(video.youtubeVideoId, playlist.youtubePlaylistId, video.channelId, { actionId: randomUUID(), actionType: 'playlist_remove' });
await this.prisma.videoPlaylist.deleteMany({ where: { videoId, playlistId } });
return { videoId, playlistId };
}
}
@@ -0,0 +1,108 @@
import { Controller, Get, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { QuotaService } from '../../shared/quota/quota.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('quota')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('quota')
export class QuotaController {
constructor(
private readonly quota: QuotaService,
private readonly prisma: PrismaService,
) {}
@Get('today')
@ApiOperation({ summary: 'YouTube API quota status for today (scoped to active team)' })
async today(@Req() req: any) {
const teamId: string = req.user.teamId;
const used = await this.quota.getTodayUsageForTeam(teamId);
const limit = this.quota.getLimit();
const remaining = Math.max(0, limit - used);
const resetMs = this.quota.msUntilQuotaReset();
const resetAt = new Date(Date.now() + resetMs).toISOString();
return { used, remaining, limit, resetAt, percentUsed: Math.round((used / limit) * 100) };
}
@Get('history')
@ApiOperation({ summary: 'Quota log entries scoped to the active team, newest first' })
@ApiQuery({ name: 'days', required: false, description: 'How many days back to fetch (default 7)' })
async history(@Req() req: any, @Query('days') days?: string) {
const teamId: string = req.user.teamId;
const daysBack = Math.min(parseInt(days ?? '7', 10) || 7, 90);
const channels = await this.prisma.channel.findMany({
where: { teamId },
select: { id: true, name: true },
});
const channelIds = channels.map((c) => c.id);
const channelNameMap = Object.fromEntries(channels.map((c) => [c.id, c.name]));
if (channelIds.length === 0) return { items: [], totalUnits: 0 };
const since = new Date();
since.setDate(since.getDate() - daysBack);
const logs = await this.prisma.quotaLog.findMany({
where: {
channelId: { in: channelIds },
createdAt: { gte: since },
},
include: {
video: { select: { id: true, title: true, youtubeVideoId: true } },
},
orderBy: { createdAt: 'desc' },
take: 500,
});
// Collect YouTube playlist IDs stored in entityId so we can resolve their names
const playlistEntityIds = logs
.map((l) => l.entityId)
.filter((id): id is string => !!id && !id.startsWith('batch:'));
const playlists = playlistEntityIds.length
? await this.prisma.playlist.findMany({
where: { youtubePlaylistId: { in: playlistEntityIds } },
select: { youtubePlaylistId: true, title: true },
})
: [];
const playlistNameMap = Object.fromEntries(playlists.map((p) => [p.youtubePlaylistId, p.title]));
const totalUnits = logs.reduce((sum, l) => sum + l.units, 0);
const items = logs.map((l) => {
let entityLabel: string | null = null;
if (l.entityId) {
if (l.entityId.startsWith('batch:')) {
entityLabel = `Import batch of ${l.entityId.slice(6)} videos`;
} else if (l.entityId.startsWith('UU')) {
// YouTube auto-generated uploads playlist — resolve channel name for context
const chName = l.channelId ? (channelNameMap[l.channelId] ?? null) : null;
entityLabel = chName ? `Uploads playlist (${chName})` : 'Uploads playlist (channel import)';
} else {
entityLabel = playlistNameMap[l.entityId] ?? l.entityId;
}
}
return {
id: l.id,
operation: l.operation,
units: l.units,
channelId: l.channelId,
channelName: l.channelId ? (channelNameMap[l.channelId] ?? null) : null,
videoId: l.videoId,
videoTitle: l.video?.title ?? null,
youtubeVideoId: l.video?.youtubeVideoId ?? null,
entityId: l.entityId,
entityLabel,
actionId: (l as any).actionId ?? null,
actionType: (l as any).actionType ?? null,
createdAt: l.createdAt.toISOString(),
};
});
return { items, totalUnits };
}
}
@@ -0,0 +1,37 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { SavedViewsService } from './saved-views.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('saved-views')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('saved-views')
export class SavedViewsController {
constructor(private readonly service: SavedViewsService) {}
@Get()
findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); }
@Get('tabs')
findTabs(@Req() req: any) { return this.service.findTabs(req.user.teamId); }
@Post() @Roles(TeamRole.EDITOR)
create(@Body() body: any, @Req() req: any) {
return this.service.create({ ...body, ownerId: req.user.id, teamId: req.user.teamId }, req.user.id);
}
@Patch(':id') @Roles(TeamRole.EDITOR)
update(@Param('id') id: string, @Body() body: any, @Req() req: any) {
return this.service.update(id, body, req.user.teamId, req.user.id);
}
@Delete(':id') @Roles(TeamRole.ADMIN)
remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); }
@Post(':id/execute') @ApiOperation({ summary: 'Execute saved view and return matching video IDs' })
execute(@Param('id') id: string, @Req() req: any) { return this.service.execute(id, req.user.teamId); }
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SavedViewsService } from './saved-views.service';
import { SavedViewsController } from './saved-views.controller';
import { AuditModule } from '../../shared/audit/audit.module';
@Module({
imports: [AuditModule],
providers: [SavedViewsService],
controllers: [SavedViewsController],
exports: [SavedViewsService],
})
export class SavedViewsModule {}
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AuditService } from '../../shared/audit/audit.service';
@Injectable()
export class SavedViewsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
findAll(teamId: string) {
return this.prisma.savedView.findMany({ where: { teamId }, orderBy: { name: 'asc' } });
}
findTabs(teamId: string) {
return this.prisma.savedView.findMany({
where: { teamId, pinnedAsTab: true },
orderBy: [{ tabOrder: 'asc' }, { name: 'asc' }],
});
}
async create(data: any, actorId: string) {
const view = await this.prisma.savedView.create({ data });
await this.audit.log(actorId, 'SavedView', view.id, 'create', null, view);
return view;
}
async update(id: string, data: any, teamId: string, actorId: string) {
const before = await this.prisma.savedView.findFirst({ where: { id, teamId } });
await this.prisma.savedView.updateMany({ where: { id, teamId }, data });
const after = before ? { ...before, ...data } : null;
await this.audit.log(actorId, 'SavedView', id, 'update', before, after);
}
async delete(id: string, teamId: string, actorId: string) {
const before = await this.prisma.savedView.findFirst({ where: { id, teamId } });
await this.prisma.savedView.deleteMany({ where: { id, teamId } });
await this.audit.log(actorId, 'SavedView', id, 'delete', before, null);
}
async execute(id: string, teamId: string) {
const view = await this.prisma.savedView.findFirstOrThrow({ where: { id, teamId } });
let query = view.queryJson as any;
if (JSON.stringify(query).includes('__MONTH_START__')) {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
query = JSON.parse(JSON.stringify(query).replace('"__MONTH_START__"', `"${monthStart.toISOString()}"`));
}
const videos = await this.prisma.video.findMany({
where: { ...query, channel: { teamId } },
select: { id: true },
});
return { viewId: id, videoIds: videos.map((v) => v.id), count: videos.length };
}
}
@@ -0,0 +1,7 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpsertTeamVariableDto {
@ApiProperty() @IsString() name: string;
@ApiProperty() @IsString() value: string;
}
@@ -0,0 +1,33 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { TeamVariablesService } from './team-variables.service';
import { UpsertTeamVariableDto } from './dto/upsert-team-variable.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('team-variables')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('team-variables')
export class TeamVariablesController {
constructor(private readonly service: TeamVariablesService) {}
@Get() @ApiOperation({ summary: 'List all team variables' })
findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); }
@Post() @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Create a team variable' })
create(@Body() dto: UpsertTeamVariableDto, @Req() req: any) { return this.service.create(dto, req.user.teamId, req.user.id); }
@Patch(':id') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Update a team variable' })
update(@Param('id') id: string, @Body() dto: UpsertTeamVariableDto, @Req() req: any) {
return this.service.update(id, dto, req.user.teamId, req.user.id);
}
@Get(':id/usage') @ApiOperation({ summary: 'Blocks that reference this variable' })
usage(@Param('id') id: string, @Req() req: any) { return this.service.getUsage(id, req.user.teamId); }
@Delete(':id') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Delete a team variable' })
remove(@Param('id') id: string, @Req() req: any) { return this.service.remove(id, req.user.teamId, req.user.id); }
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { TeamVariablesService } from './team-variables.service';
import { TeamVariablesController } from './team-variables.controller';
import { AuditModule } from '../../shared/audit/audit.module';
import { QUEUES } from '../../queues/queues.constants';
@Module({
imports: [AuditModule, BullModule.registerQueue({ name: QUEUES.RENDER })],
providers: [TeamVariablesService],
controllers: [TeamVariablesController],
exports: [TeamVariablesService],
})
export class TeamVariablesModule {}
@@ -0,0 +1,76 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AuditService } from '../../shared/audit/audit.service';
import { UpsertTeamVariableDto } from './dto/upsert-team-variable.dto';
import { QUEUES } from '../../queues/queues.constants';
@Injectable()
export class TeamVariablesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
@InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue,
) {}
findAll(teamId: string) {
return this.prisma.teamVariable.findMany({ where: { teamId }, orderBy: { name: 'asc' } });
}
async create(dto: UpsertTeamVariableDto, teamId: string, actorId: string) {
const v = await this.prisma.teamVariable.create({ data: { ...dto, teamId } });
await this.audit.log(actorId, 'TeamVariable', v.id, 'create', null, v);
await this.enqueueTeamRenders(teamId);
return v;
}
async update(id: string, dto: Partial<UpsertTeamVariableDto>, teamId: string, actorId: string) {
const existing = await this.prisma.teamVariable.findFirst({ where: { id, teamId } });
if (!existing) throw new NotFoundException(`Variable ${id} not found`);
const updated = await this.prisma.teamVariable.update({ where: { id }, data: dto });
await this.audit.log(actorId, 'TeamVariable', id, 'update', existing, updated);
await this.enqueueTeamRenders(teamId);
return updated;
}
async remove(id: string, teamId: string, actorId: string) {
const existing = await this.prisma.teamVariable.findFirst({ where: { id, teamId } });
if (!existing) throw new NotFoundException(`Variable ${id} not found`);
await this.prisma.teamVariable.delete({ where: { id } });
await this.audit.log(actorId, 'TeamVariable', id, 'delete', existing, null);
await this.enqueueTeamRenders(teamId);
return { deleted: true };
}
private async enqueueTeamRenders(teamId: string) {
const videos = await this.prisma.video.findMany({
where: { channel: { teamId }, youtubeDeletedAt: null },
select: { id: true },
});
await Promise.all(
videos.map((v) => this.renderQueue.add('render', { videoId: v.id }, { jobId: `render-${v.id}` })),
);
}
async getUsage(id: string, teamId: string) {
const variable = await this.prisma.teamVariable.findFirst({ where: { id, teamId } });
if (!variable) throw new NotFoundException(`Variable ${id} not found`);
const blocks = await this.prisma.descriptionBlock.findMany({
where: { teamId, content: { contains: `{${variable.name}}` } },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
return { blocks };
}
async getMap(teamId: string): Promise<Record<string, string>> {
const vars = await this.findAll(teamId);
return Object.fromEntries(vars.map((v) => [v.name, v.value]));
}
async getDateFormat(teamId: string): Promise<string | null> {
const team = await this.prisma.team.findUnique({ where: { id: teamId }, select: { dateFormat: true } });
return team?.dateFormat ?? null;
}
}
@@ -0,0 +1,89 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { TeamsService } from './teams.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('teams')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('teams')
export class TeamsController {
constructor(private readonly service: TeamsService) {}
@Get('mine')
@ApiOperation({ summary: 'List all teams the current user belongs to' })
listMine(@Req() req: any) {
return this.service.listMyTeams(req.user.id);
}
@Get(':teamId')
@ApiOperation({ summary: 'Get team details, members, and channels' })
getTeam(@Param('teamId') teamId: string, @Req() req: any) {
return this.service.getTeam(teamId, req.user.id);
}
@Get(':teamId/channels')
@ApiOperation({ summary: 'List connected YouTube channels for a team' })
listChannels(@Param('teamId') teamId: string, @Req() req: any) {
return this.service.listChannels(teamId, req.user.id);
}
@Post(':teamId/members')
@ApiOperation({ summary: 'Invite a user to the team by email (ADMIN+)' })
invite(
@Param('teamId') teamId: string,
@Body() body: { email: string; role: TeamRole },
@Req() req: any,
) {
return this.service.inviteMember(teamId, req.user.id, body.email, body.role);
}
@Patch(':teamId/members/:userId')
@ApiOperation({ summary: "Change a member's role (ADMIN+)" })
updateRole(
@Param('teamId') teamId: string,
@Param('userId') userId: string,
@Body() body: { role: TeamRole },
@Req() req: any,
) {
return this.service.updateMemberRole(teamId, req.user.id, userId, body.role);
}
@Get(':teamId/settings')
@ApiOperation({ summary: 'Get team-level render settings' })
getSettings(@Param('teamId') teamId: string, @Req() req: any) {
return this.service.getSettings(teamId, req.user.id);
}
@Patch(':teamId/settings')
@ApiOperation({ summary: 'Update team-level render settings (ADMIN+)' })
updateSettings(
@Param('teamId') teamId: string,
@Body() body: { dateFormat?: string | null; timezone?: string; publishingSchedule?: any[] | null; showCanvaLink?: boolean; disabledLintRules?: string[]; showDeletedVideos?: boolean; conflictDetectionEnabled?: boolean; conflictDetectionBatchSize?: number; conflictDetectionMinAgeDays?: number },
@Req() req: any,
) {
return this.service.updateSettings(teamId, req.user.id, body);
}
@Get(':teamId/next-publish-slot')
@ApiOperation({ summary: 'Find the next free publishing slot for a channel based on the team schedule' })
@ApiQuery({ name: 'channelId', required: true })
nextPublishSlot(
@Param('teamId') teamId: string,
@Query('channelId') channelId: string,
@Req() req: any,
) {
return this.service.findNextFreeSlot(teamId, req.user.id, channelId);
}
@Delete(':teamId/members/:userId')
@ApiOperation({ summary: 'Remove a member from the team (ADMIN+)' })
removeMember(
@Param('teamId') teamId: string,
@Param('userId') userId: string,
@Req() req: any,
) {
return this.service.removeMember(teamId, req.user.id, userId);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TeamsService } from './teams.service';
import { TeamsController } from './teams.controller';
import { AuditModule } from '../../shared/audit/audit.module';
@Module({
imports: [AuditModule],
controllers: [TeamsController],
providers: [TeamsService],
exports: [TeamsService],
})
export class TeamsModule {}
+344
View File
@@ -0,0 +1,344 @@
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AuditService } from '../../shared/audit/audit.service';
import { TeamRole, Prisma } from '@prisma/client';
export interface PublishingSlot {
days: number[]; // 0=Sun … 6=Sat; empty array = every day
time: string; // "HH:MM" in team timezone (24h)
}
@Injectable()
export class TeamsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async listMyTeams(userId: string) {
return this.prisma.teamMember.findMany({
where: { userId },
include: { team: { include: { channels: { select: { id: true, name: true, youtubeChannelId: true } } } } },
orderBy: { createdAt: 'asc' },
});
}
async getTeam(teamId: string, userId: string) {
await this.assertMember(teamId, userId);
return this.prisma.team.findUniqueOrThrow({
where: { id: teamId },
include: {
members: { include: { user: { select: { id: true, email: true, name: true } } } },
channels: { select: { id: true, name: true, youtubeChannelId: true, createdAt: true } },
},
});
}
async listChannels(teamId: string, userId: string) {
await this.assertMember(teamId, userId);
return this.prisma.channel.findMany({
where: { teamId },
select: { id: true, name: true, youtubeChannelId: true, uploadsPlaylistId: true, connectedBy: true, createdAt: true },
orderBy: { createdAt: 'asc' },
});
}
async inviteMember(teamId: string, actorId: string, email: string, role: TeamRole) {
await this.assertRole(teamId, actorId, TeamRole.ADMIN);
if (role === TeamRole.OWNER) throw new ForbiddenException('Cannot assign OWNER role');
const invitee = await this.prisma.user.findUnique({ where: { email } });
if (!invitee) throw new NotFoundException(`No user found with email ${email}`);
const member = await this.prisma.teamMember.upsert({
where: { userId_teamId: { userId: invitee.id, teamId } },
create: { userId: invitee.id, teamId, role },
update: { role },
});
await this.audit.log(actorId, 'TeamMember', invitee.id, 'invite', null, { email, role, teamId });
return member;
}
async updateMemberRole(teamId: string, actorId: string, targetUserId: string, role: TeamRole) {
await this.assertRole(teamId, actorId, TeamRole.ADMIN);
const target = await this.prisma.teamMember.findUnique({
where: { userId_teamId: { userId: targetUserId, teamId } },
});
if (!target) throw new NotFoundException('Member not found');
if (target.role === TeamRole.OWNER) throw new ForbiddenException('Cannot change OWNER role');
if (role === TeamRole.OWNER) throw new ForbiddenException('Cannot assign OWNER role');
const updated = await this.prisma.teamMember.update({
where: { userId_teamId: { userId: targetUserId, teamId } },
data: { role },
});
await this.audit.log(actorId, 'TeamMember', targetUserId, 'update', { role: target.role }, { role });
return updated;
}
async removeMember(teamId: string, actorId: string, targetUserId: string) {
await this.assertRole(teamId, actorId, TeamRole.ADMIN);
const target = await this.prisma.teamMember.findUnique({
where: { userId_teamId: { userId: targetUserId, teamId } },
});
if (!target) throw new NotFoundException('Member not found');
if (target.role === TeamRole.OWNER) throw new ForbiddenException('Cannot remove the team owner');
await this.prisma.teamMember.delete({
where: { userId_teamId: { userId: targetUserId, teamId } },
});
await this.audit.log(actorId, 'TeamMember', targetUserId, 'delete', { role: target.role }, null);
}
async getSettings(teamId: string, userId: string) {
await this.assertMember(teamId, userId);
const team = await this.prisma.team.findUniqueOrThrow({
where: { id: teamId },
select: { dateFormat: true, timezone: true, publishingSchedule: true, showCanvaLink: true, disabledLintRules: true, showDeletedVideos: true, conflictDetectionEnabled: true, conflictDetectionBatchSize: true, conflictDetectionMinAgeDays: true },
});
return {
dateFormat: team.dateFormat ?? null,
timezone: team.timezone,
publishingSchedule: (team.publishingSchedule as PublishingSlot[] | null) ?? null,
showCanvaLink: team.showCanvaLink,
disabledLintRules: team.disabledLintRules,
showDeletedVideos: team.showDeletedVideos,
conflictDetectionEnabled: team.conflictDetectionEnabled,
conflictDetectionBatchSize: team.conflictDetectionBatchSize,
conflictDetectionMinAgeDays: team.conflictDetectionMinAgeDays,
};
}
async updateSettings(
teamId: string,
userId: string,
data: { dateFormat?: string | null; timezone?: string; publishingSchedule?: PublishingSlot[] | null; showCanvaLink?: boolean; disabledLintRules?: string[]; showDeletedVideos?: boolean; conflictDetectionEnabled?: boolean; conflictDetectionBatchSize?: number; conflictDetectionMinAgeDays?: number },
) {
await this.assertRole(teamId, userId, TeamRole.ADMIN);
if (data.publishingSchedule != null) {
this.validateSchedule(data.publishingSchedule);
}
if (data.timezone != null) {
this.validateTimezone(data.timezone);
}
if (data.conflictDetectionBatchSize !== undefined && (data.conflictDetectionBatchSize < 1 || data.conflictDetectionBatchSize > 500)) {
throw new BadRequestException('conflictDetectionBatchSize must be between 1 and 500');
}
if (data.conflictDetectionMinAgeDays !== undefined && data.conflictDetectionMinAgeDays < 0) {
throw new BadRequestException('conflictDetectionMinAgeDays must be >= 0');
}
const team = await this.prisma.team.update({
where: { id: teamId },
data: {
...(data.dateFormat !== undefined ? { dateFormat: data.dateFormat ?? null } : {}),
...(data.timezone !== undefined ? { timezone: data.timezone } : {}),
...(data.publishingSchedule !== undefined ? { publishingSchedule: data.publishingSchedule ? (data.publishingSchedule as unknown as Prisma.InputJsonValue) : Prisma.JsonNull } : {}),
...(data.showCanvaLink !== undefined ? { showCanvaLink: data.showCanvaLink } : {}),
...(data.disabledLintRules !== undefined ? { disabledLintRules: data.disabledLintRules } : {}),
...(data.showDeletedVideos !== undefined ? { showDeletedVideos: data.showDeletedVideos } : {}),
...(data.conflictDetectionEnabled !== undefined ? { conflictDetectionEnabled: data.conflictDetectionEnabled } : {}),
...(data.conflictDetectionBatchSize !== undefined ? { conflictDetectionBatchSize: data.conflictDetectionBatchSize } : {}),
...(data.conflictDetectionMinAgeDays !== undefined ? { conflictDetectionMinAgeDays: data.conflictDetectionMinAgeDays } : {}),
},
select: { dateFormat: true, timezone: true, publishingSchedule: true, showCanvaLink: true, disabledLintRules: true, showDeletedVideos: true, conflictDetectionEnabled: true, conflictDetectionBatchSize: true, conflictDetectionMinAgeDays: true },
});
// Remove open results for disabled rules and recompute lintStatus on affected videos
if (data.disabledLintRules && data.disabledLintRules.length > 0) {
// Capture affected video IDs before deleting so we can recompute their status
const affected = await this.prisma.lintResult.findMany({
where: {
resolvedAt: null,
ruleCode: { in: data.disabledLintRules },
video: { channel: { teamId } },
},
select: { videoId: true },
distinct: ['videoId'],
});
await this.prisma.lintResult.deleteMany({
where: {
resolvedAt: null,
ruleCode: { in: data.disabledLintRules },
video: { channel: { teamId } },
},
});
// Recompute lintStatus for each affected video based on its remaining open results
await Promise.all(
affected.map(async ({ videoId }) => {
const remaining = await this.prisma.lintResult.findMany({
where: { videoId, resolvedAt: null },
select: { severity: true },
});
const status = remaining.some((r) => r.severity === 'ERROR')
? 'ERROR'
: remaining.some((r) => r.severity === 'WARNING')
? 'WARNING'
: 'OK';
await this.prisma.video.update({ where: { id: videoId }, data: { lintStatus: status as any } });
}),
);
}
return {
dateFormat: team.dateFormat ?? null,
timezone: team.timezone,
publishingSchedule: (team.publishingSchedule as PublishingSlot[] | null) ?? null,
showCanvaLink: team.showCanvaLink,
disabledLintRules: team.disabledLintRules,
showDeletedVideos: team.showDeletedVideos,
conflictDetectionEnabled: team.conflictDetectionEnabled,
conflictDetectionBatchSize: team.conflictDetectionBatchSize,
conflictDetectionMinAgeDays: team.conflictDetectionMinAgeDays,
};
}
async findNextFreeSlot(teamId: string, userId: string, channelId: string): Promise<{ slot: string | null }> {
await this.assertMember(teamId, userId);
const team = await this.prisma.team.findUniqueOrThrow({
where: { id: teamId },
select: { timezone: true, publishingSchedule: true },
});
const schedule = team.publishingSchedule as PublishingSlot[] | null;
if (!schedule || schedule.length === 0) return { slot: null };
// Verify channel belongs to this team
const channel = await this.prisma.channel.findFirst({ where: { id: channelId, teamId } });
if (!channel) throw new ForbiddenException('Channel not found in this team');
// Load all future scheduled dates for this channel
const now = new Date();
const futureVideos = await this.prisma.video.findMany({
where: { channelId, scheduledAt: { gt: now } },
select: { scheduledAt: true },
});
const takenMs = futureVideos.map((v) => v.scheduledAt!.getTime());
const COLLISION_MS = 30 * 60 * 1000; // ±30 minutes
const MAX_DAYS = 90;
const tz = team.timezone;
// Walk candidate slots day by day
for (let dayOffset = 0; dayOffset <= MAX_DAYS; dayOffset++) {
const dayStart = new Date(now.getTime() + dayOffset * 24 * 60 * 60 * 1000);
// Get the weekday (0=Sun … 6=Sat) in the team's timezone
const weekday = this.getWeekdayInZone(dayStart, tz);
// Sort slots by time so we walk them in order within a day
const daySlots = schedule
.filter((s) => s.days.length === 0 || s.days.includes(weekday))
.sort((a, b) => a.time.localeCompare(b.time));
for (const slot of daySlots) {
const candidateUtc = this.slotToUtc(dayStart, slot.time, tz);
if (candidateUtc <= now.getTime()) continue;
const isTaken = takenMs.some((t) => Math.abs(t - candidateUtc) <= COLLISION_MS);
if (!isTaken) {
return { slot: new Date(candidateUtc).toISOString() };
}
}
}
return { slot: null };
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Returns the UTC timestamp (ms) for a given "HH:MM" slot time on the calendar
* day that contains `nearDate`, interpreted in `ianaZone`.
*/
private slotToUtc(nearDate: Date, time: string, ianaZone: string): number {
const [hh, mm] = time.split(':').map(Number);
// Get the date components (year, month, day) in the target timezone for nearDate
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: ianaZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(nearDate);
const year = Number(parts.find((p) => p.type === 'year')!.value);
const month = Number(parts.find((p) => p.type === 'month')!.value) - 1;
const day = Number(parts.find((p) => p.type === 'day')!.value);
// Build a Date at midnight in UTC for that calendar date, then add hours/minutes,
// then correct for the timezone offset at that moment.
// We iterate once to converge on the correct offset (handles DST edge cases).
let candidate = Date.UTC(year, month, day, hh, mm, 0, 0);
const offset = this.getUtcOffsetMs(ianaZone, new Date(candidate));
candidate = Date.UTC(year, month, day, hh, mm, 0, 0) - offset;
return candidate;
}
/** Returns the UTC offset in milliseconds for an IANA timezone at a given instant. */
private getUtcOffsetMs(ianaZone: string, date: Date): number {
// Format the date in the target timezone and in UTC, then diff
const fmt = (tz: string) =>
new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: 'numeric', second: 'numeric',
hour12: false,
}).format(date);
const local = new Date(fmt(ianaZone) + ' UTC');
const utc = new Date(fmt('UTC') + ' UTC');
return local.getTime() - utc.getTime();
}
/** Returns the weekday (0=Sun … 6=Sat) in the given IANA timezone. */
private getWeekdayInZone(date: Date, ianaZone: string): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: ianaZone,
weekday: 'short',
}).formatToParts(date);
const name = parts.find((p) => p.type === 'weekday')!.value;
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(name);
}
private validateSchedule(slots: PublishingSlot[]) {
for (const slot of slots) {
if (!/^\d{2}:\d{2}$/.test(slot.time)) {
throw new BadRequestException(`Invalid slot time "${slot.time}" — expected HH:MM`);
}
for (const d of slot.days) {
if (d < 0 || d > 6) throw new BadRequestException(`Invalid day ${d} — must be 06`);
}
}
}
private validateTimezone(tz: string) {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
} catch {
throw new BadRequestException(`Invalid IANA timezone "${tz}"`);
}
}
private async assertMember(teamId: string, userId: string) {
const m = await this.prisma.teamMember.findUnique({
where: { userId_teamId: { userId, teamId } },
});
if (!m) throw new ForbiddenException('Not a member of this team');
return m;
}
private async assertRole(teamId: string, userId: string, minRole: TeamRole) {
const PRIORITY: Record<TeamRole, number> = {
[TeamRole.OWNER]: 5, [TeamRole.ADMIN]: 4, [TeamRole.EDITOR]: 3,
[TeamRole.REVIEWER]: 2, [TeamRole.READONLY]: 1,
};
const m = await this.assertMember(teamId, userId);
if ((PRIORITY[m.role] ?? 0) < PRIORITY[minRole]) {
throw new ForbiddenException('Insufficient role');
}
return m;
}
}
@@ -0,0 +1,12 @@
import { IsString, IsOptional, IsArray, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTemplateDto {
@ApiProperty() @IsString() name: string;
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiProperty() @IsArray() defaultBlocks: string[];
@ApiPropertyOptional() @IsOptional() @IsObject() defaultOverrides?: Record<string, any>;
@ApiProperty() @IsObject() rules: Record<string, any>;
@ApiProperty() @IsObject() variables: Record<string, any>;
@ApiPropertyOptional() @IsOptional() @IsObject() videoFields?: Record<string, any>;
}
@@ -0,0 +1,13 @@
import { IsString, IsOptional, IsArray, IsObject, IsBoolean } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateTemplateDto {
@ApiPropertyOptional() @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional() @IsOptional() @IsArray() defaultBlocks?: string[];
@ApiPropertyOptional() @IsOptional() @IsObject() defaultOverrides?: Record<string, any>;
@ApiPropertyOptional() @IsOptional() @IsObject() rules?: Record<string, any>;
@ApiPropertyOptional() @IsOptional() @IsObject() variables?: Record<string, any>;
@ApiPropertyOptional() @IsOptional() @IsObject() videoFields?: Record<string, any>;
@ApiPropertyOptional() @IsOptional() @IsBoolean() active?: boolean;
}
@@ -0,0 +1,50 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { TemplatesService, ApplyOptions } from './templates.service';
import { CreateTemplateDto } from './dto/create-template.dto';
import { UpdateTemplateDto } from './dto/update-template.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('templates')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('templates')
export class TemplatesController {
constructor(private readonly service: TemplatesService) {}
@Get()
findAll(@Req() req: any) { return this.service.findAll(req.user.teamId); }
@Get(':id')
findById(@Param('id') id: string, @Req() req: any) { return this.service.findById(id, req.user.teamId); }
@Post() @Roles(TeamRole.EDITOR)
create(@Body() dto: CreateTemplateDto, @Req() req: any) { return this.service.create(dto, req.user.id, req.user.teamId); }
@Patch(':id') @Roles(TeamRole.EDITOR)
update(@Param('id') id: string, @Body() dto: UpdateTemplateDto, @Req() req: any) { return this.service.update(id, dto, req.user.id, req.user.teamId); }
@Get(':id/usage') @ApiOperation({ summary: 'Videos using this template' })
usage(@Param('id') id: string, @Req() req: any) { return this.service.getUsage(id, req.user.teamId); }
@Delete(':id') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Delete template (only if not in use)' })
remove(@Param('id') id: string, @Req() req: any) { return this.service.delete(id, req.user.teamId, req.user.id); }
@Post(':id/apply/:videoId') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Apply template to a video' })
applyToVideo(
@Param('id') id: string,
@Param('videoId') videoId: string,
@Body() body: ApplyOptions,
@Req() req: any,
) {
return this.service.applyToVideo(id, videoId, req.user.teamId, req.user.id, body);
}
@Post(':id/render-preview') @ApiOperation({ summary: 'Render template with sample data' })
renderPreview(@Param('id') id: string, @Body() body: { variables?: Record<string, string> }, @Req() req: any) {
return this.service.renderPreview(id, body.variables ?? {}, req.user.teamId);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TemplatesService } from './templates.service';
import { TemplatesController } from './templates.controller';
import { RenderEngineModule } from '../../shared/render-engine/render-engine.module';
import { AuditModule } from '../../shared/audit/audit.module';
@Module({
imports: [RenderEngineModule, AuditModule],
providers: [TemplatesService],
controllers: [TemplatesController],
exports: [TemplatesService],
})
export class TemplatesModule {}
@@ -0,0 +1,164 @@
import { Injectable, NotFoundException, ForbiddenException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { RenderEngineService } from '../../shared/render-engine/render-engine.service';
import { AuditService } from '../../shared/audit/audit.service';
import { CreateTemplateDto } from './dto/create-template.dto';
import { UpdateTemplateDto } from './dto/update-template.dto';
export interface ApplyOptions {
applyVideoFields: boolean;
applyDescriptionConfig: boolean;
}
@Injectable()
export class TemplatesService {
constructor(
private readonly prisma: PrismaService,
private readonly renderEngine: RenderEngineService,
private readonly audit: AuditService,
) {}
findAll(teamId: string) {
return this.prisma.template.findMany({ where: { active: true, teamId }, orderBy: { name: 'asc' } });
}
async findById(id: string, teamId: string) {
const template = await this.prisma.template.findFirst({ where: { id, teamId } });
if (!template) throw new NotFoundException(`Template ${id} not found`);
return template;
}
async create(dto: CreateTemplateDto, actorId: string, teamId: string) {
const template = await this.prisma.template.create({ data: { ...dto, teamId } });
await this.audit.log(actorId, 'Template', template.id, 'create', null, template);
return template;
}
async update(id: string, dto: UpdateTemplateDto, actorId: string, teamId: string) {
const before = await this.prisma.template.findFirst({ where: { id, teamId } });
if (!before) throw new NotFoundException(`Template ${id} not found`);
await this.prisma.templateVersion.create({
data: { templateId: id, version: before.version, snapshot: before as any },
});
const updated = await this.prisma.template.update({
where: { id },
data: { ...dto, version: { increment: 1 } },
});
await this.audit.log(actorId, 'Template', id, 'update', before, updated);
return updated;
}
async applyToVideo(
templateId: string,
videoId: string,
teamId: string,
actorId: string,
options: ApplyOptions,
) {
const [template, video] = await Promise.all([
this.prisma.template.findFirst({ where: { id: templateId, teamId } }),
this.prisma.video.findFirst({ where: { id: videoId, channel: { teamId } } }),
]);
if (!template) throw new NotFoundException(`Template ${templateId} not found`);
if (!video) throw new NotFoundException(`Video ${videoId} not found`);
const appliedFields: string[] = [];
const templateAny = template as any;
if (options.applyVideoFields && templateAny.videoFields) {
const fields = templateAny.videoFields as Record<string, any>;
const updateData: Record<string, any> = {};
const ALLOWED_FIELDS = [
'tags', 'privacyStatus', 'categoryId', 'defaultLanguage',
'defaultAudioLanguage', 'selfDeclaredMadeForKids', 'embeddable', 'license', 'gameTitle',
];
for (const key of ALLOWED_FIELDS) {
if (fields[key] !== undefined) {
updateData[key] = fields[key];
appliedFields.push(key);
}
}
if (Object.keys(updateData).length > 0) {
updateData.templateId = templateId;
await this.prisma.video.update({ where: { id: videoId }, data: updateData });
}
}
if (options.applyDescriptionConfig) {
const blockOrder = (template.defaultBlocks as string[]) ?? [];
const variableValues = (template.variables as Record<string, string>) ?? {};
const blockOverrides = ((template as any).defaultOverrides as Record<string, any>) ?? {};
const existing = await this.prisma.videoConfig.findUnique({ where: { videoId } });
const configData = {
templateId,
blockOrder,
blockOverrides,
variableValues,
version: existing ? existing.version + 1 : 1,
};
if (existing) {
await this.prisma.videoConfig.update({ where: { videoId }, data: configData });
} else {
await this.prisma.videoConfig.create({ data: { videoId, ...configData } });
}
// Ensure templateId is set on the video even if no video fields were applied
if (!options.applyVideoFields) {
await this.prisma.video.update({ where: { id: videoId }, data: { templateId } });
}
appliedFields.push('descriptionConfig');
}
await this.audit.log(actorId, 'Template', templateId, 'apply', { videoId }, { appliedFields });
return { appliedFields };
}
async getUsage(id: string, teamId: string) {
const configs = await this.prisma.videoConfig.findMany({
where: { templateId: id, video: { channel: { teamId } } },
select: { videoId: true, video: { select: { title: true } } },
});
return { videos: configs.map((c) => ({ id: c.videoId, title: c.video.title })) };
}
async delete(id: string, teamId: string, actorId: string) {
const template = await this.prisma.template.findFirst({ where: { id, teamId } });
if (!template) throw new NotFoundException(`Template ${id} not found`);
const videoCount = await this.prisma.videoConfig.count({ where: { templateId: id } });
if (videoCount > 0) throw new ConflictException(`Template is used by ${videoCount} video(s) and cannot be deleted`);
await this.prisma.templateVersion.deleteMany({ where: { templateId: id } });
await this.prisma.template.delete({ where: { id } });
await this.audit.log(actorId, 'Template', id, 'delete', template, null);
return { deleted: true };
}
async renderPreview(id: string, sampleVariables: Record<string, string>, teamId: string) {
const template = await this.prisma.template.findFirst({ where: { id, teamId } });
if (!template) throw new NotFoundException(`Template ${id} not found`);
const blockOrder = template.defaultBlocks as string[];
const realBlockIds = blockOrder.filter((bid) => !bid.startsWith('freetext:'));
const blocks = await this.prisma.descriptionBlock.findMany({ where: { id: { in: realBlockIds }, teamId } });
const defaultOverrides = ((template as any).defaultOverrides as Record<string, any>) ?? {};
return this.renderEngine.render({
video: { title: 'Preview', tags: [] },
config: {
blockOrder,
blockOverrides: defaultOverrides,
variableValues: { ...(template.variables as Record<string, string>), ...sampleVariables },
collaboratorIds: [],
},
blocks,
collaborators: [],
activeCampaignBlocks: [],
});
}
}
@@ -0,0 +1,11 @@
import { IsArray, IsObject, IsOptional, IsString, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class UpsertVideoConfigDto {
@ApiPropertyOptional() @IsOptional() @IsString() templateId?: string;
@ApiProperty({ type: [String] }) @IsArray() blockOrder: string[];
@ApiProperty() @IsObject() blockOverrides: Record<string, { content?: string; active?: boolean; compact?: boolean }>;
@ApiProperty() @IsObject() variableValues: Record<string, string>;
@ApiProperty({ type: [String] }) @IsArray() @IsOptional() collaboratorIds?: string[];
@ApiPropertyOptional() @IsOptional() @IsBoolean() autoRender?: boolean;
}
@@ -0,0 +1,29 @@
import { Controller, Get, Put, Post, Param, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { VideoConfigsService } from './video-configs.service';
import { UpsertVideoConfigDto } from './dto/upsert-video-config.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('video-configs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('video-configs')
export class VideoConfigsController {
constructor(private readonly service: VideoConfigsService) {}
@Get(':videoId') @ApiOperation({ summary: 'Get config for a video' })
findOne(@Param('videoId') videoId: string) { return this.service.findByVideoId(videoId); }
@Put(':videoId') @Roles(TeamRole.EDITOR) @ApiOperation({ summary: 'Save/update video config' })
upsert(@Param('videoId') videoId: string, @Body() dto: UpsertVideoConfigDto, @Req() req: any) {
return this.service.upsert(videoId, dto, req.user.id);
}
@Post(':videoId/render-preview') @ApiOperation({ summary: 'Preview render without saving' })
renderPreview(@Param('videoId') videoId: string, @Body() dto: UpsertVideoConfigDto) {
return this.service.renderPreview(videoId, dto);
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { VideoConfigsService } from './video-configs.service';
import { VideoConfigsController } from './video-configs.controller';
import { RenderEngineModule } from '../../shared/render-engine/render-engine.module';
import { AuditModule } from '../../shared/audit/audit.module';
import { TeamVariablesModule } from '../team-variables/team-variables.module';
import { QUEUES } from '../../queues/queues.constants';
@Module({
imports: [
RenderEngineModule,
AuditModule,
TeamVariablesModule,
BullModule.registerQueue({ name: QUEUES.RENDER }),
],
providers: [VideoConfigsService],
controllers: [VideoConfigsController],
})
export class VideoConfigsModule {}
@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { RenderEngineService } from '../../shared/render-engine/render-engine.service';
import { AuditService } from '../../shared/audit/audit.service';
import { TeamVariablesService } from '../team-variables/team-variables.service';
import { QUEUES } from '../../queues/queues.constants';
import { UpsertVideoConfigDto } from './dto/upsert-video-config.dto';
@Injectable()
export class VideoConfigsService {
constructor(
private readonly prisma: PrismaService,
private readonly renderEngine: RenderEngineService,
private readonly audit: AuditService,
private readonly teamVars: TeamVariablesService,
@InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue,
) {}
async findByVideoId(videoId: string) {
return this.prisma.videoConfig.findUnique({ where: { videoId } });
}
async upsert(videoId: string, dto: UpsertVideoConfigDto, actorId: string) {
const existing = await this.prisma.videoConfig.findUnique({ where: { videoId } });
const data = {
templateId: dto.templateId,
blockOrder: dto.blockOrder,
blockOverrides: dto.blockOverrides,
variableValues: dto.variableValues,
version: existing ? existing.version + 1 : 1,
};
const config = existing
? await this.prisma.videoConfig.update({ where: { videoId }, data })
: await this.prisma.videoConfig.create({ data: { videoId, ...data } });
await this.audit.log(actorId, 'VideoConfig', videoId, existing ? 'update' : 'create', existing, config);
if (dto.autoRender) {
await this.renderQueue.add('render', { videoId });
}
return config;
}
async renderPreview(videoId: string, dto: UpsertVideoConfigDto) {
const video = await this.prisma.video.findUniqueOrThrow({
where: { id: videoId },
include: {
channel: { select: { teamId: true } },
playlists: { include: { playlist: { select: { title: true, youtubePlaylistId: true } } } },
},
});
const teamId = video.channel.teamId;
const now = new Date();
const [blocks, teamVariables, dateFormat, collaborators, activeCampaignBlocks] = await Promise.all([
this.prisma.descriptionBlock.findMany({ where: { id: { in: dto.blockOrder } } }),
this.teamVars.getMap(teamId),
this.teamVars.getDateFormat(teamId),
this.prisma.collaborator.findMany({ where: { id: { in: dto.collaboratorIds } } }),
this.prisma.descriptionBlock.findMany({
where: {
teamId,
type: 'CAMPAIGN',
active: true,
campaign: { startAt: { lte: now }, OR: [{ endAt: null }, { endAt: { gte: now } }] },
},
}),
]);
return this.renderEngine.render({
video: {
title: video.title,
tags: video.tags,
categoryId: video.categoryId,
scheduledAt: video.scheduledAt,
recordingDate: video.recordingDate,
gameTitle: video.gameTitle,
language: video.defaultLanguage,
playlists: video.playlists.map((vp) => vp.playlist),
},
config: {
blockOrder: dto.blockOrder,
blockOverrides: dto.blockOverrides,
variableValues: dto.variableValues,
teamVariables,
dateFormat,
collaboratorIds: dto.collaboratorIds ?? [],
},
blocks,
collaborators,
activeCampaignBlocks,
});
}
}
@@ -0,0 +1,9 @@
import { IsString, IsArray, IsOptional, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BulkPreviewDto {
@ApiProperty() @IsString() type: string;
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) videoIds?: string[];
@ApiPropertyOptional() @IsOptional() @IsString() savedViewId?: string;
@ApiProperty() @IsObject() payload: Record<string, any>;
}
@@ -0,0 +1,43 @@
import { IsOptional, IsString, IsEnum, IsInt, Min, IsBoolean } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { LintStatus, PrivacyStatus } from '@prisma/client';
import { Type, Transform } from 'class-transformer';
export class QueryVideosDto {
@ApiPropertyOptional() @IsOptional() @IsString() search?: string;
@ApiPropertyOptional({ enum: LintStatus }) @IsOptional() @IsEnum(LintStatus) lintStatus?: LintStatus;
@ApiPropertyOptional({ enum: PrivacyStatus }) @IsOptional() @IsEnum(PrivacyStatus) privacyStatus?: PrivacyStatus;
@ApiPropertyOptional() @IsOptional() @IsString() channelId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() templateId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() collaboratorId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() from?: string;
@ApiPropertyOptional() @IsOptional() @IsString() to?: string;
/** Filter to videos that are scheduled (PRIVATE status with a future scheduledAt) */
@ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() scheduled?: boolean;
/** Exclude videos that have a future scheduledAt (used by the Private tab to hide scheduled videos) */
@ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() notScheduled?: boolean;
/** Filter to videos with any lint issue (ERROR or WARNING) */
@ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() hasLintIssues?: boolean;
/** Filter to videos with a remote conflict */
@ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() remoteConflict?: boolean;
/** Filter to videos that have pending changes (not yet pushed to YouTube) */
@ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() pendingSync?: boolean;
/** Show only videos soft-deleted on YouTube (youtubeDeletedAt is not null); if false/absent, deleted videos are excluded */
@ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() deletedOnYouTube?: boolean;
/** Filter by tag (partial match — video must have at least one tag containing this string) */
@ApiPropertyOptional() @IsOptional() @IsString() tagsSearch?: string;
/** Filter by YouTube category ID */
@ApiPropertyOptional() @IsOptional() @IsString() categoryId?: string;
/** Filter by default language (BCP-47 code) */
@ApiPropertyOptional() @IsOptional() @IsString() defaultLanguage?: string;
/** Filter to embeddable or non-embeddable videos */
@ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() embeddable?: boolean;
/** Filter by license ('youtube' or 'creativeCommon') */
@ApiPropertyOptional() @IsOptional() @IsString() license?: string;
/** Filter to videos marked/not marked as made for kids */
@ApiPropertyOptional() @IsOptional() @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() selfDeclaredMadeForKids?: boolean;
@ApiPropertyOptional({ default: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = 1;
@ApiPropertyOptional({ default: 50 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) limit?: number = 50;
@ApiPropertyOptional() @IsOptional() @IsString() sort?: string;
@ApiPropertyOptional({ enum: ['asc', 'desc'] }) @IsOptional() @IsEnum(['asc', 'desc']) order?: 'asc' | 'desc' = 'desc';
}
@@ -0,0 +1,22 @@
import { IsOptional, IsString, IsArray, IsEnum, IsDateString, IsBoolean } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { PrivacyStatus } from '@prisma/client';
export class UpdateVideoDto {
@ApiPropertyOptional() @IsOptional() @IsString() title?: string;
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
@ApiPropertyOptional({ enum: PrivacyStatus }) @IsOptional() @IsEnum(PrivacyStatus) privacyStatus?: PrivacyStatus;
@ApiPropertyOptional() @IsOptional() @IsDateString() scheduledAt?: string;
@ApiPropertyOptional() @IsOptional() @IsString() categoryId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() templateId?: string;
// Extended metadata (madeForKids, ageRestricted, containsPaidPromotion are read-only from YouTube)
@ApiPropertyOptional() @IsOptional() @IsBoolean() selfDeclaredMadeForKids?: boolean;
@ApiPropertyOptional() @IsOptional() @IsBoolean() embeddable?: boolean;
@ApiPropertyOptional() @IsOptional() @IsString() license?: string;
@ApiPropertyOptional() @IsOptional() @IsString() defaultLanguage?: string;
@ApiPropertyOptional() @IsOptional() @IsString() defaultAudioLanguage?: string;
@ApiPropertyOptional() @IsOptional() @IsDateString() recordingDate?: string;
@ApiPropertyOptional() @IsOptional() @IsString() gameTitle?: string;
@ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsString({ each: true }) collaboratorIds?: string[];
}
@@ -0,0 +1,97 @@
import { Controller, Get, Patch, Post, Param, Body, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { VideosService } from './videos.service';
import { QueryVideosDto } from './dto/query-videos.dto';
import { UpdateVideoDto } from './dto/update-video.dto';
import { BulkPreviewDto } from './dto/bulk-preview.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('videos')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('videos')
export class VideosController {
constructor(private readonly service: VideosService) {}
@Get('diagnose')
@ApiOperation({ summary: 'Diagnose video count discrepancy' })
diagnose(@Req() req: any) {
return this.service.diagnoseCount(req.user.teamId);
}
@Post('reassign/:channelId')
@ApiOperation({ summary: 'Move videos misassigned to another channel into the correct channel' })
@Roles(TeamRole.ADMIN)
reassign(@Param('channelId') channelId: string, @Req() req: any) {
return this.service.reassignOrphanedVideos(req.user.teamId, channelId);
}
@Get('stats/overview')
@ApiOperation({ summary: 'Aggregated stats for the overview dashboard' })
overviewStats(@Req() req: any) {
return this.service.getOverviewStats(req.user.teamId);
}
@Get()
@ApiOperation({ summary: 'List videos with filters' })
findAll(@Query() query: QueryVideosDto, @Req() req: any) {
return this.service.findAll(query, req.user.teamId);
}
@Get(':id')
@ApiOperation({ summary: 'Get single video' })
findOne(@Param('id') id: string, @Req() req: any) {
return this.service.findOne(id, req.user.teamId);
}
@Patch(':id')
@ApiOperation({ summary: 'Update video fields' })
@Roles(TeamRole.EDITOR)
update(@Param('id') id: string, @Body() dto: UpdateVideoDto, @Req() req: any) {
return this.service.update(id, dto, req.user.id, req.user.teamId);
}
@Post('bulk-preview')
@ApiOperation({ summary: 'Dry-run for bulk action' })
@Roles(TeamRole.EDITOR)
bulkPreview(@Body() dto: BulkPreviewDto, @Req() req: any) {
return this.service.bulkPreview(dto, req.user.id, req.user.teamId);
}
@Post('bulk-apply')
@ApiOperation({ summary: 'Apply bulk action' })
@Roles(TeamRole.EDITOR)
bulkApply(@Body() dto: BulkPreviewDto, @Req() req: any) {
return this.service.bulkApply(dto, req.user.id, req.user.teamId);
}
@Post(':id/render')
@ApiOperation({ summary: 'Render video description' })
@Roles(TeamRole.EDITOR)
render(@Param('id') id: string, @Req() req: any) {
return this.service.renderDescription(id, req.user.teamId);
}
@Post(':id/refresh')
@ApiOperation({ summary: 'Pull latest metadata from YouTube into our DB' })
refresh(@Param('id') id: string, @Req() req: any) {
return this.service.refreshFromYouTube(id, req.user.teamId);
}
@Post(':id/sync')
@ApiOperation({ summary: 'Enqueue YouTube sync' })
@Roles(TeamRole.EDITOR)
sync(@Param('id') id: string) {
return this.service.enqueueSyncJob(id);
}
@Post(':id/accept-remote')
@ApiOperation({ summary: 'Adopt the pending remote snapshot as the new local state (no YouTube call)' })
@Roles(TeamRole.EDITOR)
acceptRemote(@Param('id') id: string, @Req() req: any) {
return this.service.acceptRemote(id, req.user.id, req.user.teamId);
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { VideosService } from './videos.service';
import { VideosController } from './videos.controller';
import { RenderEngineModule } from '../../shared/render-engine/render-engine.module';
import { AuditModule } from '../../shared/audit/audit.module';
import { YouTubeSyncModule } from '../youtube-sync/youtube-sync.module';
import { QUEUES } from '../../queues/queues.constants';
@Module({
imports: [
RenderEngineModule,
AuditModule,
YouTubeSyncModule,
BullModule.registerQueue({ name: QUEUES.YOUTUBE_SYNC }, { name: QUEUES.RENDER }),
],
providers: [VideosService],
controllers: [VideosController],
exports: [VideosService],
})
export class VideosModule {}
@@ -0,0 +1,522 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { VideoRenderService } from '../../shared/render-engine/video-render.service';
import { AuditService } from '../../shared/audit/audit.service';
import { YouTubeApiClient } from '../youtube-sync/youtube-api.client';
import { hashMetadata } from '../../shared/render-engine/hash';
import { computePendingChanges } from '../../shared/render-engine/pending-changes';
import { QUEUES } from '../../queues/queues.constants';
import { QueryVideosDto } from './dto/query-videos.dto';
import { UpdateVideoDto } from './dto/update-video.dto';
import { BulkPreviewDto } from './dto/bulk-preview.dto';
import { Prisma, PrivacyStatus } from '@prisma/client';
@Injectable()
export class VideosService {
constructor(
private readonly prisma: PrismaService,
private readonly videoRender: VideoRenderService,
private readonly audit: AuditService,
private readonly ytApi: YouTubeApiClient,
@InjectQueue(QUEUES.YOUTUBE_SYNC) private readonly syncQueue: Queue,
@InjectQueue(QUEUES.RENDER) private readonly renderQueue: Queue,
) {}
async reassignOrphanedVideos(teamId: string, targetChannelId: string) {
const channel = await this.prisma.channel.findFirstOrThrow({
where: { id: targetChannelId, teamId },
});
const playlistId =
channel.uploadsPlaylistId ?? (await this.ytApi.getUploadsPlaylistId(targetChannelId));
const { ids: youtubeIds } = await this.ytApi.listPlaylistVideoIds(targetChannelId, playlistId);
// Find which of those YouTube IDs are in the DB but NOT under this channel
const misassigned = await this.prisma.video.findMany({
where: {
youtubeVideoId: { in: youtubeIds },
NOT: { channelId: targetChannelId },
},
select: { id: true, youtubeVideoId: true, channelId: true },
});
if (misassigned.length > 0) {
await this.prisma.video.updateMany({
where: { id: { in: misassigned.map((v) => v.id) } },
data: { channelId: targetChannelId },
});
}
return { reassigned: misassigned.length, total: youtubeIds.length };
}
async diagnoseCount(teamId: string) {
const totalInDb = await this.prisma.video.count();
const visibleToTeam = await this.prisma.video.count({ where: { channel: { teamId } } });
const hidden = await this.prisma.$queryRaw<{ youtubeVideoId: string; channelId: string; channelTeamId: string | null }[]>`
SELECT v."youtubeVideoId", v."channelId", c."teamId" AS "channelTeamId"
FROM "Video" v
LEFT JOIN "Channel" c ON c.id = v."channelId"
WHERE c."teamId" IS NULL OR c."teamId" != ${teamId}
LIMIT 20
`;
return { totalInDb, visibleToTeam, hidden };
}
async findAll(query: QueryVideosDto, teamId: string) {
const {
search, lintStatus, hasLintIssues, privacyStatus, channelId, templateId, collaboratorId,
from, to, scheduled, notScheduled, remoteConflict, pendingSync, deletedOnYouTube,
tagsSearch, categoryId, defaultLanguage, embeddable, license, selfDeclaredMadeForKids,
page = 1, limit = 50, sort = 'createdAt', order = 'desc',
} = query;
const where: Prisma.VideoWhereInput = {
channel: { teamId },
youtubeDeletedAt: deletedOnYouTube ? { not: null } : null,
};
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ tags: { has: search } },
];
}
if (lintStatus) where.lintStatus = lintStatus as any;
if (hasLintIssues) where.lintStatus = { in: ['ERROR', 'WARNING'] } as any;
if (privacyStatus) where.privacyStatus = privacyStatus as any;
if (channelId) where.channelId = channelId;
if (templateId) where.templateId = templateId;
if (collaboratorId) (where as any).collaboratorIds = { array_contains: collaboratorId };
if (from || to) {
where.publishedAt = {};
if (from) (where.publishedAt as any).gte = new Date(from);
if (to) (where.publishedAt as any).lte = new Date(to);
}
if (scheduled) {
where.privacyStatus = 'PRIVATE' as any;
where.scheduledAt = { gt: new Date() };
}
if (notScheduled) {
// Exclude videos with a future scheduledAt (null or past = truly private, not scheduled)
const noFutureSlot = { OR: [{ scheduledAt: null }, { scheduledAt: { lte: new Date() } }] };
where.AND = [...(Array.isArray(where.AND) ? where.AND : []), noFutureSlot] as any;
}
if (remoteConflict) where.remoteConflict = true;
if (tagsSearch) where.tags = { has: tagsSearch };
if (categoryId) where.categoryId = categoryId;
if (defaultLanguage) where.defaultLanguage = defaultLanguage;
if (embeddable !== undefined) where.embeddable = embeddable;
if (license) (where as any).license = license;
if (selfDeclaredMadeForKids !== undefined) where.selfDeclaredMadeForKids = selfDeclaredMadeForKids;
if (pendingSync) {
const allVideos = await this.prisma.video.findMany({
where: { channel: { teamId } },
select: {
id: true, title: true, tags: true, lastSyncedHash: true,
renderedDescription: true, youtubeDescription: true,
privacyStatus: true, categoryId: true, defaultLanguage: true, defaultAudioLanguage: true,
selfDeclaredMadeForKids: true, embeddable: true, license: true, recordingDate: true,
} as any,
});
const pendingIds: string[] = [];
for (const v of allVideos as any[]) {
if (this.computePendingChanges(v)) pendingIds.push(v.id);
}
where.id = { in: pendingIds };
}
if (sort === 'publishedAt') {
// Prisma orderBy cannot express CASE WHEN scheduledAt > NOW() THEN scheduledAt ELSE ...
// A compound sort groups ALL non-null scheduledAt rows together, putting past-scheduled-
// now-published videos above recently published videos. Fix: fetch all matching IDs with
// just the date fields, sort in JS using the correct COALESCE-equivalent, then paginate.
const now = new Date();
const allDates = await this.prisma.video.findMany({
where,
select: { id: true, scheduledAt: true, publishedAt: true, createdAt: true },
});
allDates.sort((a, b) => {
const aDate = (a.scheduledAt && a.scheduledAt > now ? a.scheduledAt : null) ?? a.publishedAt ?? a.createdAt;
const bDate = (b.scheduledAt && b.scheduledAt > now ? b.scheduledAt : null) ?? b.publishedAt ?? b.createdAt;
return order === 'desc' ? bDate.getTime() - aDate.getTime() : aDate.getTime() - bDate.getTime();
});
const total = allDates.length;
const pageIds = allDates.slice((page - 1) * limit, page * limit).map((r) => r.id);
const pageItems = await this.prisma.video.findMany({
where: { id: { in: pageIds } },
include: {
template: { select: { id: true, name: true } },
lintResults: { where: { resolvedAt: null }, select: { severity: true, ruleCode: true } },
playlists: { include: { playlist: { select: { id: true, title: true } } } },
},
});
const idToItem = new Map(pageItems.map((v) => [v.id, v]));
const enriched = pageIds.map((id) => {
const v = idToItem.get(id)!;
return { ...v, hasPendingChanges: this.computePendingChanges(v as any) };
});
return { total, page, limit, items: enriched };
}
const orderBy: Prisma.VideoOrderByWithRelationInput = { [sort]: order };
const [total, items] = await Promise.all([
this.prisma.video.count({ where }),
this.prisma.video.findMany({
where,
include: {
template: { select: { id: true, name: true } },
lintResults: { where: { resolvedAt: null }, select: { severity: true, ruleCode: true } },
playlists: { include: { playlist: { select: { id: true, title: true } } } },
},
orderBy,
skip: (page - 1) * limit,
take: limit,
}),
]);
const enriched = items.map((v) => {
return { ...v, hasPendingChanges: this.computePendingChanges(v as any) };
});
return { total, page, limit, items: enriched };
}
async getOverviewStats(teamId: string) {
const now = new Date();
const allVideos = await this.prisma.video.findMany({
where: { channel: { teamId }, youtubeDeletedAt: null },
select: {
id: true, title: true,
tags: true, lastSyncedHash: true, lastSyncedAt: true,
renderedDescription: true, youtubeDescription: true,
privacyStatus: true, categoryId: true, defaultLanguage: true, defaultAudioLanguage: true,
selfDeclaredMadeForKids: true, embeddable: true, license: true, recordingDate: true,
} as any,
});
const pendingItems: { id: string; title: string }[] = [];
for (const v of allVideos as any[]) {
const shared = {
tags: v.tags as string[],
categoryId: v.categoryId ?? null,
privacyStatus: v.privacyStatus ?? null,
defaultLanguage: v.defaultLanguage ?? null,
defaultAudioLanguage: v.defaultAudioLanguage ?? null,
selfDeclaredMadeForKids: v.selfDeclaredMadeForKids ?? false,
embeddable: v.embeddable ?? true,
license: v.license ?? 'youtube',
recordingDate: v.recordingDate ?? null,
};
let pending: boolean;
if (v.lastSyncedHash !== null) {
pending = v.lastSyncedHash !== hashMetadata({ title: v.title, description: v.renderedDescription ?? v.youtubeDescription ?? '', ...shared });
} else {
const baseline = hashMetadata({ title: v.title, description: v.youtubeDescription ?? '', ...shared, recordingDate: null });
const current = hashMetadata({ title: v.title, description: v.renderedDescription ?? v.youtubeDescription ?? '', ...shared });
pending = baseline !== current;
}
if (pending) pendingItems.push({ id: v.id, title: v.title });
}
const [recentlySynced, upcomingItems, upcomingCount] = await Promise.all([
this.prisma.video.findMany({
where: { channel: { teamId }, youtubeDeletedAt: null, lastSyncedAt: { not: null } },
select: { id: true, title: true, lastSyncedAt: true },
orderBy: { lastSyncedAt: 'desc' },
take: 5,
}),
this.prisma.video.findMany({
where: { channel: { teamId }, youtubeDeletedAt: null, scheduledAt: { gte: now } },
select: { id: true, title: true, scheduledAt: true },
orderBy: { scheduledAt: 'asc' },
take: 5,
}),
this.prisma.video.count({ where: { channel: { teamId }, youtubeDeletedAt: null, scheduledAt: { gte: now } } }),
]);
return {
pendingSync: { count: pendingItems.length, items: pendingItems.slice(0, 5) },
recentlySynced: { items: recentlySynced },
upcomingScheduled: { count: upcomingCount, items: upcomingItems },
};
}
async findOne(id: string, teamId: string) {
const video = await this.prisma.video.findFirst({
where: { id, channel: { teamId } },
include: {
template: true,
config: true,
lintResults: { where: { resolvedAt: null } },
},
});
if (!video) throw new NotFoundException(`Video ${id} not found`);
return { ...video, hasPendingChanges: this.computePendingChanges(video as any) };
}
private computePendingChanges(v: any): boolean {
return computePendingChanges(v);
}
async update(id: string, dto: UpdateVideoDto, actorId: string, teamId: string) {
const before = await this.findOne(id, teamId);
const { scheduledAt, recordingDate, collaboratorIds, ...rest } = dto;
const video = await this.prisma.video.update({
where: { id },
data: {
...rest,
scheduledAt: scheduledAt ? new Date(scheduledAt) : undefined,
recordingDate: recordingDate ? new Date(recordingDate) : undefined,
...(collaboratorIds !== undefined && { collaboratorIds }),
},
});
await this.audit.log(actorId, 'Video', id, 'update', before, video);
return video;
}
async renderDescription(videoId: string, teamId: string) {
const exists = await this.prisma.video.findFirst({ where: { id: videoId, channel: { teamId } }, select: { id: true, config: { select: { videoId: true } } } });
if (!exists) throw new NotFoundException(`Video ${videoId} not found`);
if (!exists.config) throw new NotFoundException('Video has no config');
const result = await this.videoRender.renderVideo(videoId);
if (!result) throw new NotFoundException('Video has no config');
// Read-only preview — do NOT persist renderedDescription here.
// Only the background render job and sync processor write renderedDescription,
// so the diff panel cannot accidentally flip hasPendingChanges.
return result;
}
async refreshFromYouTube(id: string, teamId: string) {
const video = await this.prisma.video.findFirst({
where: { id, channel: { teamId } },
include: { channel: { select: { id: true } } },
});
if (!video) throw new NotFoundException(`Video ${id} not found`);
const item = await this.ytApi.getVideoMetadata(video.youtubeVideoId, video.channel.id, { actionId: randomUUID(), actionType: 'video_refresh' });
if (!item) throw new NotFoundException('Video not found on YouTube');
const snippet = item.snippet;
const status = item.status;
const recordingDetails = (item as any).recordingDetails;
const contentDetails = (item as any).contentDetails;
const privacyMap: Record<string, any> = { public: 'PUBLIC', private: 'PRIVATE', unlisted: 'UNLISTED' };
const refreshedTitle = snippet?.title ?? video.title;
const refreshedTags = snippet?.tags ?? video.tags;
const refreshedCategoryId = snippet?.categoryId ?? video.categoryId;
const refreshedPrivacyStatus = privacyMap[status?.privacyStatus ?? ''] ?? video.privacyStatus;
const refreshedDefaultLanguage = snippet?.defaultLanguage ?? video.defaultLanguage;
const refreshedDefaultAudioLanguage = snippet?.defaultAudioLanguage ?? video.defaultAudioLanguage;
const refreshedSelfDeclaredMadeForKids = status?.selfDeclaredMadeForKids ?? video.selfDeclaredMadeForKids;
const refreshedEmbeddable = status?.embeddable ?? video.embeddable;
const refreshedLicense = status?.license ?? video.license;
const refreshedRecordingDate = recordingDetails?.recordingDate ? new Date(recordingDetails.recordingDate) : video.recordingDate;
const refreshedYoutubeDescription = snippet?.description ?? null;
const youtubeSnapshot = {
title: refreshedTitle,
tags: refreshedTags,
categoryId: refreshedCategoryId ?? null,
privacyStatus: String(refreshedPrivacyStatus),
defaultLanguage: refreshedDefaultLanguage ?? null,
defaultAudioLanguage: refreshedDefaultAudioLanguage ?? null,
selfDeclaredMadeForKids: refreshedSelfDeclaredMadeForKids,
embeddable: refreshedEmbeddable,
license: refreshedLicense,
recordingDate: refreshedRecordingDate ? refreshedRecordingDate.toISOString().slice(0, 10) : null,
};
// Reset lastSyncedHash to the hash of what YouTube currently has. This prevents
// hasPendingChanges from being incorrectly true after a refresh when YouTube returns
// values (e.g. categoryId) that were null in the DB but valid on YouTube's end.
const lastSyncedHash = hashMetadata({
title: refreshedTitle,
description: refreshedYoutubeDescription ?? '',
tags: refreshedTags,
categoryId: refreshedCategoryId ?? null,
privacyStatus: refreshedPrivacyStatus ? String(refreshedPrivacyStatus) : null,
defaultLanguage: refreshedDefaultLanguage ?? null,
defaultAudioLanguage: refreshedDefaultAudioLanguage ?? null,
selfDeclaredMadeForKids: refreshedSelfDeclaredMadeForKids,
embeddable: refreshedEmbeddable,
license: refreshedLicense,
recordingDate: refreshedRecordingDate,
});
return this.prisma.video.update({
where: { id },
data: {
title: refreshedTitle,
youtubeDescription: refreshedYoutubeDescription,
tags: refreshedTags,
categoryId: refreshedCategoryId,
publishedAt: snippet?.publishedAt ? new Date(snippet.publishedAt) : video.publishedAt,
privacyStatus: refreshedPrivacyStatus,
scheduledAt: (status as any)?.publishAt ? new Date((status as any).publishAt) : video.scheduledAt,
thumbnailUrl: (() => { const t = (snippet as any)?.thumbnails; return t?.maxres?.url ?? t?.standard?.url ?? t?.high?.url ?? t?.medium?.url ?? t?.default?.url ?? video.thumbnailUrl; })(),
madeForKids: status?.madeForKids ?? video.madeForKids,
selfDeclaredMadeForKids: refreshedSelfDeclaredMadeForKids,
embeddable: refreshedEmbeddable,
license: refreshedLicense,
defaultLanguage: refreshedDefaultLanguage,
defaultAudioLanguage: refreshedDefaultAudioLanguage,
recordingDate: refreshedRecordingDate,
youtubeSnapshot,
lastSyncedHash,
},
});
}
async acceptRemote(id: string, actorId: string, teamId: string) {
const video = await this.prisma.video.findFirst({
where: { id, channel: { teamId } },
});
if (!video) throw new NotFoundException(`Video ${id} not found`);
if (!video.pendingRemoteSnapshot) {
throw new BadRequestException('No pending remote snapshot to accept');
}
const snap = video.pendingRemoteSnapshot as {
title: string;
tags: string[];
categoryId: string | null;
privacyStatus: string | null;
defaultLanguage: string | null;
defaultAudioLanguage: string | null;
selfDeclaredMadeForKids: boolean;
embeddable: boolean;
license: string;
recordingDate: string | null;
};
const pendingDescription = video.pendingRemoteDescription ?? '';
const recordingDate = snap.recordingDate ? new Date(snap.recordingDate) : null;
const privacyStatus = (snap.privacyStatus ?? video.privacyStatus) as PrivacyStatus;
const lastSyncedHash = hashMetadata({
title: snap.title,
description: pendingDescription,
tags: snap.tags,
categoryId: snap.categoryId,
privacyStatus: String(privacyStatus),
defaultLanguage: snap.defaultLanguage,
defaultAudioLanguage: snap.defaultAudioLanguage,
selfDeclaredMadeForKids: snap.selfDeclaredMadeForKids,
embeddable: snap.embeddable,
license: snap.license,
recordingDate,
});
const updated = await this.prisma.video.update({
where: { id },
data: {
title: snap.title,
tags: snap.tags,
categoryId: snap.categoryId,
privacyStatus,
defaultLanguage: snap.defaultLanguage,
defaultAudioLanguage: snap.defaultAudioLanguage,
selfDeclaredMadeForKids: snap.selfDeclaredMadeForKids,
embeddable: snap.embeddable,
license: snap.license,
recordingDate,
youtubeDescription: pendingDescription,
renderedDescription: pendingDescription,
youtubeSnapshot: video.pendingRemoteSnapshot as Prisma.InputJsonValue,
lastSyncedHash,
lastSyncedAt: new Date(),
remoteConflict: false,
pendingRemoteSnapshot: Prisma.JsonNull,
pendingRemoteDescription: null,
},
});
await this.audit.log(actorId, 'Video', id, 'accept-remote', video, updated);
return updated;
}
async enqueueSyncJob(videoId: string) {
const jobId = `sync-${videoId}`;
// BullMQ deduplicates by jobId across all states (failed, waiting, delayed, etc.).
// Remove any stale job so the user always gets a fresh enqueue. Never remove active
// jobs — the worker is mid-processing and removing would leave the DB in a partial state.
const existing = await this.syncQueue.getJob(jobId);
if (existing) {
const state = await existing.getState();
if (state !== 'active') await existing.remove();
}
await this.syncQueue.add('sync', { videoId }, { jobId, removeOnComplete: true, removeOnFail: { count: 5 } });
return { queued: true };
}
async bulkPreview(dto: BulkPreviewDto, actorId: string, teamId: string) {
const videoIds = await this.resolveVideoIds(dto, teamId);
const previews = await Promise.all(
videoIds.map(async (id) => {
const before = await this.prisma.video.findUnique({
where: { id },
select: { id: true, title: true, tags: true, privacyStatus: true, templateId: true },
});
return { videoId: id, before, after: this.applyBulkAction(before, dto.type, dto.payload) };
}),
);
return { type: dto.type, count: videoIds.length, previews };
}
async bulkApply(dto: BulkPreviewDto, actorId: string, teamId: string) {
const videoIds = await this.resolveVideoIds(dto, teamId);
const bulkJob = await this.prisma.bulkJob.create({
data: {
teamId,
type: dto.type,
initiatedBy: actorId,
filterSnapshot: dto as any,
targetIds: videoIds,
status: 'CONFIRMED',
totalCount: videoIds.length,
items: { create: videoIds.map((videoId) => ({ videoId, status: 'pending' })) },
},
include: { items: true },
});
return { bulkJobId: bulkJob.id, count: videoIds.length };
}
private async resolveVideoIds(dto: BulkPreviewDto, teamId: string): Promise<string[]> {
if (dto.videoIds?.length) return dto.videoIds;
if (dto.savedViewId) {
const view = await this.prisma.savedView.findFirstOrThrow({ where: { id: dto.savedViewId, teamId } });
const filter = { ...(view.queryJson as any), channel: { teamId } };
const videos = await this.prisma.video.findMany({ where: filter, select: { id: true } });
return videos.map((v) => v.id);
}
throw new BadRequestException('Provide videoIds or savedViewId');
}
private applyBulkAction(video: any, type: string, payload: any): any {
const after = { ...video };
switch (type) {
case 'SET_PRIVACY': after.privacyStatus = payload.privacyStatus; break;
case 'SET_TEMPLATE': after.templateId = payload.templateId; break;
case 'ADD_TAGS': after.tags = [...(after.tags ?? []), ...(payload.tags ?? [])]; break;
case 'REMOVE_TAGS': after.tags = (after.tags ?? []).filter((t: string) => !(payload.tags ?? []).includes(t)); break;
case 'SEARCH_REPLACE_TITLE': after.title = after.title?.replace(payload.search, payload.replace); break;
}
return after;
}
}
@@ -0,0 +1,476 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { YouTubeApiClient } from './youtube-api.client';
import { hashMetadata } from '../../shared/render-engine/hash';
import { PrivacyStatus } from '@prisma/client';
const YT_PRIVACY: Record<string, PrivacyStatus> = {
public: PrivacyStatus.PUBLIC,
private: PrivacyStatus.PRIVATE,
unlisted: PrivacyStatus.UNLISTED,
};
type ActionCtx = { actionId?: string; actionType?: string };
@Injectable()
export class ChannelImportService {
private readonly logger = new Logger(ChannelImportService.name);
constructor(
private readonly prisma: PrismaService,
private readonly ytApi: YouTubeApiClient,
) {}
async importChannel(
channelId: string,
teamId: string,
): Promise<{ total: number; created: number; updated: number; deleted: number; deletedTitles: string[] }> {
this.logger.log(`Channel import started for channel ${channelId}`);
const ctx: ActionCtx = { actionId: randomUUID(), actionType: 'channel_import' };
const channel = await this.prisma.channel.findFirstOrThrow({
where: { id: channelId, teamId },
});
const playlistId =
channel.uploadsPlaylistId ?? (await this.ytApi.getUploadsPlaylistId(channelId, ctx));
const { ids: rawIds, pageCount } = await this.ytApi.listPlaylistVideoIds(channelId, playlistId, ctx);
const playlistIds = new Set(rawIds);
if (rawIds.length !== playlistIds.size) {
const seen = new Set<string>();
const dupes = rawIds.filter((id) => seen.size === seen.add(id).size);
this.logger.warn(`Playlist has ${rawIds.length - playlistIds.size} duplicate entries: ${[...new Set(dupes)].join(', ')}`);
}
const supplemental = channel.supplementalVideoIds ?? [];
const newSupplemental = supplemental.filter((id) => !playlistIds.has(id));
if (newSupplemental.length > 0) {
this.logger.log(`Adding ${newSupplemental.length} supplemental video IDs not in playlist: ${newSupplemental.join(', ')}`);
}
const ids = [...playlistIds, ...newSupplemental];
this.logger.log(`Found ${playlistIds.size} unique videos (${rawIds.length} playlist entries) across ${pageCount} pages, plus ${newSupplemental.length} supplemental`);
let created = 0;
let updated = 0;
for (let i = 0; i < ids.length; i += 50) {
const batch = ids.slice(i, i + 50);
const items = await this.ytApi.getVideosBatch(batch, channelId, ctx);
const returnedIds = new Set(items.map((item) => item.id));
const missing = batch.filter((id) => !returnedIds.has(id));
if (missing.length > 0) {
this.logger.warn(`videos.list did not return ${missing.length} IDs: ${missing.join(', ')}`);
}
for (const item of items) {
const result = await this.upsertVideoFromYouTubeItem(item, channelId);
if (result === 'created') created++;
else if (result === 'updated') updated++;
}
}
await this.syncPlaylistMembership(channelId, ctx);
const purgeResult = await this._purgeForChannel(channelId);
this.logger.log(`Import complete: ${created} created, ${updated} updated, ${purgeResult.softDeleted} soft-deleted, ${purgeResult.hardDeleted} hard-deleted, ${purgeResult.restored} restored`);
return { total: ids.length, created, updated, deleted: purgeResult.softDeleted + purgeResult.hardDeleted, deletedTitles: purgeResult.softDeletedTitles };
}
async importSpecificVideos(
channelId: string,
teamId: string,
videoIds: string[],
): Promise<{ total: number; created: number; updated: number; notFound: string[] }> {
this.logger.log(`Importing ${videoIds.length} specific video IDs for channel ${channelId}`);
const ctx: ActionCtx = { actionId: randomUUID(), actionType: 'channel_import' };
await this.prisma.channel.findFirstOrThrow({ where: { id: channelId, teamId } });
const unique = [...new Set(videoIds)];
let created = 0;
let updated = 0;
const notFoundIds: string[] = [];
for (let i = 0; i < unique.length; i += 50) {
const batch = unique.slice(i, i + 50);
const items = await this.ytApi.getVideosBatch(batch, channelId, ctx);
const returnedIds = new Set(items.map((item) => item.id));
notFoundIds.push(...batch.filter((id) => !returnedIds.has(id)));
for (const item of items) {
const result = await this.upsertVideoFromYouTubeItem(item, channelId);
if (result === 'created') created++;
else if (result === 'updated') updated++;
}
}
if (notFoundIds.length > 0) {
this.logger.warn(`YouTube did not return ${notFoundIds.length} IDs: ${notFoundIds.join(', ')}`);
}
this.logger.log(`Specific import complete: ${created} created, ${updated} updated, ${notFoundIds.length} not found`);
return { total: unique.length, created, updated, notFound: notFoundIds };
}
async fullRefreshChannel(
channelId: string,
teamId: string,
): Promise<{
total: number;
created: number;
updated: number;
duplicateUploadsEntries: number;
notFoundOnYouTube: string[];
playlistsForceSynced: number;
orphansImported: number;
orphansRejected: number;
deleted: number;
deletedTitles: string[];
}> {
this.logger.log(`Full refresh started for channel ${channelId}`);
const ctx: ActionCtx = { actionId: randomUUID(), actionType: 'channel_import' };
const channel = await this.prisma.channel.findFirstOrThrow({
where: { id: channelId, teamId },
});
const playlistId =
channel.uploadsPlaylistId ?? (await this.ytApi.getUploadsPlaylistId(channelId, ctx));
const { ids: rawIds, pageCount } = await this.ytApi.listPlaylistVideoIds(channelId, playlistId, ctx);
const uploadsIds = new Set(rawIds);
const duplicateUploadsEntries = rawIds.length - uploadsIds.size;
if (duplicateUploadsEntries > 0) {
const seen = new Set<string>();
const dupes = rawIds.filter((id) => seen.size === seen.add(id).size);
this.logger.warn(`Uploads playlist has ${duplicateUploadsEntries} duplicate entries: ${[...new Set(dupes)].join(', ')}`);
}
const supplemental = channel.supplementalVideoIds ?? [];
const newSupplemental = supplemental.filter((id) => !uploadsIds.has(id));
const ids = [...uploadsIds, ...newSupplemental];
this.logger.log(`Full refresh: ${uploadsIds.size} unique videos (${rawIds.length} entries) across ${pageCount} pages, plus ${newSupplemental.length} supplemental`);
let created = 0;
let updated = 0;
const notFoundOnYouTube: string[] = [];
for (let i = 0; i < ids.length; i += 50) {
const batch = ids.slice(i, i + 50);
const items = await this.ytApi.getVideosBatch(batch, channelId, ctx);
const returnedIds = new Set(items.map((item) => item.id));
notFoundOnYouTube.push(...batch.filter((id) => !returnedIds.has(id)));
for (const item of items) {
const result = await this.upsertVideoFromYouTubeItem(item, channelId);
if (result === 'created') created++;
else if (result === 'updated') updated++;
}
}
if (notFoundOnYouTube.length > 0) {
this.logger.warn(`YouTube did not return ${notFoundOnYouTube.length} IDs during full refresh: ${notFoundOnYouTube.join(', ')}`);
}
// Force-sync all playlists and collect every video ID seen across them
const { synced: playlistsForceSynced, allVideoIds: playlistVideoIds } =
await this.syncPlaylistMembership(channelId, ctx, true);
// Find IDs that appear in other playlists but not in the uploads playlist or supplemental list
const knownIds = new Set([...uploadsIds, ...supplemental]);
const orphanCandidates = [...playlistVideoIds].filter((id) => !knownIds.has(id));
let orphansImported = 0;
let orphansRejected = 0;
if (orphanCandidates.length > 0) {
this.logger.log(`Found ${orphanCandidates.length} playlist video IDs not in uploads playlist — verifying channel ownership`);
for (let i = 0; i < orphanCandidates.length; i += 50) {
const batch = orphanCandidates.slice(i, i + 50);
const items = await this.ytApi.getVideosBatch(batch, channelId, ctx);
for (const item of items) {
// Only import videos that actually belong to this channel
if (item.snippet?.channelId !== channel.youtubeChannelId) {
this.logger.log(`Skipping ${item.id} — belongs to channel ${item.snippet?.channelId}, not ${channel.youtubeChannelId}`);
orphansRejected++;
continue;
}
const result = await this.upsertVideoFromYouTubeItem(item, channelId);
if (result === 'created' || result === 'updated') {
orphansImported++;
created += result === 'created' ? 1 : 0;
updated += result === 'updated' ? 1 : 0;
}
}
}
if (orphansImported > 0) {
this.logger.log(`Imported ${orphansImported} orphaned videos found in playlists but not in uploads playlist`);
}
if (orphansRejected > 0) {
this.logger.warn(`Rejected ${orphansRejected} playlist videos — belong to a different channel`);
}
}
const purgeResult = await this._purgeForChannel(channelId);
this.logger.log(`Full refresh complete: ${created} created, ${updated} updated, ${playlistsForceSynced} playlists force-synced, ${orphansImported} orphans imported, ${purgeResult.softDeleted} soft-deleted, ${purgeResult.hardDeleted} hard-deleted, ${purgeResult.restored} restored`);
return {
total: ids.length + orphansImported,
created,
updated,
duplicateUploadsEntries,
notFoundOnYouTube,
playlistsForceSynced,
orphansImported,
orphansRejected,
deleted: purgeResult.softDeleted + purgeResult.hardDeleted,
deletedTitles: purgeResult.softDeletedTitles,
};
}
async purgeDeletedVideos(channelId: string, teamId: string) {
await this.prisma.channel.findFirstOrThrow({ where: { id: channelId, teamId } });
return this._purgeForChannel(channelId);
}
private async _purgeForChannel(channelId: string): Promise<{ checked: number; softDeleted: number; restored: number; hardDeleted: number; softDeletedTitles: string[] }> {
const localVideos = await this.prisma.video.findMany({
where: { channelId },
select: { id: true, youtubeVideoId: true, title: true, youtubeDeletedAt: true },
});
const ctx: ActionCtx = { actionId: randomUUID(), actionType: 'purge_deleted' };
const softDeleteIds: string[] = [];
const softDeletedTitles: string[] = [];
const hardDeleteIds: string[] = [];
const restoreIds: string[] = [];
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
for (let i = 0; i < localVideos.length; i += 50) {
const batch = localVideos.slice(i, i + 50);
const items = await this.ytApi.getVideosBatch(batch.map((v) => v.youtubeVideoId), channelId, ctx);
// Videos returned with uploadStatus !== 'deleted' are live on YouTube
const liveYtIds = new Set(
items.filter((item) => item.status?.uploadStatus !== 'deleted').map((item) => item.id),
);
for (const v of batch) {
if (!liveYtIds.has(v.youtubeVideoId)) {
if (v.youtubeDeletedAt && v.youtubeDeletedAt < thirtyDaysAgo) {
hardDeleteIds.push(v.id);
} else if (!v.youtubeDeletedAt) {
softDeleteIds.push(v.id);
softDeletedTitles.push(v.title);
}
// already soft-deleted and within 30 days — no action
} else if (v.youtubeDeletedAt) {
restoreIds.push(v.id);
}
}
}
if (softDeleteIds.length > 0) {
await this.prisma.video.updateMany({ where: { id: { in: softDeleteIds } }, data: { youtubeDeletedAt: new Date() } });
this.logger.log(`Soft-deleted ${softDeleteIds.length} videos from channel ${channelId}: ${softDeletedTitles.join(', ')}`);
}
if (hardDeleteIds.length > 0) {
await this.prisma.video.deleteMany({ where: { id: { in: hardDeleteIds } } });
this.logger.log(`Hard-deleted ${hardDeleteIds.length} expired videos from channel ${channelId}`);
}
if (restoreIds.length > 0) {
await this.prisma.video.updateMany({ where: { id: { in: restoreIds } }, data: { youtubeDeletedAt: null } });
this.logger.log(`Restored ${restoreIds.length} videos for channel ${channelId}`);
}
return {
checked: localVideos.length,
softDeleted: softDeleteIds.length,
restored: restoreIds.length,
hardDeleted: hardDeleteIds.length,
softDeletedTitles,
};
}
private async upsertVideoFromYouTubeItem(
item: any,
channelId: string,
): Promise<'created' | 'updated' | 'skipped'> {
const ytId = item.id;
if (!ytId) return 'skipped';
const snippet = item.snippet;
const status = item.status;
const title = snippet?.title ?? '(Untitled)';
const youtubeDescription = snippet?.description ?? undefined;
const tags = snippet?.tags ?? [];
const categoryId = snippet?.categoryId ?? undefined;
const publishedAt = snippet?.publishedAt ? new Date(snippet.publishedAt) : undefined;
const privacyStatus: PrivacyStatus =
YT_PRIVACY[status?.privacyStatus ?? ''] ?? PrivacyStatus.PRIVATE;
const defaultLanguage = snippet?.defaultLanguage ?? undefined;
const defaultAudioLanguage = snippet?.defaultAudioLanguage ?? undefined;
const selfDeclaredMadeForKids = (status as any)?.selfDeclaredMadeForKids ?? undefined;
const embeddable = (status as any)?.embeddable ?? undefined;
const license = (status as any)?.license ?? undefined;
const scheduledAt = (status as any)?.publishAt ? new Date((status as any).publishAt) : undefined;
const thumbnails = snippet?.thumbnails as any;
const thumbnailUrl =
thumbnails?.maxres?.url ??
thumbnails?.standard?.url ??
thumbnails?.high?.url ??
thumbnails?.medium?.url ??
thumbnails?.default?.url ??
undefined;
const ytFields = {
title, youtubeDescription, tags, categoryId, publishedAt, privacyStatus,
defaultLanguage, defaultAudioLanguage, selfDeclaredMadeForKids, embeddable, license,
scheduledAt, thumbnailUrl,
youtubeDeletedAt: null,
};
const existing = await this.prisma.video.findUnique({ where: { youtubeVideoId: ytId } });
// For fields YouTube doesn't reliably return, fall back to existing DB value so
// the import hash stays consistent with what computePendingChanges will compute.
// recordingDate is never returned by the API, so preserving it prevents false
// "push pending" after re-import.
const resolvedFields = {
defaultLanguage: defaultLanguage ?? existing?.defaultLanguage ?? null,
defaultAudioLanguage: defaultAudioLanguage ?? existing?.defaultAudioLanguage ?? null,
selfDeclaredMadeForKids: selfDeclaredMadeForKids ?? existing?.selfDeclaredMadeForKids ?? false,
embeddable: embeddable ?? existing?.embeddable ?? true,
license: license ?? existing?.license ?? 'youtube',
recordingDate: existing?.recordingDate ?? null,
};
const importHash = hashMetadata({
title,
description: youtubeDescription ?? '',
tags,
categoryId: categoryId ?? null,
privacyStatus: privacyStatus ? String(privacyStatus) : null,
...resolvedFields,
});
const youtubeSnapshot = {
title,
tags,
categoryId: categoryId ?? null,
privacyStatus: String(privacyStatus),
defaultLanguage: resolvedFields.defaultLanguage,
defaultAudioLanguage: resolvedFields.defaultAudioLanguage,
selfDeclaredMadeForKids: resolvedFields.selfDeclaredMadeForKids,
embeddable: resolvedFields.embeddable,
license: resolvedFields.license,
recordingDate: resolvedFields.recordingDate
? resolvedFields.recordingDate.toISOString().slice(0, 10)
: null,
};
if (!existing) {
await this.prisma.video.create({
data: { youtubeVideoId: ytId, channelId, ...ytFields, lastSyncedHash: importHash, youtubeSnapshot },
});
return 'created';
} else {
// Always update channelId in case the video was previously imported under a
// stale/wrong channel — this re-assigns ownership to the correct channel.
await this.prisma.video.update({
where: { youtubeVideoId: ytId },
data: { channelId, ...ytFields, lastSyncedHash: importHash, youtubeSnapshot },
});
return 'updated';
}
}
private async syncPlaylistMembership(
channelId: string,
ctx?: ActionCtx,
force = false,
): Promise<{ synced: number; skipped: number; allVideoIds: Set<string> }> {
const ytPlaylists = await this.ytApi.listChannelPlaylists(channelId, ctx);
const existingPlaylists = await this.prisma.playlist.findMany({
where: { channelId },
select: { id: true, youtubePlaylistId: true, itemCount: true },
});
const existingMap = new Map(existingPlaylists.map((pl) => [pl.youtubePlaylistId, pl]));
for (const pl of ytPlaylists) {
await this.prisma.playlist.upsert({
where: { youtubePlaylistId: pl.youtubePlaylistId },
create: { channelId, ...pl },
update: { title: pl.title, description: pl.description, itemCount: pl.itemCount, privacyStatus: pl.privacyStatus },
});
}
const dbPlaylists = await this.prisma.playlist.findMany({
where: { channelId },
select: { id: true, youtubePlaylistId: true },
});
const playlistIdMap = new Map(dbPlaylists.map((pl) => [pl.youtubePlaylistId, pl.id]));
let skipped = 0;
let synced = 0;
const allVideoIds = new Set<string>();
for (const pl of ytPlaylists) {
const dbPlaylistId = playlistIdMap.get(pl.youtubePlaylistId);
if (!dbPlaylistId) continue;
const existing = existingMap.get(pl.youtubePlaylistId);
if (!force && existing && existing.itemCount === pl.itemCount) {
skipped++;
continue;
}
const { ids: ytVideoIds } = await this.ytApi.listPlaylistVideoIds(channelId, pl.youtubePlaylistId, ctx);
for (const id of ytVideoIds) allVideoIds.add(id);
const matchingVideos = await this.prisma.video.findMany({
where: { youtubeVideoId: { in: ytVideoIds }, channelId },
select: { id: true },
});
await this.prisma.videoPlaylist.deleteMany({ where: { playlistId: dbPlaylistId } });
if (matchingVideos.length > 0) {
await this.prisma.videoPlaylist.createMany({
data: matchingVideos.map((v) => ({ videoId: v.id, playlistId: dbPlaylistId })),
skipDuplicates: true,
});
}
synced++;
}
this.logger.log(`Playlist membership synced: ${synced} updated, ${skipped} skipped (itemCount unchanged)`);
return { synced, skipped, allVideoIds };
}
async addSupplementalVideoIds(channelId: string, teamId: string, videoIds: string[]): Promise<string[]> {
const channel = await this.prisma.channel.findFirstOrThrow({ where: { id: channelId, teamId } });
const existing = new Set(channel.supplementalVideoIds ?? []);
for (const id of videoIds) existing.add(id);
const updated = [...existing];
await this.prisma.channel.update({ where: { id: channelId }, data: { supplementalVideoIds: updated } });
this.logger.log(`Channel ${channelId} supplemental IDs updated: ${updated.length} total`);
return updated;
}
}
@@ -0,0 +1,239 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { google } from 'googleapis';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AuthService } from '../auth/auth.service';
import { QuotaService } from '../../shared/quota/quota.service';
import { PrivacyStatus } from '@prisma/client';
const PRIVACY_MAP: Record<PrivacyStatus, string> = {
PUBLIC: 'public',
PRIVATE: 'private',
UNLISTED: 'unlisted',
};
@Injectable()
export class YouTubeApiClient {
constructor(
private readonly prisma: PrismaService,
private readonly authService: AuthService,
private readonly config: ConfigService,
private readonly quota: QuotaService,
) {}
private async getYouTubeClient(channelId: string) {
const channel = await this.prisma.channel.findUniqueOrThrow({ where: { id: channelId } });
const oauth2 = new google.auth.OAuth2(
this.config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
this.config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
);
const accessToken = this.authService.decryptToken(channel.youtubeAccessToken!);
const refreshToken = channel.youtubeRefreshToken
? this.authService.decryptToken(channel.youtubeRefreshToken)
: undefined;
oauth2.setCredentials({ access_token: accessToken, refresh_token: refreshToken });
// Persist rotated tokens back to the Channel record
oauth2.on('tokens', async (tokens) => {
await this.prisma.channel.update({
where: { id: channelId },
data: {
...(tokens.access_token && {
youtubeAccessToken: this.authService.encryptToken(tokens.access_token),
youtubeTokenExpiry: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined,
}),
...(tokens.refresh_token && {
youtubeRefreshToken: this.authService.encryptToken(tokens.refresh_token),
}),
},
});
});
return google.youtube({ version: 'v3', auth: oauth2 });
}
async getUploadsPlaylistId(channelId: string, ctx?: { actionId?: string; actionType?: string }): Promise<string> {
const yt = await this.getYouTubeClient(channelId);
const res = await yt.channels.list({ part: ['contentDetails'], mine: true });
await this.quota.spend(1, 'channels.list', channelId, undefined, undefined, undefined, ctx?.actionId, ctx?.actionType);
const playlistId = res.data.items?.[0]?.contentDetails?.relatedPlaylists?.uploads;
if (!playlistId) throw new Error('Could not find uploads playlist for channel');
// Cache on the channel record to save quota on future calls
await this.prisma.channel.update({
where: { id: channelId },
data: { uploadsPlaylistId: playlistId },
});
return playlistId;
}
async listPlaylistVideoIds(
channelId: string,
playlistId: string,
ctx?: { actionId?: string; actionType?: string },
): Promise<{ ids: string[]; pageCount: number }> {
const yt = await this.getYouTubeClient(channelId);
const ids: string[] = [];
let pageToken: string | undefined;
let pageCount = 0;
do {
const res = await yt.playlistItems.list({
part: ['contentDetails'],
playlistId,
maxResults: 50,
...(pageToken ? { pageToken } : {}),
});
pageCount++;
await this.quota.spend(1, 'playlistItems.list', channelId, undefined, undefined, playlistId, ctx?.actionId, ctx?.actionType);
for (const item of res.data.items ?? []) {
const vid = item.contentDetails?.videoId;
if (vid) ids.push(vid);
}
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
return { ids, pageCount };
}
async getVideosBatch(youtubeVideoIds: string[], channelId: string, ctx?: { actionId?: string; actionType?: string }) {
const yt = await this.getYouTubeClient(channelId);
const res = await yt.videos.list({
part: ['snippet', 'status'],
id: youtubeVideoIds,
maxResults: 50,
});
await this.quota.spend(1, 'videos.list', channelId, undefined, undefined, `batch:${youtubeVideoIds.length}`, ctx?.actionId, ctx?.actionType);
return res.data.items ?? [];
}
async getVideoMetadata(youtubeVideoId: string, channelId: string, ctx?: { actionId?: string; actionType?: string }) {
const yt = await this.getYouTubeClient(channelId);
const res = await yt.videos.list({
part: ['snippet', 'status', 'recordingDetails', 'contentDetails'],
id: [youtubeVideoId],
});
await this.quota.spend(1, 'videos.list', channelId, undefined, undefined, youtubeVideoId, ctx?.actionId, ctx?.actionType);
return res.data.items?.[0] ?? null;
}
async listChannelPlaylists(channelId: string, ctx?: { actionId?: string; actionType?: string }): Promise<{ youtubePlaylistId: string; title: string; description: string; itemCount: number; privacyStatus: string }[]> {
const yt = await this.getYouTubeClient(channelId);
const results: any[] = [];
let pageToken: string | undefined;
do {
const res = await yt.playlists.list({
part: ['snippet', 'status', 'contentDetails'],
mine: true,
maxResults: 50,
...(pageToken ? { pageToken } : {}),
});
await this.quota.spend(1, 'playlists.list', channelId, undefined, undefined, undefined, ctx?.actionId, ctx?.actionType);
for (const item of res.data.items ?? []) {
results.push({
youtubePlaylistId: item.id!,
title: item.snippet?.title ?? '',
description: item.snippet?.description ?? '',
itemCount: item.contentDetails?.itemCount ?? 0,
privacyStatus: item.status?.privacyStatus ?? 'public',
});
}
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
return results;
}
async addVideoToPlaylist(youtubeVideoId: string, youtubePlaylistId: string, channelId: string, ctx?: { actionId?: string; actionType?: string }): Promise<string> {
const yt = await this.getYouTubeClient(channelId);
const res = await yt.playlistItems.insert({
part: ['snippet'],
requestBody: {
snippet: {
playlistId: youtubePlaylistId,
resourceId: { kind: 'youtube#video', videoId: youtubeVideoId },
},
},
});
await this.quota.spend(50, 'playlistItems.insert', channelId, undefined, undefined, youtubePlaylistId, ctx?.actionId, ctx?.actionType);
return res.data.id!;
}
async removeVideoFromPlaylist(youtubeVideoId: string, youtubePlaylistId: string, channelId: string, ctx?: { actionId?: string; actionType?: string }): Promise<void> {
const yt = await this.getYouTubeClient(channelId);
const res = await yt.playlistItems.list({
part: ['id'],
playlistId: youtubePlaylistId,
videoId: youtubeVideoId,
});
await this.quota.spend(1, 'playlistItems.list', channelId, undefined, undefined, youtubePlaylistId, ctx?.actionId, ctx?.actionType);
const itemId = res.data.items?.[0]?.id;
if (!itemId) return;
await yt.playlistItems.delete({ id: itemId });
await this.quota.spend(50, 'playlistItems.delete', channelId, undefined, undefined, youtubePlaylistId, ctx?.actionId, ctx?.actionType);
}
async updateVideoMetadata(
youtubeVideoId: string,
meta: {
title: string;
description: string;
tags: string[];
categoryId?: string;
privacyStatus: PrivacyStatus;
scheduledAt?: Date | null;
defaultLanguage?: string;
defaultAudioLanguage?: string;
selfDeclaredMadeForKids?: boolean;
embeddable?: boolean;
license?: string;
recordingDate?: Date | null;
},
channelId: string,
ctx?: { actionId?: string; actionType?: string },
) {
const yt = await this.getYouTubeClient(channelId);
const parts: string[] = ['snippet', 'status'];
if (meta.recordingDate !== undefined) parts.push('recordingDetails');
// Preserve scheduled publishing: YouTube represents a scheduled video as
// privacyStatus=private + publishAt. If we send privacyStatus=private without
// publishAt, YouTube silently cancels the schedule.
const isFutureSchedule = meta.scheduledAt && meta.scheduledAt > new Date();
const result = await yt.videos.update({
part: parts,
requestBody: {
id: youtubeVideoId,
snippet: {
title: meta.title,
description: meta.description,
tags: meta.tags,
categoryId: meta.categoryId,
...(meta.defaultLanguage !== undefined && { defaultLanguage: meta.defaultLanguage || undefined }),
...(meta.defaultAudioLanguage !== undefined && { defaultAudioLanguage: meta.defaultAudioLanguage || undefined }),
},
status: {
privacyStatus: PRIVACY_MAP[meta.privacyStatus],
...(isFutureSchedule && { publishAt: meta.scheduledAt!.toISOString() }),
...(meta.selfDeclaredMadeForKids !== undefined && { selfDeclaredMadeForKids: meta.selfDeclaredMadeForKids }),
...(meta.embeddable !== undefined && { embeddable: meta.embeddable }),
...(meta.license !== undefined && { license: meta.license }),
},
...(meta.recordingDate !== undefined && {
recordingDetails: {
recordingDate: meta.recordingDate ? meta.recordingDate.toISOString().slice(0, 10) : undefined,
},
}),
},
});
await this.quota.spend(50, 'videos.update', channelId, undefined, undefined, youtubeVideoId, ctx?.actionId, ctx?.actionType);
return result;
}
}
@@ -0,0 +1,115 @@
import { Controller, Get, Post, Param, UseGuards, Req, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeamRole } from '@prisma/client';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { ChannelImportService } from './channel-import.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { QUEUES } from '../../queues/queues.constants';
@ApiTags('youtube-sync')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('youtube-sync')
export class YouTubeSyncController {
constructor(
private readonly importService: ChannelImportService,
private readonly prisma: PrismaService,
@InjectQueue(QUEUES.YOUTUBE_SYNC) private readonly syncQueue: Queue,
) {}
@Get('queue-status')
@ApiOperation({ summary: 'Current state of the YouTube sync queue' })
async queueStatus() {
const [active, waiting, failed, completed] = await Promise.all([
this.syncQueue.getActive(),
this.syncQueue.getWaiting(),
this.syncQueue.getFailed(0, 10),
this.syncQueue.getCompleted(0, 5),
]);
const allVideoIds = [...new Set(
[...active, ...waiting, ...failed, ...completed]
.map((j) => j.data?.videoId as string)
.filter(Boolean),
)];
const videos = await this.prisma.video.findMany({
where: { id: { in: allVideoIds } },
select: { id: true, title: true },
});
const titleMap = new Map(videos.map((v) => [v.id, v.title]));
const mapJob = (j: any) => ({
jobId: String(j.id),
videoId: j.data?.videoId as string,
videoTitle: titleMap.get(j.data?.videoId) ?? 'Unknown',
addedAt: j.timestamp ? new Date(j.timestamp).toISOString() : null,
});
return {
active: active.map(mapJob),
waiting: waiting.map(mapJob),
recentFailed: failed.map((j) => ({
...mapJob(j),
failedReason: j.failedReason ?? null,
failedAt: j.finishedOn ? new Date(j.finishedOn).toISOString() : null,
})),
recentCompleted: completed.map((j) => ({
...mapJob(j),
completedAt: j.finishedOn ? new Date(j.finishedOn).toISOString() : null,
})),
};
}
@Post('channel-import')
@Roles(TeamRole.EDITOR, TeamRole.ADMIN, TeamRole.OWNER)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Pull all videos from a connected YouTube channel' })
runChannelImport(@Req() req: any, @Body('channelId') channelId: string) {
return this.importService.importChannel(channelId, req.user.teamId);
}
@Post('import-video-ids')
@Roles(TeamRole.ADMIN, TeamRole.OWNER)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Immediately import specific YouTube video IDs into the DB' })
importVideoIds(
@Req() req: any,
@Body('channelId') channelId: string,
@Body('videoIds') videoIds: string[],
) {
return this.importService.importSpecificVideos(channelId, req.user.teamId, videoIds);
}
@Post('channel-full-refresh')
@Roles(TeamRole.ADMIN, TeamRole.OWNER)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Force-reimport all videos and playlists for a channel (ignores itemCount cache)' })
fullRefreshChannel(@Req() req: any, @Body('channelId') channelId: string) {
return this.importService.fullRefreshChannel(channelId, req.user.teamId);
}
@Post('channel-purge-deleted')
@Roles(TeamRole.ADMIN, TeamRole.OWNER)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete local videos that no longer exist on YouTube' })
purgeDeleted(@Req() req: any, @Body('channelId') channelId: string) {
return this.importService.purgeDeletedVideos(channelId, req.user.teamId);
}
@Post('channels/:channelId/supplemental-ids')
@Roles(TeamRole.ADMIN, TeamRole.OWNER)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Add video IDs to the channel supplemental list (always imported regardless of playlist membership)' })
addSupplementalIds(
@Param('channelId') channelId: string,
@Req() req: any,
@Body('videoIds') videoIds: string[],
) {
return this.importService.addSupplementalVideoIds(channelId, req.user.teamId, videoIds);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { YouTubeSyncService } from './youtube-sync.service';
import { YouTubeApiClient } from './youtube-api.client';
import { ChannelImportService } from './channel-import.service';
import { YouTubeSyncController } from './youtube-sync.controller';
import { QuotaModule } from '../../shared/quota/quota.module';
import { AuthModule } from '../auth/auth.module';
import { QUEUES } from '../../queues/queues.constants';
@Module({
imports: [QuotaModule, AuthModule, BullModule.registerQueue({ name: QUEUES.YOUTUBE_SYNC })],
controllers: [YouTubeSyncController],
providers: [YouTubeSyncService, YouTubeApiClient, ChannelImportService],
exports: [YouTubeSyncService, YouTubeApiClient, ChannelImportService],
})
export class YouTubeSyncModule {}
@@ -0,0 +1,147 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { QuotaService } from '../../shared/quota/quota.service';
import { YouTubeApiClient } from './youtube-api.client';
import { hashMetadata } from '../../shared/render-engine/hash';
import { PrivacyStatus, Prisma, Video } from '@prisma/client';
@Injectable()
export class YouTubeSyncService {
constructor(
private readonly prisma: PrismaService,
private readonly quota: QuotaService,
private readonly ytClient: YouTubeApiClient,
) {}
async fetchRemote(youtubeVideoId: string, channelId: string) {
if (!(await this.quota.canSpend(1))) throw new Error('Quota exhausted');
return this.ytClient.getVideoMetadata(youtubeVideoId, channelId);
}
async pushUpdate(
video: { youtubeVideoId: string; channelId: string },
meta: {
title: string;
description: string;
tags: string[];
categoryId?: string;
privacyStatus: PrivacyStatus;
scheduledAt?: Date | null;
defaultLanguage?: string;
defaultAudioLanguage?: string;
selfDeclaredMadeForKids?: boolean;
embeddable?: boolean;
license?: string;
recordingDate?: Date | null;
},
ctx?: { actionId?: string; actionType?: string },
) {
await this.ytClient.updateVideoMetadata(video.youtubeVideoId, meta, video.channelId, ctx);
}
async detectConflictsForVideos(
videoIds: string[],
): Promise<{ scanned: number; conflicts: number; quotaExhausted: boolean }> {
if (videoIds.length === 0) return { scanned: 0, conflicts: 0, quotaExhausted: false };
const videos = await this.prisma.video.findMany({ where: { id: { in: videoIds } } });
// Batch API calls are scoped to a channel's OAuth client, so group first.
const byChannel = new Map<string, Video[]>();
for (const v of videos) {
const bucket = byChannel.get(v.channelId);
if (bucket) bucket.push(v);
else byChannel.set(v.channelId, [v]);
}
let scanned = 0;
let conflicts = 0;
for (const [channelId, channelVideos] of byChannel) {
for (let i = 0; i < channelVideos.length; i += 50) {
const batch = channelVideos.slice(i, i + 50);
if (!(await this.quota.canSpend(1))) {
return { scanned, conflicts, quotaExhausted: true };
}
const items = await this.ytClient.getVideosBatch(
batch.map((v) => v.youtubeVideoId),
channelId,
{ actionId: randomUUID(), actionType: 'conflict_detection' },
);
const remoteByYtId = new Map(items.map((item) => [item.id, item]));
for (const video of batch) {
const remote = remoteByYtId.get(video.youtubeVideoId);
if (!remote) continue; // YouTube didn't return this ID; likely deleted, purge job handles it
const conflict = await this.applyConflictDetection(video, remote);
scanned++;
if (conflict) conflicts++;
}
}
}
return { scanned, conflicts, quotaExhausted: false };
}
private async applyConflictDetection(video: Video, remote: any): Promise<boolean> {
const remoteDescription = remote.snippet?.description ?? '';
const remotePrivacyStatus = remote.status?.privacyStatus?.toUpperCase() ?? null;
const remoteRecordingDate = remote.recordingDetails?.recordingDate
? new Date(remote.recordingDetails.recordingDate)
: null;
const remoteHash = hashMetadata({
title: remote.snippet?.title ?? '',
description: remoteDescription,
tags: remote.snippet?.tags ?? [],
categoryId: remote.snippet?.categoryId,
privacyStatus: remotePrivacyStatus,
defaultLanguage: remote.snippet?.defaultLanguage,
defaultAudioLanguage: remote.snippet?.defaultAudioLanguage,
selfDeclaredMadeForKids: remote.status?.selfDeclaredMadeForKids,
embeddable: remote.status?.embeddable,
license: remote.status?.license,
recordingDate: remoteRecordingDate,
});
const conflict = remoteHash !== video.lastSyncedHash;
if (conflict) {
const pendingRemoteSnapshot = {
title: remote.snippet?.title ?? '',
tags: remote.snippet?.tags ?? [],
categoryId: remote.snippet?.categoryId ?? null,
privacyStatus: remotePrivacyStatus,
defaultLanguage: remote.snippet?.defaultLanguage ?? null,
defaultAudioLanguage: remote.snippet?.defaultAudioLanguage ?? null,
selfDeclaredMadeForKids: remote.status?.selfDeclaredMadeForKids ?? false,
embeddable: remote.status?.embeddable ?? true,
license: remote.status?.license ?? 'youtube',
recordingDate: remoteRecordingDate ? remoteRecordingDate.toISOString().slice(0, 10) : null,
};
await this.prisma.video.update({
where: { id: video.id },
data: {
remoteConflict: true,
pendingRemoteSnapshot,
pendingRemoteDescription: remoteDescription,
},
});
} else if (video.remoteConflict || video.pendingRemoteSnapshot) {
// Self-heal: remote matches lastSyncedHash again (e.g. the creator reverted their edit).
await this.prisma.video.update({
where: { id: video.id },
data: {
remoteConflict: false,
pendingRemoteSnapshot: Prisma.JsonNull,
pendingRemoteDescription: null,
},
});
}
return conflict;
}
}