UNPKG

@rytass/cms-base-nestjs-module

Version:

Rytass Content Management System NestJS Base Module

537 lines (534 loc) 27 kB
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 };