From cfc501514bfeb4f3fc184195a4c0140a4a1b1ec7 Mon Sep 17 00:00:00 2001 From: Kifungo A <45813955+adkif@users.noreply.github.com> Date: Wed, 25 Dec 2024 12:49:08 +0500 Subject: [PATCH] [Feat] Create video plugin module (#8613) * feat: adds video model and refactors imports Introduces a new video model to support video functionalities. This includes properties for video metadata, storage information, and relationships with other models like time slots and employees. Additionally, several imports are reorganized for better code structure. * feat: adds Video entity This introduces the `Video` entity to store video-related information, including title, file path, recording date, duration, size, URL, storage provider, description, resolution, codec, frame rate, and uploader details. It includes validation and relationships with `TimeSlot` and `Employee` entities. * feat: introduces file storage path generation This change introduces a new mechanism for generating file storage paths. It uses a combination of date, tenant ID, and employee ID to create unique and organized storage locations. A factory class simplifies the creation of storage engines with this path generation logic. * feat(videos): adds DTOs for video management Introduces Data Transfer Objects (DTOs) for creating, updating, and deleting videos. Includes a FileDTO for handling video uploads and a BaseVideoDTO for common video properties. These DTOs provide type safety and validation for video-related operations. * feat: adds video repositories for MikroORM and TypeORM Introduces `MikroOrmVideoRepository` and `TypeOrmVideoRepository` to support different ORM integrations. * feat: adds video service Introduces a new service to manage video entities. It utilizes both TypeORM and MikroORM repositories for data access. * feat: add video entity and migration This commit introduces the `Video` entity and its corresponding database migration. The migration creates the `videos` table with fields for video metadata like title, file path, duration, size, storage provider, and relations to other entities such as tenant, organization, time slot, and uploading employee. It also includes indexes for efficient querying. The migration handles different database types (PostgreSQL, SQLite, Better SQLite3, MySQL) and includes specific up and down migration logic for each. * feat: adds commands and handlers for video management This commit introduces new commands and handlers for managing videos: - Creates `CreateVideoCommand` and `CreateVideoHandler` to add new videos. - Implements `UpdateVideoCommand` and `UpdateVideoHandler` to modify existing videos. - Adds `DeleteVideoCommand` and `DeleteVideoHandler` to remove videos. This change enables basic CRUD operations for videos within the application. * feat: adds queries and handlers for fetching videos This introduces new queries and handlers for retrieving video data: - `GetVideoQuery` and `GetVideoQueryHandler`: Fetch a single video by ID. - `GetVideosQuery` and `GetVideosQueryHandler`: Fetch a list of videos with pagination support. * feat: adds video subscriber for lifecycle management Implements a TypeORM subscriber for the `Video` entity to manage file storage during entity lifecycle events. - Sets the `fullUrl` property after loading a video entity by retrieving the URL from the configured storage provider. - Deletes the associated video file from storage after a video entity is deleted. This ensures consistent data and efficient storage management by automatically handling file operations related to videos. * feat(videos): implements video controller This commit introduces a new video controller feature, including: - Creating new video records with associated metadata and file uploads. - Retrieving video records by ID. - Listing all video records with pagination support. - Deleting video records. The implementation uses CQRS and leverages a file storage service for managing video files. It also includes validation and error handling for file uploads and data integrity. * feat: Implements video plugin module This commit introduces a new video plugin. It includes: - A new module for videos with controllers, services, and repositories. - Integration with TypeORM and MikroORM for database operations. - CQRS implementation for handling commands and queries. - Role-based permission management. - Routing configuration for the plugin. * feat: adds video entity and subscriber Introduces the `Video` entity and its corresponding subscriber to the core module. This lays the foundation for managing video data within the application. * feat: adds PluginModule to AppModule Registers the PluginModule in the application's root module to enable plugin functionality. * fix: cspell spelling * feat(plugin-video-capture): create library for video capture plugin * refactor: plugin video capture module & entity * fix(migration): video table for plugin video capture * fix(videos): Implement query handlers for video CRUD operations * fix(migration): refactor `video` [table] for MySQL * refactor: suggestion by coderabbit AI * fix(migration): refactor `video` [table] for DBs * fix(subscriber): video subscriber initialization and event handling * fix(cspell): typo spelling :-) --------- Co-authored-by: Rahul R. --- .cspell.json | 12 +- apps/api/config/custom-webpack.config.js | 2 +- apps/api/package.json | 1 + apps/api/src/plugins.ts | 5 +- package.json | 11 +- packages/contracts/src/index.ts | 21 +- packages/core/src/index.ts | 2 + .../lib/core/entities/subscribers/index.ts | 1 + .../directory-path-generator.interface.ts | 4 + .../helpers/directory-path-generator.ts | 37 +++ .../helpers/file-storage-factory.ts | 20 ++ .../lib/core/file-storage/helpers/index.ts | 3 + .../core/src/lib/core/file-storage/index.ts | 1 + .../core/src/lib/core/interceptors/index.ts | 2 +- .../1735058989058-CreateVideoTable.ts | 207 ++++++++++++++++ packages/plugins/video-capture/.dockerignore | 23 ++ packages/plugins/video-capture/.gitignore | 11 + packages/plugins/video-capture/.npmignore | 4 + packages/plugins/video-capture/CHANGELOG.md | 3 + packages/plugins/video-capture/README.md | 29 +++ .../plugins/video-capture/eslint.config.js | 19 ++ packages/plugins/video-capture/jest.config.ts | 10 + packages/plugins/video-capture/package.json | 59 +++++ packages/plugins/video-capture/project.json | 44 ++++ packages/plugins/video-capture/src/index.ts | 5 + .../src/lib/commands/create-video.command.ts | 7 + .../src/lib/commands/delete-video.command.ts | 7 + .../commands/handlers/create-video.handler.ts | 31 +++ .../commands/handlers/delete-video.handler.ts | 40 +++ .../src/lib/commands/handlers/index.ts | 5 + .../commands/handlers/update-video.handler.ts | 37 +++ .../src/lib/commands/update-video.command.ts | 7 + .../src/lib/dto/base-video.dto.ts | 4 + .../src/lib/dto/create-video.dto.ts | 30 +++ .../src/lib/dto/delete-video.dto.ts | 21 ++ .../video-capture/src/lib/dto/file.dto.ts | 94 +++++++ .../video-capture/src/lib/dto/index.ts | 5 + .../src/lib/dto/update-video.dto.ts | 19 ++ .../src/lib/entities/video.entity.ts | 229 ++++++++++++++++++ .../src/lib/queries/get-video.query.ts | 8 + .../src/lib/queries/get-videos.query.ts | 9 + .../lib/queries/handlers/get-video.handler.ts | 35 +++ .../queries/handlers/get-videos.handler.ts | 25 ++ .../src/lib/queries/handlers/index.ts | 4 + .../video-capture/src/lib/queries/index.ts | 2 + .../mikro-orm-video.repository.ts | 4 + .../repositories/type-orm-video.repository.ts | 11 + .../src/lib/services/videos.service.ts | 15 ++ .../src/lib/subscribers/video.subscriber.ts | 76 ++++++ .../src/lib/video-capture.plugin.ts | 33 +++ .../video-capture/src/lib/video.model.ts | 84 +++++++ .../src/lib/videos.controller.spec.ts | 20 ++ .../src/lib/videos.controller.ts | 228 +++++++++++++++++ .../video-capture/src/lib/videos.module.ts | 20 ++ .../src/lib/videos.service.spec.ts | 18 ++ packages/plugins/video-capture/tsconfig.json | 21 ++ .../plugins/video-capture/tsconfig.lib.json | 16 ++ .../plugins/video-capture/tsconfig.spec.json | 9 + tsconfig.json | 1 + yarn.lock | 2 +- 60 files changed, 1693 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/lib/core/file-storage/helpers/directory-path-generator.interface.ts create mode 100644 packages/core/src/lib/core/file-storage/helpers/directory-path-generator.ts create mode 100644 packages/core/src/lib/core/file-storage/helpers/file-storage-factory.ts create mode 100644 packages/core/src/lib/core/file-storage/helpers/index.ts create mode 100644 packages/core/src/lib/database/migrations/1735058989058-CreateVideoTable.ts create mode 100644 packages/plugins/video-capture/.dockerignore create mode 100644 packages/plugins/video-capture/.gitignore create mode 100644 packages/plugins/video-capture/.npmignore create mode 100644 packages/plugins/video-capture/CHANGELOG.md create mode 100644 packages/plugins/video-capture/README.md create mode 100644 packages/plugins/video-capture/eslint.config.js create mode 100644 packages/plugins/video-capture/jest.config.ts create mode 100644 packages/plugins/video-capture/package.json create mode 100644 packages/plugins/video-capture/project.json create mode 100644 packages/plugins/video-capture/src/index.ts create mode 100644 packages/plugins/video-capture/src/lib/commands/create-video.command.ts create mode 100644 packages/plugins/video-capture/src/lib/commands/delete-video.command.ts create mode 100644 packages/plugins/video-capture/src/lib/commands/handlers/create-video.handler.ts create mode 100644 packages/plugins/video-capture/src/lib/commands/handlers/delete-video.handler.ts create mode 100644 packages/plugins/video-capture/src/lib/commands/handlers/index.ts create mode 100644 packages/plugins/video-capture/src/lib/commands/handlers/update-video.handler.ts create mode 100644 packages/plugins/video-capture/src/lib/commands/update-video.command.ts create mode 100644 packages/plugins/video-capture/src/lib/dto/base-video.dto.ts create mode 100644 packages/plugins/video-capture/src/lib/dto/create-video.dto.ts create mode 100644 packages/plugins/video-capture/src/lib/dto/delete-video.dto.ts create mode 100644 packages/plugins/video-capture/src/lib/dto/file.dto.ts create mode 100644 packages/plugins/video-capture/src/lib/dto/index.ts create mode 100644 packages/plugins/video-capture/src/lib/dto/update-video.dto.ts create mode 100644 packages/plugins/video-capture/src/lib/entities/video.entity.ts create mode 100644 packages/plugins/video-capture/src/lib/queries/get-video.query.ts create mode 100644 packages/plugins/video-capture/src/lib/queries/get-videos.query.ts create mode 100644 packages/plugins/video-capture/src/lib/queries/handlers/get-video.handler.ts create mode 100644 packages/plugins/video-capture/src/lib/queries/handlers/get-videos.handler.ts create mode 100644 packages/plugins/video-capture/src/lib/queries/handlers/index.ts create mode 100644 packages/plugins/video-capture/src/lib/queries/index.ts create mode 100644 packages/plugins/video-capture/src/lib/repositories/mikro-orm-video.repository.ts create mode 100644 packages/plugins/video-capture/src/lib/repositories/type-orm-video.repository.ts create mode 100644 packages/plugins/video-capture/src/lib/services/videos.service.ts create mode 100644 packages/plugins/video-capture/src/lib/subscribers/video.subscriber.ts create mode 100644 packages/plugins/video-capture/src/lib/video-capture.plugin.ts create mode 100644 packages/plugins/video-capture/src/lib/video.model.ts create mode 100644 packages/plugins/video-capture/src/lib/videos.controller.spec.ts create mode 100644 packages/plugins/video-capture/src/lib/videos.controller.ts create mode 100644 packages/plugins/video-capture/src/lib/videos.module.ts create mode 100644 packages/plugins/video-capture/src/lib/videos.service.spec.ts create mode 100644 packages/plugins/video-capture/tsconfig.json create mode 100644 packages/plugins/video-capture/tsconfig.lib.json create mode 100644 packages/plugins/video-capture/tsconfig.spec.json diff --git a/.cspell.json b/.cspell.json index ff0761d6e07..66f9fa3f041 100644 --- a/.cspell.json +++ b/.cspell.json @@ -525,7 +525,17 @@ "xplatframework", "localforage", "longform", - "rfdc" + "rfdc", + "libx264", + "libx", + "WXGA", + "SXGA", + "UXGA", + "NTSC", + "libvpx", + "libaom", + "theora", + "Theora" ], "useGitignore": true, "ignorePaths": [ diff --git a/apps/api/config/custom-webpack.config.js b/apps/api/config/custom-webpack.config.js index 3bbe158fa41..f28665359dc 100644 --- a/apps/api/config/custom-webpack.config.js +++ b/apps/api/config/custom-webpack.config.js @@ -75,7 +75,7 @@ module.exports = composePlugins( ); // Log for debugging - console.log('Copy Patterns:', packagePatterns); + // console.log('Copy Patterns:', packagePatterns); // Log final config for debugging // console.log('Final Webpack config:', JSON.stringify(config, null, 2)); diff --git a/apps/api/package.json b/apps/api/package.json index e0f1f240e40..b5722287f21 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -53,6 +53,7 @@ "@gauzy/plugin-knowledge-base": "^0.1.0", "@gauzy/plugin-product-reviews": "^0.1.0", "@gauzy/plugin-sentry": "^0.1.0", + "@gauzy/plugin-video-capture": "^0.1.0", "dotenv": "^16.0.3", "yargs": "^17.5.0" }, diff --git a/apps/api/src/plugins.ts b/apps/api/src/plugins.ts index 20b2e2efc72..eb2a67df6ad 100644 --- a/apps/api/src/plugins.ts +++ b/apps/api/src/plugins.ts @@ -11,6 +11,7 @@ import { JobProposalPlugin } from '@gauzy/plugin-job-proposal'; import { JobSearchPlugin } from '@gauzy/plugin-job-search'; import { KnowledgeBasePlugin } from '@gauzy/plugin-knowledge-base'; import { ProductReviewsPlugin } from '@gauzy/plugin-product-reviews'; +import { VideoCapturePlugin } from '@gauzy/plugin-video-capture'; import { SentryTracing as SentryPlugin } from './sentry'; @@ -50,5 +51,7 @@ export const plugins = [ // Indicates the inclusion or intention to use the KnowledgeBasePlugin in the codebase. KnowledgeBasePlugin, // Indicates the inclusion or intention to use the ProductReviewsPlugin in the codebase. - ProductReviewsPlugin + ProductReviewsPlugin, + // Indicates the inclusion or intention to use the VideoCapturePlugin in the codebase. + VideoCapturePlugin ]; diff --git a/package.json b/package.json index e64b0284762..e4b7bec5959 100644 --- a/package.json +++ b/package.json @@ -156,9 +156,9 @@ "build:package:plugins:pre": "yarn run build:package:ui-core && yarn run build:package:ui-auth && yarn run build:package:plugin:onboarding-ui && yarn run build:package:plugin:legal-ui && yarn run build:package:plugin:job-search-ui && yarn run build:package:plugin:job-matching-ui && yarn run build:package:plugin:job-employee-ui && yarn run build:package:plugin:job-proposal-ui && yarn run build:package:plugin:public-layout-ui && yarn run build:package:plugin:maintenance-ui && yarn run build:integration-ui-plugins", "build:package:plugins:pre:prod": "yarn run build:package:ui-core:prod && yarn run build:package:ui-auth:prod && yarn run build:package:plugin:onboarding-ui:prod && yarn run build:package:plugin:legal-ui:prod && yarn run build:package:plugin:job-search-ui:prod && yarn run build:package:plugin:job-matching-ui:prod && yarn run build:package:plugin:job-employee-ui:prod && yarn run build:package:plugin:job-proposal-ui:prod && yarn run build:package:plugin:public-layout-ui:prod && yarn run build:package:plugin:maintenance-ui:prod && yarn run build:integration-ui-plugins:prod", "build:package:plugins:pre:docker": "yarn run build:package:ui-core:docker && yarn run build:package:ui-auth:docker && yarn run build:package:plugin:onboarding-ui:docker && yarn run build:package:plugin:legal-ui:docker && yarn run build:package:plugin:job-search-ui:docker && yarn run build:package:plugin:job-matching-ui:docker && yarn run build:package:plugin:job-employee-ui:docker && yarn run build:package:plugin:job-proposal-ui:docker && yarn run build:package:plugin:public-layout-ui:docker && yarn run build:package:plugin:maintenance-ui:docker && yarn run build:integration-ui-plugins:docker", - "build:package:plugins:post": "yarn run build:package:plugin:integration-jira && yarn run build:package:plugin:integration-ai && yarn run build:package:plugin:sentry && yarn run build:package:plugin:jitsu-analytic && yarn run build:package:plugin:product-reviews && yarn run build:package:plugin:job-search && yarn run build:package:plugin:job-proposal && yarn run build:package:plugin:integration-github && yarn run build:package:plugin:knowledge-base && yarn run build:package:plugin:changelog && yarn run build:package:plugin:integration-hubstaff && yarn run build:package:plugin:integration-upwork", - "build:package:plugins:post:prod": "yarn run build:package:plugin:integration-jira:prod && yarn run build:package:plugin:integration-ai:prod && yarn run build:package:plugin:sentry:prod && yarn run build:package:plugin:jitsu-analytic:prod && yarn run build:package:plugin:product-reviews:prod && yarn run build:package:plugin:job-search:prod && yarn run build:package:plugin:job-proposal:prod && yarn run build:package:plugin:integration-github:prod && yarn run build:package:plugin:knowledge-base:prod && yarn run build:package:plugin:changelog:prod && yarn run build:package:plugin:integration-hubstaff:prod && yarn run build:package:plugin:integration-upwork:prod", - "build:package:plugins:post:docker": "yarn run build:package:plugin:integration-jira:docker && yarn run build:package:plugin:integration-ai:docker && yarn run build:package:plugin:sentry:docker && yarn run build:package:plugin:jitsu-analytic:docker && yarn run build:package:plugin:product-reviews:docker && yarn run build:package:plugin:job-search:docker && yarn run build:package:plugin:job-proposal:docker && yarn run build:package:plugin:integration-github:docker && yarn run build:package:plugin:knowledge-base:docker && yarn run build:package:plugin:changelog:docker && yarn run build:package:plugin:integration-hubstaff:docker && yarn run build:package:plugin:integration-upwork:docker", + "build:package:plugins:post": "yarn run build:package:plugin:integration-jira && yarn run build:package:plugin:integration-ai && yarn run build:package:plugin:sentry && yarn run build:package:plugin:jitsu-analytic && yarn run build:package:plugin:product-reviews && yarn run build:package:plugin:job-search && yarn run build:package:plugin:job-proposal && yarn run build:package:plugin:integration-github && yarn run build:package:plugin:knowledge-base && yarn run build:package:plugin:changelog && yarn run build:package:plugin:integration-hubstaff && yarn run build:package:plugin:integration-upwork && yarn run build:package:plugin:video-capture", + "build:package:plugins:post:prod": "yarn run build:package:plugin:integration-jira:prod && yarn run build:package:plugin:integration-ai:prod && yarn run build:package:plugin:sentry:prod && yarn run build:package:plugin:jitsu-analytic:prod && yarn run build:package:plugin:product-reviews:prod && yarn run build:package:plugin:job-search:prod && yarn run build:package:plugin:job-proposal:prod && yarn run build:package:plugin:integration-github:prod && yarn run build:package:plugin:knowledge-base:prod && yarn run build:package:plugin:changelog:prod && yarn run build:package:plugin:integration-hubstaff:prod && yarn run build:package:plugin:integration-upwork:prod && yarn run build:package:plugin:video-capture:prod", + "build:package:plugins:post:docker": "yarn run build:package:plugin:integration-jira:docker && yarn run build:package:plugin:integration-ai:docker && yarn run build:package:plugin:sentry:docker && yarn run build:package:plugin:jitsu-analytic:docker && yarn run build:package:plugin:product-reviews:docker && yarn run build:package:plugin:job-search:docker && yarn run build:package:plugin:job-proposal:docker && yarn run build:package:plugin:integration-github:docker && yarn run build:package:plugin:knowledge-base:docker && yarn run build:package:plugin:changelog:docker && yarn run build:package:plugin:integration-hubstaff:docker && yarn run build:package:plugin:integration-upwork:docker && yarn run build:package:plugin:video-capture:docker", "build:package:plugin:integration-ai": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-integration-ai", "build:package:plugin:integration-ai:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-integration-ai", "build:package:plugin:integration-ai:docker": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=60000 yarn nx build plugin-integration-ai", @@ -231,6 +231,9 @@ "build:package:plugin:knowledge-base": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-knowledge-base", "build:package:plugin:knowledge-base:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-knowledge-base", "build:package:plugin:knowledge-base:docker": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=60000 yarn nx build plugin-knowledge-base", + "build:package:plugin:video-capture": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-video-capture", + "build:package:plugin:video-capture:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-video-capture", + "build:package:plugin:video-capture:docker": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=60000 yarn nx build plugin-video-capture", "build:package:plugin:changelog": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-changelog", "build:package:plugin:changelog:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-changelog", "build:package:plugin:changelog:docker": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=60000 yarn nx build plugin-changelog", @@ -481,7 +484,7 @@ "husky": "^6.0.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", - "jest-environment-node": "^29.4.1", + "jest-environment-node": "^29.7.0", "jest-jasmine2": "29.7.0", "jest-preset-angular": "14.1.1", "jsonc-eslint-parser": "^2.1.0", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 46cb49220bf..948fc364450 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,6 +4,7 @@ export * from './lib/accounting-template.model'; /** App Setting Model */ export * from './lib/activity-log.model'; +export * from './lib/activity-watch.model'; export * from './lib/api-call-log.model'; export * from './lib/app.model'; export * from './lib/appointment-employees.model'; @@ -29,6 +30,7 @@ export * from './lib/core.model'; export * from './lib/country.model'; export * from './lib/currency.model'; export * from './lib/custom-smtp.model'; +export * from './lib/daily-plan.model'; export * from './lib/date-picker.model'; export * from './lib/dashboard.model'; export * from './lib/deal.model'; @@ -51,7 +53,6 @@ export * from './lib/equipment.model'; export * from './lib/estimate-email.model'; export * from './lib/event-type.model'; export * from './lib/expense-category.model'; -export * from './lib/expense-category.model'; export * from './lib/expense.model'; export * from './lib/favorite.model'; export * from './lib/feature.model'; @@ -67,8 +68,8 @@ export * from './lib/hubstaff.model'; export * from './lib/image-asset.model'; export * from './lib/import-export.model'; export * from './lib/income.model'; -export * from './lib/integration.model'; export * from './lib/integration-setting.model'; +export * from './lib/integration.model'; export * from './lib/invite.model'; export * from './lib/invoice-estimate-history.model'; export * from './lib/invoice-item.model'; @@ -87,8 +88,8 @@ export * from './lib/organization-employment-type.model'; export * from './lib/organization-expense-category.model'; export * from './lib/organization-language.model'; export * from './lib/organization-positions.model'; -export * from './lib/organization-projects.model'; export * from './lib/organization-project-module.model'; +export * from './lib/organization-projects.model'; export * from './lib/organization-recurring-expense.model'; export * from './lib/organization-sprint.model'; export * from './lib/organization-task-setting.model'; @@ -113,6 +114,7 @@ export * from './lib/request-approval.model'; export * from './lib/resource-link.model'; export * from './lib/role-permission.model'; export * from './lib/role.model'; +export * from './lib/screening-task.model'; export * from './lib/screenshot.model'; export * from './lib/seed.model'; export * from './lib/shared-types'; @@ -130,8 +132,6 @@ export * from './lib/task-status.model'; export * from './lib/task-version.model'; export * from './lib/task-view.model'; export * from './lib/task.model'; -export * from './lib/daily-plan.model'; -export * from './lib/screening-task.model'; export * from './lib/tenant.model'; export * from './lib/time-off.model'; export * from './lib/timesheet-statistics.model'; @@ -142,18 +142,17 @@ export * from './lib/upwork.model'; export * from './lib/user-organization.model'; export * from './lib/user.model'; export * from './lib/wakatime.model'; -export * from './lib/activity-watch.model'; export { + ActorTypeEnum, + BaseEntityEnum, IBaseEntityModel as BaseEntityModel, - ID, IBasePerTenantAndOrganizationEntityModel, IBasePerTenantEntityModel, - IBaseSoftDeleteEntityModel, IBaseRelationsEntityModel, - ActorTypeEnum, - JsonData, - BaseEntityEnum + IBaseSoftDeleteEntityModel, + ID, + JsonData } from './lib/base-entity.model'; export * from './lib/proxy.model'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c765857bfb5..d13742879ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,8 @@ export { export * from './lib/logger'; export * from './lib/core'; export * from './lib/core/seeds'; +export { LazyFileInterceptor } from './lib/core/interceptors'; +export { FileStorage, FileStorageFactory, UploadedFileStorage } from './lib/core/file-storage'; export * from './lib/shared'; export * from './lib/event-bus'; diff --git a/packages/core/src/lib/core/entities/subscribers/index.ts b/packages/core/src/lib/core/entities/subscribers/index.ts index 1818e16c531..a713672a901 100644 --- a/packages/core/src/lib/core/entities/subscribers/index.ts +++ b/packages/core/src/lib/core/entities/subscribers/index.ts @@ -49,6 +49,7 @@ import { } from '../internal'; import { TenantOrganizationBaseEntityEventSubscriber } from './tenant-organization-base-entity.subscriber'; +// Get the ORM type from the MultiORMEnum const ormType = getORMType(); /** diff --git a/packages/core/src/lib/core/file-storage/helpers/directory-path-generator.interface.ts b/packages/core/src/lib/core/file-storage/helpers/directory-path-generator.interface.ts new file mode 100644 index 00000000000..7af3fb56087 --- /dev/null +++ b/packages/core/src/lib/core/file-storage/helpers/directory-path-generator.interface.ts @@ -0,0 +1,4 @@ +export interface IDirectoryPathGenerator { + getBaseDirectory(name: string): string; + getSubDirectory(): string; +} diff --git a/packages/core/src/lib/core/file-storage/helpers/directory-path-generator.ts b/packages/core/src/lib/core/file-storage/helpers/directory-path-generator.ts new file mode 100644 index 00000000000..7a89c9c8f92 --- /dev/null +++ b/packages/core/src/lib/core/file-storage/helpers/directory-path-generator.ts @@ -0,0 +1,37 @@ +// Concrete implementation of the path generator +import * as moment from 'moment'; +import * as path from 'path'; +import { v4 as uuid } from 'uuid'; +import { RequestContext } from '../../context'; +import { IDirectoryPathGenerator } from './directory-path-generator.interface'; + +export class DirectoryPathGenerator implements IDirectoryPathGenerator { + /** + * Generates the base directory path with the given name. + * Includes a timestamped subdirectory in the format `YYYY/MM/DD`. + * + * @param name - The name to be used as the base directory. + * @returns The full base directory path including the timestamped subdirectory. + */ + public getBaseDirectory(name: string): string { + return path.join(name, moment().format('YYYY/MM/DD')); + } + + /** + * Generates a subdirectory path specific to the current user context. + * Uses the `tenantId` and `employeeId` from the current user, or generates UUIDs if not available. + * + * @returns The subdirectory path in the format `/`. + */ + public getSubDirectory(): string { + // Retrieve the current user from the request context + const user = RequestContext.currentUser(); + + // Extract or generate identifiers for the tenant and employee + const tenantId = user?.tenantId || uuid(); // Use the tenantId if available, otherwise generate a UUID + const employeeId = user?.employeeId || uuid(); // Use the employeeId if available, otherwise generate a UUID + + // Construct and return the subdirectory path + return path.join(tenantId, employeeId); + } +} diff --git a/packages/core/src/lib/core/file-storage/helpers/file-storage-factory.ts b/packages/core/src/lib/core/file-storage/helpers/file-storage-factory.ts new file mode 100644 index 00000000000..adf0a19c5b5 --- /dev/null +++ b/packages/core/src/lib/core/file-storage/helpers/file-storage-factory.ts @@ -0,0 +1,20 @@ +import multer from 'multer'; +import * as path from 'path'; +import { FileStorage } from '../file-storage'; +import { DirectoryPathGenerator } from './directory-path-generator'; +import { IDirectoryPathGenerator } from './directory-path-generator.interface'; + +// FileStorageFactory +export class FileStorageFactory { + private static readonly pathGenerator: IDirectoryPathGenerator = new DirectoryPathGenerator(); + + public static create(baseDirname: string): multer.StorageEngine { + const baseDirectory = this.pathGenerator.getBaseDirectory(baseDirname); + const subDirectory = this.pathGenerator.getSubDirectory(); + + return new FileStorage().storage({ + dest: () => path.join(baseDirectory, subDirectory), + prefix: baseDirname + }); + } +} diff --git a/packages/core/src/lib/core/file-storage/helpers/index.ts b/packages/core/src/lib/core/file-storage/helpers/index.ts new file mode 100644 index 00000000000..72d377ccdbf --- /dev/null +++ b/packages/core/src/lib/core/file-storage/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './directory-path-generator'; +export * from './directory-path-generator.interface'; +export * from './file-storage-factory'; diff --git a/packages/core/src/lib/core/file-storage/index.ts b/packages/core/src/lib/core/file-storage/index.ts index 1bb71c141f5..12559986cec 100644 --- a/packages/core/src/lib/core/file-storage/index.ts +++ b/packages/core/src/lib/core/file-storage/index.ts @@ -1,4 +1,5 @@ export * from './file-storage'; export * from './file-storage.module'; +export * from './helpers'; export * from './tenant-settings.middleware'; export * from './uploaded-file-storage'; diff --git a/packages/core/src/lib/core/interceptors/index.ts b/packages/core/src/lib/core/interceptors/index.ts index b40de9b64a6..6d8a1de0332 100644 --- a/packages/core/src/lib/core/interceptors/index.ts +++ b/packages/core/src/lib/core/interceptors/index.ts @@ -1,4 +1,4 @@ export * from './cloud-migrate.interceptor'; export * from './serializer.interceptor'; export * from './lazy-file-interceptor'; -export * from './transform.interceptor'; \ No newline at end of file +export * from './transform.interceptor'; diff --git a/packages/core/src/lib/database/migrations/1735058989058-CreateVideoTable.ts b/packages/core/src/lib/database/migrations/1735058989058-CreateVideoTable.ts new file mode 100644 index 00000000000..04b7c9f1008 --- /dev/null +++ b/packages/core/src/lib/database/migrations/1735058989058-CreateVideoTable.ts @@ -0,0 +1,207 @@ + +import { MigrationInterface, QueryRunner } from "typeorm"; +import * as chalk from 'chalk'; +import { DatabaseTypeEnum } from "@gauzy/config"; + +export class CreateVideoTable1735058989058 implements MigrationInterface { + + name = 'CreateVideoTable1735058989058'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(chalk.yellow(this.name + ' start running!')); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."video_storageprovider_enum" AS ENUM('LOCAL', 'S3', 'WASABI', 'CLOUDINARY', 'DIGITALOCEAN')`); + await queryRunner.query(`CREATE TABLE "video" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "title" character varying NOT NULL, "file" character varying NOT NULL, "recordedAt" TIMESTAMP, "duration" integer, "size" integer, "fullUrl" character varying, "storageProvider" "public"."video_storageprovider_enum", "description" character varying, "resolution" character varying DEFAULT '1920:1080', "codec" character varying DEFAULT 'libx264', "frameRate" integer DEFAULT '15', "timeSlotId" uuid, "uploadedById" uuid, CONSTRAINT "PK_e4c86c0cf95aff16e9fb8220f6b" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_eea665c6f09c4cd9a520a028d1" ON "video" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_4adb6b1409e7b614d06e44fb84" ON "video" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_1f9ad46e9fbeddbc609af9976a" ON "video" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_dcbf77e688d65ced41055c3faf" ON "video" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_cb34a3e97002e3af9cc219f4e4" ON "video" ("recordedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_b3e784ea168736c83f4d647abf" ON "video" ("storageProvider") `); + await queryRunner.query(`CREATE INDEX "IDX_d5a38b4293d90e31a6b1f3189e" ON "video" ("timeSlotId") `); + await queryRunner.query(`CREATE INDEX "IDX_159f8e5c7959016a0863ec419a" ON "video" ("uploadedById") `); + await queryRunner.query(`ALTER TABLE "video" ADD CONSTRAINT "FK_1f9ad46e9fbeddbc609af9976ae" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "video" ADD CONSTRAINT "FK_dcbf77e688d65ced41055c3fafe" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "video" ADD CONSTRAINT "FK_d5a38b4293d90e31a6b1f3189e1" FOREIGN KEY ("timeSlotId") REFERENCES "time_slot"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "video" ADD CONSTRAINT "FK_159f8e5c7959016a0863ec419a3" FOREIGN KEY ("uploadedById") REFERENCES "employee"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "video" DROP CONSTRAINT "FK_159f8e5c7959016a0863ec419a3"`); + await queryRunner.query(`ALTER TABLE "video" DROP CONSTRAINT "FK_d5a38b4293d90e31a6b1f3189e1"`); + await queryRunner.query(`ALTER TABLE "video" DROP CONSTRAINT "FK_dcbf77e688d65ced41055c3fafe"`); + await queryRunner.query(`ALTER TABLE "video" DROP CONSTRAINT "FK_1f9ad46e9fbeddbc609af9976ae"`); + await queryRunner.query(`DROP INDEX "public"."IDX_159f8e5c7959016a0863ec419a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d5a38b4293d90e31a6b1f3189e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b3e784ea168736c83f4d647abf"`); + await queryRunner.query(`DROP INDEX "public"."IDX_cb34a3e97002e3af9cc219f4e4"`); + await queryRunner.query(`DROP INDEX "public"."IDX_dcbf77e688d65ced41055c3faf"`); + await queryRunner.query(`DROP INDEX "public"."IDX_1f9ad46e9fbeddbc609af9976a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4adb6b1409e7b614d06e44fb84"`); + await queryRunner.query(`DROP INDEX "public"."IDX_eea665c6f09c4cd9a520a028d1"`); + await queryRunner.query(`DROP TABLE "video"`); + await queryRunner.query(`DROP TYPE "public"."video_storageprovider_enum"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "video" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "title" varchar NOT NULL, "file" varchar NOT NULL, "recordedAt" datetime, "duration" integer, "size" integer, "fullUrl" varchar, "storageProvider" varchar CHECK( "storageProvider" IN ('LOCAL','S3','WASABI','CLOUDINARY','DIGITALOCEAN') ), "description" varchar, "resolution" varchar DEFAULT ('1920:1080'), "codec" varchar DEFAULT ('libx264'), "frameRate" integer DEFAULT (15), "timeSlotId" varchar, "uploadedById" varchar)`); + await queryRunner.query(`CREATE INDEX "IDX_eea665c6f09c4cd9a520a028d1" ON "video" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_4adb6b1409e7b614d06e44fb84" ON "video" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_1f9ad46e9fbeddbc609af9976a" ON "video" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_dcbf77e688d65ced41055c3faf" ON "video" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_cb34a3e97002e3af9cc219f4e4" ON "video" ("recordedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_b3e784ea168736c83f4d647abf" ON "video" ("storageProvider") `); + await queryRunner.query(`CREATE INDEX "IDX_d5a38b4293d90e31a6b1f3189e" ON "video" ("timeSlotId") `); + await queryRunner.query(`CREATE INDEX "IDX_159f8e5c7959016a0863ec419a" ON "video" ("uploadedById") `); + await queryRunner.query(`DROP INDEX "IDX_eea665c6f09c4cd9a520a028d1"`); + await queryRunner.query(`DROP INDEX "IDX_4adb6b1409e7b614d06e44fb84"`); + await queryRunner.query(`DROP INDEX "IDX_1f9ad46e9fbeddbc609af9976a"`); + await queryRunner.query(`DROP INDEX "IDX_dcbf77e688d65ced41055c3faf"`); + await queryRunner.query(`DROP INDEX "IDX_cb34a3e97002e3af9cc219f4e4"`); + await queryRunner.query(`DROP INDEX "IDX_b3e784ea168736c83f4d647abf"`); + await queryRunner.query(`DROP INDEX "IDX_d5a38b4293d90e31a6b1f3189e"`); + await queryRunner.query(`DROP INDEX "IDX_159f8e5c7959016a0863ec419a"`); + await queryRunner.query(`CREATE TABLE "temporary_video" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "title" varchar NOT NULL, "file" varchar NOT NULL, "recordedAt" datetime, "duration" integer, "size" integer, "fullUrl" varchar, "storageProvider" varchar CHECK( "storageProvider" IN ('LOCAL','S3','WASABI','CLOUDINARY','DIGITALOCEAN') ), "description" varchar, "resolution" varchar DEFAULT ('1920:1080'), "codec" varchar DEFAULT ('libx264'), "frameRate" integer DEFAULT (15), "timeSlotId" varchar, "uploadedById" varchar, CONSTRAINT "FK_1f9ad46e9fbeddbc609af9976ae" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_dcbf77e688d65ced41055c3fafe" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_d5a38b4293d90e31a6b1f3189e1" FOREIGN KEY ("timeSlotId") REFERENCES "time_slot" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_159f8e5c7959016a0863ec419a3" FOREIGN KEY ("uploadedById") REFERENCES "employee" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_video"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "title", "file", "recordedAt", "duration", "size", "fullUrl", "storageProvider", "description", "resolution", "codec", "frameRate", "timeSlotId", "uploadedById") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "title", "file", "recordedAt", "duration", "size", "fullUrl", "storageProvider", "description", "resolution", "codec", "frameRate", "timeSlotId", "uploadedById" FROM "video"`); + await queryRunner.query(`DROP TABLE "video"`); + await queryRunner.query(`ALTER TABLE "temporary_video" RENAME TO "video"`); + await queryRunner.query(`CREATE INDEX "IDX_eea665c6f09c4cd9a520a028d1" ON "video" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_4adb6b1409e7b614d06e44fb84" ON "video" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_1f9ad46e9fbeddbc609af9976a" ON "video" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_dcbf77e688d65ced41055c3faf" ON "video" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_cb34a3e97002e3af9cc219f4e4" ON "video" ("recordedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_b3e784ea168736c83f4d647abf" ON "video" ("storageProvider") `); + await queryRunner.query(`CREATE INDEX "IDX_d5a38b4293d90e31a6b1f3189e" ON "video" ("timeSlotId") `); + await queryRunner.query(`CREATE INDEX "IDX_159f8e5c7959016a0863ec419a" ON "video" ("uploadedById") `); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_159f8e5c7959016a0863ec419a"`); + await queryRunner.query(`DROP INDEX "IDX_d5a38b4293d90e31a6b1f3189e"`); + await queryRunner.query(`DROP INDEX "IDX_b3e784ea168736c83f4d647abf"`); + await queryRunner.query(`DROP INDEX "IDX_cb34a3e97002e3af9cc219f4e4"`); + await queryRunner.query(`DROP INDEX "IDX_dcbf77e688d65ced41055c3faf"`); + await queryRunner.query(`DROP INDEX "IDX_1f9ad46e9fbeddbc609af9976a"`); + await queryRunner.query(`DROP INDEX "IDX_4adb6b1409e7b614d06e44fb84"`); + await queryRunner.query(`DROP INDEX "IDX_eea665c6f09c4cd9a520a028d1"`); + await queryRunner.query(`ALTER TABLE "video" RENAME TO "temporary_video"`); + await queryRunner.query(`CREATE TABLE "video" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "title" varchar NOT NULL, "file" varchar NOT NULL, "recordedAt" datetime, "duration" integer, "size" integer, "fullUrl" varchar, "storageProvider" varchar CHECK( "storageProvider" IN ('LOCAL','S3','WASABI','CLOUDINARY','DIGITALOCEAN') ), "description" varchar, "resolution" varchar DEFAULT ('1920:1080'), "codec" varchar DEFAULT ('libx264'), "frameRate" integer DEFAULT (15), "timeSlotId" varchar, "uploadedById" varchar)`); + await queryRunner.query(`INSERT INTO "video"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "title", "file", "recordedAt", "duration", "size", "fullUrl", "storageProvider", "description", "resolution", "codec", "frameRate", "timeSlotId", "uploadedById") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "title", "file", "recordedAt", "duration", "size", "fullUrl", "storageProvider", "description", "resolution", "codec", "frameRate", "timeSlotId", "uploadedById" FROM "temporary_video"`); + await queryRunner.query(`DROP TABLE "temporary_video"`); + await queryRunner.query(`CREATE INDEX "IDX_159f8e5c7959016a0863ec419a" ON "video" ("uploadedById") `); + await queryRunner.query(`CREATE INDEX "IDX_d5a38b4293d90e31a6b1f3189e" ON "video" ("timeSlotId") `); + await queryRunner.query(`CREATE INDEX "IDX_b3e784ea168736c83f4d647abf" ON "video" ("storageProvider") `); + await queryRunner.query(`CREATE INDEX "IDX_cb34a3e97002e3af9cc219f4e4" ON "video" ("recordedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_dcbf77e688d65ced41055c3faf" ON "video" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_1f9ad46e9fbeddbc609af9976a" ON "video" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_4adb6b1409e7b614d06e44fb84" ON "video" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_eea665c6f09c4cd9a520a028d1" ON "video" ("isActive") `); + await queryRunner.query(`DROP INDEX "IDX_159f8e5c7959016a0863ec419a"`); + await queryRunner.query(`DROP INDEX "IDX_d5a38b4293d90e31a6b1f3189e"`); + await queryRunner.query(`DROP INDEX "IDX_b3e784ea168736c83f4d647abf"`); + await queryRunner.query(`DROP INDEX "IDX_cb34a3e97002e3af9cc219f4e4"`); + await queryRunner.query(`DROP INDEX "IDX_dcbf77e688d65ced41055c3faf"`); + await queryRunner.query(`DROP INDEX "IDX_1f9ad46e9fbeddbc609af9976a"`); + await queryRunner.query(`DROP INDEX "IDX_4adb6b1409e7b614d06e44fb84"`); + await queryRunner.query(`DROP INDEX "IDX_eea665c6f09c4cd9a520a028d1"`); + await queryRunner.query(`DROP TABLE "video"`); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE \`video\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`title\` varchar(255) NOT NULL, \`file\` varchar(255) NOT NULL, \`recordedAt\` datetime NULL, \`duration\` int NULL, \`size\` int NULL, \`fullUrl\` varchar(255) NULL, \`storageProvider\` enum ('LOCAL', 'S3', 'WASABI', 'CLOUDINARY', 'DIGITALOCEAN') NULL, \`description\` varchar(255) NULL, \`resolution\` varchar(255) NULL DEFAULT '1920:1080', \`codec\` varchar(255) NULL DEFAULT 'libx264', \`frameRate\` int NULL DEFAULT '15', \`timeSlotId\` varchar(255) NULL, \`uploadedById\` varchar(255) NULL, INDEX \`IDX_eea665c6f09c4cd9a520a028d1\` (\`isActive\`), INDEX \`IDX_4adb6b1409e7b614d06e44fb84\` (\`isArchived\`), INDEX \`IDX_1f9ad46e9fbeddbc609af9976a\` (\`tenantId\`), INDEX \`IDX_dcbf77e688d65ced41055c3faf\` (\`organizationId\`), INDEX \`IDX_cb34a3e97002e3af9cc219f4e4\` (\`recordedAt\`), INDEX \`IDX_b3e784ea168736c83f4d647abf\` (\`storageProvider\`), INDEX \`IDX_d5a38b4293d90e31a6b1f3189e\` (\`timeSlotId\`), INDEX \`IDX_159f8e5c7959016a0863ec419a\` (\`uploadedById\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`ALTER TABLE \`video\` ADD CONSTRAINT \`FK_1f9ad46e9fbeddbc609af9976ae\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE \`video\` ADD CONSTRAINT \`FK_dcbf77e688d65ced41055c3fafe\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`video\` ADD CONSTRAINT \`FK_d5a38b4293d90e31a6b1f3189e1\` FOREIGN KEY (\`timeSlotId\`) REFERENCES \`time_slot\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE \`video\` ADD CONSTRAINT \`FK_159f8e5c7959016a0863ec419a3\` FOREIGN KEY (\`uploadedById\`) REFERENCES \`employee\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`video\` DROP FOREIGN KEY \`FK_159f8e5c7959016a0863ec419a3\``); + await queryRunner.query(`ALTER TABLE \`video\` DROP FOREIGN KEY \`FK_d5a38b4293d90e31a6b1f3189e1\``); + await queryRunner.query(`ALTER TABLE \`video\` DROP FOREIGN KEY \`FK_dcbf77e688d65ced41055c3fafe\``); + await queryRunner.query(`ALTER TABLE \`video\` DROP FOREIGN KEY \`FK_1f9ad46e9fbeddbc609af9976ae\``); + await queryRunner.query(`DROP INDEX \`IDX_159f8e5c7959016a0863ec419a\` ON \`video\``); + await queryRunner.query(`DROP INDEX \`IDX_d5a38b4293d90e31a6b1f3189e\` ON \`video\``); + await queryRunner.query(`DROP INDEX \`IDX_b3e784ea168736c83f4d647abf\` ON \`video\``); + await queryRunner.query(`DROP INDEX \`IDX_cb34a3e97002e3af9cc219f4e4\` ON \`video\``); + await queryRunner.query(`DROP INDEX \`IDX_dcbf77e688d65ced41055c3faf\` ON \`video\``); + await queryRunner.query(`DROP INDEX \`IDX_1f9ad46e9fbeddbc609af9976a\` ON \`video\``); + await queryRunner.query(`DROP INDEX \`IDX_4adb6b1409e7b614d06e44fb84\` ON \`video\``); + await queryRunner.query(`DROP INDEX \`IDX_eea665c6f09c4cd9a520a028d1\` ON \`video\``); + await queryRunner.query(`DROP TABLE \`video\``); + } +} diff --git a/packages/plugins/video-capture/.dockerignore b/packages/plugins/video-capture/.dockerignore new file mode 100644 index 00000000000..14ea7e1f315 --- /dev/null +++ b/packages/plugins/video-capture/.dockerignore @@ -0,0 +1,23 @@ +# Ignore Git related files and directories +.git +.gitignore +.gitmodules + +# Ignore README file +README.md + +# Ignore Docker-related files and directories +docker + +# Ignore Node.js modules +node_modules + +# Ignore temporary files and directories +tmp + +# Ignore build and compilation output +build +dist + +# Ignore environment configuration files +.env diff --git a/packages/plugins/video-capture/.gitignore b/packages/plugins/video-capture/.gitignore new file mode 100644 index 00000000000..84e698df22c --- /dev/null +++ b/packages/plugins/video-capture/.gitignore @@ -0,0 +1,11 @@ + +# dependencies +node_modules/ + +# yarn +yarn-error.log + +# misc +npm-debug.log +dist +build diff --git a/packages/plugins/video-capture/.npmignore b/packages/plugins/video-capture/.npmignore new file mode 100644 index 00000000000..1eb4beb9572 --- /dev/null +++ b/packages/plugins/video-capture/.npmignore @@ -0,0 +1,4 @@ +# .npmignore + +src/ +node_modules/ diff --git a/packages/plugins/video-capture/CHANGELOG.md b/packages/plugins/video-capture/CHANGELOG.md new file mode 100644 index 00000000000..364dc5114a3 --- /dev/null +++ b/packages/plugins/video-capture/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog for @gauzy/plugin-video-capture + +## [Unreleased] diff --git a/packages/plugins/video-capture/README.md b/packages/plugins/video-capture/README.md new file mode 100644 index 00000000000..7bda8daa79d --- /dev/null +++ b/packages/plugins/video-capture/README.md @@ -0,0 +1,29 @@ +# @gauzy/plugin-video-capture + +This library was generated with [Nx](https://nx.dev). It contains the Video Capture plugin for the Gauzy platform. + +## Overview + +This plugin provides continuous video recording capabilities for your projects. + +## Building + +Run `yarn nx build plugin-video-capture` to build the library. + +## Running unit tests + +Run `yarn nx test plugin-video-capture` to execute the unit tests via [Jest](https://jestjs.io). + +## Publishing + +After building your library with `yarn nx build plugin-video-capture`, go to the dist folder `dist/packages/plugins/video-capture` and run `npm publish`. + +## Installation + +Install the Video Capture plugin using your preferred package manager: + +```bash +npm install @gauzy/plugin-video-capture +# or +yarn add @gauzy/plugin-video-capture +``` diff --git a/packages/plugins/video-capture/eslint.config.js b/packages/plugins/video-capture/eslint.config.js new file mode 100644 index 00000000000..ba4f5d8daad --- /dev/null +++ b/packages/plugins/video-capture/eslint.config.js @@ -0,0 +1,19 @@ +const baseConfig = require('../../../.eslintrc.json'); + +module.exports = [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'] + } + ] + }, + languageOptions: { + parser: require('jsonc-eslint-parser') + } + } +]; diff --git a/packages/plugins/video-capture/jest.config.ts b/packages/plugins/video-capture/jest.config.ts new file mode 100644 index 00000000000..3911db51777 --- /dev/null +++ b/packages/plugins/video-capture/jest.config.ts @@ -0,0 +1,10 @@ +export default { + displayName: 'plugin-video-capture', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/packages/plugins/video-capture' +}; diff --git a/packages/plugins/video-capture/package.json b/packages/plugins/video-capture/package.json new file mode 100644 index 00000000000..134549a1b8d --- /dev/null +++ b/packages/plugins/video-capture/package.json @@ -0,0 +1,59 @@ +{ + "name": "@gauzy/plugin-video-capture", + "version": "0.1.0", + "description": "Ever Gauzy Platform plugin for continuous video recording", + "author": { + "name": "Ever Co. LTD", + "email": "ever@ever.co", + "url": "https://ever.co" + }, + "repository": { + "type": "git", + "url": "https://github.com/ever-co/ever-gauzy" + }, + "bugs": { + "url": "https://github.com/ever-co/ever-gauzy/issues" + }, + "homepage": "https://ever.co", + "license": "AGPL-3.0", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "typings": "./src/index.d.ts", + "scripts": { + "lib:build": "yarn nx build plugin-video-capture", + "lib:build:prod": "yarn nx build plugin-video-capture", + "lib:watch": "yarn nx build plugin-video-capture --watch" + }, + "dependencies": { + "@gauzy/contracts": "^0.1.0", + "@gauzy/core": "^0.1.0", + "@gauzy/plugin": "^0.1.0", + "tslib": "^2.6.2" + }, + "devDependencies": { + "@types/express": "^4.17.13", + "@types/jest": "29.5.14", + "@types/node": "^20.14.9", + "typescript": "5.5.4" + }, + "keywords": [ + "video", + "capture", + "plugin", + "NestJS", + "Gauzy", + "Ever Gauzy", + "platform", + "management", + "tool", + "software", + "development", + "typescript" + ], + "engines": { + "node": ">=20.11.1", + "yarn": ">=1.22.19" + }, + "sideEffects": false +} diff --git a/packages/plugins/video-capture/project.json b/packages/plugins/video-capture/project.json new file mode 100644 index 00000000000..284de0f241b --- /dev/null +++ b/packages/plugins/video-capture/project.json @@ -0,0 +1,44 @@ +{ + "name": "plugin-video-capture", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/plugins/video-capture/src", + "projectType": "library", + "release": { + "version": { + "generatorOptions": { + "packageRoot": "dist/{projectRoot}", + "currentVersionResolver": "git-tag" + } + } + }, + "tags": [], + "implicitDependencies": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/plugins/video-capture", + "tsConfig": "packages/plugins/video-capture/tsconfig.lib.json", + "packageJson": "packages/plugins/video-capture/package.json", + "main": "packages/plugins/video-capture/src/index.ts", + "assets": ["packages/plugins/video-capture/*.md"] + } + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/plugins/video-capture/jest.config.ts" + } + } + } +} diff --git a/packages/plugins/video-capture/src/index.ts b/packages/plugins/video-capture/src/index.ts new file mode 100644 index 00000000000..eff3a286876 --- /dev/null +++ b/packages/plugins/video-capture/src/index.ts @@ -0,0 +1,5 @@ +// Export the main plugin module +export * from './lib/video-capture.plugin'; + +// Export individual models +export * from './lib/video.model'; diff --git a/packages/plugins/video-capture/src/lib/commands/create-video.command.ts b/packages/plugins/video-capture/src/lib/commands/create-video.command.ts new file mode 100644 index 00000000000..cc322194753 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/commands/create-video.command.ts @@ -0,0 +1,7 @@ +import { ICommand } from '@nestjs/cqrs'; +import { CreateVideoDTO } from '../dto'; + +export class CreateVideoCommand implements ICommand { + public static readonly type = '[Create] Video'; + constructor(public readonly input: CreateVideoDTO) {} +} diff --git a/packages/plugins/video-capture/src/lib/commands/delete-video.command.ts b/packages/plugins/video-capture/src/lib/commands/delete-video.command.ts new file mode 100644 index 00000000000..ec635f9d610 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/commands/delete-video.command.ts @@ -0,0 +1,7 @@ +import { ICommand } from '@nestjs/cqrs'; +import { DeleteVideoDTO } from '../dto'; + +export class DeleteVideoCommand implements ICommand { + public static readonly type = '[Delete] Video'; + constructor(public readonly input: DeleteVideoDTO) {} +} diff --git a/packages/plugins/video-capture/src/lib/commands/handlers/create-video.handler.ts b/packages/plugins/video-capture/src/lib/commands/handlers/create-video.handler.ts new file mode 100644 index 00000000000..b87e0f712b7 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/commands/handlers/create-video.handler.ts @@ -0,0 +1,31 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { IVideo } from '../../video.model'; +import { Video } from '../../entities/video.entity'; +import { VideosService } from '../../services/videos.service'; +import { CreateVideoCommand } from '../create-video.command'; + +@CommandHandler(CreateVideoCommand) +export class CreateVideoHandler implements ICommandHandler { + constructor(private readonly videosService: VideosService) {} + + /** + * Handles the `CreateVideoCommand` to create a new video entity in the database. + * + * @param command - The `CreateVideoCommand` containing the input data for the new video. + * + * @returns A promise resolving to the newly created video entity (`IVideo`). + */ + public async execute(command: CreateVideoCommand): Promise { + // Extract input data from the command + const { input } = command; + + // Step 1: Create a new video entity with the provided input + const video = new Video({ + ...input, + file: input.file.key // Extract the file key if a file object is provided + }); + + // Step 2: Save the new video entity to the database + return this.videosService.create(video); + } +} diff --git a/packages/plugins/video-capture/src/lib/commands/handlers/delete-video.handler.ts b/packages/plugins/video-capture/src/lib/commands/handlers/delete-video.handler.ts new file mode 100644 index 00000000000..8dfd976525b --- /dev/null +++ b/packages/plugins/video-capture/src/lib/commands/handlers/delete-video.handler.ts @@ -0,0 +1,40 @@ +import { NotFoundException } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { DeleteResult } from 'typeorm'; +import { VideosService } from '../../services/videos.service'; +import { DeleteVideoCommand } from '../delete-video.command'; + +@CommandHandler(DeleteVideoCommand) +export class DeleteVideoHandler implements ICommandHandler { + constructor(private readonly videosService: VideosService) {} + + /** + * Handles the `DeleteVideoCommand` to delete a video entity from the database. + * Validates the existence of the video and performs the deletion based on the provided criteria. + * + * @param command - The `DeleteVideoCommand` containing the video ID and additional options for deletion. + * + * @returns A promise resolving to a `DeleteResult`, which includes metadata about the deletion operation. + * + * @throws {NotFoundException} If the video with the specified ID does not exist. + */ + public async execute(command: DeleteVideoCommand): Promise { + // Destructure the command to extract input data + const { + input: { id, options = {} } + } = command; + + // Step 1: Check if the video exists + const video = await this.videosService.findOneByWhereOptions({ ...options, id }); + + // Step 2: Throw a NotFoundException if the video does not exist + if (!video) { + throw new NotFoundException(`Video with ID ${id} not found.`); + } + + // Step 3: Delete the video entity from the database + return this.videosService.delete(id, { + where: options + }); + } +} diff --git a/packages/plugins/video-capture/src/lib/commands/handlers/index.ts b/packages/plugins/video-capture/src/lib/commands/handlers/index.ts new file mode 100644 index 00000000000..93f9834b6ad --- /dev/null +++ b/packages/plugins/video-capture/src/lib/commands/handlers/index.ts @@ -0,0 +1,5 @@ +import { CreateVideoHandler } from './create-video.handler'; +import { DeleteVideoHandler } from './delete-video.handler'; +import { UpdateVideoHandler } from './update-video.handler'; + +export const CommandHandlers = [CreateVideoHandler, DeleteVideoHandler, UpdateVideoHandler]; diff --git a/packages/plugins/video-capture/src/lib/commands/handlers/update-video.handler.ts b/packages/plugins/video-capture/src/lib/commands/handlers/update-video.handler.ts new file mode 100644 index 00000000000..1ab3fe25f28 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/commands/handlers/update-video.handler.ts @@ -0,0 +1,37 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { IVideo } from '../../video.model'; +import { VideosService } from '../../services/videos.service'; +import { UpdateVideoCommand } from '../update-video.command'; + +@CommandHandler(UpdateVideoCommand) +export class UpdateVideoHandler implements ICommandHandler { + constructor(public readonly videosService: VideosService) {} + + /** + * Handles the update of a video entity in the database. + * This method receives an `UpdateVideoCommand`, updates the video entity with the provided data, + * and returns the updated video entity. + * + * @param command - The command containing the input data for updating the video. + * + * @returns A promise that resolves to the updated video entity (`IVideo`). + */ + public async execute(command: UpdateVideoCommand): Promise { + // Extract input data from the command + const { input } = command; + + // Destructure the input fields for clarity + const { id, title, size, file, duration } = 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 + }); + + // Fetch and return the updated video entity by its ID + return this.videosService.findOneByIdString(id); + } +} diff --git a/packages/plugins/video-capture/src/lib/commands/update-video.command.ts b/packages/plugins/video-capture/src/lib/commands/update-video.command.ts new file mode 100644 index 00000000000..43c0cd3888e --- /dev/null +++ b/packages/plugins/video-capture/src/lib/commands/update-video.command.ts @@ -0,0 +1,7 @@ +import { ICommand } from '@nestjs/cqrs'; +import { UpdateVideoDTO } from '../dto'; + +export class UpdateVideoCommand implements ICommand { + public static readonly type = '[Update] Video'; + constructor(public readonly input: UpdateVideoDTO) {} +} diff --git a/packages/plugins/video-capture/src/lib/dto/base-video.dto.ts b/packages/plugins/video-capture/src/lib/dto/base-video.dto.ts new file mode 100644 index 00000000000..7121370a27f --- /dev/null +++ b/packages/plugins/video-capture/src/lib/dto/base-video.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { Video } from '../entities/video.entity'; + +export class BaseVideoDTO extends OmitType(Video, ['id', 'file', 'createdAt', 'updatedAt', 'deletedAt'] as const) {} diff --git a/packages/plugins/video-capture/src/lib/dto/create-video.dto.ts b/packages/plugins/video-capture/src/lib/dto/create-video.dto.ts new file mode 100644 index 00000000000..7b2c9da74c0 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/dto/create-video.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsDefined, IsNotEmpty, Length, Matches, ValidateNested } from 'class-validator'; +import { BaseVideoDTO } from './base-video.dto'; +import { FileDTO } from './file.dto'; + +export class CreateVideoDTO extends BaseVideoDTO { + @ApiProperty({ + description: 'The title of the video', + example: 'My Project Demo Video 2024', + minLength: 3, + maxLength: 255 + }) + @IsDefined({ message: 'Title is required and cannot be empty.' }) + @IsNotEmpty({ message: 'Title is required' }) + @Length(3, 255, { message: 'Title must be between 3 and 255 characters' }) + @Matches(/^[\w\s-]+$/i, { + message: 'Title can only contain letters, numbers, spaces, and hyphens' + }) + title: string; + + @ApiProperty({ + type: FileDTO, + description: 'The uploaded video file object containing metadata and properties', + required: true + }) + @ValidateNested({ message: 'File must be a valid FileDTO object' }) + @Type(() => FileDTO) + file: FileDTO; +} diff --git a/packages/plugins/video-capture/src/lib/dto/delete-video.dto.ts b/packages/plugins/video-capture/src/lib/dto/delete-video.dto.ts new file mode 100644 index 00000000000..c9cc9448422 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/dto/delete-video.dto.ts @@ -0,0 +1,21 @@ +import { ID } from '@gauzy/contracts'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; +import { IDeleteVideo } from '../video.model'; + +export class DeleteVideoDTO { + /** + * The ID of the video to delete + */ + @ApiProperty({ description: 'The ID of the video to delete', example: '123e4567-e89b-12d3-a456-426614174000' }) + @IsNotEmpty() + @IsUUID() + readonly id: ID; + + /** + * The options to delete the video + */ + @ApiProperty({ required: false }) + @IsOptional() + options?: IDeleteVideo; +} diff --git a/packages/plugins/video-capture/src/lib/dto/file.dto.ts b/packages/plugins/video-capture/src/lib/dto/file.dto.ts new file mode 100644 index 00000000000..5d302c4273a --- /dev/null +++ b/packages/plugins/video-capture/src/lib/dto/file.dto.ts @@ -0,0 +1,94 @@ +import { UploadedFile } from '@gauzy/contracts'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, Matches, MaxLength, Min } from 'class-validator'; + +export class FileDTO implements UploadedFile { + @ApiProperty({ + description: 'The field name associated with the file', + example: 'videoFile' + }) + @IsNotEmpty({ message: 'Field name must not be empty' }) + @IsString({ message: 'Field name must be a string' }) + @MaxLength(255, { message: 'Field name must not exceed 255 characters' }) + fieldname: string; + + @ApiProperty({ + description: 'The file path or identifier of the video', + example: 'project-demo-2024.mp4', + pattern: '/\\.(mp4)$/' + }) + @IsNotEmpty({ message: 'File key must not be empty' }) + @IsString({ message: 'File key must be a string' }) + @MaxLength(255, { message: 'File key must not exceed 255 characters' }) + @Matches(/\.(mp4)$/, { + message: + 'File must be a valid video format mp4 and contain only letters, numbers, spaces, hyphens, or underscores' + }) + key: string; + + @ApiProperty({ + description: 'The original file name', + example: 'project-demo-original.mp4' + }) + @IsNotEmpty({ message: 'Original name must not be empty' }) + @IsString({ message: 'Original name must be a string' }) + @MaxLength(255, { message: 'Original name must not exceed 255 characters' }) + originalname: string; + + @ApiProperty({ + description: 'The size of the file in bytes', + example: 10485760 + }) + @IsNotEmpty({ message: 'File size must not be empty' }) + @IsNumber({}, { message: 'File size must be a number' }) + @IsPositive({ message: 'File size must be a positive number' }) + @Min(1, { message: 'File size must be at least 1 byte' }) + size: number; + + @ApiProperty({ + description: 'The file encoding (if available)', + example: '7bit' + }) + @IsOptional() + @IsString({ message: 'Encoding must be a string' }) + @MaxLength(50, { message: 'Encoding must not exceed 50 characters' }) + encoding?: string; + + @ApiProperty({ + description: 'The MIME type of the file (if available)', + example: 'video/mp4' + }) + @IsOptional() + @IsString({ message: 'MIME type must be a string' }) + @MaxLength(50, { message: 'MIME type must not exceed 50 characters' }) + @Matches(/^video\/(mp4)$/, { + message: 'MIME type must be a valid video MIME type video/mp4' + }) + mimetype?: string; + + @ApiProperty({ + description: 'The file name', + example: 'project-demo-2024.mp4' + }) + @IsNotEmpty({ message: 'File name must not be empty' }) + @IsString({ message: 'File name must be a string' }) + @MaxLength(255, { message: 'File name must not exceed 255 characters' }) + filename: string; + + @ApiProperty({ + description: 'The public URL of the file', + example: 'https://example.com/project-demo-2024.mp4' + }) + @IsNotEmpty({ message: 'File URL must not be empty' }) + @MaxLength(2083, { message: 'File URL must not exceed 2083 characters' }) // 2083 is the maximum URL length in browsers + url: string; + + @ApiProperty({ + description: 'The full path of the file', + example: '/uploads/project-demo-2024.mp4' + }) + @IsNotEmpty({ message: 'File path must not be empty' }) + @IsString({ message: 'File path must be a string' }) + @MaxLength(1024, { message: 'File path must not exceed 1024 characters' }) + path: string; +} diff --git a/packages/plugins/video-capture/src/lib/dto/index.ts b/packages/plugins/video-capture/src/lib/dto/index.ts new file mode 100644 index 00000000000..08f52ea1623 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/dto/index.ts @@ -0,0 +1,5 @@ +export * from './base-video.dto'; +export * from './create-video.dto'; +export * from './delete-video.dto'; +export * from './file.dto'; +export * from './update-video.dto'; diff --git a/packages/plugins/video-capture/src/lib/dto/update-video.dto.ts b/packages/plugins/video-capture/src/lib/dto/update-video.dto.ts new file mode 100644 index 00000000000..f30f52070a9 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/dto/update-video.dto.ts @@ -0,0 +1,19 @@ +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; +} diff --git a/packages/plugins/video-capture/src/lib/entities/video.entity.ts b/packages/plugins/video-capture/src/lib/entities/video.entity.ts new file mode 100644 index 00000000000..f89be25e021 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/entities/video.entity.ts @@ -0,0 +1,229 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Exclude, Transform } from 'class-transformer'; +import { + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUrl, + IsUUID, + Length, + Matches, + Max, + Min, + ValidateIf +} from 'class-validator'; +import { JoinColumn, RelationId } from 'typeorm'; +import { FileStorageProvider, FileStorageProviderEnum, ID, IEmployee, ITimeSlot } from '@gauzy/contracts'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '@gauzy/core'; +import { Employee, TenantOrganizationBaseEntity, TimeSlot } from '@gauzy/core'; +import { IVideo, VideoCodecEnum, VideoResolutionEnum } from '../video.model'; +import { MikroOrmVideoRepository } from '../repositories/mikro-orm-video.repository'; + +@MultiORMEntity('video', { mikroOrmRepository: () => MikroOrmVideoRepository }) +export class Video extends TenantOrganizationBaseEntity implements IVideo { + /** + * Title of the video. + * This is a required field with a maximum length of 255 characters. + */ + @ApiProperty({ type: () => String, description: 'Title of the video' }) + @IsNotEmpty({ message: 'Title is required' }) + @IsString({ message: 'Title must be a string' }) + @Length(3, 255, { message: 'Title must be between 3 and 255 characters' }) + @Matches(/^[\p{L}\p{N}\s-]+$/u, { message: 'Title can contain letters, numbers, spaces, and hyphens from any language' }) + @MultiORMColumn() + title: string; + + /** + * Video file path or identifier. + * Must be a valid MP4 file with a valid name format. + */ + @ApiProperty({ type: () => String, description: 'Video file path or identifier' }) + @IsNotEmpty({ message: 'File is required' }) + @IsString({ message: 'File must be a string' }) + @Matches(/^[\w-]+\.(mp4)$/i, { + message: 'File must be a valid MP4 format and contain only letters, numbers, and hyphens' + }) + @MultiORMColumn() + file: string; + + /** + * Date when the video was recorded. + * This is optional and must be a valid past ISO 8601 date string. + */ + @ApiPropertyOptional({ type: () => 'timestamptz', description: 'Date when the video was recorded' }) + @IsOptional() + @IsDateString({}, { message: 'Recorded date must be a valid ISO 8601 date string' }) + @ValidateIf((o) => o.recordedAt && o.recordedAt <= new Date()) + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + recordedAt?: Date; + + /** + * Duration of the video in seconds. + * This is optional and must be a positive number. + */ + @ApiProperty({ type: () => Number, description: 'Duration of the video in seconds' }) + @IsOptional() + @IsNumber({}, { message: 'Duration must be a number' }) + @Min(0, { message: 'Duration must be greater than or equal to 0' }) + @Transform(({ value }) => parseFloat(value), { toClassOnly: true }) + @MultiORMColumn({ nullable: true }) + duration?: number; + + /** + * Size of the video file in bytes. + * Optional with a maximum size limit of 10GB (10737418240 bytes). + */ + @ApiProperty({ type: () => Number, description: 'Size of the video file in bytes' }) + @IsOptional() + @IsNumber({}, { message: 'Size must be a number' }) + @Min(0, { message: 'Size must be greater than or equal to 0' }) + @Max(10737418240, { message: 'Size cannot exceed 10GB (10737418240 bytes)' }) + @Transform(({ value }) => parseFloat(value), { toClassOnly: true }) + @MultiORMColumn({ nullable: true }) + size?: number; + + /** + * Full URL to access the video. + * Optional and must be a valid HTTP or HTTPS URL. + */ + @ApiProperty({ type: () => String, description: 'Full URL to access the video' }) + @IsOptional() + @IsUrl( + { protocols: ['http', 'https'], require_protocol: true }, + { message: 'Full URL must be a valid HTTPS or HTTP URL' } + ) + @MultiORMColumn({ nullable: true }) + fullUrl?: string | null; + + /** + * Storage provider used for storing the video file. + * Optional and must match one of the predefined storage providers. + */ + @ApiPropertyOptional({ type: () => String, enum: FileStorageProviderEnum }) + @IsOptional() + @IsEnum(FileStorageProviderEnum, { + message: `Storage provider must be one of: ${Object.values(FileStorageProviderEnum).join(', ')}` + }) + @Exclude({ toPlainOnly: true }) + @ColumnIndex() + @MultiORMColumn({ type: 'simple-enum', nullable: true, enum: FileStorageProviderEnum }) + storageProvider?: FileStorageProvider; + + /** + * Description of the video. + * Optional with a maximum length of 1000 characters. + */ + @ApiProperty({ type: () => String, description: 'Video description' }) + @IsOptional() + @IsString({ message: 'Description must be a string' }) + @Length(0, 1000, { message: 'Description must not exceed 1000 characters' }) + @Matches(/^[\w\s.,!?-]*$/i, { + message: 'Description can only contain letters, numbers, spaces, and basic punctuation' + }) + @MultiORMColumn({ nullable: true }) + description?: string; + + /** + * Video resolution in the format WIDTH:HEIGHT. + * Optional and restricted to standard resolutions defined in VideoResolutionEnum. + */ + @ApiProperty({ + type: () => String, + enum: VideoResolutionEnum, + description: 'Video resolution in format WIDTH:HEIGHT (e.g., 1920:1080, 3840:2160)' + }) + @IsOptional() + @IsEnum(VideoResolutionEnum, { + message: `Resolution must be one of the following: ${Object.values(VideoResolutionEnum).join(', ')}` + }) + @MultiORMColumn({ nullable: true, default: VideoResolutionEnum.FullHD }) + resolution?: VideoResolutionEnum; + + /** + * Video codec used for encoding. + * Optional and restricted to standard codecs defined in VideoCodecEnum. + */ + @ApiPropertyOptional({ + type: () => String, + enum: VideoCodecEnum, + description: 'Video codec used for encoding (e.g., libx264, libx265, vp9)' + }) + @IsOptional() + @IsEnum(VideoCodecEnum, { + message: `Codec must be one of the following: ${Object.values(VideoCodecEnum).join(', ')}` + }) + @MultiORMColumn({ nullable: true, default: VideoCodecEnum.libx264 }) + codec?: VideoCodecEnum; + + /** + * Video frame rate in frames per second. + * Optional with a range from 1 to 240 fps. + */ + @ApiProperty({ type: () => Number, description: 'Video frame rate' }) + @IsOptional() + @IsNumber({}, { message: 'Frame rate must be a number' }) + @Min(1, { message: 'Frame rate must be at least 1 fps' }) + @Max(240, { message: 'Frame rate cannot exceed 240 fps' }) + @Transform(({ value }) => parseFloat(value), { toClassOnly: true }) + @MultiORMColumn({ nullable: true, default: 15 }) + frameRate?: number; + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + + /** + * Represents the associated TimeSlot for the video. + * This is an optional many-to-one relationship with cascading delete. + */ + @MultiORMManyToOne(() => TimeSlot, { + /** Specifies whether the relation column can have null values. */ + nullable: true, + + /** Specifies the action to take when the related entity is deleted. */ + onDelete: 'CASCADE' + }) + @JoinColumn() // Indicates this is the owning side of the relationship and specifies the join column. + timeSlot?: ITimeSlot; + + /** + * Represents the ID of the associated TimeSlot. + * This is an optional UUID (version 4) used as a foreign key reference. + */ + @ApiPropertyOptional({ type: () => String, description: 'The UUID of the associated TimeSlot' }) + @IsOptional() + @IsUUID('4', { message: 'TimeSlot ID must be a valid UUID v4' }) // Validates the ID is a proper UUID v4. + @RelationId((video: Video) => video.timeSlot) // Extracts the foreign key for the relationship. + @ColumnIndex() // Adds a database index for faster queries on the timeSlotId column. + @MultiORMColumn({ nullable: true, relationId: true }) // Marks it as a relation identifier. + timeSlotId?: ID; + + /** + * Represents the Employee who uploaded the video. + * This is an optional many-to-one relationship with cascading delete. + */ + @MultiORMManyToOne(() => Employee, { + /** Specifies whether the relation column can have null values. */ + nullable: true, + + /** Specifies the action to take when the related entity is deleted. */ + onDelete: 'CASCADE' + }) + @JoinColumn() // Indicates this is the owning side of the relationship and specifies the join column. + uploadedBy?: IEmployee; + + /** + * Represents the ID of the Employee who uploaded the video. + * This is an optional UUID (version 4) used as a foreign key reference. + */ + @RelationId((video: Video) => video.uploadedBy) // Extracts the foreign key for the relationship. + @ColumnIndex() // Adds a database index for faster queries on the uploadedById column. + @MultiORMColumn({ nullable: true, relationId: true }) // Marks it as a relation identifier. + uploadedById?: ID; +} diff --git a/packages/plugins/video-capture/src/lib/queries/get-video.query.ts b/packages/plugins/video-capture/src/lib/queries/get-video.query.ts new file mode 100644 index 00000000000..ec800d77230 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/queries/get-video.query.ts @@ -0,0 +1,8 @@ +import { IQuery } from '@nestjs/cqrs'; +import { FindOneOptions } from 'typeorm'; +import { IVideo } from '../video.model'; + +export class GetVideoQuery implements IQuery { + public static readonly type = '[Video] Get'; + constructor(public readonly id: string, public readonly options: FindOneOptions) {} +} diff --git a/packages/plugins/video-capture/src/lib/queries/get-videos.query.ts b/packages/plugins/video-capture/src/lib/queries/get-videos.query.ts new file mode 100644 index 00000000000..e0a930d9f6d --- /dev/null +++ b/packages/plugins/video-capture/src/lib/queries/get-videos.query.ts @@ -0,0 +1,9 @@ +import { IQuery } from '@nestjs/cqrs'; +import { PaginationParams } from '@gauzy/core'; +import { IVideo } from '../video.model'; + +export class GetVideosQuery implements IQuery { + public static readonly type = '[Videos] Get All'; + + constructor(public readonly params: PaginationParams) {} +} diff --git a/packages/plugins/video-capture/src/lib/queries/handlers/get-video.handler.ts b/packages/plugins/video-capture/src/lib/queries/handlers/get-video.handler.ts new file mode 100644 index 00000000000..b84972093a9 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/queries/handlers/get-video.handler.ts @@ -0,0 +1,35 @@ +import { NotFoundException } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { VideosService } from '../../services/videos.service'; +import { IVideo } from '../../video.model'; +import { GetVideoQuery } from '../get-video.query'; + +@QueryHandler(GetVideoQuery) +export class GetVideoQueryHandler implements IQueryHandler { + constructor(private readonly videosService: VideosService) {} + + /** + * Handles the `GetVideoQuery` to retrieve a video entity by its ID. + * + * @param query - The `GetVideoQuery` containing the ID of the video to be fetched and optional query options. + * + * @returns A promise resolving to the video entity (`IVideo`) if found. + * + * @throws {NotFoundException} If the video with the specified ID is not found. + */ + public async execute(query: GetVideoQuery): Promise { + // Destructure the query to extract the video ID and options + const { id, options = {} } = query; + + // Step 1: Fetch the video entity from the database + const video = await this.videosService.findOneByIdString(id, options); + + // Step 2: Throw a NotFoundException if the video does not exist + if (!video) { + throw new NotFoundException(`Video with ID ${id} not found.`); + } + + // Step 3: Return the video entity + return video; + } +} diff --git a/packages/plugins/video-capture/src/lib/queries/handlers/get-videos.handler.ts b/packages/plugins/video-capture/src/lib/queries/handlers/get-videos.handler.ts new file mode 100644 index 00000000000..2e4e413b514 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/queries/handlers/get-videos.handler.ts @@ -0,0 +1,25 @@ +import { IPagination } from '@gauzy/contracts'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { IVideo } from '../../video.model'; +import { VideosService } from '../../services/videos.service'; +import { GetVideosQuery } from '../get-videos.query'; + +@QueryHandler(GetVideosQuery) +export class GetVideosQueryHandler implements IQueryHandler { + constructor(private readonly videosService: VideosService) {} + + /** + * Handles the `GetVideosQuery` to retrieve a paginated list of video entities. + * + * @param query - The `GetVideosQuery` containing parameters for pagination and filtering. + * + * @returns A promise resolving to a paginated result (`IPagination`), including a list of videos and metadata. + */ + public async execute(query: GetVideosQuery): Promise> { + // 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); + } +} diff --git a/packages/plugins/video-capture/src/lib/queries/handlers/index.ts b/packages/plugins/video-capture/src/lib/queries/handlers/index.ts new file mode 100644 index 00000000000..3a7b071e03e --- /dev/null +++ b/packages/plugins/video-capture/src/lib/queries/handlers/index.ts @@ -0,0 +1,4 @@ +import { GetVideoQueryHandler } from './get-video.handler'; +import { GetVideosQueryHandler } from './get-videos.handler'; + +export const QueryHandlers = [GetVideoQueryHandler, GetVideosQueryHandler]; diff --git a/packages/plugins/video-capture/src/lib/queries/index.ts b/packages/plugins/video-capture/src/lib/queries/index.ts new file mode 100644 index 00000000000..79fb221477c --- /dev/null +++ b/packages/plugins/video-capture/src/lib/queries/index.ts @@ -0,0 +1,2 @@ +export * from './get-video.query'; +export * from './get-videos.query'; diff --git a/packages/plugins/video-capture/src/lib/repositories/mikro-orm-video.repository.ts b/packages/plugins/video-capture/src/lib/repositories/mikro-orm-video.repository.ts new file mode 100644 index 00000000000..c9a0f1d9662 --- /dev/null +++ b/packages/plugins/video-capture/src/lib/repositories/mikro-orm-video.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '@gauzy/core'; +import { Video } from '../entities/video.entity'; + +export class MikroOrmVideoRepository extends MikroOrmBaseEntityRepository