Browse Source

feat(core): Implement deleteAsset mutation

Relates to #285

BREAKING CHANGE: In order to accommodate Asset deletion, some non-destructive DB modifications have been made which will require a migration.
Michael Bromley 5 years ago
parent
commit
efa12ba425

+ 8 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1789,6 +1789,8 @@ export type Mutation = {
   createTaxRate: TaxRate;
   /** Create a new Zone */
   createZone: Zone;
+  /** Delete an Asset */
+  deleteAsset: DeletionResponse;
   /** Delete a Channel */
   deleteChannel: DeletionResponse;
   /** Delete a Collection and all of its descendants */
@@ -2029,6 +2031,12 @@ export type MutationCreateZoneArgs = {
 };
 
 
+export type MutationDeleteAssetArgs = {
+  id: Scalars['ID'];
+  force?: Maybe<Scalars['Boolean']>;
+};
+
+
 export type MutationDeleteChannelArgs = {
   id: Scalars['ID'];
 };

+ 7 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1743,6 +1743,8 @@ export type Mutation = {
     createAssets: Array<Asset>;
     /** Update an existing Asset */
     updateAsset: Asset;
+    /** Delete an Asset */
+    deleteAsset: DeletionResponse;
     login: LoginResult;
     logout: Scalars['Boolean'];
     /** Create a new Channel */
@@ -1896,6 +1898,11 @@ export type MutationUpdateAssetArgs = {
     input: UpdateAssetInput;
 };
 
+export type MutationDeleteAssetArgs = {
+    id: Scalars['ID'];
+    force?: Maybe<Scalars['Boolean']>;
+};
+
 export type MutationLoginArgs = {
     username: Scalars['String'];
     password: Scalars['String'];

+ 8 - 0
packages/common/src/generated-types.ts

@@ -1740,6 +1740,8 @@ export type Mutation = {
   createAssets: Array<Asset>;
   /** Update an existing Asset */
   updateAsset: Asset;
+  /** Delete an Asset */
+  deleteAsset: DeletionResponse;
   login: LoginResult;
   logout: Scalars['Boolean'];
   /** Create a new Channel */
@@ -1899,6 +1901,12 @@ export type MutationUpdateAssetArgs = {
 };
 
 
+export type MutationDeleteAssetArgs = {
+  id: Scalars['ID'];
+  force?: Maybe<Scalars['Boolean']>;
+};
+
+
 export type MutationLoginArgs = {
   username: Scalars['String'];
   password: Scalars['String'];

+ 99 - 5
packages/core/e2e/asset.e2e-spec.ts

@@ -1,3 +1,4 @@
+/* tslint:disable:no-non-null-assertion */
 import { omit } from '@vendure/common/lib/omit';
 import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
@@ -9,17 +10,26 @@ import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-conf
 import { ASSET_FRAGMENT } from './graphql/fragments';
 import {
     CreateAssets,
+    DeleteAsset,
+    DeletionResult,
     GetAsset,
     GetAssetList,
+    GetProductWithVariants,
     SortOrder,
     UpdateAsset,
 } from './graphql/generated-e2e-admin-types';
-import { GET_ASSET_LIST, UPDATE_ASSET } from './graphql/shared-definitions';
+import {
+    DELETE_ASSET,
+    GET_ASSET_LIST,
+    GET_PRODUCT_WITH_VARIANTS,
+    UPDATE_ASSET,
+} from './graphql/shared-definitions';
 
 describe('Asset resolver', () => {
     const { server, adminClient } = createTestEnvironment(testConfig);
 
     let firstAssetId: string;
+    let createdAssetId: string;
 
     beforeAll(async () => {
         await server.init({
@@ -47,7 +57,7 @@ describe('Asset resolver', () => {
         );
 
         expect(assets.totalItems).toBe(4);
-        expect(assets.items.map(a => omit(a, ['id']))).toEqual([
+        expect(assets.items.map((a) => omit(a, ['id']))).toEqual([
             {
                 fileSize: 1680,
                 mimeType: 'image/jpeg',
@@ -111,12 +121,12 @@ describe('Asset resolver', () => {
         const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
             mutation: CREATE_ASSETS,
             filePaths: filesToUpload,
-            mapVariables: filePaths => ({
-                input: filePaths.map(p => ({ file: null })),
+            mapVariables: (filePaths) => ({
+                input: filePaths.map((p) => ({ file: null })),
             }),
         });
 
-        expect(createAssets.map(a => omit(a, ['id'])).sort((a, b) => (a.name < b.name ? -1 : 1))).toEqual([
+        expect(createAssets.map((a) => omit(a, ['id'])).sort((a, b) => (a.name < b.name ? -1 : 1))).toEqual([
             {
                 fileSize: 1680,
                 focalPoint: null,
@@ -136,6 +146,8 @@ describe('Asset resolver', () => {
                 type: 'IMAGE',
             },
         ]);
+
+        createdAssetId = createAssets[0].id;
     });
 
     describe('updateAsset', () => {
@@ -187,6 +199,88 @@ describe('Asset resolver', () => {
             expect(updateAsset.focalPoint).toEqual(null);
         });
     });
+
+    describe('deleteAsset', () => {
+        let firstProduct: GetProductWithVariants.Product;
+
+        beforeAll(async () => {
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: 'T_1',
+            });
+
+            firstProduct = product!;
+        });
+
+        it('non-featured asset', async () => {
+            const { deleteAsset } = await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(
+                DELETE_ASSET,
+                {
+                    id: createdAssetId,
+                },
+            );
+
+            expect(deleteAsset.result).toBe(DeletionResult.DELETED);
+
+            const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+                id: createdAssetId,
+            });
+            expect(asset).toBeNull();
+        });
+
+        it('featured asset not deleted', async () => {
+            const { deleteAsset } = await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(
+                DELETE_ASSET,
+                {
+                    id: firstProduct.featuredAsset!.id,
+                },
+            );
+
+            expect(deleteAsset.result).toBe(DeletionResult.NOT_DELETED);
+            expect(deleteAsset.message).toContain(`The selected Asset is featured by 1 Product`);
+
+            const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+                id: firstAssetId,
+            });
+            expect(asset).not.toBeNull();
+        });
+
+        it('featured asset force deleted', async () => {
+            const { product: p1 } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: firstProduct.id,
+            });
+            expect(p1!.assets.length).toEqual(1);
+
+            const { deleteAsset } = await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(
+                DELETE_ASSET,
+                {
+                    id: firstProduct.featuredAsset!.id,
+                    force: true,
+                },
+            );
+
+            expect(deleteAsset.result).toBe(DeletionResult.DELETED);
+
+            const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+                id: firstAssetId,
+            });
+            expect(asset).not.toBeNull();
+
+            const { product } = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+            >(GET_PRODUCT_WITH_VARIANTS, {
+                id: firstProduct.id,
+            });
+            expect(product!.featuredAsset).toBeNull();
+            expect(product!.assets.length).toEqual(0);
+        });
+    });
 });
 
 export const GET_ASSET = gql`

+ 39 - 16
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -19,6 +19,7 @@ import {
     CreateCollection,
     CreateFacet,
     CurrencyCode,
+    DeleteAsset,
     DeleteProduct,
     DeleteProductVariant,
     LanguageCode,
@@ -41,6 +42,7 @@ import {
     CREATE_CHANNEL,
     CREATE_COLLECTION,
     CREATE_FACET,
+    DELETE_ASSET,
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
     REMOVE_PRODUCT_FROM_CHANNEL,
@@ -585,8 +587,8 @@ describe('Default search plugin', () => {
                 ]);
             });
 
-            it('updates index when asset focalPoint is changed', async () => {
-                function doSearch() {
+            describe('asset changes', () => {
+                function searchForLaptop() {
                     return adminClient.query<SearchGetAssets.Query, SearchGetAssets.Variables>(
                         SEARCH_GET_ASSETS,
                         {
@@ -597,27 +599,48 @@ describe('Default search plugin', () => {
                         },
                     );
                 }
-                const { search: search1 } = await doSearch();
 
-                expect(search1.items[0].productAsset!.id).toBe('T_1');
-                expect(search1.items[0].productAsset!.focalPoint).toBeNull();
+                it('updates index when asset focalPoint is changed', async () => {
+                    const { search: search1 } = await searchForLaptop();
 
-                await adminClient.query<UpdateAsset.Mutation, UpdateAsset.Variables>(UPDATE_ASSET, {
-                    input: {
-                        id: 'T_1',
-                        focalPoint: {
-                            x: 0.42,
-                            y: 0.42,
+                    expect(search1.items[0].productAsset!.id).toBe('T_1');
+                    expect(search1.items[0].productAsset!.focalPoint).toBeNull();
+
+                    await adminClient.query<UpdateAsset.Mutation, UpdateAsset.Variables>(UPDATE_ASSET, {
+                        input: {
+                            id: 'T_1',
+                            focalPoint: {
+                                x: 0.42,
+                                y: 0.42,
+                            },
                         },
-                    },
+                    });
+
+                    await awaitRunningJobs(adminClient);
+
+                    const { search: search2 } = await searchForLaptop();
+
+                    expect(search2.items[0].productAsset!.id).toBe('T_1');
+                    expect(search2.items[0].productAsset!.focalPoint).toEqual({ x: 0.42, y: 0.42 });
                 });
 
-                await awaitRunningJobs(adminClient);
+                it('updates index when asset deleted', async () => {
+                    const { search: search1 } = await searchForLaptop();
+
+                    const assetId = search1.items[0].productAsset?.id;
+                    expect(assetId).toBeTruthy();
 
-                const { search: search2 } = await doSearch();
+                    await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(DELETE_ASSET, {
+                        id: assetId!,
+                        force: true,
+                    });
 
-                expect(search2.items[0].productAsset!.id).toBe('T_1');
-                expect(search2.items[0].productAsset!.focalPoint).toEqual({ x: 0.42, y: 0.42 });
+                    await awaitRunningJobs(adminClient);
+
+                    const { search: search2 } = await searchForLaptop();
+
+                    expect(search2.items[0].productAsset).toBeNull();
+                });
             });
 
             it('does not include deleted ProductVariants in index', async () => {

+ 22 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1743,6 +1743,8 @@ export type Mutation = {
     createAssets: Array<Asset>;
     /** Update an existing Asset */
     updateAsset: Asset;
+    /** Delete an Asset */
+    deleteAsset: DeletionResponse;
     login: LoginResult;
     logout: Scalars['Boolean'];
     /** Create a new Channel */
@@ -1896,6 +1898,11 @@ export type MutationUpdateAssetArgs = {
     input: UpdateAssetInput;
 };
 
+export type MutationDeleteAssetArgs = {
+    id: Scalars['ID'];
+    force?: Maybe<Scalars['Boolean']>;
+};
+
 export type MutationLoginArgs = {
     username: Scalars['String'];
     password: Scalars['String'];
@@ -3565,6 +3572,15 @@ export type CreateAssetsMutation = { __typename?: 'Mutation' } & {
     >;
 };
 
+export type DeleteAssetMutationVariables = {
+    id: Scalars['ID'];
+    force?: Maybe<Scalars['Boolean']>;
+};
+
+export type DeleteAssetMutation = { __typename?: 'Mutation' } & {
+    deleteAsset: { __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result' | 'message'>;
+};
+
 export type CanCreateCustomerMutationVariables = {
     input: CreateCustomerInput;
 };
@@ -5458,6 +5474,12 @@ export namespace CreateAssets {
     export type FocalPoint = NonNullable<NonNullable<CreateAssetsMutation['createAssets'][0]>['focalPoint']>;
 }
 
+export namespace DeleteAsset {
+    export type Variables = DeleteAssetMutationVariables;
+    export type Mutation = DeleteAssetMutation;
+    export type DeleteAsset = DeleteAssetMutation['deleteAsset'];
+}
+
 export namespace CanCreateCustomer {
     export type Variables = CanCreateCustomerMutationVariables;
     export type Mutation = CanCreateCustomerMutation;

+ 9 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -355,3 +355,12 @@ export const UPDATE_ASSET = gql`
     }
     ${ASSET_FRAGMENT}
 `;
+
+export const DELETE_ASSET = gql`
+    mutation DeleteAsset($id: ID!, $force: Boolean) {
+        deleteAsset(id: $id, force: $force) {
+            result
+            message
+        }
+    }
+`;

+ 7 - 0
packages/core/src/api/resolvers/admin/asset.resolver.ts

@@ -1,6 +1,7 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     MutationCreateAssetsArgs,
+    MutationDeleteAssetArgs,
     MutationUpdateAssetArgs,
     Permission,
     QueryAssetArgs,
@@ -48,4 +49,10 @@ export class AssetResolver {
     async updateAsset(@Ctx() ctx: RequestContext, @Args() { input }: MutationUpdateAssetArgs) {
         return this.assetService.update(ctx, input);
     }
+
+    @Mutation()
+    @Allow(Permission.DeleteCatalog)
+    async deleteAsset(@Ctx() ctx: RequestContext, @Args() { id, force }: MutationDeleteAssetArgs) {
+        return this.assetService.delete(ctx, id, force || undefined);
+    }
 }

+ 2 - 0
packages/core/src/api/schema/admin-api/asset.api.graphql

@@ -10,6 +10,8 @@ type Mutation {
     createAssets(input: [CreateAssetInput!]!): [Asset!]!
     "Update an existing Asset"
     updateAsset(input: UpdateAssetInput!): Asset!
+    "Delete an Asset"
+    deleteAsset(id: ID!, force: Boolean): DeletionResponse!
 }
 
 # generated by generateListOptions function

+ 1 - 1
packages/core/src/entity/asset/orderable-asset.entity.ts

@@ -20,7 +20,7 @@ export abstract class OrderableAsset extends VendureEntity implements Orderable
     @Column()
     assetId: ID;
 
-    @ManyToOne(type => Asset, { eager: true })
+    @ManyToOne((type) => Asset, { eager: true, onDelete: 'CASCADE' })
     asset: Asset;
 
     @Column()

+ 8 - 8
packages/core/src/entity/product/product.entity.ts

@@ -41,29 +41,29 @@ export class Product extends VendureEntity
     @Column({ default: true })
     enabled: boolean;
 
-    @ManyToOne(type => Asset)
+    @ManyToOne((type) => Asset, { onDelete: 'SET NULL' })
     featuredAsset: Asset;
 
-    @OneToMany(type => ProductAsset, productAsset => productAsset.product)
+    @OneToMany((type) => ProductAsset, (productAsset) => productAsset.product, { onDelete: 'SET NULL' })
     assets: ProductAsset[];
 
-    @OneToMany(type => ProductTranslation, translation => translation.base, { eager: true })
+    @OneToMany((type) => ProductTranslation, (translation) => translation.base, { eager: true })
     translations: Array<Translation<Product>>;
 
-    @OneToMany(type => ProductVariant, variant => variant.product)
+    @OneToMany((type) => ProductVariant, (variant) => variant.product)
     variants: ProductVariant[];
 
-    @OneToMany(type => ProductOptionGroup, optionGroup => optionGroup.product)
+    @OneToMany((type) => ProductOptionGroup, (optionGroup) => optionGroup.product)
     optionGroups: ProductOptionGroup[];
 
-    @ManyToMany(type => FacetValue)
+    @ManyToMany((type) => FacetValue)
     @JoinTable()
     facetValues: FacetValue[];
 
-    @Column(type => CustomProductFields)
+    @Column((type) => CustomProductFields)
     customFields: CustomProductFields;
 
-    @ManyToMany(type => Channel)
+    @ManyToMany((type) => Channel)
     @JoinTable()
     channels: Channel[];
 }

+ 1 - 0
packages/core/src/i18n/messages/en.json

@@ -71,6 +71,7 @@
     "unexpected-password-on-registration": "Do not provide a password when `authOptions.requireVerification` is set to \"true\""
   },
   "message": {
+    "asset-to-be-deleted-is-featured": "The selected Asset is featured by {products, plural, =0 {} one {1 Product} other {# Products}}{variants, plural, =0 {} one { 1 ProductVariant} other { # ProductVariants}}{collections, plural, =0 {} one { 1 Collection} other { # Collections}}",
     "country-used-in-addresses": "The selected Country cannot be deleted as it is used in {count, plural, one {1 Address} other {# Addresses}}",
     "facet-force-deleted": "The Facet was deleted and its FacetValues were removed from {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",
     "facet-used": "The selected Facet includes FacetValues which are assigned to {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",

+ 3 - 0
packages/core/src/plugin/default-search-plugin/default-search-plugin.ts

@@ -87,6 +87,9 @@ export class DefaultSearchPlugin implements OnVendureBootstrap {
             if (event.type === 'updated') {
                 return this.searchIndexService.updateAsset(event.ctx, event.asset);
             }
+            if (event.type === 'deleted') {
+                return this.searchIndexService.deleteAsset(event.ctx, event.asset);
+            }
         });
         this.eventBus.ofType(ProductChannelEvent).subscribe((event) => {
             if (event.type === 'assigned') {

+ 16 - 1
packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts

@@ -4,7 +4,7 @@ import { InjectConnection } from '@nestjs/typeorm';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
-import { defer, Observable } from 'rxjs';
+import { Observable } from 'rxjs';
 import { Connection } from 'typeorm';
 import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
 
@@ -20,6 +20,7 @@ import { asyncObservable } from '../../../worker/async-observable';
 import { SearchIndexItem } from '../search-index-item.entity';
 import {
     AssignProductToChannelMessage,
+    DeleteAssetMessage,
     DeleteProductMessage,
     DeleteVariantMessage,
     ReindexMessage,
@@ -213,6 +214,20 @@ export class IndexerController {
         });
     }
 
+    @MessagePattern(DeleteAssetMessage.pattern)
+    deleteAsset(data: DeleteAssetMessage['data']): Observable<DeleteAssetMessage['response']> {
+        return asyncObservable(async () => {
+            const id = data.asset.id;
+            await this.connection
+                .getRepository(SearchIndexItem)
+                .update({ productAssetId: id }, { productAssetId: null });
+            await this.connection
+                .getRepository(SearchIndexItem)
+                .update({ productVariantAssetId: id }, { productVariantAssetId: null });
+            return true;
+        });
+    }
+
     private async updateProductInChannel(
         ctx: RequestContext,
         productId: ID,

+ 11 - 0
packages/core/src/plugin/default-search-plugin/indexer/search-index.service.ts

@@ -1,5 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { ID } from '@vendure/common/lib/shared-types';
+import { assertNever } from '@vendure/common/lib/shared-utils';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { Logger } from '../../../config/logger/vendure-logger';
@@ -13,6 +14,7 @@ import { WorkerMessage } from '../../../worker/types';
 import { WorkerService } from '../../../worker/worker.service';
 import {
     AssignProductToChannelMessage,
+    DeleteAssetMessage,
     DeleteProductMessage,
     DeleteVariantMessage,
     ReindexMessage,
@@ -63,12 +65,17 @@ export class SearchIndexService {
                     case 'update-asset':
                         this.sendMessage(job, new UpdateAssetMessage(data));
                         break;
+                    case 'delete-asset':
+                        this.sendMessage(job, new DeleteAssetMessage(data));
+                        break;
                     case 'assign-product-to-channel':
                         this.sendMessage(job, new AssignProductToChannelMessage(data));
                         break;
                     case 'remove-product-from-channel':
                         this.sendMessage(job, new RemoveProductFromChannelMessage(data));
                         break;
+                    default:
+                        assertNever(data);
                 }
             },
         });
@@ -104,6 +111,10 @@ export class SearchIndexService {
         this.addJobToQueue({ type: 'update-asset', ctx: ctx.serialize(), asset: asset as any });
     }
 
+    deleteAsset(ctx: RequestContext, asset: Asset) {
+        this.addJobToQueue({ type: 'delete-asset', ctx: ctx.serialize(), asset: asset as any });
+    }
+
     assignProductToChannel(ctx: RequestContext, productId: ID, channelId: ID) {
         this.addJobToQueue({
             type: 'assign-product-to-channel',

+ 5 - 0
packages/core/src/plugin/default-search-plugin/types.ts

@@ -70,6 +70,9 @@ export class RemoveProductFromChannelMessage extends WorkerMessage<ProductChanne
 export class UpdateAssetMessage extends WorkerMessage<UpdateAssetMessageData, boolean> {
     static readonly pattern = 'UpdateAsset';
 }
+export class DeleteAssetMessage extends WorkerMessage<UpdateAssetMessageData, boolean> {
+    static readonly pattern = 'DeleteAsset';
+}
 
 type NamedJobData<Type extends string, MessageData> = { type: Type } & MessageData;
 
@@ -80,6 +83,7 @@ type DeleteProductJobData = NamedJobData<'delete-product', UpdateProductMessageD
 type DeleteVariantJobData = NamedJobData<'delete-variant', UpdateVariantMessageData>;
 type UpdateVariantsByIdJobData = NamedJobData<'update-variants-by-id', UpdateVariantsByIdMessageData>;
 type UpdateAssetJobData = NamedJobData<'update-asset', UpdateAssetMessageData>;
+type DeleteAssetJobData = NamedJobData<'delete-asset', UpdateAssetMessageData>;
 type AssignProductToChannelJobData = NamedJobData<'assign-product-to-channel', ProductChannelMessageData>;
 type RemoveProductFromChannelJobData = NamedJobData<'remove-product-from-channel', ProductChannelMessageData>;
 export type UpdateIndexQueueJobData =
@@ -90,5 +94,6 @@ export type UpdateIndexQueueJobData =
     | DeleteVariantJobData
     | UpdateVariantsByIdJobData
     | UpdateAssetJobData
+    | DeleteAssetJobData
     | AssignProductToChannelJobData
     | RemoveProductFromChannelJobData;

+ 64 - 5
packages/core/src/service/services/asset.service.ts

@@ -1,6 +1,12 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import { AssetType, CreateAssetInput, UpdateAssetInput } from '@vendure/common/lib/generated-types';
+import {
+    AssetType,
+    CreateAssetInput,
+    DeletionResponse,
+    DeletionResult,
+    UpdateAssetInput,
+} from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList, Type } from '@vendure/common/lib/shared-types';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { ReadStream } from 'fs-extra';
@@ -10,7 +16,7 @@ import { Stream } from 'stream';
 import { Connection, Like } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
-import { InternalServerError, UserInputError } from '../../common/error/errors';
+import { InternalServerError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { getAssetType, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
@@ -18,6 +24,9 @@ import { Logger } from '../../config/logger/vendure-logger';
 import { Asset } from '../../entity/asset/asset.entity';
 import { OrderableAsset } from '../../entity/asset/orderable-asset.entity';
 import { VendureEntity } from '../../entity/base/base.entity';
+import { Collection } from '../../entity/collection/collection.entity';
+import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
+import { Product } from '../../entity/product/product.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { AssetEvent } from '../../event-bus/events/asset-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
@@ -94,7 +103,7 @@ export class AssetService {
                 });
             assets = (entityWithAssets && entityWithAssets.assets) || [];
         }
-        return assets.sort((a, b) => a.position - b.position).map(a => a.asset);
+        return assets.sort((a, b) => a.position - b.position).map((a) => a.asset);
     }
 
     async updateFeaturedAsset<T extends EntityWithAssets>(entity: T, input: EntityAssetInput): Promise<T> {
@@ -124,7 +133,7 @@ export class AssetService {
         if (assetIds && assetIds.length) {
             const assets = await this.connection.getRepository(Asset).findByIds(assetIds);
             const sortedAssets = assetIds
-                .map(id => assets.find(a => idsAreEqual(a.id, id)))
+                .map((id) => assets.find((a) => idsAreEqual(a.id, id)))
                 .filter(notNullOrUndefined);
             await this.removeExistingOrderableAssets(entity);
             entity.assets = await this.createOrderableAssets(entity, sortedAssets);
@@ -158,6 +167,30 @@ export class AssetService {
         return updatedAsset;
     }
 
+    async delete(ctx: RequestContext, id: ID, force: boolean = false): Promise<DeletionResponse> {
+        const asset = await getEntityOrThrow(this.connection, Asset, id);
+        const usages = await this.findAssetUsages(asset);
+        const hasUsages = !!(usages.products.length || usages.variants.length || usages.collections.length);
+        if (hasUsages && !force) {
+            return {
+                result: DeletionResult.NOT_DELETED,
+                message: ctx.translate('message.asset-to-be-deleted-is-featured', {
+                    products: usages.products.length,
+                    variants: usages.variants.length,
+                    collections: usages.collections.length,
+                }),
+            };
+        }
+        // Create a new asset so that the id is still available
+        // after deletion (the .remove() method sets it to undefined)
+        const deletedAsset = new Asset(asset);
+        await this.connection.getRepository(Asset).remove(asset);
+        this.eventBus.publish(new AssetEvent(ctx, deletedAsset, 'deleted'));
+        return {
+            result: DeletionResult.DELETED,
+        };
+    }
+
     /**
      * Create an Asset from a file stream created during data import.
      */
@@ -270,7 +303,7 @@ export class AssetService {
     private getOrderableAssetType(entity: EntityWithAssets): Type<OrderableAsset> {
         const assetRelation = this.connection
             .getRepository(entity.constructor)
-            .metadata.relations.find(r => r.propertyName === 'assets');
+            .metadata.relations.find((r) => r.propertyName === 'assets');
         if (!assetRelation || typeof assetRelation.type === 'string') {
             throw new InternalServerError('error.could-not-find-matching-orderable-asset');
         }
@@ -290,4 +323,30 @@ export class AssetService {
                 throw new InternalServerError('error.could-not-find-matching-orderable-asset');
         }
     }
+
+    /**
+     * Find the entities which reference the given Asset as a featuredAsset.
+     */
+    private async findAssetUsages(
+        asset: Asset,
+    ): Promise<{ products: Product[]; variants: ProductVariant[]; collections: Collection[] }> {
+        const products = await this.connection.getRepository(Product).find({
+            where: {
+                featuredAsset: asset,
+                deletedAt: null,
+            },
+        });
+        const variants = await this.connection.getRepository(ProductVariant).find({
+            where: {
+                featuredAsset: asset,
+                deletedAt: null,
+            },
+        });
+        const collections = await this.connection.getRepository(Collection).find({
+            where: {
+                featuredAsset: asset,
+            },
+        });
+        return { products, variants, collections };
+    }
 }

+ 7 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -1743,6 +1743,8 @@ export type Mutation = {
     createAssets: Array<Asset>;
     /** Update an existing Asset */
     updateAsset: Asset;
+    /** Delete an Asset */
+    deleteAsset: DeletionResponse;
     login: LoginResult;
     logout: Scalars['Boolean'];
     /** Create a new Channel */
@@ -1896,6 +1898,11 @@ export type MutationUpdateAssetArgs = {
     input: UpdateAssetInput;
 };
 
+export type MutationDeleteAssetArgs = {
+    id: Scalars['ID'];
+    force?: Maybe<Scalars['Boolean']>;
+};
+
 export type MutationLoginArgs = {
     username: Scalars['String'];
     password: Scalars['String'];

File diff suppressed because it is too large
+ 0 - 0
schema-admin.json


Some files were not shown because too many files changed in this diff