Browse Source

feat(server): Implement soft delete for Product entity

Relates to #21
Michael Bromley 7 years ago
parent
commit
a66bb29dcb

+ 120 - 12
server/e2e/product.e2e-spec.ts

@@ -1,3 +1,16 @@
+import gql from 'graphql-tag';
+
+import {
+    ADD_OPTION_GROUP_TO_PRODUCT,
+    CREATE_PRODUCT,
+    GENERATE_PRODUCT_VARIANTS,
+    GET_ASSET_LIST,
+    GET_PRODUCT_LIST,
+    GET_PRODUCT_WITH_VARIANTS,
+    REMOVE_OPTION_GROUP_FROM_PRODUCT,
+    UPDATE_PRODUCT,
+    UPDATE_PRODUCT_VARIANTS,
+} from '../../admin-ui/src/app/data/definitions/product-definitions';
 import {
     AddOptionGroupToProduct,
     CreateProduct,
@@ -14,18 +27,6 @@ import {
 } from '../../shared/generated-types';
 import { omit } from '../../shared/omit';
 
-import {
-    ADD_OPTION_GROUP_TO_PRODUCT,
-    CREATE_PRODUCT,
-    GENERATE_PRODUCT_VARIANTS,
-    GET_ASSET_LIST,
-    GET_PRODUCT_LIST,
-    GET_PRODUCT_WITH_VARIANTS,
-    REMOVE_OPTION_GROUP_FROM_PRODUCT,
-    UPDATE_PRODUCT,
-    UPDATE_PRODUCT_VARIANTS,
-} from '../../admin-ui/src/app/data/definitions/product-definitions';
-
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
@@ -594,4 +595,111 @@ describe('Product resolver', () => {
             });
         });
     });
+
+    describe('deletion', () => {
+        let allProducts: GetProductList.Items[];
+        let productToDelete: GetProductList.Items;
+
+        beforeAll(async () => {
+            const result = await client.query<GetProductList.Query>(GET_PRODUCT_LIST);
+            allProducts = result.products.items;
+        });
+
+        it('deletes a product', async () => {
+            productToDelete = allProducts[0];
+            const result = await client.query(DELETE_PRODUCT, { id: productToDelete.id });
+
+            expect(result.deleteProduct).toBe(true);
+        });
+
+        it('cannot get a deleted product', async () => {
+            const result = await client.query<GetProductWithVariants.Query, GetProductWithVariants.Variables>(
+                GET_PRODUCT_WITH_VARIANTS,
+                {
+                    id: productToDelete.id,
+                },
+            );
+
+            expect(result.product).toBe(null);
+        });
+
+        it('deleted product omitted from list', async () => {
+            const result = await client.query<GetProductList.Query>(GET_PRODUCT_LIST);
+
+            expect(result.products.items.length).toBe(allProducts.length - 1);
+            expect(result.products.items.map(c => c.id).includes(productToDelete.id)).toBe(false);
+        });
+
+        it('updateProduct throws for deleted product', async () => {
+            try {
+                await client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+                    input: {
+                        id: productToDelete.id,
+                        facetValueIds: ['T_1'],
+                    },
+                });
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining(`No Product with the id '1' could be found`),
+                );
+            }
+        });
+
+        it('addOptionGroupToProduct throws for deleted product', async () => {
+            try {
+                await client.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
+                    ADD_OPTION_GROUP_TO_PRODUCT,
+                    {
+                        optionGroupId: 'T_1',
+                        productId: productToDelete.id,
+                    },
+                );
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining(`No Product with the id '1' could be found`),
+                );
+            }
+        });
+
+        it('removeOptionGroupToProduct throws for deleted product', async () => {
+            try {
+                await client.query<
+                    RemoveOptionGroupFromProduct.Mutation,
+                    RemoveOptionGroupFromProduct.Variables
+                >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                    optionGroupId: 'T_1',
+                    productId: productToDelete.id,
+                });
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining(`No Product with the id '1' could be found`),
+                );
+            }
+        });
+
+        it('generateVariantsForProduct throws for deleted product', async () => {
+            try {
+                await client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
+                    GENERATE_PRODUCT_VARIANTS,
+                    {
+                        productId: productToDelete.id,
+                    },
+                );
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining(`No Product with the id '1' could be found`),
+                );
+            }
+        });
+    });
 });
