├── src ├── shared │ ├── constants │ │ ├── constants.ts │ │ ├── status.enum.ts │ │ ├── slack-channel.enum.ts │ │ ├── file-variant-cf-status.enum.ts │ │ ├── bucket-type.enum.ts │ │ ├── file-status.enum.ts │ │ ├── file-variant-status.enum.ts │ │ └── gcp-scope.ts │ ├── interfaces │ │ └── gcp-credentials-options.interface.ts │ └── services │ │ ├── utils.service.ts │ │ ├── cloud-pubsub.service.ts │ │ ├── cloud-iam.service.ts │ │ └── cloud-storage.service.ts ├── modules │ ├── file │ │ ├── events │ │ │ ├── file-archive.event.ts │ │ │ ├── project-plugin-registered.event.ts │ │ │ └── file-accessed.event.ts │ │ ├── results │ │ │ ├── read-signed-url.result.ts │ │ │ ├── archive-file.result.ts │ │ │ ├── confirm-upload.result.ts │ │ │ ├── file-variant-read.result.ts │ │ │ ├── write-signed-url.result.ts │ │ │ ├── bulk-read-signed-url.result.ts │ │ │ └── file-variant-create.result.ts │ │ ├── bo │ │ │ ├── read-file.bo.ts │ │ │ ├── bulk-read-file.bo.ts │ │ │ ├── create-file-variant.bo.ts │ │ │ ├── update-file-variant.bo.ts │ │ │ └── register-file.bo.ts │ │ ├── interfaces │ │ │ ├── file.interface.ts │ │ │ ├── cf-file-variant-response-message.interface.ts │ │ │ └── file-variant-pubsub-message.interface.ts │ │ ├── dto │ │ │ ├── archive-file.dto.ts │ │ │ ├── bulk-read-file.dto.ts │ │ │ ├── confirm-file-uploaded.dto.ts │ │ │ ├── register-plugin.dto.ts │ │ │ ├── create-file-variant-dto.ts │ │ │ ├── cf-file-variant-response.dto.ts │ │ │ └── register-file-dto.ts │ │ ├── dao │ │ │ ├── access-log.dao.ts │ │ │ ├── file-variant-log.dao.ts │ │ │ ├── project-plugin.dao.ts │ │ │ ├── file-variant.dao.ts │ │ │ ├── plugin.dao.ts │ │ │ └── file.dao.ts │ │ ├── exceptions │ │ │ ├── invalid-file.exception.ts │ │ │ ├── invalid-plugin.exception.ts │ │ │ ├── duplicate-reference-number.exception.ts │ │ │ ├── invalid-template.exception.ts │ │ │ └── file-not-uploaded.exception.ts │ │ ├── repositories │ │ │ ├── file-variant-log.repository.ts │ │ │ ├── mime-type.repository.ts │ │ │ ├── access-log.repository.ts │ │ │ ├── template.repository.ts │ │ │ ├── plugin.repository.ts │ │ │ ├── project-plugin.repository.ts │ │ │ ├── file-variant.repository.ts │ │ │ └── file.repository.ts │ │ ├── entities │ │ │ ├── access-log.entity.ts │ │ │ ├── mime-type.entity.ts │ │ │ ├── plugin.entity.ts │ │ │ ├── project-plugin.entity.ts │ │ │ ├── file-variant-log.entity.ts │ │ │ ├── file-variant.entity.ts │ │ │ ├── template.entity.ts │ │ │ └── file.entity.ts │ │ ├── services │ │ │ ├── plugin.service.ts │ │ │ └── file.service.ts │ │ ├── constants │ │ │ └── errors.enum.ts │ │ ├── controllers │ │ │ ├── v2 │ │ │ │ ├── plugin.controller.ts │ │ │ │ └── file.controller.ts │ │ │ └── v1 │ │ │ │ ├── file-cron.controller.ts │ │ │ │ └── file.controller.ts │ │ ├── listeners │ │ │ ├── log-file-accessed.listener.ts │ │ │ ├── log-bulk-file-accessed.listener.ts │ │ │ ├── archive-file.listener.ts │ │ │ └── project-plugin-registered.listener.ts │ │ └── file.module.ts │ └── auth │ │ ├── constants │ │ └── role.enum.ts │ │ ├── guards │ │ └── jwt-auth.guard.ts │ │ ├── services │ │ ├── auth.service.ts │ │ └── project.service.ts │ │ ├── repositories │ │ ├── bucket-config.repository.ts │ │ └── project.repository.ts │ │ ├── entities │ │ ├── bucket-config.entity.ts │ │ └── project.entity.ts │ │ ├── strategies │ │ └── jwt.strategy.ts │ │ ├── controllers │ │ └── v2 │ │ │ └── project.controller.ts │ │ └── auth.module.ts ├── config │ ├── config.module.ts │ └── app-config.service.ts ├── database │ └── migrations │ │ ├── 1668676038571-AddCreatedByColumnInFiles.ts │ │ ├── 1668693727308-AddCreatedByColumnInFileVariants.ts │ │ ├── 1668676113634-AddCreatedByForeignKeyRelationInFiles.ts │ │ ├── 1668693808238-AddCreatedByForeignKeyRelationInFileVariants.ts │ │ └── 1668668777575-InitialStructure.ts ├── app-cluster.service.ts ├── main.ts └── app.module.ts ├── .npmrc ├── tsconfig.build.json ├── nest-cli.json ├── docker-compose.yml ├── Dockerfile ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── ormconfig.ts ├── .env.sample ├── README.md ├── .eslintrc.js ├── package.json ├── JenkinsfileStaging └── Jenkinsfile /src/shared/constants/constants.ts: -------------------------------------------------------------------------------- 1 | export const GCP_IAM_ACCESS_TOKEN_LIFETIME_IN_SECONDS = 900; 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //npm.pkg.github.com/:_authToken=${NPM_GITHUB_TOKEN} 2 | @goapptiv:registry=https://npm.pkg.github.com/ -------------------------------------------------------------------------------- /src/shared/constants/status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Status { 2 | ACTIVE = 'active', 3 | INACTIVE = 'inactive', 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/file/events/file-archive.event.ts: -------------------------------------------------------------------------------- 1 | export class FileArchiveEvent { 2 | id: number; 3 | isDirectory: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/file/results/read-signed-url.result.ts: -------------------------------------------------------------------------------- 1 | export interface ReadSignedUrlResult { 2 | uuid: string; 3 | url: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/constants/slack-channel.enum.ts: -------------------------------------------------------------------------------- 1 | export enum SlackChannel { 2 | GENERAL = 'general', 3 | EXCEPTION = 'exception', 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/file/bo/read-file.bo.ts: -------------------------------------------------------------------------------- 1 | export interface ReadFileBO { 2 | uuid: string; 3 | ip?: string; 4 | userAgent?: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/file/results/archive-file.result.ts: -------------------------------------------------------------------------------- 1 | export interface ArchiveFileResult { 2 | uuid: string; 3 | processed: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/file/results/confirm-upload.result.ts: -------------------------------------------------------------------------------- 1 | export interface ConfirmUploadResult { 2 | uuid: string; 3 | result: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/file/bo/bulk-read-file.bo.ts: -------------------------------------------------------------------------------- 1 | export interface BulkReadFileBO { 2 | uuids: []; 3 | ip: string; 4 | userAgent: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/file/interfaces/file.interface.ts: -------------------------------------------------------------------------------- 1 | export interface File { 2 | fileName: string; 3 | mimeType: string; 4 | size: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/constants/file-variant-cf-status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum FileVariantCfStatus { 2 | SUCCESS = 'success', 3 | FAILED = 'failed', 4 | } 5 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/auth/constants/role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | ADMIN = 'admin', 3 | USER = 'user', 4 | CRON_MANAGER = 'cron_manager', 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/file/events/project-plugin-registered.event.ts: -------------------------------------------------------------------------------- 1 | export class ProjectPluginRegisteredEvent { 2 | projectId: number; 3 | pluginId: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/file/dto/archive-file.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class ArchiveFileDTO { 4 | @IsString() 5 | uuid: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/file/dto/bulk-read-file.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsArray } from 'class-validator'; 2 | 3 | export class BulkReadFileDTO { 4 | @IsArray() 5 | uuids: []; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/file/results/file-variant-read.result.ts: -------------------------------------------------------------------------------- 1 | export interface FileVariantReadResult { 2 | variantId: number; 3 | pluginCode: string; 4 | url: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/file/results/write-signed-url.result.ts: -------------------------------------------------------------------------------- 1 | export interface WriteSignedUrlResult { 2 | reference_number: string; 3 | uuid: string; 4 | url: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/file/dto/confirm-file-uploaded.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class ConfirmFileUploadedDTO { 4 | @IsString() 5 | uuid: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/file/bo/create-file-variant.bo.ts: -------------------------------------------------------------------------------- 1 | class PluginBO { 2 | code: string; 3 | } 4 | 5 | export interface CreateFileVariantBO { 6 | uuid: string; 7 | plugins: PluginBO[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/constants/bucket-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum BucketType { 2 | STANDARD = 'standard', 3 | NEARLINE = 'nearline', 4 | COLDLINE = 'coldline', 5 | ARCHIVE = 'archive', 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/constants/file-status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum FileStatus { 2 | REQUESTED = 'requested', 3 | UPLOADED = 'uploaded', 4 | PROCESSED = 'processed', 5 | DELETED = 'deleted', 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/file/events/file-accessed.event.ts: -------------------------------------------------------------------------------- 1 | export class FileAccessedEvent { 2 | fileId: number; 3 | projectId: number; 4 | ip: string; 5 | userAgent: string; 6 | isArchived: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/file/results/bulk-read-signed-url.result.ts: -------------------------------------------------------------------------------- 1 | import { ReadSignedUrlResult } from './read-signed-url.result'; 2 | 3 | export interface BulkReadSignedUrlResult { 4 | urls: ReadSignedUrlResult[]; 5 | } 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | file-management-nestjs: 4 | image: asia.gcr.io/goapptiv/file-management/file-management-nestjs:latest 5 | restart: unless-stopped 6 | ports: 7 | - 80:80 -------------------------------------------------------------------------------- /src/modules/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /src/shared/constants/file-variant-status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum FileVariantStatus { 2 | REQUESTED = 'requested', 3 | QUEUED = 'queued', 4 | CREATED = 'created', 5 | DELETED = 'deleted', 6 | ERROR = 'error', 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/file/dto/register-plugin.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsUrl } from 'class-validator'; 2 | 3 | export class RegisterPluginDTO { 4 | @IsString() 5 | pluginCode: string; 6 | 7 | @IsUrl() 8 | webhookUrl: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/auth/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | 4 | @Injectable() 5 | export class AuthService { 6 | constructor(private jwtService: JwtService) {} 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/file/dao/access-log.dao.ts: -------------------------------------------------------------------------------- 1 | interface AccessLog { 2 | fileId: number; 3 | projectId: number; 4 | ip: string; 5 | userAgent: string; 6 | isArchived: boolean; 7 | } 8 | 9 | export type StoreAccessLogDAO = AccessLog; 10 | -------------------------------------------------------------------------------- /src/modules/file/results/file-variant-create.result.ts: -------------------------------------------------------------------------------- 1 | import { FileVariantStatus } from 'src/shared/constants/file-variant-status.enum'; 2 | 3 | export interface FileVariantCreateResult { 4 | variantId: number; 5 | pluginCode: string; 6 | status: FileVariantStatus; 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/interfaces/gcp-credentials-options.interface.ts: -------------------------------------------------------------------------------- 1 | interface Credentials { 2 | email: string; 3 | privateKey: string; 4 | } 5 | 6 | export interface GcpCredentialsOptions { 7 | projectId: string; 8 | logName: string; 9 | pubSub: Credentials; 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.13.0 2 | 3 | WORKDIR /app 4 | 5 | ARG NPM_GITHUB_TOKEN 6 | 7 | COPY package*.json ./ 8 | 9 | COPY .npmrc .npmrc 10 | 11 | RUN npm ci 12 | 13 | RUN rm -f .npmrc 14 | 15 | COPY . . 16 | 17 | RUN npx nest build 18 | 19 | CMD ["npm", "run" , "start:prod"] 20 | -------------------------------------------------------------------------------- /src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { AppConfigService } from './app-config.service'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [AppConfigService], 7 | exports: [AppConfigService], 8 | }) 9 | export class AppConfigModule {} 10 | -------------------------------------------------------------------------------- /src/modules/file/bo/update-file-variant.bo.ts: -------------------------------------------------------------------------------- 1 | import { FileVariantCfStatus } from 'src/shared/constants/file-variant-cf-status.enum'; 2 | 3 | export interface UpdateFileVariantBO { 4 | cfStatus: FileVariantCfStatus; 5 | filePath: string; 6 | fileName: string; 7 | messageId: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/file/bo/register-file.bo.ts: -------------------------------------------------------------------------------- 1 | class FileBo { 2 | name: string; 3 | size: number; 4 | type: string; 5 | } 6 | 7 | export interface RegisterFileBO { 8 | templateCode: string; 9 | referenceNumber: string; 10 | projectId: number; 11 | createdBy: number; 12 | file: FileBo; 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/constants/gcp-scope.ts: -------------------------------------------------------------------------------- 1 | export const GCP_SCOPE = { 2 | cloudStorage: { 3 | fullControl: 'https://www.googleapis.com/auth/devstorage.full_control', 4 | readOnly: 'https://www.googleapis.com/auth/devstorage.read_only', 5 | writeOnly: 'https://www.googleapis.com/auth/devstorage.read_write', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/modules/file/dao/file-variant-log.dao.ts: -------------------------------------------------------------------------------- 1 | import { FileVariantStatus } from 'src/shared/constants/file-variant-status.enum'; 2 | 3 | interface FileVariantLog { 4 | variantId: number; 5 | pluginId: number; 6 | status: FileVariantStatus; 7 | messageId: string; 8 | } 9 | 10 | export type StoreFileVarianLogDAO = FileVariantLog; 11 | -------------------------------------------------------------------------------- /src/modules/file/dao/project-plugin.dao.ts: -------------------------------------------------------------------------------- 1 | import { Status } from 'src/shared/constants/status.enum'; 2 | 3 | interface ProjectPlugin { 4 | projectId: number; 5 | pluginId: number; 6 | webhookUrl: string; 7 | pubsubStatusSubscriber: string; 8 | status: Status; 9 | } 10 | 11 | export type StoreProjectPluginDAO = ProjectPlugin; 12 | -------------------------------------------------------------------------------- /src/modules/file/dao/file-variant.dao.ts: -------------------------------------------------------------------------------- 1 | import { FileVariantStatus } from 'src/shared/constants/file-variant-status.enum'; 2 | 3 | export class FileVariant { 4 | fileId: number; 5 | pluginId: number; 6 | storagePath?: string; 7 | status: FileVariantStatus; 8 | createdBy: number; 9 | } 10 | 11 | export type StoreFileVariantDAO = FileVariant; 12 | -------------------------------------------------------------------------------- /src/modules/file/interfaces/cf-file-variant-response-message.interface.ts: -------------------------------------------------------------------------------- 1 | import { FileVariantCfStatus } from 'src/shared/constants/file-variant-cf-status.enum'; 2 | 3 | export interface CfFileVariantResponseMessage { 4 | message: { 5 | uuid: string; 6 | variantId: number; 7 | filePath: string; 8 | fileName: string; 9 | status: FileVariantCfStatus; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/file/dao/plugin.dao.ts: -------------------------------------------------------------------------------- 1 | import { Status } from 'src/shared/constants/status.enum'; 2 | 3 | interface Plugin { 4 | id: number; 5 | name: string; 6 | code: string; 7 | cloudFunctionTopic: string; 8 | cloudFunctionResponseTopic: string; 9 | status: Status; 10 | } 11 | 12 | export type StorePluginDAO = Plugin; 13 | 14 | export type FilterPluginDAO = Pick; 15 | -------------------------------------------------------------------------------- /src/modules/auth/repositories/bucket-config.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { BucketConfig } from '../entities/bucket-config.entity'; 4 | 5 | @Injectable() 6 | export class BucketConfigRepository extends Repository { 7 | constructor(dataSource: DataSource) { 8 | super(BucketConfig, dataSource.manager); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/file/dto/create-file-variant-dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsArray, IsString, ValidateNested } from 'class-validator'; 3 | 4 | class PluginDTO { 5 | @IsString() 6 | code: string; 7 | } 8 | 9 | export class CreateFileVariantDTO { 10 | @IsString() 11 | uuid: string; 12 | 13 | @IsArray() 14 | @ValidateNested() 15 | @Type(() => PluginDTO) 16 | plugins: PluginDTO[]; 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/file/dao/file.dao.ts: -------------------------------------------------------------------------------- 1 | import { FileStatus } from 'src/shared/constants/file-status.enum'; 2 | 3 | interface File { 4 | uuid: string; 5 | referenceNumber: string; 6 | status: FileStatus; 7 | storagePath: string; 8 | isUploaded: boolean; 9 | isArchived: boolean; 10 | fileSize: number; 11 | templateId: number; 12 | mimeTypeId: number; 13 | projectId: number; 14 | createdBy: number; 15 | archivalDate: Date; 16 | } 17 | 18 | export type StoreFileDAO = File; 19 | -------------------------------------------------------------------------------- /src/modules/file/exceptions/invalid-file.exception.ts: -------------------------------------------------------------------------------- 1 | import { GaNotFoundException } from '@goapptiv/exception-handler-nestjs'; 2 | import { FileErrorCode } from '../constants/errors.enum'; 3 | 4 | export class InvalidFileException extends GaNotFoundException { 5 | constructor(uuid: string) { 6 | super([ 7 | { 8 | type: FileErrorCode.E404_FILE, 9 | message: 'Invalid file', 10 | context: { 11 | uuid, 12 | }, 13 | }, 14 | ]); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/file/exceptions/invalid-plugin.exception.ts: -------------------------------------------------------------------------------- 1 | import { GaNotFoundException } from '@goapptiv/exception-handler-nestjs'; 2 | import { PluginErrorCode } from '../constants/errors.enum'; 3 | 4 | export class InvalidPluginException extends GaNotFoundException { 5 | constructor(pluginCode: string) { 6 | super([ 7 | { 8 | type: PluginErrorCode.E404_PLUGIN, 9 | message: 'Invalid plugin code', 10 | context: { 11 | pluginCode, 12 | }, 13 | }, 14 | ]); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/file/exceptions/duplicate-reference-number.exception.ts: -------------------------------------------------------------------------------- 1 | import { GaConflictException } from '@goapptiv/exception-handler-nestjs'; 2 | import { FileErrorCode } from '../constants/errors.enum'; 3 | 4 | export class DuplicateReferenceNumberException extends GaConflictException { 5 | constructor(referenceNumber: string) { 6 | super([ 7 | { 8 | type: FileErrorCode.E409_FILE_DUPLICATE_REFERENCE_NUMBER, 9 | message: 'Duplicate reference number', 10 | context: { referenceNumber }, 11 | }, 12 | ]); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/file/exceptions/invalid-template.exception.ts: -------------------------------------------------------------------------------- 1 | import { GaNotFoundException } from '@goapptiv/exception-handler-nestjs'; 2 | import { FileTemplateErrorCode } from '../constants/errors.enum'; 3 | 4 | export class InvalidTemplateException extends GaNotFoundException { 5 | constructor(templateCode: string) { 6 | super([ 7 | { 8 | type: FileTemplateErrorCode.E404_FILE_TEMPLATE, 9 | message: 'Invalid template code', 10 | context: { 11 | templateCode, 12 | }, 13 | }, 14 | ]); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/file/exceptions/file-not-uploaded.exception.ts: -------------------------------------------------------------------------------- 1 | import { GaConflictException } from '@goapptiv/exception-handler-nestjs'; 2 | import { FileErrorCode } from '../constants/errors.enum'; 3 | 4 | export class FileNotUploadedException extends GaConflictException { 5 | constructor(uuid: string) { 6 | super([ 7 | { 8 | type: FileErrorCode.E404_FILE_UPLOAD_NOT_FOUND, 9 | message: 10 | 'File upload not found in the bucket, cannot confirm file uploaded.', 11 | context: { uuid }, 12 | }, 13 | ]); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/file/dto/cf-file-variant-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { 3 | IsDateString, 4 | IsNotEmptyObject, 5 | IsString, 6 | ValidateNested, 7 | } from 'class-validator'; 8 | 9 | class MessageDTO { 10 | @IsString() 11 | data: string; 12 | 13 | @IsString() 14 | messageId: string; 15 | 16 | @IsDateString() 17 | publishTime: Date; 18 | } 19 | 20 | export class CfFileVariantStatusResponseDTO { 21 | @IsNotEmptyObject() 22 | @ValidateNested() 23 | @Type(() => MessageDTO) 24 | message: MessageDTO; 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/file/interfaces/file-variant-pubsub-message.interface.ts: -------------------------------------------------------------------------------- 1 | export interface FileVariantPubSubMessage { 2 | metadata: { 3 | uuid: string; 4 | variantId: number; 5 | projectId: number; 6 | }; 7 | response: { 8 | topic: string; 9 | }; 10 | bucket: { 11 | source: { 12 | readSignedUrl: string; 13 | bucketName: string; 14 | accessToken: string; 15 | path: string; 16 | file: string; 17 | }; 18 | destination: { 19 | bucketName: string; 20 | accessToken: string; 21 | path: string; 22 | }; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "semi": true, 6 | "experimentalTernaries": false, 7 | "singleQuote": true, 8 | "jsxSingleQuote": true, 9 | "quoteProps": "as-needed", 10 | "trailingComma": "all", 11 | "singleAttributePerLine": false, 12 | "htmlWhitespaceSensitivity": "css", 13 | "vueIndentScriptAndStyle": false, 14 | "proseWrap": "preserve", 15 | "insertPragma": false, 16 | "printWidth": 80, 17 | "requirePragma": false, 18 | "useTabs": false, 19 | "embeddedLanguageFormatting": "auto", 20 | "tabWidth": 2 21 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # Environment 38 | .env 39 | .env.production 40 | ormconfig.json 41 | 42 | # npm 43 | .npmrc -------------------------------------------------------------------------------- /src/database/migrations/1668676038571-AddCreatedByColumnInFiles.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class AddCreatedByColumnInFiles1668676038571 4 | implements MigrationInterface 5 | { 6 | name = 'AddCreatedByColumnInFiles1668676038571'; 7 | 8 | public async up(queryRunner: QueryRunner): Promise { 9 | await queryRunner.query( 10 | `ALTER TABLE \`files\` ADD \`created_by\` int NOT NULL AFTER \`project_id\``, 11 | ); 12 | } 13 | 14 | public async down(queryRunner: QueryRunner): Promise { 15 | await queryRunner.query(`ALTER TABLE \`files\` DROP COLUMN \`created_by\``); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/file/repositories/file-variant-log.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { StoreFileVarianLogDAO } from '../dao/file-variant-log.dao'; 4 | import { FileVariantLog } from '../entities/file-variant-log.entity'; 5 | 6 | @Injectable() 7 | export class FileVariantLogRepository extends Repository { 8 | constructor(dataSource: DataSource) { 9 | super(FileVariantLog, dataSource.manager); 10 | } 11 | 12 | /** 13 | * Creates new record 14 | */ 15 | store(data: StoreFileVarianLogDAO): Promise { 16 | return this.save(data); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/modules/file/repositories/mime-type.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { MimeType } from '../entities/mime-type.entity'; 4 | 5 | @Injectable() 6 | export class MimeTypeRepository extends Repository { 7 | constructor(dataSource: DataSource) { 8 | super(MimeType, dataSource.manager); 9 | } 10 | 11 | /** 12 | * Finds entity which matches the type 13 | */ 14 | findByType( 15 | type: string, 16 | relations?: (keyof MimeType)[] | string[], 17 | ): Promise { 18 | return this.findOne({ where: { type }, relations: relations }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "esModuleInterop": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ormconfig.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { DataSource } from 'typeorm'; 3 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; 4 | 5 | const { DB_USERNAME, DB_PASSWORD, DB_DATABASE, DB_HOST, DB_PORT } = process.env; 6 | 7 | const config = new DataSource({ 8 | type: 'mysql', 9 | host: DB_HOST, 10 | port: Number(DB_PORT), 11 | username: DB_USERNAME, 12 | password: DB_PASSWORD, 13 | database: DB_DATABASE, 14 | synchronize: false, 15 | entities: ['dist/src/modules/**/entities/*.entity{.ts,.js}'], 16 | migrations: ['dist/src/database/migrations/*{.ts,.js}'], 17 | namingStrategy: new SnakeNamingStrategy(), 18 | }); 19 | 20 | export default config; 21 | -------------------------------------------------------------------------------- /src/modules/auth/entities/bucket-config.entity.ts: -------------------------------------------------------------------------------- 1 | import { Exclude } from 'class-transformer'; 2 | import { 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm'; 9 | 10 | @Entity('bucket_configs') 11 | export class BucketConfig { 12 | @PrimaryGeneratedColumn() 13 | id: number; 14 | 15 | @Column() 16 | name: string; 17 | 18 | @Column() 19 | email: string; 20 | 21 | @Exclude() 22 | @Column({ type: 'longtext' }) 23 | key: string; 24 | 25 | @CreateDateColumn({ name: 'created_at' }) 26 | createdAt: Date; 27 | 28 | @UpdateDateColumn({ name: 'updated_at' }) 29 | updatedAt: Date; 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/file/dto/register-file-dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { 3 | IsInt, 4 | IsMimeType, 5 | IsNotEmptyObject, 6 | IsOptional, 7 | IsString, 8 | ValidateNested, 9 | } from 'class-validator'; 10 | class FileDTO { 11 | @IsString() 12 | name: string; 13 | 14 | @IsInt() 15 | size: number; 16 | 17 | @IsMimeType() 18 | type: string; 19 | } 20 | export class RegisterFileDTO { 21 | @IsString() 22 | referenceNumber: string; 23 | 24 | @IsString() 25 | templateCode: string; 26 | 27 | @IsNotEmptyObject() 28 | @ValidateNested() 29 | @Type(() => FileDTO) 30 | file: FileDTO; 31 | 32 | @IsInt() 33 | @IsOptional() 34 | projectId: number; 35 | } 36 | -------------------------------------------------------------------------------- /src/database/migrations/1668693727308-AddCreatedByColumnInFileVariants.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class AddCreatedByColumnInFileVariants1668693727308 4 | implements MigrationInterface 5 | { 6 | name = 'AddCreatedByColumnInFileVariants1668693727308'; 7 | 8 | public async up(queryRunner: QueryRunner): Promise { 9 | await queryRunner.query( 10 | `ALTER TABLE \`file_variants\` ADD \`created_by\` int NOT NULL AFTER \`status\``, 11 | ); 12 | } 13 | 14 | public async down(queryRunner: QueryRunner): Promise { 15 | await queryRunner.query( 16 | `ALTER TABLE \`file_variants\` DROP COLUMN \`created_by\``, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/file/entities/access-log.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | Index, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm'; 9 | 10 | @Entity('access_logs') 11 | export class AccessLog { 12 | @PrimaryGeneratedColumn() 13 | id: number; 14 | 15 | @Column() 16 | @Index() 17 | fileId: number; 18 | 19 | @Column() 20 | projectId: number; 21 | 22 | @Column() 23 | ip: string; 24 | 25 | @Column() 26 | userAgent: string; 27 | 28 | @Column() 29 | isArchived: boolean; 30 | 31 | @CreateDateColumn({ name: 'created_at' }) 32 | createdAt: Date; 33 | 34 | @UpdateDateColumn({ name: 'updated_at' }) 35 | updatedAt: Date; 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/file/entities/mime-type.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToMany, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm'; 9 | import { Template } from './template.entity'; 10 | 11 | @Entity('mime_types') 12 | export class MimeType { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @Column() 17 | name: string; 18 | 19 | @Column() 20 | type: string; 21 | 22 | @Column() 23 | extension: string; 24 | 25 | @ManyToMany(() => Template, (template) => template.mimeTypes) 26 | templates: Template[]; 27 | 28 | @CreateDateColumn({ name: 'created_at' }) 29 | createdAt: Date; 30 | 31 | @UpdateDateColumn({ name: 'updated_at' }) 32 | updatedAt: Date; 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/file/services/plugin.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Status } from 'src/shared/constants/status.enum'; 3 | import { FilterPluginDAO } from '../dao/plugin.dao'; 4 | import { Plugin } from '../entities/plugin.entity'; 5 | import { PluginRepository } from '../repositories/plugin.repository'; 6 | 7 | @Injectable() 8 | export class PluginService { 9 | constructor(private readonly pluginRepository: PluginRepository) {} 10 | 11 | /** 12 | * Returns an action that fetches the plugins. 13 | */ 14 | async fetch(): Promise { 15 | const filters: FilterPluginDAO = { 16 | status: Status.ACTIVE, 17 | }; 18 | return await this.pluginRepository.fetch(['id', 'name', 'code'], filters); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/database/migrations/1668676113634-AddCreatedByForeignKeyRelationInFiles.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class AddCreatedByForeignKeyRelationInFiles1668676113634 4 | implements MigrationInterface 5 | { 6 | name = 'AddCreatedByForeignKeyRelationInFiles1668676113634'; 7 | 8 | public async up(queryRunner: QueryRunner): Promise { 9 | await queryRunner.query( 10 | `ALTER TABLE \`files\` ADD CONSTRAINT \`FK_e92953fa5019c241b3f1a7c1520\` FOREIGN KEY (\`created_by\`) REFERENCES \`projects\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, 11 | ); 12 | } 13 | 14 | public async down(queryRunner: QueryRunner): Promise { 15 | await queryRunner.query( 16 | `ALTER TABLE \`files\` DROP FOREIGN KEY \`FK_e92953fa5019c241b3f1a7c1520\``, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/file/constants/errors.enum.ts: -------------------------------------------------------------------------------- 1 | export enum FileTemplateErrorCode { 2 | E404_FILE_TEMPLATE = 'E404_FILE_TEMPLATE', 3 | } 4 | 5 | export enum FileErrorCode { 6 | E404_FILE = 'E404_FILE', 7 | E404_FILE_MIME_TYPE = 'E404_FILE_MIME_TYPE', 8 | E409_FILE_SIZE_EXCEEDED = 'E409_FILE_SIZE_EXCEEDED', 9 | E409_FILE_DUPLICATE_REFERENCE_NUMBER = 'E409_FILE_DUPLICATE_REFERENCE_NUMBER', 10 | E409_FILE_ARCHIVE_STATE_NOT_ALLOWED = 'E409_FILE_ARCHIVE_STATE_NOT_ALLOWED', 11 | E409_FILE_ARCHIVE_ALREADY_ARCHIVED = 'E409_FILE_ARCHIVE_ALREADY_ARCHIVED', 12 | E404_FILE_UPLOAD_NOT_FOUND = 'E404_FILE_UPLOAD_NOT_FOUND', 13 | } 14 | 15 | export enum PluginErrorCode { 16 | E404_PLUGIN = 'E404_PLUGIN', 17 | E409_PLUGIN_ALREADY_REGISTERED = 'E409_PLUGIN_ALREADY_REGISTERED', 18 | E400_PLUGIN_INVALID_WEBHOOK_URL = 'E400_PLUGIN_INVALID_WEBHOOK_URL', 19 | } 20 | -------------------------------------------------------------------------------- /src/app-cluster.service.ts: -------------------------------------------------------------------------------- 1 | import cluster from 'node:cluster'; 2 | import * as os from 'os'; 3 | import { Injectable } from '@nestjs/common'; 4 | 5 | const numCPUs = os.cpus().length; 6 | 7 | @Injectable() 8 | export class AppClusterService { 9 | static clusterize(callback: any): void { 10 | if (cluster.isPrimary) { 11 | console.info(`Master server started on ${process.pid}`); 12 | for (let i = 0; i < numCPUs; i++) { 13 | cluster.fork(); 14 | } 15 | cluster.on('exit', (worker, code, signal) => { 16 | console.info( 17 | `Worker ${worker.process.pid} died. Restarting`, 18 | code, 19 | signal, 20 | ); 21 | cluster.fork(); 22 | }); 23 | } else { 24 | console.info(`Cluster server started on ${process.pid}`); 25 | callback(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/auth/entities/project.entity.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'src/modules/file/entities/plugin.entity'; 2 | import { 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | JoinTable, 7 | OneToMany, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | 12 | @Entity('projects') 13 | export class Project { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @Column() 18 | name: string; 19 | 20 | @Column({ unique: true }) 21 | code: string; 22 | 23 | @Column({ default: false }) 24 | isAdmin: boolean; 25 | 26 | @OneToMany(() => Plugin, (plugin) => plugin.projects) 27 | @JoinTable({ name: 'project_plugins' }) 28 | plugins: Plugin[]; 29 | 30 | @CreateDateColumn({ name: 'created_at' }) 31 | createdAt: Date; 32 | 33 | @UpdateDateColumn({ name: 'updated_at' }) 34 | updatedAt: Date; 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/file/controllers/v2/plugin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GaRestResponse, 3 | ResponseError, 4 | ResponseSuccess, 5 | } from '@goapptiv/rest-response-nestjs'; 6 | import { Get, UseGuards } from '@nestjs/common'; 7 | import { Controller } from '@nestjs/common'; 8 | import { JwtAuthGuard } from 'src/modules/auth/guards/jwt-auth.guard'; 9 | import { PluginService } from '../../services/plugin.service'; 10 | 11 | @Controller({ 12 | path: 'plugins', 13 | version: '2', 14 | }) 15 | export class PluginController { 16 | constructor(private readonly pluginService: PluginService) {} 17 | 18 | /** 19 | * Fetch plugins 20 | */ 21 | @Get() 22 | @UseGuards(JwtAuthGuard) 23 | async index(): Promise { 24 | const data = await this.pluginService.fetch(); 25 | return GaRestResponse.success(data); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/database/migrations/1668693808238-AddCreatedByForeignKeyRelationInFileVariants.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class AddCreatedByForeignKeyRelationInFileVariants1668693808238 4 | implements MigrationInterface 5 | { 6 | name = 'AddCreatedByForeignKeyRelationInFileVariants1668693808238'; 7 | 8 | public async up(queryRunner: QueryRunner): Promise { 9 | await queryRunner.query( 10 | `ALTER TABLE \`file_variants\` ADD CONSTRAINT \`FK_e54117ffea046f84c8aae3f5307\` FOREIGN KEY (\`created_by\`) REFERENCES \`projects\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, 11 | ); 12 | } 13 | 14 | public async down(queryRunner: QueryRunner): Promise { 15 | await queryRunner.query( 16 | `ALTER TABLE \`file_variants\` DROP FOREIGN KEY \`FK_e54117ffea046f84c8aae3f5307\``, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/auth/repositories/project.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { Project } from '../entities/project.entity'; 4 | 5 | @Injectable() 6 | export class ProjectRepository extends Repository { 7 | constructor(dataSource: DataSource) { 8 | super(Project, dataSource.manager); 9 | } 10 | 11 | /** 12 | * Finds entity which matches the id 13 | */ 14 | findById( 15 | id: number, 16 | relations?: (keyof Project)[] | string[], 17 | ): Promise { 18 | return this.findOne({ where: { id }, relations: relations }); 19 | } 20 | 21 | /** 22 | * Finds entity which matches the code 23 | */ 24 | findByCode( 25 | code: string, 26 | relations?: (keyof Project)[] | string[], 27 | ): Promise { 28 | return this.findOne({ where: { code }, relations: relations }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/file/repositories/access-log.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, InsertResult, Repository } from 'typeorm'; 3 | import { StoreAccessLogDAO } from '../dao/access-log.dao'; 4 | import { AccessLog } from '../entities/access-log.entity'; 5 | 6 | @Injectable() 7 | export class AccessLogRepository extends Repository { 8 | constructor(dataSource: DataSource) { 9 | super(AccessLog, dataSource.manager); 10 | } 11 | 12 | /** 13 | * Creates new record 14 | */ 15 | store(data: StoreAccessLogDAO): Promise { 16 | return this.save(data); 17 | } 18 | 19 | /** 20 | * Creates new records in bulk 21 | */ 22 | async bulkStore(data: StoreAccessLogDAO[]): Promise { 23 | return await this.createQueryBuilder() 24 | .insert() 25 | .into(AccessLog) 26 | .values(data) 27 | .execute(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # App Config 2 | APP_NAME="File Storage Service" 3 | APP_DESCRIPTION="GoApptiv File Storage Service" 4 | APP_MAINTAINER="Sagar Vaghela" 5 | APP_SUPPORT_EMAIL="sagar.vaghela@goapptiv.com" 6 | APP_URL="https://github.com/GoApptiv/file-management-nestjs" 7 | NODE_ENV="development" 8 | APP_ENVIRONMENT="staging" 9 | 10 | # Github Config 11 | NPM_GITHUB_TOKEN="github-token-with-read-packages-permission" 12 | 13 | # Database 14 | DB_CONNECTION_TYPE="socket | host" 15 | DB_HOST="localhost" 16 | DB_SOCKET_PATH="/cloudsql/[project:region:instance]" 17 | DB_PORT=3306 18 | DB_DATABASE="file_management" 19 | DB_USERNAME="root" 20 | DB_PASSWORD="password" 21 | 22 | # Auth 23 | JWT_TOKEN_SECRET="SOME_SECRET" 24 | 25 | # GCP 26 | GCP_PROJECT_ID="onegoapptiv" 27 | GCP_LOG_NAME="file-management" 28 | GCP_PUBSUB_EMAIL="pubsub-email" 29 | GCP_PUBSUB_KEY="pubsub-key" 30 | 31 | # Slack Exception Notifier Webhook 32 | SLACK_EXCEPTION_NOTIFIER_WEBHOOK="https://hooks.slack.com/services/XXXXX/XXXXX/XXXXX" -------------------------------------------------------------------------------- /src/modules/file/repositories/template.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { Template } from '../entities/template.entity'; 4 | 5 | @Injectable() 6 | export class TemplateRepository extends Repository