Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 0–6`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user