+
+const DELETE_PRODUCT = gql`
+    mutation DeleteProduct($id: ID!) {
+        deleteProduct(id: $id)
+    }
+`;

+ 11 - 3
server/src/api/resolvers/product.resolver.ts

@@ -3,6 +3,7 @@ import { Args, Mutation, Parent, Query, ResolveProperty, Resolver } from '@nestj
 import {
     AddOptionGroupToProductMutationArgs,
     CreateProductMutationArgs,
+    DeleteProductMutationArgs,
     GenerateVariantsForProductMutationArgs,
     Permission,
     ProductQueryArgs,
@@ -11,9 +12,7 @@ import {
     UpdateProductMutationArgs,
     UpdateProductVariantsMutationArgs,
 } from '../../../../shared/generated-types';
-import { ID, PaginatedList } from '../../../../shared/shared-types';
-import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
-import { EntityNotFoundError } from '../../common/error/errors';
+import { PaginatedList } from '../../../../shared/shared-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound } from '../../common/utils';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
@@ -85,6 +84,15 @@ export class ProductResolver {
         return this.productService.update(ctx, input);
     }
 
+    @Mutation()
+    @Allow(Permission.DeleteCatalog)
+    async deleteProduct(
+        @Ctx() ctx: RequestContext,
+        @Args() args: DeleteProductMutationArgs,
+    ): Promise<boolean> {
+        return this.productService.softDelete(args.id);
+    }
+
     @Mutation()
     @Allow(Permission.UpdateCatalog)
     @Decode('productId', 'optionGroupId')

+ 3 - 0
server/src/api/types/product.api.graphql

@@ -10,6 +10,9 @@ type Mutation {
     "Update an existing Product"
     updateProduct(input: UpdateProductInput!): Product!
 
+    "Delete a Product"
+    deleteProduct(id: ID!): Boolean
+
     "Add an OptionGroup to a Product"
     addOptionGroupToProduct(productId: ID!, optionGroupId: ID!): Product!
 

+ 1 - 1
server/src/common/types/common-types.ts

@@ -14,7 +14,7 @@ export interface ChannelAware {
  * Entities which can be soft deleted should implement this interface.
  */
 export interface SoftDeletable {
-    deletedAt: Date;
+    deletedAt: Date | null;
 }
 
 /**

+ 2 - 2
server/src/entity/customer/customer.entity.ts

@@ -16,8 +16,8 @@ export class Customer extends VendureEntity implements HasCustomFields, SoftDele
         super(input);
     }
 
-    @Column({ nullable: true, default: null })
-    deletedAt: Date;
+    @Column({ type: Date, nullable: true, default: null })
+    deletedAt: Date | null;
 
     @Column({ nullable: true })
     title: string;

+ 7 - 2
server/src/entity/product/product.entity.ts

@@ -1,7 +1,7 @@
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { DeepPartial, HasCustomFields } from '../../../../shared/shared-types';
-import { ChannelAware } from '../../common/types/common-types';
+import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
@@ -14,10 +14,15 @@ import { ProductVariant } from '../product-variant/product-variant.entity';
 import { ProductTranslation } from './product-translation.entity';
 
 @Entity()
-export class Product extends VendureEntity implements Translatable, HasCustomFields, ChannelAware {
+export class Product extends VendureEntity
+    implements Translatable, HasCustomFields, ChannelAware, SoftDeletable {
     constructor(input?: DeepPartial<Product>) {
         super(input);
     }
+
+    @Column({ type: Date, nullable: true, default: null })
+    deletedAt: Date | null;
+
     name: LocaleString;
 
     slug: LocaleString;

+ 2 - 1
server/src/service/helpers/utils/get-entity-or-throw.ts

@@ -2,6 +2,7 @@ import { Connection, FindOneOptions } from 'typeorm';
 
 import { ID, Type } from '../../../../../shared/shared-types';
 import { EntityNotFoundError } from '../../../common/error/errors';
+import { SoftDeletable } from '../../../common/types/common-types';
 import { VendureEntity } from '../../../entity/base/base.entity';
 
 /**
@@ -14,7 +15,7 @@ export async function getEntityOrThrow<T extends VendureEntity>(
     findOptions?: FindOneOptions<T>,
 ): Promise<T> {
     const entity = await connection.getRepository(entityType).findOne(id, findOptions);
-    if (!entity) {
+    if (!entity || (entity.hasOwnProperty('deletedAt') && (entity as T & SoftDeletable).deletedAt !== null)) {
         throw new EntityNotFoundError(entityType.name as any, id);
     }
     return entity;

+ 1 - 5
server/src/service/services/customer.service.ts

@@ -37,8 +37,7 @@ export class CustomerService {
 
     findAll(options: ListQueryOptions<Customer> | undefined): Promise<PaginatedList<Customer>> {
         return this.listQueryBuilder
-            .build(Customer, options)
-            .andWhere('customer.deletedAt IS NULL')
+            .build(Customer, options, { where: { deletedAt: null } })
             .getManyAndCount()
             .then(([items, totalItems]) => ({ items, totalItems }));
     }
@@ -133,9 +132,6 @@ export class CustomerService {
 
     async update(input: UpdateCustomerInput): Promise<Customer> {
         const customer = await getEntityOrThrow(this.connection, Customer, input.id);
-        if (customer.deletedAt) {
-            throw new EntityNotFoundError('Customer', input.id);
-        }
         const updatedCustomer = patchEntity(customer, input);
         await this.connection.getRepository(Customer).save(customer);
         return assertFound(this.findOne(customer.id));

+ 1 - 0
server/src/service/services/product-variant.service.ts

@@ -163,6 +163,7 @@ export class ProductVariantService {
     ): Promise<Array<Translated<ProductVariant>>> {
         const product = await this.connection.getRepository(Product).findOne(productId, {
             relations: ['optionGroups', 'optionGroups.options'],
+            where: { deletedAt: null },
         });
 
         if (!product) {

+ 13 - 2
server/src/service/services/product.service.ts

@@ -17,6 +17,7 @@ import { CatalogModificationEvent } from '../../event-bus/events/catalog-modific
 import { AssetUpdater } from '../helpers/asset-updater/asset-updater';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
+import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { ChannelService } from './channel.service';
@@ -63,7 +64,7 @@ export class ProductService {
             .build(Product, options, {
                 relations: this.relations,
                 channelId: ctx.channelId,
-                where,
+                where: { ...where, deletedAt: null },
             })
             .getManyAndCount()
             .then(async ([products, totalItems]) => {
@@ -84,6 +85,9 @@ export class ProductService {
     async findOne(ctx: RequestContext, productId: ID): Promise<Translated<Product> | undefined> {
         const product = await this.connection.manager.findOne(Product, productId, {
             relations: this.relations,
+            where: {
+                deletedAt: null,
+            },
         });
         if (!product) {
             return;
@@ -112,6 +116,7 @@ export class ProductService {
     }
 
     async update(ctx: RequestContext, input: UpdateProductInput): Promise<Translated<Product>> {
+        await getEntityOrThrow(this.connection, Product, input.id);
         const product = await this.translatableSaver.update({
             input,
             entityType: Product,
@@ -127,6 +132,12 @@ export class ProductService {
         return assertFound(this.findOne(ctx, product.id));
     }
 
+    async softDelete(productId: ID): Promise<boolean> {
+        await getEntityOrThrow(this.connection, Product, productId);
+        await this.connection.getRepository(Product).update({ id: productId }, { deletedAt: new Date() });
+        return true;
+    }
+
     async addOptionGroupToProduct(
         ctx: RequestContext,
         productId: ID,
@@ -179,7 +190,7 @@ export class ProductService {
     private async getProductWithOptionGroups(productId: ID): Promise<Product> {
         const product = await this.connection
             .getRepository(Product)
-            .findOne(productId, { relations: ['optionGroups'] });
+            .findOne(productId, { relations: ['optionGroups'], where: { deletedAt: null } });
         if (!product) {
             throw new EntityNotFoundError('Product', productId);
         }