Selaa lähdekoodia

feat(provider-video-sync): enhance sync functionality with new run endpoints and checkpoint saving

Dave 1 kuukausi sitten
vanhempi
säilyke
ae2cae99f7

+ 172 - 133
apps/box-mgnt-api/src/mgnt-backend/feature/provider-video-sync/provider-video-sync.controller.ts

@@ -1,155 +1,194 @@
 // provider-video-sync.controller.ts
-import { Controller, Post, Get, Query, Logger } from '@nestjs/common';
-import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
-import { ProviderVideoSyncService } from './provider-video-sync.service';
+import { Body, Controller, Get, Post, Query } from '@nestjs/common';
+import {
+  ApiBody,
+  ApiOperation,
+  ApiQuery,
+  ApiResponse,
+  ApiTags,
+} from '@nestjs/swagger';
+import {
+  IsBoolean,
+  IsInt,
+  IsObject,
+  IsOptional,
+  IsString,
+  Max,
+  Min,
+  ValidateNested,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+import {
+  ProviderVideoSyncOptions,
+  ProviderVideoSyncResult,
+  ProviderVideoSyncService,
+} from './provider-video-sync.service';
+
+class ProviderVideoSyncParamDto {
+  @IsOptional()
+  @IsString()
+  status?: string;
+
+  /**
+   * ISO string; provider filters "updated after"
+   * Example: "2025-12-18T21:19:09.227Z"
+   */
+  @IsOptional()
+  @IsString()
+  updatedAt?: string;
+
+  /**
+   * Allow extra provider param keys without strict validation.
+   * If you want to lock this down later, replace with explicit fields.
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  [k: string]: any;
+}
+
+export class ProviderVideoSyncRunDto {
+  @IsOptional()
+  @IsString()
+  providerCode?: string;
+
+  @IsOptional()
+  @IsBoolean()
+  fullSync?: boolean;
+
+  @IsOptional()
+  @IsBoolean()
+  resetState?: boolean;
+
+  /**
+   * Optional override. In normal usage:
+   * - fullSync: resumes from SyncState cursor
+   * - incremental: pageNum is forced to 1
+   */
+  @IsOptional()
+  @IsInt()
+  @Min(1)
+  pageNum?: number;
+
+  /**
+   * Default is handled by service; capped at 500 by service.
+   */
+  @IsOptional()
+  @IsInt()
+  @Min(1)
+  @Max(500)
+  pageSize?: number;
+
+  @IsOptional()
+  @IsObject()
+  @ValidateNested()
+  @Type(() => ProviderVideoSyncParamDto)
+  param?: ProviderVideoSyncParamDto;
+}
 
 @ApiTags('Provider Video Sync')
 @Controller('provider-video-sync')
 export class ProviderVideoSyncController {
-  private readonly logger = new Logger(ProviderVideoSyncController.name);
-
-  constructor(
-    private readonly providerVideoSyncService: ProviderVideoSyncService,
-  ) {}
+  constructor(private readonly service: ProviderVideoSyncService) {}
 
   @Post('run')
   @ApiOperation({
-    summary: 'Trigger provider video sync',
-    description:
-      'Triggers a sync of video media data from configured providers. Currently processes a single page of data.',
+    summary: 'Trigger provider video sync (full sync or incremental)',
+    description: [
+      'POST body supports optional parameters: providerCode, fullSync, resetState, pageNum, pageSize, param.',
+      'Incremental sync: send param.updatedAt (ISO string).',
+      'Full sync: set fullSync=true; service ignores param.updatedAt and resumes by pageNum cursor.',
+    ].join('\n'),
   })
-  @ApiQuery({
-    name: 'providerCode',
-    required: false,
-    type: 'string',
-    description: 'Filter by specific provider code (e.g., RVAKC)',
+  @ApiBody({ type: ProviderVideoSyncRunDto })
+  @ApiResponse({ status: 200, type: Object })
+  async run(
+    @Body() dto: ProviderVideoSyncRunDto,
+  ): Promise<ProviderVideoSyncResult> {
+    const options: ProviderVideoSyncOptions = {
+      providerCode: dto.providerCode,
+      fullSync: dto.fullSync,
+      resetState: dto.resetState,
+      pageNum: dto.pageNum,
+      pageSize: dto.pageSize,
+      param: dto.param,
+    };
+
+    return this.service.syncFromProvider(options);
+  }
+
+  @Get('last-summary')
+  @ApiOperation({
+    summary: 'Get last sync summary from memory (this process only)',
   })
-  @ApiQuery({
-    name: 'page',
-    required: false,
-    type: 'number',
-    description: 'Page number (default: 1)',
+  @ApiResponse({ status: 200, type: Object })
+  getLastSummary(): ProviderVideoSyncResult | null {
+    return this.service.getLastSyncSummary();
+  }
+
+  @Post('run-incremental')
+  @ApiOperation({
+    summary: 'Trigger incremental sync quickly (query params)',
+    description:
+      'Convenience endpoint. Use /run for full control. Incremental uses param.updatedAt.',
   })
   @ApiQuery({
-    name: 'pageSize',
+    name: 'updatedAt',
     required: false,
-    type: 'number',
-    description: 'Number of records per page (default: 100)',
+    description: 'ISO string updatedAt cursor',
   })
-  @ApiResponse({
-    status: 200,
-    description: 'Provider video sync triggered successfully',
-    schema: {
-      type: 'object',
-      properties: {
-        imported: {
-          type: 'number',
-          description: 'Total number of videos processed',
-          example: 50,
-        },
-        created: {
-          type: 'number',
-          description: 'Number of new ProviderVideoSync records created',
-          example: 10,
-        },
-        updated: {
-          type: 'number',
-          description: 'Number of existing ProviderVideoSync records updated',
-          example: 35,
-        },
-        skipped: {
-          type: 'number',
-          description: 'Number of records skipped due to errors',
-          example: 5,
-        },
-        errors: {
-          type: 'array',
-          description: 'Array of errors encountered (if any)',
-          items: {
-            type: 'object',
-            properties: {
-              id: { type: 'string', example: '6650a9e5f9c3f12a8b000001' },
-              error: {
-                type: 'string',
-                example: 'Sync failed: provider timeout',
-              },
-            },
-          },
-        },
-      },
-    },
-  })
-  async runSync(
-    @Query('providerCode') providerCode?: string,
-    @Query('page') page?: string,
+  @ApiQuery({ name: 'pageSize', required: false, description: '1..500' })
+  @ApiQuery({ name: 'providerCode', required: false })
+  @ApiQuery({ name: 'resetState', required: false, description: 'true/false' })
+  async runIncremental(
+    @Query('updatedAt') updatedAt?: string,
     @Query('pageSize') pageSize?: string,
-  ) {
-    this.logger.log('[runSync] Trigger received');
-    this.logger.debug('[runSync] Query params:', {
-      providerCode,
-      page,
-      pageSize,
-    });
-
-    const result = await this.providerVideoSyncService.syncFromProvider({
-      providerCode,
-      page: page ? parseInt(page, 10) : 1,
-      pageSize: pageSize ? parseInt(pageSize, 10) : 100,
-    });
-
-    this.logger.log('[runSync] Sync completed', result);
-    return result;
+    @Query('providerCode') providerCode?: string,
+    @Query('resetState') resetState?: string,
+  ): Promise<ProviderVideoSyncResult> {
+    const options: ProviderVideoSyncOptions = {
+      providerCode: providerCode || undefined,
+      fullSync: false,
+      resetState: resetState === 'true',
+      pageSize: pageSize ? Number(pageSize) : undefined,
+      param: {
+        status: 'Completed',
+        updatedAt: updatedAt || undefined,
+      },
+    };
+
+    return this.service.syncFromProvider(options);
   }
 
-  @Get('status')
+  @Post('run-full')
   @ApiOperation({
-    summary: 'Get last provider video sync status',
-    description: 'Returns a summary of the last provider video sync operation.',
+    summary: 'Trigger full sync quickly (query params)',
+    description:
+      'Convenience endpoint. Full sync ignores updatedAt filter and resumes by stored pageNum cursor.',
   })
-  @ApiResponse({
-    status: 200,
-    description: 'Last sync status retrieved successfully',
-    schema: {
-      type: 'object',
-      nullable: true,
-      properties: {
-        imported: {
-          type: 'number',
-          description: 'Total number of videos processed',
-          example: 50,
-        },
-        created: {
-          type: 'number',
-          description: 'Number of new ProviderVideoSync records created',
-          example: 10,
-        },
-        updated: {
-          type: 'number',
-          description: 'Number of existing ProviderVideoSync records updated',
-          example: 35,
-        },
-        skipped: {
-          type: 'number',
-          description: 'Number of records skipped due to errors',
-          example: 5,
-        },
-        errors: {
-          type: 'array',
-          description: 'Array of errors encountered (if any)',
-          items: {
-            type: 'object',
-            properties: {
-              id: { type: 'string' },
-              error: { type: 'string' },
-            },
-          },
-        },
-      },
-    },
+  @ApiQuery({ name: 'pageSize', required: false, description: '1..500' })
+  @ApiQuery({ name: 'providerCode', required: false })
+  @ApiQuery({ name: 'resetState', required: false, description: 'true/false' })
+  @ApiQuery({
+    name: 'pageNum',
+    required: false,
+    description: 'override start pageNum (resetState recommended)',
   })
-  async getStatus() {
-    this.logger.log('[getStatus] Status requested');
-    const status = this.providerVideoSyncService.getLastSyncSummary();
-    return status || { message: 'No sync has been performed yet' };
+  async runFull(
+    @Query('pageSize') pageSize?: string,
+    @Query('providerCode') providerCode?: string,
+    @Query('resetState') resetState?: string,
+    @Query('pageNum') pageNum?: string,
+  ): Promise<ProviderVideoSyncResult> {
+    const options: ProviderVideoSyncOptions = {
+      providerCode: providerCode || undefined,
+      fullSync: true,
+      resetState: resetState === 'true',
+      pageSize: pageSize ? Number(pageSize) : undefined,
+      pageNum: pageNum ? Number(pageNum) : undefined,
+      param: {
+        status: 'Completed',
+      },
+    };
+
+    return this.service.syncFromProvider(options);
   }
 }

+ 39 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/provider-video-sync/provider-video-sync.service.ts

@@ -612,6 +612,45 @@ export class ProviderVideoSyncService {
     });
   }
 
+  private async saveCheckpoint(args: {
+    entity: EntityType;
+    nextUpdatedAtCursor?: string; // ONLY when batch completed
+    fullSyncCompleted: boolean;
+  }) {
+    const now = new Date();
+    const nowSec = Math.floor(Date.now() / 1000);
+
+    // Build referId safely (do not overwrite blindly)
+    const state = await this.mongo.syncState.findUnique({
+      where: { entity: args.entity },
+      select: { referId: true },
+    });
+
+    let persisted: { updatedAtCursor?: string } = {};
+    if (state?.referId) {
+      try {
+        persisted = JSON.parse(state.referId);
+      } catch {
+        persisted = {};
+      }
+    }
+
+    // Only commit updatedAtCursor when explicitly provided
+    if (args.nextUpdatedAtCursor) {
+      persisted.updatedAtCursor = args.nextUpdatedAtCursor;
+    }
+
+    await this.mongo.syncState.update({
+      where: { entity: args.entity },
+      data: {
+        referId: JSON.stringify(persisted),
+        lastRunAt: now,
+        lastFullSyncAt: args.fullSyncCompleted ? now : undefined,
+        updatedAt: nowSec,
+      },
+    });
+  }
+
   private safeParseCursor(
     raw: string | null | undefined,
   ): Partial<SyncCursor> | null {