facet.service.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. import { Injectable } from '@nestjs/common';
  2. import {
  3. AssignFacetsToChannelInput,
  4. CreateFacetInput,
  5. DeletionResponse,
  6. DeletionResult,
  7. LanguageCode,
  8. Permission,
  9. RemoveFacetFromChannelResult,
  10. RemoveFacetsFromChannelInput,
  11. UpdateFacetInput,
  12. } from '@vendure/common/lib/generated-types';
  13. import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
  14. import { In } from 'typeorm';
  15. import { RequestContext } from '../../api/common/request-context';
  16. import { RelationPaths } from '../../api/decorators/relations.decorator';
  17. import { ErrorResultUnion, FacetInUseError, ForbiddenError, UserInputError } from '../../common';
  18. import { ListQueryOptions } from '../../common/types/common-types';
  19. import { Translated } from '../../common/types/locale-types';
  20. import { assertFound, idsAreEqual } from '../../common/utils';
  21. import { ConfigService } from '../../config/config.service';
  22. import { TransactionalConnection } from '../../connection/transactional-connection';
  23. import { FacetTranslation } from '../../entity/facet/facet-translation.entity';
  24. import { Facet } from '../../entity/facet/facet.entity';
  25. import { FacetValue } from '../../entity/facet-value/facet-value.entity';
  26. import { EventBus } from '../../event-bus';
  27. import { FacetEvent } from '../../event-bus/events/facet-event';
  28. import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
  29. import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
  30. import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
  31. import { TranslatorService } from '../helpers/translator/translator.service';
  32. import { translateDeep } from '../helpers/utils/translate-entity';
  33. import { ChannelService } from './channel.service';
  34. import { FacetValueService } from './facet-value.service';
  35. import { RoleService } from './role.service';
  36. /**
  37. * @description
  38. * Contains methods relating to {@link Facet} entities.
  39. *
  40. * @docsCategory services
  41. */
  42. @Injectable()
  43. export class FacetService {
  44. constructor(
  45. private connection: TransactionalConnection,
  46. private facetValueService: FacetValueService,
  47. private translatableSaver: TranslatableSaver,
  48. private listQueryBuilder: ListQueryBuilder,
  49. private configService: ConfigService,
  50. private channelService: ChannelService,
  51. private customFieldRelationService: CustomFieldRelationService,
  52. private eventBus: EventBus,
  53. private translator: TranslatorService,
  54. private roleService: RoleService,
  55. ) {}
  56. findAll(
  57. ctx: RequestContext,
  58. options?: ListQueryOptions<Facet>,
  59. relations?: RelationPaths<Facet>,
  60. ): Promise<PaginatedList<Translated<Facet>>> {
  61. return this.listQueryBuilder
  62. .build(Facet, options, {
  63. relations: relations ?? ['values', 'values.facet', 'channels'],
  64. ctx,
  65. channelId: ctx.channelId,
  66. })
  67. .getManyAndCount()
  68. .then(([facets, totalItems]) => {
  69. const items = facets.map(facet =>
  70. this.translator.translate(facet, ctx, ['values', ['values', 'facet']]),
  71. );
  72. return {
  73. items,
  74. totalItems,
  75. };
  76. });
  77. }
  78. findOne(
  79. ctx: RequestContext,
  80. facetId: ID,
  81. relations?: RelationPaths<Facet>,
  82. ): Promise<Translated<Facet> | undefined> {
  83. return this.connection
  84. .findOneInChannel(ctx, Facet, facetId, ctx.channelId, {
  85. relations: relations ?? ['values', 'values.facet', 'channels'],
  86. })
  87. .then(
  88. facet =>
  89. (facet && this.translator.translate(facet, ctx, ['values', ['values', 'facet']])) ??
  90. undefined,
  91. );
  92. }
  93. /**
  94. * @deprecated Use {@link FacetService.findByCode findByCode(ctx, facetCode, lang)} instead
  95. */
  96. findByCode(facetCode: string, lang: LanguageCode): Promise<Translated<Facet> | undefined>;
  97. findByCode(
  98. ctx: RequestContext,
  99. facetCode: string,
  100. lang: LanguageCode,
  101. ): Promise<Translated<Facet> | undefined>;
  102. findByCode(
  103. ctxOrFacetCode: RequestContext | string,
  104. facetCodeOrLang: string | LanguageCode,
  105. lang?: LanguageCode,
  106. ): Promise<Translated<Facet> | undefined> {
  107. const relations = ['values', 'values.facet'];
  108. const [repository, facetCode, languageCode] =
  109. ctxOrFacetCode instanceof RequestContext
  110. ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  111. [this.connection.getRepository(ctxOrFacetCode, Facet), facetCodeOrLang, lang!]
  112. : [
  113. this.connection.rawConnection.getRepository(Facet),
  114. ctxOrFacetCode,
  115. facetCodeOrLang as LanguageCode,
  116. ];
  117. // TODO: Implement usage of channelLanguageCode
  118. return repository
  119. .findOne({
  120. where: {
  121. code: facetCode,
  122. },
  123. relations,
  124. })
  125. .then(
  126. facet =>
  127. (facet && translateDeep(facet, languageCode, ['values', ['values', 'facet']])) ??
  128. undefined,
  129. );
  130. }
  131. /**
  132. * @description
  133. * Returns the Facet which contains the given FacetValue id.
  134. */
  135. async findByFacetValueId(ctx: RequestContext, id: ID): Promise<Translated<Facet> | undefined> {
  136. const facet = await this.connection
  137. .getRepository(ctx, Facet)
  138. .createQueryBuilder('facet')
  139. .leftJoinAndSelect('facet.translations', 'translations')
  140. .leftJoin('facet.values', 'facetValue')
  141. .where('facetValue.id = :id', { id })
  142. .getOne();
  143. if (facet) {
  144. return this.translator.translate(facet, ctx);
  145. }
  146. }
  147. async create(ctx: RequestContext, input: CreateFacetInput): Promise<Translated<Facet>> {
  148. const facet = await this.translatableSaver.create({
  149. ctx,
  150. input,
  151. entityType: Facet,
  152. translationType: FacetTranslation,
  153. beforeSave: async f => {
  154. f.code = await this.ensureUniqueCode(ctx, f.code);
  155. await this.channelService.assignToCurrentChannel(f, ctx);
  156. },
  157. });
  158. const facetWithRelations = await this.customFieldRelationService.updateRelations(
  159. ctx,
  160. Facet,
  161. input,
  162. facet,
  163. );
  164. await this.eventBus.publish(new FacetEvent(ctx, facetWithRelations, 'created', input));
  165. return assertFound(this.findOne(ctx, facet.id));
  166. }
  167. async update(ctx: RequestContext, input: UpdateFacetInput): Promise<Translated<Facet>> {
  168. const facet = await this.translatableSaver.update({
  169. ctx,
  170. input,
  171. entityType: Facet,
  172. translationType: FacetTranslation,
  173. beforeSave: async f => {
  174. if (f.code) {
  175. f.code = await this.ensureUniqueCode(ctx, f.code, f.id);
  176. }
  177. },
  178. });
  179. await this.customFieldRelationService.updateRelations(ctx, Facet, input, facet);
  180. await this.eventBus.publish(new FacetEvent(ctx, facet, 'updated', input));
  181. return assertFound(this.findOne(ctx, facet.id));
  182. }
  183. async delete(ctx: RequestContext, id: ID, force: boolean = false): Promise<DeletionResponse> {
  184. const facet = await this.connection.getEntityOrThrow(ctx, Facet, id, {
  185. relations: ['values'],
  186. channelId: ctx.channelId,
  187. });
  188. let productCount = 0;
  189. let variantCount = 0;
  190. if (facet.values.length) {
  191. const counts = await this.facetValueService.checkFacetValueUsage(
  192. ctx,
  193. facet.values.map(fv => fv.id),
  194. );
  195. productCount = counts.productCount;
  196. variantCount = counts.variantCount;
  197. }
  198. const isInUse = !!(productCount || variantCount);
  199. const both = !!(productCount && variantCount) ? 'both' : 'single';
  200. const i18nVars = { products: productCount, variants: variantCount, both, facetCode: facet.code };
  201. let message = '';
  202. let result: DeletionResult;
  203. const deletedFacet = new Facet(facet);
  204. if (!isInUse) {
  205. await this.connection.getRepository(ctx, Facet).remove(facet);
  206. await this.eventBus.publish(new FacetEvent(ctx, deletedFacet, 'deleted', id));
  207. result = DeletionResult.DELETED;
  208. } else if (force) {
  209. await this.connection.getRepository(ctx, Facet).remove(facet);
  210. await this.eventBus.publish(new FacetEvent(ctx, deletedFacet, 'deleted', id));
  211. message = ctx.translate('message.facet-force-deleted', i18nVars);
  212. result = DeletionResult.DELETED;
  213. } else {
  214. message = ctx.translate('message.facet-used', i18nVars);
  215. result = DeletionResult.NOT_DELETED;
  216. }
  217. return {
  218. result,
  219. message,
  220. };
  221. }
  222. /**
  223. * Checks to ensure the Facet code is unique. If there is a conflict, then the code is suffixed
  224. * with an incrementing integer.
  225. */
  226. private async ensureUniqueCode(ctx: RequestContext, code: string, id?: ID) {
  227. let candidate = code;
  228. let suffix = 1;
  229. let conflict = false;
  230. const alreadySuffixed = /-\d+$/;
  231. do {
  232. const match = await this.connection
  233. .getRepository(ctx, Facet)
  234. .findOne({ where: { code: candidate } });
  235. conflict = !!match && ((id != null && !idsAreEqual(match.id, id)) || id == null);
  236. if (conflict) {
  237. suffix++;
  238. if (alreadySuffixed.test(candidate)) {
  239. candidate = candidate.replace(alreadySuffixed, `-${suffix}`);
  240. } else {
  241. candidate = `${candidate}-${suffix}`;
  242. }
  243. }
  244. } while (conflict);
  245. return candidate;
  246. }
  247. /**
  248. * @description
  249. * Assigns Facets to the specified Channel
  250. */
  251. async assignFacetsToChannel(
  252. ctx: RequestContext,
  253. input: AssignFacetsToChannelInput,
  254. ): Promise<Array<Translated<Facet>>> {
  255. const hasPermission = await this.roleService.userHasAnyPermissionsOnChannel(ctx, input.channelId, [
  256. Permission.UpdateFacet,
  257. Permission.UpdateCatalog,
  258. ]);
  259. if (!hasPermission) {
  260. throw new ForbiddenError();
  261. }
  262. const facetsToAssign = await this.connection
  263. .getRepository(ctx, Facet)
  264. .find({ where: { id: In(input.facetIds) }, relations: ['values'] });
  265. const valuesToAssign = facetsToAssign.reduce(
  266. (values, facet) => [...values, ...facet.values],
  267. [] as FacetValue[],
  268. );
  269. await Promise.all<any>([
  270. ...facetsToAssign.map(async facet => {
  271. return this.channelService.assignToChannels(ctx, Facet, facet.id, [input.channelId]);
  272. }),
  273. ...valuesToAssign.map(async value =>
  274. this.channelService.assignToChannels(ctx, FacetValue, value.id, [input.channelId]),
  275. ),
  276. ]);
  277. return this.connection
  278. .findByIdsInChannel(
  279. ctx,
  280. Facet,
  281. facetsToAssign.map(f => f.id),
  282. ctx.channelId,
  283. {},
  284. )
  285. .then(facets => facets.map(facet => translateDeep(facet, ctx.languageCode)));
  286. }
  287. /**
  288. * @description
  289. * Remove Facets from the specified Channel
  290. */
  291. async removeFacetsFromChannel(
  292. ctx: RequestContext,
  293. input: RemoveFacetsFromChannelInput,
  294. ): Promise<Array<ErrorResultUnion<RemoveFacetFromChannelResult, Facet>>> {
  295. const hasPermission = await this.roleService.userHasAnyPermissionsOnChannel(ctx, input.channelId, [
  296. Permission.DeleteFacet,
  297. Permission.DeleteCatalog,
  298. ]);
  299. if (!hasPermission) {
  300. throw new ForbiddenError();
  301. }
  302. const defaultChannel = await this.channelService.getDefaultChannel(ctx);
  303. if (idsAreEqual(input.channelId, defaultChannel.id)) {
  304. throw new UserInputError('error.items-cannot-be-removed-from-default-channel');
  305. }
  306. const facetsToRemove = await this.connection
  307. .getRepository(ctx, Facet)
  308. .find({ where: { id: In(input.facetIds) }, relations: ['values'] });
  309. const results: Array<ErrorResultUnion<RemoveFacetFromChannelResult, Facet>> = [];
  310. for (const facet of facetsToRemove) {
  311. let productCount = 0;
  312. let variantCount = 0;
  313. if (facet.values.length) {
  314. const counts = await this.facetValueService.checkFacetValueUsage(
  315. ctx,
  316. facet.values.map(fv => fv.id),
  317. input.channelId,
  318. );
  319. productCount = counts.productCount;
  320. variantCount = counts.variantCount;
  321. const isInUse = !!(productCount || variantCount);
  322. const both = !!(productCount && variantCount) ? 'both' : 'single';
  323. const i18nVars = { products: productCount, variants: variantCount, both };
  324. let result: Translated<Facet> | undefined;
  325. if (!isInUse || input.force) {
  326. await this.channelService.removeFromChannels(ctx, Facet, facet.id, [input.channelId]);
  327. await Promise.all(
  328. facet.values.map(fv =>
  329. this.channelService.removeFromChannels(ctx, FacetValue, fv.id, [input.channelId]),
  330. ),
  331. );
  332. result = await this.findOne(ctx, facet.id);
  333. if (result) {
  334. results.push(result);
  335. }
  336. } else {
  337. results.push(new FacetInUseError({ facetCode: facet.code, productCount, variantCount }));
  338. }
  339. }
  340. }
  341. return results;
  342. }
  343. }