@rytass/cms-base-nestjs-module
Version:
Rytass Content Management System NestJS Base Module
979 lines (977 loc) • 59.3 kB
JavaScript
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', {