UNPKG

@rytass/cms-base-nestjs-module

Version:

Rytass Content Management System NestJS Base Module

979 lines (977 loc) 59.3 kB
import { Injectable, Inject, Logger, BadRequestException } from '@nestjs/common'; import { Brackets, Not, LessThanOrEqual, In, IsNull, Repository, DataSource } from 'typeorm'; import { RESOLVED_ARTICLE_REPO, RESOLVED_ARTICLE_VERSION_REPO, RESOLVED_ARTICLE_VERSION_CONTENT_REPO, RESOLVED_CATEGORY_REPO, MULTIPLE_LANGUAGE_MODE, FULL_TEXT_SEARCH_MODE, DRAFT_MODE, SIGNATURE_LEVELS, RESOLVED_SIGNATURE_LEVEL_REPO, AUTO_RELEASE_AFTER_APPROVED } from '../typings/cms-base-providers.js'; import { DEFAULT_LANGUAGE } from '../constants/default-language.js'; import { ArticleSorter } from '../typings/article-sorter.enum.js'; import { InjectDataSource } from '@nestjs/typeorm'; import { MultipleLanguageModeIsDisabledError } from '../constants/errors/base.errors.js'; import { ArticleNotFoundError, ArticleVersionNotFoundError } from '../constants/errors/article.errors.js'; import { CategoryNotFoundError } from '../constants/errors/category.errors.js'; import { ArticleSearchMode } from '../typings/article-search-mode.enum.js'; import { FULL_TEXT_SEARCH_TOKEN_VERSION } from '../constants/full-text-search-token-version.js'; import { ArticleSignatureResult } from '../typings/article-signature-result.enum.js'; import { ArticleStage } from '../typings/article-stage.enum.js'; import { removeArticleInvalidFields, removeArticleVersionInvalidFields, removeArticleVersionContentInvalidFields, removeMultipleLanguageArticleVersionInvalidFields } from '../utils/remove-invalid-fields.js'; import { ArticleSignatureRepo } from '../models/article-signature.entity.js'; import { BaseSignatureLevelEntity } from '../models/base-signature-level.entity.js'; import { ArticleDataLoader } from '../data-loaders/article.dataloader.js'; import { SignatureService } from './signature.service.js'; function _ts_decorate(decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; } function _ts_metadata(k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); } function _ts_param(paramIndex, decorator) { return function(target, key) { decorator(target, key, paramIndex); }; } class ArticleBaseService { baseArticleRepo; baseArticleVersionRepo; baseArticleVersionContentRepo; baseCategoryRepo; multipleLanguageMode; fullTextSearchMode; draftMode; signatureLevels; signatureLevelRepo; articleSignatureRepo; autoReleaseAfterApproved; dataSource; articleDataLoader; signatureService; constructor(baseArticleRepo, baseArticleVersionRepo, baseArticleVersionContentRepo, baseCategoryRepo, multipleLanguageMode, fullTextSearchMode, draftMode, signatureLevels, signatureLevelRepo, articleSignatureRepo, autoReleaseAfterApproved, dataSource, articleDataLoader, signatureService){ this.baseArticleRepo = baseArticleRepo; this.baseArticleVersionRepo = baseArticleVersionRepo; this.baseArticleVersionContentRepo = baseArticleVersionContentRepo; this.baseCategoryRepo = baseCategoryRepo; this.multipleLanguageMode = multipleLanguageMode; this.fullTextSearchMode = fullTextSearchMode; this.draftMode = draftMode; this.signatureLevels = signatureLevels; this.signatureLevelRepo = signatureLevelRepo; this.articleSignatureRepo = articleSignatureRepo; this.autoReleaseAfterApproved = autoReleaseAfterApproved; this.dataSource = dataSource; this.articleDataLoader = articleDataLoader; this.signatureService = signatureService; } logger = new Logger(ArticleBaseService.name); queryStagesFeaturesCheck = (stage)=>{ switch(stage){ case ArticleStage.DRAFT: if (!this.draftMode) { throw new Error('Draft mode is disabled.'); } break; case ArticleStage.VERIFIED: case ArticleStage.REVIEWING: if (!this.signatureService.signatureEnabled) { throw new Error('Signature mode is disabled.'); } break; } }; limitStageWithQueryBuilder(qb, stage, signatureLevel) { switch(stage){ case ArticleStage.DRAFT: qb.innerJoin((subQb)=>{ subQb.from(this.baseArticleVersionRepo.metadata.tableName, 'versions'); subQb.select('versions.articleId', 'articleId'); subQb.addSelect('versions.version', 'version'); subQb.addSelect('ROW_NUMBER() OVER (PARTITION BY versions."articleId" ORDER BY versions."createdAt" DESC)', 'rowIndex'); subQb.andWhere('versions.releasedAt IS NULL'); subQb.andWhere('versions.submittedAt IS NULL'); return subQb; }, 'stage_ranked', 'stage_ranked."articleId" = versions."articleId" AND stage_ranked."version" = versions."version" AND stage_ranked."rowIndex" = 1'); break; case ArticleStage.REVIEWING: qb.andWhere(`versions.releasedAt IS NULL`); qb.andWhere(`versions.submittedAt IS NOT NULL`); qb.leftJoin('versions.signatures', 'signatures', `signatures.result = :result`, { result: ArticleSignatureResult.APPROVED }); qb.leftJoin('signatures.signatureLevel', 'signatureLevel', 'signatureLevel.name = :signatureLevel', { signatureLevel: signatureLevel ?? this.signatureService.finalSignatureLevel?.name }); qb.andWhere('signatureLevel.id IS NULL'); break; case ArticleStage.VERIFIED: qb.andWhere(`versions.releasedAt IS NULL`); qb.innerJoin((subQb)=>{ subQb.from(this.articleSignatureRepo.metadata.tableName, 'signatures'); subQb.innerJoin('signatures.articleVersion', 'articleVersion'); subQb.select('signatures.articleId', 'articleId'); subQb.addSelect('signatures.version', 'version'); subQb.addSelect('ROW_NUMBER() OVER (PARTITION BY signatures."articleId" ORDER BY signatures."signedAt" DESC)', 'rowIndex'); subQb.andWhere(`signatures.result = :result AND signatures."signatureLevelId" = :signatureLevelId`, { result: ArticleSignatureResult.APPROVED, signatureLevelId: this.signatureService.finalSignatureLevel?.id }); return subQb; }, 'stage_ranked', 'stage_ranked."articleId" = versions."articleId" AND stage_ranked."version" = versions."version" AND stage_ranked."rowIndex" = 1'); break; case ArticleStage.SCHEDULED: qb.innerJoin((subQb)=>{ subQb.from(this.baseArticleVersionRepo.metadata.tableName, 'versions'); subQb.select('versions.articleId', 'articleId'); subQb.addSelect('versions.version', 'version'); subQb.addSelect('ROW_NUMBER() OVER (PARTITION BY versions."articleId" ORDER BY versions."releasedAt" ASC)', 'rowIndex'); subQb.andWhere(`versions.releasedAt IS NOT NULL`); subQb.andWhere(`versions.releasedAt > CURRENT_TIMESTAMP`); return subQb; }, 'stage_ranked', 'stage_ranked."articleId" = versions."articleId" AND stage_ranked."version" = versions."version" AND stage_ranked."rowIndex" = 1'); break; case ArticleStage.RELEASED: default: qb.innerJoin((subQb)=>{ subQb.from(this.baseArticleVersionRepo.metadata.tableName, 'versions'); subQb.select('versions.articleId', 'articleId'); subQb.addSelect('versions.version', 'version'); subQb.addSelect('ROW_NUMBER() OVER (PARTITION BY versions."articleId" ORDER BY versions."releasedAt" DESC)', 'rowIndex'); subQb.andWhere(`versions.releasedAt IS NOT NULL`); subQb.andWhere(`versions.releasedAt <= CURRENT_TIMESTAMP`); return subQb; }, 'stage_ranked', 'stage_ranked."articleId" = versions."articleId" AND stage_ranked."version" = versions."version" AND stage_ranked."rowIndex" = 1'); break; } return qb; } getDefaultQueryBuilder(alias = 'articles', options, runner) { if (options?.version && options?.stage) { this.logger.warn(`Combining version and stage filters, only version filter will be applied.`); } if (options?.stage) { this.queryStagesFeaturesCheck(options.stage); } const qb = runner ? runner.manager.createQueryBuilder(this.baseArticleRepo.metadata.tableName, alias) : this.baseArticleRepo.createQueryBuilder(alias); qb.leftJoinAndSelect(`${alias}.categories`, 'categories'); qb.innerJoinAndSelect(`${alias}.versions`, 'versions'); qb.innerJoinAndSelect('versions.multiLanguageContents', 'multiLanguageContents'); if (options?.version !== undefined) { qb.andWhere('versions.version = :version', { version: options.version }); } else if (options?.stage) { this.limitStageWithQueryBuilder(qb, options.stage, (options.stage === ArticleStage.REVIEWING ? options?.signatureLevel : undefined) ?? undefined); } else { qb.innerJoin((subQb)=>{ subQb.from(this.baseArticleVersionRepo.metadata.tableName, 'versions'); subQb.select('versions.articleId', 'articleId'); subQb.addSelect('MAX(versions.version)', 'version'); subQb.groupBy('versions.articleId'); return subQb; }, 'target', 'target.version = versions.version AND target."articleId" = versions."articleId"'); } return qb; } async bindSearchTokens(articleContent, tags, runner) { const jiebaModule = await import('@node-rs/jieba'); const jiebaInstance = new jiebaModule.default.Jieba(); const cut = jiebaInstance.cut.bind(jiebaInstance); const tokens = cut(articleContent.content.filter((content)=>content.type === 'p').map((content)=>content.children.filter((child)=>child.text).map((child)=>child.text).join('\n')).join('\n')); await (runner ?? this.dataSource).query(`UPDATE "${this.baseArticleVersionContentRepo.metadata.tableName}" SET "searchTokenVersion" = '${FULL_TEXT_SEARCH_TOKEN_VERSION}', "searchTokens" = setweight(to_tsvector('simple', $1), 'A') || setweight(to_tsvector('simple', $2), 'B') || setweight(to_tsvector('simple', $3), 'C') || setweight(to_tsvector('simple', $4), 'D') WHERE "articleId" = $5 AND "version" = $6 AND "language" = $7`, [ articleContent.title, tags?.join(' ') ?? '', articleContent.description ?? '', tokens.join(' ') ?? '', articleContent.articleId, articleContent.version, articleContent.language ]); } async onApplicationBootstrap() { // Auto indexing if full text search mode enabled if (this.fullTextSearchMode) { const qb = this.getDefaultQueryBuilder('articles'); qb.orWhere('multiLanguageContents.searchTokens IS NULL'); qb.orWhere('multiLanguageContents.searchTokenVersion != :searchTokenVersion', { searchTokenVersion: FULL_TEXT_SEARCH_TOKEN_VERSION }); const articles = await qb.getMany(); if (articles.length) { this.logger.log('Start indexing articles...'); await articles.map((article)=>()=>this.bindSearchTokens(article.versions[0].multiLanguageContents[0], article.versions[0].tags)).reduce((prev, next)=>prev.then(next), Promise.resolve()); this.logger.log('Indexing articles done.'); } await this.dataSource.query(`CREATE INDEX IF NOT EXISTS "article_version_contents_search_tokens_idx" ON "${this.baseArticleVersionContentRepo.metadata.tableName}" USING GIN ("searchTokens")`); } } async findById(id, options, runner) { if (options?.language && !this.multipleLanguageMode) { throw new MultipleLanguageModeIsDisabledError(); } const qb = this.getDefaultQueryBuilder('articles', { stage: options?.stage ?? undefined, version: options?.version ?? undefined }, runner); qb.andWhere('articles.id = :id', { id }); if (options?.language) { qb.andWhere('multiLanguageContents.language = :language', { language: options.language }); } const article = await qb.getOne(); if (!article) { throw new ArticleNotFoundError(); } if (options?.stage) { this.articleDataLoader.stageCache.set(`${article.id}:${article.versions[0].version}`, Promise.resolve(options.stage)); } if (options?.language || !this.multipleLanguageMode) { const languageContent = article.versions[0].multiLanguageContents.find((content)=>content.language === (options?.language || DEFAULT_LANGUAGE)); return { ...removeArticleVersionContentInvalidFields(languageContent), ...removeArticleVersionInvalidFields(article.versions[0]), ...removeArticleInvalidFields(article), id: article.id, createdAt: article.createdAt, updatedAt: article.versions[0].createdAt, deletedAt: article.deletedAt, updatedBy: article.versions[0].createdBy }; } return { ...removeMultipleLanguageArticleVersionInvalidFields(article.versions[0]), ...removeArticleInvalidFields(article), id: article.id, createdAt: article.createdAt, updatedAt: article.versions[0].createdAt, deletedAt: article.deletedAt, updatedBy: article.versions[0].createdBy }; } async getFindAllQueryBuilder(options) { const qb = this.getDefaultQueryBuilder('articles', { stage: options?.stage ?? undefined, signatureLevel: options?.signatureLevel }); if (options?.ids?.length) { qb.andWhere('articles.id IN (:...ids)', { ids: options.ids }); } if (options?.language) { qb.andWhere('multiLanguageContents.language = :language', { language: options.language }); } if (options?.requiredCategoryIds?.length) { const relationMetadata = this.baseArticleRepo.metadata.manyToManyRelations.find((relation)=>relation.inverseEntityMetadata.tableName === this.baseCategoryRepo.metadata.tableName)?.junctionEntityMetadata; const junctionTableName = relationMetadata?.tableName ?? 'article_categories'; options?.requiredCategoryIds?.forEach((categoryId, index)=>{ const relationQb = this.dataSource.createQueryBuilder(); const junctionSchema = relationMetadata?.schema; relationQb.from(junctionSchema ? `${junctionSchema}.${junctionTableName}` : junctionTableName, `requiredCategoryRelations${index}`); relationQb.andWhere(`"requiredCategoryRelations${index}"."categoryId" = :requiredCategoryId${index}`, { [`requiredCategoryId${index}`]: categoryId }); relationQb.andWhere(`"requiredCategoryRelations${index}"."articleId" = articles.id`); qb.andWhereExists(relationQb); }); } if (options?.categoryIds?.length) { const relationMetadata = this.baseArticleRepo.metadata.manyToManyRelations.find((relation)=>relation.inverseEntityMetadata.tableName === this.baseCategoryRepo.metadata.tableName)?.junctionEntityMetadata; const junctionTableName = relationMetadata?.tableName ?? 'article_categories'; const relationQb = this.dataSource.createQueryBuilder(); const junctionSchema = relationMetadata?.schema; relationQb.from(junctionSchema ? `${junctionSchema}.${junctionTableName}` : junctionTableName, `categoryRelations`); relationQb.andWhere('"categoryRelations"."categoryId" IN (:...categoryIds)', { categoryIds: options.categoryIds }); relationQb.andWhere(`"categoryRelations"."articleId" = articles.id`); qb.andWhereExists(relationQb); } if (options?.searchTerm) { switch(options?.searchMode){ case ArticleSearchMode.FULL_TEXT: { if (!this.fullTextSearchMode) throw new Error('Full text search is disabled.'); const jiebaModule = await import('@node-rs/jieba'); const jiebaInstance = new jiebaModule.default.Jieba(); const cut = jiebaInstance.cut.bind(jiebaInstance); const tokens = cut(options.searchTerm.trim()); const searchQb = this.dataSource.createQueryBuilder(); const fullTextContentSchema = this.baseArticleVersionContentRepo.metadata.schema; searchQb.from(fullTextContentSchema ? `${fullTextContentSchema}.${this.baseArticleVersionContentRepo.metadata.tableName}` : this.baseArticleVersionContentRepo.metadata.tableName, 'contents'); searchQb.andWhere("contents.searchTokens @@ to_tsquery('simple', :searchTerm)", { searchTerm: tokens.join('|') }); searchQb.andWhere('contents."entityName" = :entityName', { entityName: this.baseArticleVersionContentRepo.metadata.targetName }); searchQb.andWhere('contents."articleId" = "versions"."articleId"'); searchQb.andWhere('contents."version" = "versions"."version"'); qb.andWhereExists(searchQb); break; } case ArticleSearchMode.TITLE_AND_TAG: case ArticleSearchMode.TITLE: default: { const searchQb = this.dataSource.createQueryBuilder(); const titleSearchContentSchema = this.baseArticleVersionContentRepo.metadata.schema; searchQb.from(titleSearchContentSchema ? `${titleSearchContentSchema}.${this.baseArticleVersionContentRepo.metadata.tableName}` : this.baseArticleVersionContentRepo.metadata.tableName, 'contents'); searchQb.andWhere(new Brackets((qb)=>{ if (options?.searchMode === ArticleSearchMode.TITLE_AND_TAG) { qb.orWhere(':tagSearchTerm = ANY (SELECT LOWER(value) FROM jsonb_array_elements_text(versions.tags))', { tagSearchTerm: `${options.searchTerm?.toLocaleLowerCase() ?? ''}` }); } qb.orWhere('contents.title ILIKE :searchTerm', { searchTerm: `%${options.searchTerm}%` }); qb.orWhere('contents.description ILIKE :searchTerm', { searchTerm: `%${options.searchTerm}%` }); })); searchQb.andWhere('contents."entityName" = :entityName', { entityName: this.baseArticleVersionContentRepo.metadata.targetName }); searchQb.andWhere('contents."articleId" = "versions"."articleId"'); searchQb.andWhere('contents."version" = "versions"."version"'); qb.andWhereExists(searchQb); break; } } } switch(options?.sorter){ case ArticleSorter.RELEASED_AT_ASC: qb.addOrderBy('versions.releasedAt', 'ASC'); break; case ArticleSorter.RELEASED_AT_DESC: qb.addOrderBy('versions.releasedAt', 'DESC'); break; case ArticleSorter.SUBMITTED_AT_ASC: qb.addOrderBy('versions.submittedAt', 'ASC'); break; case ArticleSorter.SUBMITTED_AT_DESC: qb.addOrderBy('versions.submittedAt', 'DESC'); break; case ArticleSorter.UPDATED_AT_ASC: qb.addOrderBy('versions.createdAt', 'ASC'); break; case ArticleSorter.UPDATED_AT_DESC: qb.addOrderBy('versions.createdAt', 'DESC'); break; case ArticleSorter.CREATED_AT_ASC: qb.addOrderBy('articles.createdAt', 'ASC'); break; case ArticleSorter.CREATED_AT_DESC: default: qb.addOrderBy('articles.createdAt', 'DESC'); break; } qb.addOrderBy('articles.id', 'ASC'); return qb; } async findCollection(options) { if (options?.language && !this.multipleLanguageMode) { throw new MultipleLanguageModeIsDisabledError(); } const qb = await this.getFindAllQueryBuilder(options); qb.skip(options?.offset ?? 0); qb.take(Math.min(options?.limit ?? 20, 100)); const [articles, total] = await qb.getManyAndCount(); if (options?.stage) { articles.forEach((article)=>{ this.articleDataLoader.stageCache.set(`${article.id}:${article.versions[0].version}`, Promise.resolve(options.stage)); }); } if (options?.language || !this.multipleLanguageMode) { return { articles: articles.map((article)=>{ const languageContent = article.versions[0].multiLanguageContents.find((content)=>content.language === (options?.language || DEFAULT_LANGUAGE)); return { ...removeArticleVersionContentInvalidFields(languageContent), ...removeArticleVersionInvalidFields(article.versions[0]), ...removeArticleInvalidFields(article), id: article.id, createdAt: article.createdAt, updatedAt: article.versions[0].createdAt, deletedAt: article.deletedAt, updatedBy: article.versions[0].createdBy }; }), total, offset: options?.offset ?? 0, limit: Math.min(options?.limit ?? 20, 100) }; } return { articles: articles.map((article)=>({ ...removeMultipleLanguageArticleVersionInvalidFields(article.versions[0]), ...removeArticleInvalidFields(article), id: article.id, createdAt: article.createdAt, updatedAt: article.versions[0].createdAt, deletedAt: article.deletedAt, updatedBy: article.versions[0].createdBy })), total, offset: options?.offset ?? 0, limit: Math.min(options?.limit ?? 20, 100) }; } async findAll(options) { if (options?.language && !this.multipleLanguageMode) { throw new MultipleLanguageModeIsDisabledError(); } const qb = await this.getFindAllQueryBuilder(options); qb.skip(options?.offset ?? 0); qb.take(Math.min(options?.limit ?? 20, 100)); const articles = await qb.getMany(); if (options?.stage) { articles.forEach((article)=>{ this.articleDataLoader.stageCache.set(`${article.id}:${article.versions[0].version}`, Promise.resolve(options.stage)); }); } if (options?.language || !this.multipleLanguageMode) { return articles.map((article)=>{ const languageContent = article.versions[0].multiLanguageContents.find((content)=>content.language === (options?.language || DEFAULT_LANGUAGE)); return { ...removeArticleVersionContentInvalidFields(languageContent), ...removeArticleVersionInvalidFields(article.versions[0]), ...removeArticleInvalidFields(article), id: article.id, createdAt: article.createdAt, updatedAt: article.versions[0].createdAt, deletedAt: article.deletedAt, updatedBy: article.versions[0].createdBy }; }); } return articles.map((article)=>({ ...removeMultipleLanguageArticleVersionInvalidFields(article.versions[0]), ...removeArticleInvalidFields(article), id: article.id, createdAt: article.createdAt, updatedAt: article.versions[0].createdAt, deletedAt: article.deletedAt, updatedBy: article.versions[0].createdBy })); } async deleteVersion(id, version) { const qb = this.baseArticleVersionRepo.createQueryBuilder('versions'); qb.andWhere('versions.articleId = :id', { id }); qb.andWhere('versions.version = :version', { version }); const targetVersion = await qb.getOne(); if (!targetVersion) { throw new ArticleVersionNotFoundError(); } await this.baseArticleVersionRepo.softRemove(targetVersion); } async archive(id) { const article = await this.baseArticleRepo.findOne({ where: { id } }); if (!article) { throw new ArticleNotFoundError(); } await this.baseArticleRepo.softDelete(id); } async withdraw(id, version) { if (!this.draftMode) { throw new Error('Draft mode is disabled.'); } const article = await this.findById(id, { version }); const stage = await this.articleDataLoader.stageLoader.load({ id: article.id, version: article.version }); if (![ ArticleStage.RELEASED, ArticleStage.SCHEDULED ].includes(stage)) { throw new BadRequestException(`Article ${id} is not in released or scheduled stage [${stage}].`); } const targetPlaceArticle = await this.findById(id, { stage: this.signatureService.signatureEnabled ? ArticleStage.VERIFIED : ArticleStage.DRAFT }).catch((_)=>null); this.logger.debug(`Withdraw article ${id} [${article.version}]`); const runner = this.dataSource.createQueryRunner(); await runner.connect(); await runner.startTransaction(); try { if (targetPlaceArticle) { this.logger.debug(`Article ${id} is already in draft or verified [${targetPlaceArticle.version}]. Removing previous version.`); await runner.manager.softDelete(this.baseArticleVersionRepo.metadata.tableName, { articleId: id, version: targetPlaceArticle.version }); } await runner.manager.softDelete(this.baseArticleVersionRepo.metadata.tableName, { articleId: id, releasedAt: LessThanOrEqual(new Date()), version: Not(article.version) }); await runner.manager.update(this.baseArticleVersionRepo.metadata.tableName, { articleId: id, version: article.version }, { releasedAt: null }); await runner.commitTransaction(); } catch (ex) { await runner.rollbackTransaction(); throw ex; } finally{ await runner.release(); } article.releasedAt = null; this.articleDataLoader.stageCache.set(`${article.id}:${article.version}`, Promise.resolve(this.signatureService.signatureEnabled ? ArticleStage.VERIFIED : ArticleStage.DRAFT)); return this.findById(article.id, { version: article.version }); } async release(id, options) { const article = await this.findById(id, { version: options?.version ?? undefined }); const shouldDeleteVersion = await this.findById(id, { stage: (options?.releasedAt?.getTime() ?? Date.now()) <= Date.now() ? ArticleStage.RELEASED : ArticleStage.SCHEDULED }).catch((_)=>null); this.logger.debug(`Release article ${id} [${article.version}]`); const willReleasedAt = options?.releasedAt ?? new Date(); const runner = this.dataSource.createQueryRunner(); await runner.connect(); await runner.startTransaction(); try { if (shouldDeleteVersion && shouldDeleteVersion.version !== article.version) { this.logger.debug(`Article ${id} is already scheduled or released [${shouldDeleteVersion.version}]. Removing previous version.`); await runner.manager.softRemove(this.baseArticleVersionRepo.metadata.tableName, { articleId: shouldDeleteVersion.id, version: shouldDeleteVersion.version }); } await runner.manager.update(this.baseArticleVersionRepo.metadata.tableName, { articleId: id, version: article.version }, { releasedAt: willReleasedAt, releasedBy: options?.userId ?? undefined }); await runner.commitTransaction(); } catch (ex) { await runner.rollbackTransaction(); throw ex; } finally{ await runner.release(); } article.releasedAt = willReleasedAt; this.articleDataLoader.stageCache.set(`${article.id}:${article.version}`, Promise.resolve(willReleasedAt.getTime() > Date.now() ? ArticleStage.SCHEDULED : ArticleStage.RELEASED)); return this.findById(article.id, { version: article.version }); } async submit(id, options) { if (!this.signatureService.signatureEnabled) { throw new Error('Signature mode is disabled.'); } const article = await this.findById(id); if (article.submittedAt) { throw new BadRequestException(`Article ${id} is already submitted [${article.version}] at ${article.submittedAt}.`); } const pendingReviewArticle = await this.findById(id, { stage: ArticleStage.REVIEWING }).catch((_)=>null); const runner = this.dataSource.createQueryRunner(); await runner.connect(); await runner.startTransaction(); try { if (pendingReviewArticle) { this.logger.debug(`Article ${id} is already pending review [${pendingReviewArticle.version}]. Removing previous version.`); await runner.manager.softRemove(this.baseArticleVersionRepo.metadata.tableName, { articleId: id, version: pendingReviewArticle.version }); } await runner.manager.update(this.baseArticleVersionRepo.metadata.tableName, { articleId: id, version: article.version }, { submittedAt: new Date(), submittedBy: options?.userId ?? undefined }); await runner.commitTransaction(); } catch (ex) { await runner.rollbackTransaction(); throw ex; } finally{ await runner.release(); } article.submittedAt = new Date(); this.articleDataLoader.stageCache.set(`${article.id}:${article.version}`, Promise.resolve(ArticleStage.REVIEWING)); return this.findById(article.id, { version: article.version }); } async putBack(id) { if (!this.signatureService.signatureEnabled) { throw new Error('Signature mode is disabled.'); } const draftArticle = await this.findById(id, { stage: ArticleStage.DRAFT }).catch((_)=>null); if (draftArticle) { const errorMessage = `Article ${id} is already in draft [${draftArticle.version}].`; this.logger.debug(errorMessage); throw new BadRequestException(errorMessage); } const article = await this.findById(id); if (!article.submittedAt) { throw new BadRequestException(`Article ${id} is not submitted yet [${article.version}].`); } const runner = this.dataSource.createQueryRunner(); await runner.connect(); await runner.startTransaction(); try { await runner.manager.update(this.baseArticleVersionRepo.metadata.tableName, { articleId: id, version: article.version }, { submittedAt: null, submittedBy: null }); await runner.commitTransaction(); } catch (ex) { await runner.rollbackTransaction(); throw ex; } finally{ await runner.release(); } article.submittedAt = null; this.articleDataLoader.stageCache.set(`${article.id}:${article.version}`, Promise.resolve(ArticleStage.DRAFT)); return this.findById(article.id, { version: article.version }); } getPlacedArticleStage({ submitted, releasedAt, signatureLevel }) { if (submitted) { return ArticleStage.REVIEWING; } if (signatureLevel) { if (signatureLevel === this.signatureService.finalSignatureLevel?.name) { return ArticleStage.VERIFIED; } return ArticleStage.REVIEWING; } if (releasedAt) { if (releasedAt.getTime() > Date.now()) { return ArticleStage.SCHEDULED; } return ArticleStage.RELEASED; } if (this.draftMode) { return ArticleStage.DRAFT; } return ArticleStage.RELEASED; } optionsCheck(options) { if (options.submitted && options.signatureLevel) { throw new Error('Signature level is not allowed when submitting an article version.'); } if (options.submitted && options.releasedAt) { throw new Error('Released at is not allowed when submitting an article version.'); } if (options.releasedAt && options.signatureLevel && this.signatureService.finalSignatureLevel?.name !== options.signatureLevel) { throw new Error('Only final signature level is allowed when releasing an article version.'); } if (options.submitted && !this.signatureService.signatureEnabled) { throw new Error('Signature mode is disabled.'); } if (options.releasedAt && !this.draftMode) { throw new Error('Draft mode is disabled.'); } } async addVersion(id, options) { this.optionsCheck(options); const placedArticleStage = this.getPlacedArticleStage({ submitted: options.submitted ?? false, releasedAt: options.releasedAt ?? null, signatureLevel: options.signatureLevel ?? null }); const placedArticle = await this.findById(id, { stage: placedArticleStage }).catch((_)=>null); const targetCategories = options?.categoryIds?.length ? await this.baseCategoryRepo.find({ where: { id: In(options.categoryIds), bindable: true } }) : []; if (targetCategories.length !== (options?.categoryIds?.length ?? 0)) { throw new CategoryNotFoundError(); } const article = await this.baseArticleRepo.findOne({ where: { id }, relations: [ 'categories' ] }); if (!article) { throw new ArticleNotFoundError(); } if (article.categories.length && !options.categoryIds) { this.logger.warn(`Article ${id} has categories, but no categoryIds provided when add version. The article categories will no change after version added.`); } const latestQb = this.baseArticleVersionRepo.createQueryBuilder('articleVersions'); latestQb.withDeleted(); latestQb.andWhere('articleVersions.articleId = :id', { id }); latestQb.addOrderBy('articleVersions.version', 'DESC'); const latestVersion = await latestQb.getOne(); if (!latestVersion) { throw new ArticleVersionNotFoundError(); } const runner = this.dataSource.createQueryRunner(); await runner.connect(); await runner.startTransaction(); try { if (placedArticle) { await runner.manager.softRemove(this.baseArticleVersionRepo.metadata.tableName, { articleId: id, version: placedArticle.version }); } await runner.manager.save(this.baseArticleRepo.create({ ...article, ...removeArticleInvalidFields(options), ...options.categoryIds ? { categories: targetCategories } : {} })); const version = this.baseArticleVersionRepo.create({ ...removeArticleVersionInvalidFields(options), articleId: article.id, version: latestVersion.version + 1, submittedAt: options.submitted || options.releasedAt || options.signatureLevel ? new Date() : null, submittedBy: options.submitted || options.releasedAt || options.signatureLevel ? options.userId : undefined, releasedAt: this.draftMode ? options.releasedAt : new Date(), releasedBy: options.releasedAt ? options.userId : undefined, createdBy: options.userId }); await runner.manager.save(version); if ('multiLanguageContents' in options) { if (!this.multipleLanguageMode) throw new MultipleLanguageModeIsDisabledError(); const savedContents = await runner.manager.save(Object.entries(options.multiLanguageContents).map(([language, content])=>this.baseArticleVersionContentRepo.create({ ...removeArticleVersionContentInvalidFields(content), articleId: article.id, version: latestVersion.version + 1, language }))); if (this.fullTextSearchMode) { await savedContents.map((articleContent)=>()=>this.bindSearchTokens(articleContent, options.tags ?? [], runner)).reduce((prev, next)=>prev.then(next), Promise.resolve()); } } else { const savedContent = await runner.manager.save(this.baseArticleVersionContentRepo.create({ ...removeArticleVersionContentInvalidFields(options), articleId: article.id, version: latestVersion.version + 1, language: DEFAULT_LANGUAGE })); if (this.fullTextSearchMode) { await this.bindSearchTokens(savedContent, options.tags ?? [], runner); } } if (options.signatureLevel || options.releasedAt) { await this.approveVersion({ id: article.id, version: version.version }, { signatureLevel: options.signatureLevel ?? this.signatureService.finalSignatureLevel?.name, signerId: options.userId, runner }); } await runner.commitTransaction(); this.articleDataLoader.stageCache.set(`${article.id}:${version.version}`, Promise.resolve((()=>{ if (options.releasedAt) { return options.releasedAt.getTime() > Date.now() ? ArticleStage.SCHEDULED : ArticleStage.RELEASED; } if (options.signatureLevel) { return options.signatureLevel === this.signatureService.finalSignatureLevel?.name ? ArticleStage.VERIFIED : ArticleStage.REVIEWING; } if (options.submitted) { return ArticleStage.REVIEWING; } return ArticleStage.DRAFT; })())); return this.findById(article.id, { version: version.version }); } catch (ex) { await runner.rollbackTransaction(); // If it's already a BadRequestException (including custom errors), rethrow as-is if (ex instanceof BadRequestException) { throw ex; } throw new BadRequestException(ex); } finally{ await runner.release(); } } async create(options) { this.optionsCheck(options); const targetCategories = options?.categoryIds?.length ? await this.baseCategoryRepo.find({ where: { id: In(options.categoryIds), bindable: true } }) : []; if (targetCategories.length !== (options?.categoryIds?.length ?? 0)) { throw new CategoryNotFoundError(); } const article = this.baseArticleRepo.create({ ...removeArticleInvalidFields(options), categories: targetCategories }); const runner = this.dataSource.createQueryRunner(); await runner.connect(); await runner.startTransaction(); try { await runner.manager.save(article); const version = this.baseArticleVersionRepo.create({ ...removeArticleVersionInvalidFields(options), articleId: article.id, submittedAt: options.submitted || options.releasedAt || options.signatureLevel ? new Date() : null, submittedBy: options.submitted || options.releasedAt || options.signatureLevel ? options.userId : undefined, releasedAt: this.draftMode ? options.releasedAt : new Date(), releasedBy: options.releasedAt ? options.userId : undefined, createdBy: options.userId }); await runner.manager.save(version); if ('multiLanguageContents' in options) { if (!this.multipleLanguageMode) throw new MultipleLanguageModeIsDisabledError(); const savedContents = await runner.manager.save(Object.entries(options.multiLanguageContents).map(([language, content])=>this.baseArticleVersionContentRepo.create({ ...removeArticleVersionContentInvalidFields(content), articleId: article.id, version: version.version, language }))); if (this.fullTextSearchMode) { await savedContents.map((articleContent)=>()=>this.bindSearchTokens(articleContent, options.tags ?? [], runner)).reduce((prev, next)=>prev.then(next), Promise.resolve()); } } else { const savedContent = await runner.manager.save(this.baseArticleVersionContentRepo.create({ ...removeArticleVersionContentInvalidFields(options), articleId: article.id, version: version.version, language: DEFAULT_LANGUAGE })); if (this.fullTextSearchMode) { await this.bindSearchTokens(savedContent, options.tags ?? [], runner); } } if (options.signatureLevel || options.releasedAt) { await this.approveVersion({ id: article.id, version: version.version }, { signatureLevel: options.signatureLevel ?? this.signatureService.finalSignatureLevel?.name, signerId: options.userId, runner }); } await runner.commitTransaction(); this.articleDataLoader.stageCache.set(`${article.id}:${version.version}`, Promise.resolve((()=>{ if (options.releasedAt) { return options.releasedAt.getTime() > Date.now() ? ArticleStage.SCHEDULED : ArticleStage.RELEASED; } if (options.signatureLevel) { return options.signatureLevel === this.signatureService.finalSignatureLevel?.name ? ArticleStage.VERIFIED : ArticleStage.REVIEWING; } if (options.submitted) { return ArticleStage.REVIEWING; } return ArticleStage.DRAFT; })())); return this.findById(article.id, { version: version.version }); } catch (ex) { await runner.rollbackTransaction(); // If it's already a BadRequestException (including custom errors), rethrow as-is if (ex instanceof BadRequestException) { throw ex; } throw new BadRequestException(ex); } finally{ await runner.release(); } } async rejectVersion(articleVersion, signatureInfo) { const reviewingArticle = await this.findById(articleVersion.id, { stage: ArticleStage.REVIEWING }); if (!reviewingArticle) { throw new BadRequestException(`Article ${articleVersion.id} is not in reviewing stage.`); } return this.signature(ArticleSignatureResult.REJECTED, reviewingArticle, signatureInfo); } approveVersion(articleVersion, signatureInfo) { return this.signature(ArticleSignatureResult.APPROVED, articleVersion, signatureInfo); } async signature(result, articleVersion, signatureInfo) { if (!this.signatureService.signatureEnabled) { throw new BadRequestException('Signature is not enabled'); } const placedArticle = await this.findById(articleVersion.id, { stage: result === ArticleSignatureResult.APPROVED ? (signatureInfo?.signatureLevel ?? this.signatureLevels[0]) === this.signatureService.finalSignatureLevel?.name ? ArticleStage.VERIFIED : ArticleStage.REVIEWING : ArticleStage.DRAFT }, signatureInfo?.runner).catch(/* istanbul ignore next: defensive error handling */ (_)=>null); if (signatureInfo?.runner) { if (!await signatureInfo.runner.manager.exists(this.baseArticleVersionRepo.metadata.tableName, { where: { articleId: articleVersion.id, version: articleVersion.version } })) { throw new BadRequestException('Invalid article version'); } } else if (!await this.baseArticleVersionRepo.exists({ where: { articleId: articleVersion.id, version: articleVersion.version } })) { throw new BadRequestException('Invalid article version'); } if (this.signatureService.signatureLevelsCache.length > 1 && !signatureInfo?.signatureLevel) { throw new BadRequestException('Signature level is required'); } const targetLevelIndex = signatureInfo?.signatureLevel ? this.signatureService.signatureLevelsCache.findIndex((level)=>signatureInfo.signatureLevel instanceof BaseSignatureLevelEntity ? level.id === signatureInfo.signatureLevel.id : level.name === signatureInfo.signatureLevel) : 0; if (signatureInfo?.signatureLevel && !~targetLevelIndex) { throw new BadRequestException('Invalid signature level'); } const runner = signatureInfo?.runner ?? this.dataSource.createQueryRunner(); if (!signatureInfo?.runner) { await runner.connect(); await runner.startTransaction(); } try { const qb = runner.manager.createQueryBuilder(this.articleSignatureRepo.metadata.tableName, 'signatures'); qb.andWhere('signatures.articleId = :articleId', { articleId: articleVersion.id }); qb.andWhere('signatures.version = :version', {