@rytass/cms-base-nestjs-module
Version:
Rytass Content Management System NestJS Base Module
537 lines (534 loc) • 27 kB
JavaScript
import { Injectable, Inject, Logger, BadRequestException } from '@nestjs/common';
import { In } from 'typeorm';
import { BaseArticleVersionEntity } from '../models/base-article-version.entity.js';
import { RESOLVED_ARTICLE_REPO, RESOLVED_ARTICLE_VERSION_REPO, RESOLVED_ARTICLE_VERSION_CONTENT_REPO, RESOLVED_CATEGORY_REPO, MULTIPLE_LANGUAGE_MODE, FULL_TEXT_SEARCH_MODE, ENABLE_SIGNATURE_MODE, ARTICLE_SIGNATURE_SERVICE, DRAFT_MODE } 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 { ArticleNotIncludeFields, ArticleVersionNotIncludeFields, ArticleVersionContentNotIncludeFields } from '../constants/not-include-entity-fields.js';
import { ArticleSignatureResult } from '../typings/article-signature-result.enum.js';
import { ArticleFindVersionType } from '../typings/article-find-version-type.enum.js';
/* eslint-disable quotes */ 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_param(paramIndex, decorator) {
return function(target, key) {
decorator(target, key, paramIndex);
};
}
class ArticleBaseService {
baseArticleRepo;
baseArticleVersionRepo;
baseArticleVersionContentRepo;
baseCategoryRepo;
multipleLanguageMode;
fullTextSearchMode;
signatureMode;
articleSignatureService;
draftMode;
dataSource;
constructor(baseArticleRepo, baseArticleVersionRepo, baseArticleVersionContentRepo, baseCategoryRepo, multipleLanguageMode, fullTextSearchMode, signatureMode, articleSignatureService, draftMode, dataSource){
this.baseArticleRepo = baseArticleRepo;
this.baseArticleVersionRepo = baseArticleVersionRepo;
this.baseArticleVersionContentRepo = baseArticleVersionContentRepo;
this.baseCategoryRepo = baseCategoryRepo;
this.multipleLanguageMode = multipleLanguageMode;
this.fullTextSearchMode = fullTextSearchMode;
this.signatureMode = signatureMode;
this.articleSignatureService = articleSignatureService;
this.draftMode = draftMode;
this.dataSource = dataSource;
this.logger = new Logger(ArticleBaseService.name);
}
logger;
getDefaultQueryBuilder(alias = 'articles', options) {
const qb = this.baseArticleRepo.createQueryBuilder(alias);
qb.leftJoinAndSelect(`${alias}.categories`, 'categories');
qb.innerJoinAndSelect(`${alias}.versions`, 'versions');
qb.innerJoinAndSelect('versions.multiLanguageContents', 'multiLanguageContents');
qb.innerJoin((subQb)=>{
subQb.from(BaseArticleVersionEntity, 'versions');
subQb.select('versions.articleId', 'articleId');
subQb.addSelect('MAX(versions.version)', 'version');
subQb.groupBy('versions.articleId');
if (this.draftMode && options?.versionType === ArticleFindVersionType.RELEASED) {
subQb.andWhere('versions.releasedAt IS NOT NULL');
}
if (this.signatureMode && options?.onlyApproved && options?.signatureLevel) {
this.logger.debug(`When signature level provided with onlyApproved, only signature level will be used.`);
}
if (this.signatureMode && options?.signatureLevel) {
subQb.innerJoin('versions.signatures', 'signatures');
subQb.andWhere('signatures.result = :result', {
result: ArticleSignatureResult.APPROVED
});
subQb.andWhere('signatures.signatureLevelId = :signatureLevelId', {
signatureLevelId: options.signatureLevel
});
} else if (this.signatureMode && options?.onlyApproved) {
subQb.innerJoin('versions.signatures', 'signatures');
subQb.andWhere('signatures.result = :result', {
result: ArticleSignatureResult.APPROVED
});
const latestId = this.articleSignatureService.finalSignatureLevel?.id;
if (latestId) {
subQb.andWhere('signatures.signatureLevelId = :signatureLevelId', {
signatureLevelId: latestId
});
} else {
subQb.andWhere('signatures.signatureLevelId IS NULL');
}
}
return subQb;
}, 'target', 'target.version = versions.version AND target."articleId" = versions."articleId"');
return qb;
}
async bindSearchTokens(articleContent, tags, runner) {
const { cut } = await import('@node-rs/jieba');
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) {
if (options?.language && !this.multipleLanguageMode) {
throw new MultipleLanguageModeIsDisabledError();
}
const qb = this.getDefaultQueryBuilder('articles', {
versionType: (this.draftMode ? options?.versionType : ArticleFindVersionType.RELEASED) ?? ArticleFindVersionType.RELEASED,
onlyApproved: options?.onlyApproved
});
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?.language || !this.multipleLanguageMode) {
const defaultContent = article.versions[0].multiLanguageContents.find((content)=>content.language === (options?.language || DEFAULT_LANGUAGE));
return {
...article,
versions: undefined,
...defaultContent,
id: article.id,
version: article.versions[0].version,
tags: article.versions[0].tags
};
}
return {
...article,
versions: undefined,
id: article.id,
tags: article.versions[0].tags,
version: article.versions[0].version,
multiLanguageContents: article.versions[0].multiLanguageContents
};
}
async getFindAllQueryBuilder(options) {
const qb = this.getDefaultQueryBuilder('articles', {
versionType: (this.draftMode ? options?.versionType : ArticleFindVersionType.RELEASED) ?? ArticleFindVersionType.RELEASED,
onlyApproved: options?.onlyApproved,
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)=>`${this.baseArticleRepo.metadata.schema}.${relation.propertyPath}` === this.baseCategoryRepo.metadata.tablePath)?.junctionEntityMetadata;
options?.requiredCategoryIds?.forEach((categoryId, index)=>{
const relationQb = this.dataSource.createQueryBuilder();
relationQb.from(`${this.baseArticleRepo.metadata.schema}.${relationMetadata?.tableName}`, `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)=>`${this.baseArticleRepo.metadata.schema}.${relation.propertyPath}` === this.baseCategoryRepo.metadata.tablePath)?.junctionEntityMetadata;
const relationQb = this.dataSource.createQueryBuilder();
relationQb.from(`${this.baseArticleRepo.metadata.schema}.${relationMetadata?.tableName}`, `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 { cut } = await import('@node-rs/jieba');
const tokens = cut(options.searchTerm.trim());
const searchQb = this.dataSource.createQueryBuilder();
searchQb.from(`${this.baseArticleVersionContentRepo.metadata.schema}.${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();
searchQb.from(`${this.baseArticleVersionContentRepo.metadata.schema}.${this.baseArticleVersionContentRepo.metadata.tableName}`, 'contents');
if (options?.searchMode === ArticleSearchMode.TITLE_AND_TAG) {
searchQb.orWhere(':tagSearchTerm = ANY (SELECT LOWER(value) FROM jsonb_array_elements_text(versions.tags))', {
tagSearchTerm: `${options.searchTerm?.toLocaleLowerCase()}`
});
}
searchQb.orWhere('contents.title ILIKE :searchTerm', {
searchTerm: `%${options.searchTerm}%`
});
searchQb.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.CREATED_AT_ASC:
qb.addOrderBy('articles.createdAt', 'ASC');
break;
case ArticleSorter.CREATED_AT_DESC:
default:
qb.addOrderBy('articles.createdAt', 'DESC');
break;
}
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?.language || !this.multipleLanguageMode) {
return {
articles: articles.map((article)=>{
const defaultContent = article.versions[0].multiLanguageContents.find((content)=>content.language === (options?.language || DEFAULT_LANGUAGE));
return {
...article,
versions: undefined,
...defaultContent,
id: article.id,
tags: article.versions[0].tags
};
}),
total,
offset: options?.offset ?? 0,
limit: Math.min(options?.limit ?? 20, 100)
};
}
return {
articles: articles.map((article)=>({
...article,
versions: undefined,
version: article.versions[0].version,
tags: article.versions[0].tags,
multiLanguageContents: article.versions[0].multiLanguageContents
})),
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?.language || !this.multipleLanguageMode) {
return articles.map((article)=>{
const defaultContent = article.versions[0].multiLanguageContents.find((content)=>content.language === (options?.language || DEFAULT_LANGUAGE));
return {
...article,
versions: undefined,
...defaultContent,
id: article.id,
tags: article.versions[0].tags
};
});
}
return articles.map((article)=>({
...article,
versions: undefined,
version: article.versions[0].version,
tags: article.versions[0].tags,
multiLanguageContents: article.versions[0].multiLanguageContents
}));
}
async archive(id) {
const article = await this.baseArticleRepo.findOne({
where: {
id
}
});
if (!article) {
throw new ArticleNotFoundError();
}
await this.baseArticleRepo.softDelete(id);
}
async release(id, releasedAt) {
if (!this.draftMode) {
throw new Error('Draft mode is disabled.');
}
const article = await this.findById(id, {
versionType: ArticleFindVersionType.LATEST
});
if (article.releasedAt) {
this.logger.debug(`Article ${id} is already released [${article.version}] at ${article.releasedAt}.`);
return article;
}
this.logger.debug(`Release article ${id} [${article.version}]`);
const willReleasedAt = releasedAt ?? new Date();
await this.baseArticleVersionRepo.update({
articleId: id,
version: article.version
}, {
releasedAt: willReleasedAt
});
article.releasedAt = willReleasedAt;
return article;
}
async addVersion(id, 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 = 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.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 {
await runner.manager.save(this.baseArticleRepo.create({
...article,
...Object.entries(options).filter(([key])=>!~ArticleNotIncludeFields.indexOf(key)).reduce((vars, [key, value])=>({
...vars,
[key]: value
}), {}),
...options.categoryIds ? {
categories: targetCategories
} : {}
}));
const version = this.baseArticleVersionRepo.create({
...Object.entries(options).filter(([key])=>!~ArticleVersionNotIncludeFields.indexOf(key)).reduce((vars, [key, value])=>({
...vars,
[key]: value
}), {}),
articleId: article.id,
version: latestVersion.version + 1,
tags: options.tags ?? [],
releasedAt: this.draftMode ? options.releasedAt : new Date()
});
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({
...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({
...Object.entries(options).filter(([key])=>!~ArticleVersionContentNotIncludeFields.indexOf(key)).reduce((vars, [key, value])=>({
...vars,
[key]: value
}), {}),
articleId: article.id,
version: latestVersion.version + 1,
language: DEFAULT_LANGUAGE
}));
if (this.fullTextSearchMode) {
await this.bindSearchTokens(savedContent, options.tags ?? [], runner);
}
}
await runner.commitTransaction();
return article;
} catch (ex) {
await runner.rollbackTransaction();
throw new BadRequestException(ex);
} finally{
await runner.release();
}
}
async create(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({
...Object.entries(options).filter(([key])=>!~ArticleNotIncludeFields.indexOf(key)).reduce((vars, [key, value])=>({
...vars,
[key]: value
}), {}),
categories: targetCategories
});
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
try {
await runner.manager.save(article);
const version = this.baseArticleVersionRepo.create({
...Object.entries(options).filter(([key])=>!~ArticleVersionNotIncludeFields.indexOf(key)).reduce((vars, [key, value])=>({
...vars,
[key]: value
}), {}),
articleId: article.id,
tags: options.tags ?? [],
releasedAt: this.draftMode ? options.releasedAt : new Date()
});
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({
...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({
...Object.entries(options).filter(([key])=>!~ArticleVersionContentNotIncludeFields.indexOf(key)).reduce((vars, [key, value])=>({
...vars,
[key]: value
}), {}),
articleId: article.id,
version: version.version,
language: DEFAULT_LANGUAGE
}));
if (this.fullTextSearchMode) {
await this.bindSearchTokens(savedContent, options.tags ?? [], runner);
}
}
await runner.commitTransaction();
return article;
} catch (ex) {
await runner.rollbackTransaction();
throw new BadRequestException(ex);
} finally{
await runner.release();
}
}
}
ArticleBaseService = _ts_decorate([
Injectable(),
_ts_param(0, Inject(RESOLVED_ARTICLE_REPO)),
_ts_param(1, Inject(RESOLVED_ARTICLE_VERSION_REPO)),
_ts_param(2, Inject(RESOLVED_ARTICLE_VERSION_CONTENT_REPO)),
_ts_param(3, Inject(RESOLVED_CATEGORY_REPO)),
_ts_param(4, Inject(MULTIPLE_LANGUAGE_MODE)),
_ts_param(5, Inject(FULL_TEXT_SEARCH_MODE)),
_ts_param(6, Inject(ENABLE_SIGNATURE_MODE)),
_ts_param(7, Inject(ARTICLE_SIGNATURE_SERVICE)),
_ts_param(8, Inject(DRAFT_MODE)),
_ts_param(9, InjectDataSource())
], ArticleBaseService);
export { ArticleBaseService };