[Fix] Video filtering (#8765)

* feat: adds API for video count and updates video service

Adds a new API endpoint to retrieve the count of videos.

Updates the video service to use the new API endpoint for checking video availability.  This improves performance by only retrieving the count instead of the entire list of videos.

Adds update video API endpoint to update video metadata.

* refactor: video service imports

Moves RxJS operators import to be with other RxJS imports.  This improves code readability and consistency.

* refactor: permission Check Enhancements

- Updated `VideoService` in `video.service.ts` to refine selection logic, prioritizing `selectedOrganization` over `organizationId`.
- Enhanced `GetVideoCountQueryHandler` in `get-video-count.handler.ts` by introducing permission checks for `CHANGE_SELECTED_EMPLOYEE`, ensuring queries respect user permissions.

* feat: enhance video retrieval with timezone-based date filtering

- Introduced `moment-timezone` for converting start and end dates to UTC.
- Updated query to dynamically filter videos based on provided `startDate`, `endDate`, and `timeZone`.
- Implemented dynamic WHERE clause with optional employee filtering using `TypeORM`'s `In` and `Between` utilities.

* feat: conditional date filtering in video query

Modify the query to conditionally include the recordedAt filter
only when both startDate and endDate are provided. This change
enhances the query flexibility by avoiding unnecessary date filtering.

* fix: hide date selector on detailled page

* refactor: deleteVideo method in VideosController

- Updated import statements for DTOs and models in videos.controller.ts
- Changed the Delete method to accept an optional IDeleteVideo parameter

* refactor: enhance UpdateVideo command structure

- Updated the VideoEffects update logic for better state management
- Refactored UpdateVideoHandler to include description in video updates
- Modified UpdateVideoCommand and UpdateVideoDTO for simplified DTO structure
- Revised VideosController to adapt to new UpdateVideoCommand constructor

* fix: reset video store on filter change

- Ensures that the video store is cleared before fetching new video data when the filter changes.

* feat(video-page): modify filter component inputs

- Disable log type, source, and activity level filters in the video page filter component.
- This change simplifies the available filters for a streamlined user experience.
This commit is contained in:
Kifungo A
2025-02-07 21:34:59 +01:00
committed by GitHub
parent f0e05c217e
commit 619b6645fa
14 changed files with 277 additions and 55 deletions
@@ -105,7 +105,8 @@ export class VideoEffects {
tap((video) => {
const videos = this.videoQuery.videos;
this.videoStore.update({
videos: [...new Map([...videos, video].map((item) => [item.id, item])).values()]
videos: [...new Map([...videos, video].map((item) => [item.id, item])).values()],
video
});
this.toastrService.success('Video updated successfully');
}),
@@ -7,6 +7,9 @@
[isTimeFormat]="true"
[saveFilters]="(datePickerConfig$ | async).isSaveDatePicker"
(filtersChange)="filtersChange($event)"
[hasLogTypeFilter]="false"
[hasSourceFilter]="false"
[hasActivityLevelFilter]="false"
>
</ngx-gauzy-filters>
</div>
@@ -53,6 +53,7 @@ export class VideoPageComponent extends BaseSelectorFilterComponent implements O
distinctUntilChange(),
tap(() => {
this.skip = 1;
this.videoStore.update({ videos: [] });
this.fetchVideos();
this.hasNext = hasNext;
})
@@ -1,8 +1,9 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { map, Observable } from 'rxjs';
import { IPagination } from '@gauzy/contracts';
import { API_PREFIX, toParams } from '@gauzy/ui-core/common';
import { Store } from '@gauzy/ui-core/core';
import { catchError, map, Observable, of } from 'rxjs';
import { IVideo } from '../models/video.model';
@Injectable({
@@ -10,7 +11,7 @@ import { IVideo } from '../models/video.model';
})
export class VideoService {
private readonly API_ENDPOINT = `${API_PREFIX}/plugins/videos`;
constructor(private readonly http: HttpClient) {}
constructor(private readonly http: HttpClient, private readonly store: Store) {}
public getAll<T>(params?: T): Observable<IPagination<IVideo>> {
return this.http.get<IPagination<IVideo>>(this.API_ENDPOINT, {
@@ -30,7 +31,24 @@ export class VideoService {
return this.http.delete<void>(`${this.API_ENDPOINT}/${id}`);
}
public getCount<T>(params?: T): Observable<number> {
return this.http.get<number>(`${this.API_ENDPOINT}/count`, {
params: toParams(params)
});
}
public get isAvailable$(): Observable<boolean> {
return this.getAll({}).pipe(map(({ total }) => total > 0));
const { id: selectedOrganizationId } = this.store.selectedOrganization;
const organizationId = selectedOrganizationId || this.store.organizationId;
const tenantId = this.store.tenantId;
if (!organizationId || !tenantId) {
return of(false);
}
return this.getCount({ organizationId, tenantId }).pipe(
map((count) => count > 0),
catchError(() => of(false))
);
}
}
@@ -36,7 +36,7 @@ const routes: Routes = [
data: {
title: 'Video',
selectors: {
date: true,
date: false,
employee: false,
project: false,
team: false
@@ -18,17 +18,15 @@ export class UpdateVideoHandler implements ICommandHandler<UpdateVideoCommand> {
*/
public async execute(command: UpdateVideoCommand): Promise<IVideo> {
// Extract input data from the command
const { input } = command;
const { input, id } = command;
// Destructure the input fields for clarity
const { id, title, size, file, duration } = input;
const { title, description } = input;
// Update the video entity in the database using the provided ID and input fields
await this.videosService.update(id, {
title,
size,
duration,
file: file?.key // Extract the file key if a file object is provided
description
});
// Fetch and return the updated video entity by its ID
@@ -1,7 +1,8 @@
import { ICommand } from '@nestjs/cqrs';
import { UpdateVideoDTO } from '../dto';
import { ID } from '@gauzy/contracts';
export class UpdateVideoCommand implements ICommand {
public static readonly type = '[Update] Video';
constructor(public readonly input: UpdateVideoDTO) {}
constructor(public readonly id: ID, public readonly input: UpdateVideoDTO) {}
}
@@ -0,0 +1,70 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsISO8601, IsOptional, IsUUID } from 'class-validator';
/**
* Entity Count Video DTO
*
* Represents the DTO for counting videos within a specific tenant and organization,
* optionally filtered by a date range.
*/
export class CountVideoDTO {
/**
* The ID of the tenant.
*
* @example 'd3b07384-d9a0-4d5f-bf6d-f1b5b71e9a37'
*/
@ApiProperty({
description: 'The ID of the tenant.',
example: 'd3b07384-d9a0-4d5f-bf6d-f1b5b71e9a37',
type: String
})
@IsUUID('4', { message: 'tenantId must be a valid UUID' })
tenantId: string;
/**
* The ID of the organization within the tenant.
*
* @example 'a9e3fbc9-d0b7-4e85-b6f2-2eaf3a5d72dc'
*/
@ApiProperty({
description: 'The ID of the organization within the tenant.',
example: 'a9e3fbc9-d0b7-4e85-b6f2-2eaf3a5d72dc',
type: String
})
@IsUUID('4', { message: 'organizationId must be a valid UUID' })
organizationId: string;
/**
* The start date for filtering video records.
* Can be provided as a string or a Date object.
*
* @example '2023-01-01T00:00:00.000Z'
*/
@ApiProperty({
description: 'The start date for filtering video records. Can be provided as a string or a Date object.',
example: '2023-01-01T00:00:00.000Z',
type: 'string',
format: 'date-time',
nullable: true
})
@IsOptional()
@IsISO8601({ strict: true }, { message: 'startDate must be a valid ISO 8601 date string' })
startDate: Date | string;
/**
* The end date for filtering video records.
* Can be provided as a string or a Date object.
*
* @example '2023-12-31T23:59:59.999Z'
*/
@ApiProperty({
description: 'The end date for filtering video records. Can be provided as a string or a Date object.',
example: '2023-12-31T23:59:59.999Z',
type: 'string',
format: 'date-time',
nullable: true
})
@IsOptional()
@IsISO8601({ strict: true }, { message: 'endDate must be a valid ISO 8601 date string' })
endDate: Date | string;
}
@@ -1,19 +1,3 @@
import { ID } from '@gauzy/contracts';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsUUID, ValidateNested } from 'class-validator';
import { BaseVideoDTO } from './base-video.dto';
import { FileDTO } from './file.dto';
export class UpdateVideoDTO extends BaseVideoDTO {
@ApiProperty({ description: 'The ID of the video to update', example: '123e4567-e89b-12d3-a456-426614174000' })
@IsNotEmpty()
@IsUUID()
id: ID;
@ApiProperty({ type: FileDTO })
@ValidateNested()
@Type(() => FileDTO)
@IsOptional()
file?: FileDTO;
}
export class UpdateVideoDTO extends BaseVideoDTO {}
@@ -0,0 +1,8 @@
import { IQuery } from '@nestjs/cqrs';
import { CountVideoDTO } from '../dto/count-video.dto';
export class GetVideoCountQuery implements IQuery {
public static readonly type = '[Video] Get Count';
constructor(public readonly options: CountVideoDTO) {}
}
@@ -0,0 +1,35 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { VideosService } from '../../services/videos.service';
import { GetVideoCountQuery } from '../get-video-count.query';
import { RequestContext } from '@gauzy/core';
import { PermissionsEnum } from '@gauzy/contracts';
@QueryHandler(GetVideoCountQuery)
export class GetVideoCountQueryHandler implements IQueryHandler<GetVideoCountQuery> {
constructor(private readonly videosService: VideosService) {}
/**
* Handles the `GetVideoCountQuery` to retrieve the count of video entities.
*
* @param query - The `GetVideoCountQuery` containing the ID of the video to be fetched and optional query options.
*
* @returns A promise resolving to the count of video entities (`number`).
*
*/
public async execute(query: GetVideoCountQuery): Promise<number> {
// Destructure the query to extract the video ID and options
const { options } = query || {};
// Fetch the video entity from the database
const { organizationId, tenantId } = options;
// Check if the current user has the permission
const permission = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE);
// Fetch the count of video entities from the database
return this.videosService.count({
where: {
...(!permission && { uploadedById: RequestContext.currentEmployeeId() }),
organizationId,
tenantId
}
});
}
}
@@ -1,7 +1,9 @@
import { IPagination } from '@gauzy/contracts';
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IVideo } from '../../video.model';
import * as moment from 'moment-timezone';
import { Between, In } from 'typeorm';
import { VideosService } from '../../services/videos.service';
import { IVideo } from '../../video.model';
import { GetVideosQuery } from '../get-videos.query';
@QueryHandler(GetVideosQuery)
@@ -19,7 +21,39 @@ export class GetVideosQueryHandler implements IQueryHandler<GetVideosQuery> {
// Extract pagination and filter parameters from the query
const { params } = query;
// Step 1: Fetch the paginated list of videos from the database
return this.videosService.paginate(params);
const {
startDate,
endDate,
tenantId,
organizationId,
employeeIds = [],
timeZone = 'UTC'
} = (params || {}) as any;
// Build the dynamic WHERE clause for the query
const where: Record<string, any> = {
tenantId,
organizationId
};
// Add recordedAt only if startDate and endDate are provided
if (startDate && endDate) {
// Convert startDate and endDate to UTC based on the provided timeZone
const startDateUtc = moment.tz(startDate, timeZone).utc().toDate();
const endDateUtc = moment.tz(endDate, timeZone).utc().toDate();
// Update the 'valueDate' property to filter records between the specified dates
where.recordedAt = Between(startDateUtc, endDateUtc);
}
// Add employee filter only if employeeIds is provided and non-empty
if (employeeIds.length > 0) {
where.uploadedById = In(employeeIds);
}
// Fetch paginated videos from the service
return this.videosService.paginate({
...params,
where
});
}
}
@@ -1,4 +1,5 @@
import { GetVideoCountQueryHandler } from './get-video-count.handler';
import { GetVideoQueryHandler } from './get-video.handler';
import { GetVideosQueryHandler } from './get-videos.handler';
export const QueryHandlers = [GetVideoQueryHandler, GetVideosQueryHandler];
export const QueryHandlers = [GetVideoQueryHandler, GetVideosQueryHandler, GetVideoCountQueryHandler];
@@ -1,3 +1,17 @@
import { FileStorageProviderEnum, ID, IPagination, PermissionsEnum } from '@gauzy/contracts';
import {
FileStorage,
FileStorageFactory,
LazyFileInterceptor,
PaginationParams,
PermissionGuard,
Permissions,
RequestContext,
TenantPermissionGuard,
UploadedFileStorage,
UseValidationPipe,
UUIDValidationPipe
} from '@gauzy/core';
import {
BadRequestException,
Body,
@@ -7,6 +21,7 @@ import {
HttpStatus,
Param,
Post,
Put,
Query,
UseGuards,
UseInterceptors
@@ -16,27 +31,16 @@ import { ApiConsumes, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { FindOneOptions } from 'typeorm';
import { FileStorageProviderEnum, ID, IPagination, PermissionsEnum } from '@gauzy/contracts';
import {
FileStorage,
FileStorageFactory,
PaginationParams,
Permissions,
PermissionGuard,
UploadedFileStorage,
TenantPermissionGuard,
UseValidationPipe,
RequestContext,
UUIDValidationPipe,
LazyFileInterceptor
} from '@gauzy/core';
import { IVideo } from './video.model';
import { CreateVideoCommand } from './commands/create-video.command';
import { DeleteVideoCommand } from './commands/delete-video.command';
import { UpdateVideoCommand } from './commands/update-video.command';
import { CreateVideoDTO, FileDTO, UpdateVideoDTO } from './dto';
import { CountVideoDTO } from './dto/count-video.dto';
import { Video } from './entities/video.entity';
import { GetVideoCountQuery } from './queries/get-video-count.query';
import { GetVideoQuery } from './queries/get-video.query';
import { GetVideosQuery } from './queries/get-videos.query';
import { CreateVideoDTO, DeleteVideoDTO, FileDTO } from './dto';
import { Video } from './entities/video.entity';
import { IDeleteVideo, IVideo } from './video.model';
@ApiTags('Video Plugin')
@UseGuards(TenantPermissionGuard, PermissionGuard)
@@ -158,6 +162,75 @@ export class VideosController {
}
}
/**
* GET video count in the same tenant.
*
* This endpoint retrieves the count of videos within a specific tenant.
* It takes query parameters to filter the video count by certain criteria.
*
* @param options Query parameters to filter the video count.
* @returns A promise resolving to the total count of videos in the tenant.
*/
@ApiOperation({ summary: 'Get video count in the same tenant' })
@ApiResponse({
status: HttpStatus.OK,
description: 'Successfully retrieved the video count.'
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Invalid query parameters. Please check your input.'
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'An error occurred while retrieving the video count.'
})
@Get('count')
@UseValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true
})
async getCount(@Query() options: CountVideoDTO): Promise<number> {
return this.queryBus.execute(new GetVideoCountQuery(options));
}
/**
* Updates an existing video record.
*
* This endpoint allows authorized users to update an existing video record by providing its ID
* and the necessary updated metadata.
*
* @param id - The UUID of the video to update.
* @param input - The updated video metadata.
* @returns A Promise that resolves with the details of the updated video.
*/
@ApiOperation({
summary: 'Update a video by ID',
description: 'Updates an existing video record based on the provided ID and metadata.'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'The video has been successfully updated.',
type: Video
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Video record not found.'
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Invalid input, The response body may contain clues as to what went wrong.'
})
@UseValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true
})
@Put(':id')
public async update(@Param('id', UUIDValidationPipe) id: ID, @Body() input: UpdateVideoDTO): Promise<IVideo> {
return this.commandBus.execute(new UpdateVideoCommand(id, input));
}
/**
* Retrieves a video record by its ID.
*
@@ -215,13 +288,8 @@ export class VideosController {
transform: true,
forbidNonWhitelisted: true
})
@UseValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true
})
@Delete(':id')
public async delete(@Param('id', UUIDValidationPipe) id: ID, @Query() options: DeleteVideoDTO): Promise<void> {
public async delete(@Param('id', UUIDValidationPipe) id: ID, @Query() options?: IDeleteVideo): Promise<void> {
// Execute the delete video command
return this.commandBus.execute(new DeleteVideoCommand({ id, options }));
}