Parcourir la source

feat(server): Add Customer /Address update & delete mutations

Closes #67
Michael Bromley il y a 7 ans
Parent
commit
4a7ebcdba4

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
schema-admin.json


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
schema-shop.json


+ 30 - 43
schema.json

@@ -2254,22 +2254,6 @@
             "isDeprecated": false,
             "deprecationReason": null
           },
-          {
-            "name": "passwordHash",
-            "description": null,
-            "args": [],
-            "type": {
-              "kind": "NON_NULL",
-              "name": null,
-              "ofType": {
-                "kind": "SCALAR",
-                "name": "String",
-                "ofType": null
-              }
-            },
-            "isDeprecated": false,
-            "deprecationReason": null
-          },
           {
             "name": "verified",
             "description": null,
@@ -14441,22 +14425,39 @@
           },
           {
             "name": "createCustomerAddress",
-            "description": "Create a new Address and associate it with the Customer specified by customerId",
+            "description": "Create a new Customer Address",
             "args": [
               {
-                "name": "customerId",
+                "name": "input",
                 "description": null,
                 "type": {
                   "kind": "NON_NULL",
                   "name": null,
                   "ofType": {
-                    "kind": "SCALAR",
-                    "name": "ID",
+                    "kind": "INPUT_OBJECT",
+                    "name": "CreateAddressInput",
                     "ofType": null
                   }
                 },
                 "defaultValue": null
-              },
+              }
+            ],
+            "type": {
+              "kind": "NON_NULL",
+              "name": null,
+              "ofType": {
+                "kind": "OBJECT",
+                "name": "Address",
+                "ofType": null
+              }
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
+          },
+          {
+            "name": "updateCustomerAddress",
+            "description": "Update an existing Address",
+            "args": [
               {
                 "name": "input",
                 "description": null,
@@ -14465,7 +14466,7 @@
                   "name": null,
                   "ofType": {
                     "kind": "INPUT_OBJECT",
-                    "name": "CreateAddressInput",
+                    "name": "UpdateAddressInput",
                     "ofType": null
                   }
                 },
@@ -14485,18 +14486,18 @@
             "deprecationReason": null
           },
           {
-            "name": "updateCustomerAddress",
-            "description": "Update an existing Address",
+            "name": "deleteCustomerAddress",
+            "description": "Delete an existing Address",
             "args": [
               {
-                "name": "input",
+                "name": "id",
                 "description": null,
                 "type": {
                   "kind": "NON_NULL",
                   "name": null,
                   "ofType": {
-                    "kind": "INPUT_OBJECT",
-                    "name": "UpdateAddressInput",
+                    "kind": "SCALAR",
+                    "name": "ID",
                     "ofType": null
                   }
                 },
@@ -14507,8 +14508,8 @@
               "kind": "NON_NULL",
               "name": null,
               "ofType": {
-                "kind": "OBJECT",
-                "name": "Address",
+                "kind": "SCALAR",
+                "name": "Boolean",
                 "ofType": null
               }
             },
@@ -17041,20 +17042,6 @@
         "description": null,
         "fields": null,
         "inputFields": [
-          {
-            "name": "id",
-            "description": null,
-            "type": {
-              "kind": "NON_NULL",
-              "name": null,
-              "ofType": {
-                "kind": "SCALAR",
-                "name": "ID",
-                "ofType": null
-              }
-            },
-            "defaultValue": null
-          },
           {
             "name": "title",
             "description": null,

+ 25 - 0
server/e2e/customer.e2e-spec.ts

@@ -59,6 +59,7 @@ describe('Customer resolver', () => {
 
     describe('addresses', () => {
         let firstCustomerAddressIds: string[] = [];
+        let firstCustomerThirdAddressId: string;
 
         it(
             'createCustomerAddress throws on invalid countryCode',
@@ -219,6 +220,30 @@ describe('Customer resolver', () => {
             expect(result2.customer!.addresses![1].defaultBillingAddress).toBe(false);
             expect(result2.customer!.addresses![2].defaultShippingAddress).toBe(true);
             expect(result2.customer!.addresses![2].defaultBillingAddress).toBe(true);
+
+            firstCustomerThirdAddressId = result2.customer!.addresses![2].id;
+        });
+
+        it('deleteCustomerAddress on default address resets defaults', async () => {
+            const result = await adminClient.query(
+                gql`
+                    mutation DeleteCustomerAddress($id: ID!) {
+                        deleteCustomerAddress(id: $id)
+                    }
+                `,
+                { id: firstCustomerThirdAddressId },
+            );
+
+            expect(result.deleteCustomerAddress).toBe(true);
+
+            const result2 = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+                id: firstCustomer.id,
+            });
+            expect(result2.customer!.addresses!.length).toBe(2);
+            expect(result2.customer!.addresses![0].defaultShippingAddress).toBe(true);
+            expect(result2.customer!.addresses![0].defaultBillingAddress).toBe(true);
+            expect(result2.customer!.addresses![1].defaultShippingAddress).toBe(false);
+            expect(result2.customer!.addresses![1].defaultBillingAddress).toBe(false);
         });
     });
 

+ 204 - 0
server/e2e/shop-customer.e2e-spec.ts

@@ -0,0 +1,204 @@
+/* tslint:disable:no-non-null-assertion */
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { ATTEMPT_LOGIN } from '../../admin-ui/src/app/data/definitions/auth-definitions';
+import {
+    CUSTOMER_FRAGMENT,
+    GET_CUSTOMER,
+} from '../../admin-ui/src/app/data/definitions/customer-definitions';
+import {
+    CreateAddressInput,
+    UpdateAddressInput,
+    UpdateCustomerInput,
+} from '../../shared/generated-shop-types';
+import { AttemptLogin, GetCustomer } from '../../shared/generated-types';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { TestAdminClient, TestShopClient } from './test-client';
+import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './test-utils';
+
+describe('Shop customers', () => {
+    const shopClient = new TestShopClient();
+    const adminClient = new TestAdminClient();
+    const server = new TestServer();
+    let customer: GetCustomer.Customer;
+
+    beforeAll(async () => {
+        const token = await server.init({
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 2,
+        });
+        await shopClient.init();
+        await adminClient.init();
+
+        // Fetch the first Customer and store it as the `customer` variable.
+        const { customers } = await adminClient.query(gql`
+            query {
+                customers {
+                    items {
+                        id
+                    }
+                }
+            }
+        `);
+        const result = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
+            id: customers.items[0].id,
+        });
+        customer = result.customer!;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it(
+        'updateCustomer throws if not logged in',
+        assertThrowsWithMessage(async () => {
+            const input: UpdateCustomerInput = {
+                firstName: 'xyz',
+            };
+            await shopClient.query(UPDATE_CUSTOMER, { input });
+        }, 'You are not currently authorized to perform this action'),
+    );
+
+    it(
+        'createCustomerAddress throws if not logged in',
+        assertThrowsWithMessage(async () => {
+            const input: CreateAddressInput = {
+                streetLine1: '1 Test Street',
+                countryCode: 'GB',
+            };
+            await shopClient.query(CREATE_ADDRESS, { input });
+        }, 'You are not currently authorized to perform this action'),
+    );
+
+    it(
+        'updateCustomerAddress throws if not logged in',
+        assertThrowsWithMessage(async () => {
+            const input: UpdateAddressInput = {
+                id: 'T_1',
+                streetLine1: 'zxc',
+            };
+            await shopClient.query(UPDATE_ADDRESS, { input });
+        }, 'You are not currently authorized to perform this action'),
+    );
+
+    it(
+        'deleteCustomerAddress throws if not logged in',
+        assertThrowsWithMessage(async () => {
+            await shopClient.query(DELETE_ADDRESS, { id: 'T_1' });
+        }, 'You are not currently authorized to perform this action'),
+    );
+
+    describe('logged in Customer', () => {
+        let addressId: string;
+
+        beforeAll(async () => {
+            await shopClient.query<AttemptLogin.Mutation, AttemptLogin.Variables>(ATTEMPT_LOGIN, {
+                username: customer.emailAddress,
+                password: 'test',
+                rememberMe: false,
+            });
+        });
+
+        it('updateCustomer works', async () => {
+            const input: UpdateCustomerInput = {
+                firstName: 'xyz',
+            };
+            const result = await shopClient.query(UPDATE_CUSTOMER, { input });
+
+            expect(result.updateCustomer.firstName).toBe('xyz');
+        });
+
+        it('createCustomerAddress works', async () => {
+            const input: CreateAddressInput = {
+                streetLine1: '1 Test Street',
+                countryCode: 'GB',
+            };
+            const { createCustomerAddress } = await shopClient.query(CREATE_ADDRESS, { input });
+
+            expect(createCustomerAddress).toEqual({
+                id: 'T_3',
+                streetLine1: '1 Test Street',
+                country: {
+                    code: 'GB',
+                },
+            });
+            addressId = createCustomerAddress.id;
+        });
+
+        it('updateCustomerAddress works', async () => {
+            const input: UpdateAddressInput = {
+                id: addressId,
+                streetLine1: '5 Test Street',
+            };
+            const result = await shopClient.query(UPDATE_ADDRESS, { input });
+
+            expect(result.updateCustomerAddress.streetLine1).toEqual('5 Test Street');
+        });
+
+        it(
+            'updateCustomerAddress fails for address not owned by Customer',
+            assertThrowsWithMessage(async () => {
+                const input: UpdateAddressInput = {
+                    id: 'T_2',
+                    streetLine1: '1 Test Street',
+                };
+                await shopClient.query(UPDATE_ADDRESS, { input });
+            }, 'You are not currently authorized to perform this action'),
+        );
+
+        it('deleteCustomerAddress works', async () => {
+            const result = await shopClient.query(DELETE_ADDRESS, { id: 'T_3' });
+
+            expect(result.deleteCustomerAddress).toBe(true);
+        });
+
+        it(
+            'deleteCustomerAddress fails for address not owned by Customer',
+            assertThrowsWithMessage(async () => {
+                await shopClient.query(DELETE_ADDRESS, { id: 'T_2' });
+            }, 'You are not currently authorized to perform this action'),
+        );
+    });
+});
+
+const CREATE_ADDRESS = gql`
+    mutation CreateAddress($input: CreateAddressInput!) {
+        createCustomerAddress(input: $input) {
+            id
+            streetLine1
+            country {
+                code
+            }
+        }
+    }
+`;
+
+const UPDATE_ADDRESS = gql`
+    mutation UpdateAddress($input: UpdateAddressInput!) {
+        updateCustomerAddress(input: $input) {
+            streetLine1
+            country {
+                code
+            }
+        }
+    }
+`;
+
+const DELETE_ADDRESS = gql`
+    mutation DeleteAddress($id: ID!) {
+        deleteCustomerAddress(id: $id)
+    }
+`;
+
+const UPDATE_CUSTOMER = gql`
+    mutation UpdateCustomer($input: UpdateCustomerInput!) {
+        updateCustomer(input: $input) {
+            ...Customer
+        }
+    }
+    ${CUSTOMER_FRAGMENT}
+`;

+ 11 - 0
server/src/api/resolvers/admin/customer.resolver.ts

@@ -5,6 +5,7 @@ import {
     CreateCustomerMutationArgs,
     CustomerQueryArgs,
     CustomersQueryArgs,
+    DeleteCustomerAddressMutationArgs,
     DeleteCustomerMutationArgs,
     DeletionResponse,
     Permission,
@@ -72,6 +73,16 @@ export class CustomerResolver {
         return this.customerService.updateAddress(ctx, input);
     }
 
+    @Mutation()
+    @Allow(Permission.DeleteCustomer)
+    async deleteCustomerAddress(
+        @Ctx() ctx: RequestContext,
+        @Args() args: DeleteCustomerAddressMutationArgs,
+    ): Promise<boolean> {
+        const { id } = args;
+        return this.customerService.deleteAddress(id);
+    }
+
     @Mutation()
     @Allow(Permission.DeleteCustomer)
     async deleteCustomer(@Args() args: DeleteCustomerMutationArgs): Promise<DeletionResponse> {

+ 43 - 18
server/src/api/resolvers/shop/shop-customer.resolver.ts

@@ -1,13 +1,13 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 
-import { Decode } from '../..';
+import { DeleteCustomerAddressMutationArgs } from '../../../../../shared/generated-shop-types';
 import {
     CreateCustomerAddressMutationArgs,
     Permission,
     UpdateCustomerAddressMutationArgs,
     UpdateCustomerMutationArgs,
 } from '../../../../../shared/generated-types';
-import { UnauthorizedError } from '../../../common/error/errors';
+import { ForbiddenError, InternalServerError } from '../../../common/error/errors';
 import { idsAreEqual } from '../../../common/utils';
 import { Address, Customer } from '../../../entity';
 import { CustomerService } from '../../../service/services/customer.service';
@@ -30,20 +30,25 @@ export class ShopCustomerResolver {
 
     @Mutation()
     @Allow(Permission.Owner)
-    async updateCustomer(@Args() args: UpdateCustomerMutationArgs): Promise<Customer> {
-        // TODO: implement for owner
-        return null as any;
+    async updateCustomer(
+        @Ctx() ctx: RequestContext,
+        @Args() args: UpdateCustomerMutationArgs,
+    ): Promise<Customer> {
+        const customer = await this.getCustomerForOwner(ctx);
+        return this.customerService.update({
+            id: customer.id,
+            ...args.input,
+        });
     }
 
     @Mutation()
     @Allow(Permission.Owner)
-    @Decode('customerId')
     async createCustomerAddress(
         @Ctx() ctx: RequestContext,
         @Args() args: CreateCustomerAddressMutationArgs,
     ): Promise<Address> {
-        // TODO: implement for owner
-        return null as any;
+        const customer = await this.getCustomerForOwner(ctx);
+        return this.customerService.createAddress(ctx, customer.id as string, args.input);
     }
 
     @Mutation()
@@ -52,20 +57,40 @@ export class ShopCustomerResolver {
         @Ctx() ctx: RequestContext,
         @Args() args: UpdateCustomerAddressMutationArgs,
     ): Promise<Address> {
-        // TODO: implement for owner
-        return null as any;
+        const customer = await this.getCustomerForOwner(ctx);
+        const customerAddresses = await this.customerService.findAddressesByCustomerId(ctx, customer.id);
+        if (!customerAddresses.find(address => idsAreEqual(address.id, args.input.id))) {
+            throw new ForbiddenError();
+        }
+        return this.customerService.updateAddress(ctx, args.input);
+    }
+
+    @Mutation()
+    @Allow(Permission.Owner)
+    async deleteCustomerAddress(
+        @Ctx() ctx: RequestContext,
+        @Args() args: DeleteCustomerAddressMutationArgs,
+    ): Promise<boolean> {
+        const customer = await this.getCustomerForOwner(ctx);
+        const customerAddresses = await this.customerService.findAddressesByCustomerId(ctx, customer.id);
+        if (!customerAddresses.find(address => idsAreEqual(address.id, args.id))) {
+            throw new ForbiddenError();
+        }
+        return this.customerService.deleteAddress(args.id);
     }
 
     /**
-     * If the current request is authorized as the Owner, ensure that the userId matches that
-     * of the Customer data being requested.
+     * Returns the Customer entity associated with the current user.
      */
-    private checkOwnerPermissions(ctx: RequestContext, customer: Customer) {
-        if (ctx.authorizedAsOwnerOnly) {
-            const userId = customer.user && customer.user.id;
-            if (userId && !idsAreEqual(userId, ctx.activeUserId)) {
-                throw new UnauthorizedError();
-            }
+    private async getCustomerForOwner(ctx: RequestContext): Promise<Customer> {
+        const userId = ctx.activeUserId;
+        if (!userId) {
+            throw new ForbiddenError();
+        }
+        const customer = await this.customerService.findOneByUserId(userId);
+        if (!customer) {
+            throw new InternalServerError(`error.no-customer-found-for-current-user`);
         }
+        return customer;
     }
 }

+ 12 - 0
server/src/api/schema/admin-api/customer.api.graphql

@@ -18,6 +18,18 @@ type Mutation {
 
     "Update an existing Address"
     updateCustomerAddress(input: UpdateAddressInput!): Address!
+
+    "Update an existing Address"
+    deleteCustomerAddress(id: ID!): Boolean!
+}
+
+input UpdateCustomerInput {
+    id: ID!
+    title: String
+    firstName: String
+    lastName: String
+    phoneNumber: String
+    emailAddress: String
 }
 
 # generated by generateListOptions function

+ 0 - 9
server/src/api/schema/common/common-types.graphql

@@ -125,15 +125,6 @@ input CreateCustomerInput {
     emailAddress: String!
 }
 
-input UpdateCustomerInput {
-    id: ID!
-    title: String
-    firstName: String
-    lastName: String
-    phoneNumber: String
-    emailAddress: String
-}
-
 input CreateAddressInput {
     fullName: String
     company: String

+ 12 - 0
server/src/api/schema/shop-api/shop.api.graphql

@@ -33,8 +33,12 @@ type Mutation {
     registerCustomerAccount(input: RegisterCustomerInput!): Boolean!
     "Update an existing Customer"
     updateCustomer(input: UpdateCustomerInput!): Customer!
+    "Create a new Customer Address"
+    createCustomerAddress(input: CreateAddressInput!): Address!
     "Update an existing Address"
     updateCustomerAddress(input: UpdateAddressInput!): Address!
+    "Delete an existing Address"
+    deleteCustomerAddress(id: ID!): Boolean!
     "Verify a Customer email address with the token sent to that address. Only applicable if `authOptions.requireVerification` is set to true."
     verifyCustomerAccount(token: String!, password: String!): LoginResult!
 }
@@ -47,6 +51,14 @@ input RegisterCustomerInput {
     password: String
 }
 
+input UpdateCustomerInput {
+    title: String
+    firstName: String
+    lastName: String
+    phoneNumber: String
+    emailAddress: String
+}
+
 input PaymentInput {
     method: String!
     metadata: JSON!

+ 0 - 1
server/src/api/schema/type/user.type.graphql

@@ -3,7 +3,6 @@ type User implements Node {
     createdAt: DateTime!
     updatedAt: DateTime!
     identifier: String!
-    passwordHash: String!
     verified: Boolean!
     roles: [Role!]!
     lastLogin: String

+ 36 - 0
server/src/service/services/customer.service.ts

@@ -214,6 +214,13 @@ export class CustomerService {
         return updatedAddress;
     }
 
+    async deleteAddress(id: ID): Promise<boolean> {
+        const address = await getEntityOrThrow(this.connection, Address, id);
+        await this.reassignDefaultsForDeletedAddress(address);
+        await this.connection.getRepository(Address).remove(address);
+        return true;
+    }
+
     async softDelete(customerId: ID): Promise<DeletionResponse> {
         await getEntityOrThrow(this.connection, Customer, customerId);
         await this.connection.getRepository(Customer).update({ id: customerId }, { deletedAt: new Date() });
@@ -245,4 +252,33 @@ export class CustomerService {
             }
         }
     }
+
+    /**
+     * If a Customer Address is to be deleted, check if it is assigned as a default for shipping or
+     * billing. If so, attempt to transfer default status to one of the other addresses if there are
+     * any.
+     */
+    private async reassignDefaultsForDeletedAddress(addressToDelete: Address) {
+        if (!addressToDelete.defaultBillingAddress && !addressToDelete.defaultShippingAddress) {
+            return;
+        }
+        const result = await this.connection
+            .getRepository(Address)
+            .findOne(addressToDelete.id, { relations: ['customer', 'customer.addresses'] });
+        if (result) {
+            const customerAddresses = result.customer.addresses;
+            if (1 < customerAddresses.length) {
+                const otherAddresses = customerAddresses.filter(
+                    address => !idsAreEqual(address.id, addressToDelete.id),
+                );
+                if (addressToDelete.defaultShippingAddress) {
+                    otherAddresses[0].defaultShippingAddress = true;
+                }
+                if (addressToDelete.defaultBillingAddress) {
+                    otherAddresses[0].defaultBillingAddress = true;
+                }
+                await this.connection.getRepository(Address).save(otherAddresses[0]);
+            }
+        }
+    }
 }

+ 7 - 8
shared/generated-shop-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-02-22T10:57:55+01:00
+// Generated in 2019-02-25T17:30:36+01:00
 export type Maybe<T> = T | null;
 
 export interface OrderListOptions {
@@ -259,8 +259,6 @@ export interface RegisterCustomerInput {
 }
 
 export interface UpdateCustomerInput {
-    id: string;
-
     title?: Maybe<string>;
 
     firstName?: Maybe<string>;
@@ -1252,8 +1250,6 @@ export interface User extends Node {
 
     identifier: string;
 
-    passwordHash: string;
-
     verified: boolean;
 
     roles: Role[];
@@ -1509,7 +1505,7 @@ export interface Mutation {
     addPaymentToOrder?: Maybe<Order>;
 
     setCustomerForOrder?: Maybe<Order>;
-    /** Create a new Address and associate it with the Customer specified by customerId */
+    /** Create a new Customer Address */
     createCustomerAddress: Address;
 
     login: LoginResult;
@@ -1523,6 +1519,8 @@ export interface Mutation {
     updateCustomer: Customer;
     /** Update an existing Address */
     updateCustomerAddress: Address;
+    /** Delete an existing Address */
+    deleteCustomerAddress: boolean;
     /** Verify a Customer email address with the token sent to that address. Only applicable if `authOptions.requireVerification` is set to true. */
     verifyCustomerAccount: LoginResult;
 }
@@ -1742,8 +1740,6 @@ export interface SetCustomerForOrderMutationArgs {
     input: CreateCustomerInput;
 }
 export interface CreateCustomerAddressMutationArgs {
-    customerId: string;
-
     input: CreateAddressInput;
 }
 export interface LoginMutationArgs {
@@ -1765,6 +1761,9 @@ export interface UpdateCustomerMutationArgs {
 export interface UpdateCustomerAddressMutationArgs {
     input: UpdateAddressInput;
 }
+export interface DeleteCustomerAddressMutationArgs {
+    id: string;
+}
 export interface VerifyCustomerAccountMutationArgs {
     token: string;
 

+ 7 - 3
shared/generated-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-02-22T10:57:56+01:00
+// Generated in 2019-02-25T17:30:37+01:00
 export type Maybe<T> = T | null;
 
 
@@ -4563,8 +4563,6 @@ export interface User extends Node {
   
   identifier: string;
   
-  passwordHash: string;
-  
   verified: boolean;
   
   roles: Role[];
@@ -5550,6 +5548,8 @@ export interface Mutation {
   createCustomerAddress: Address;
   /** Update an existing Address */
   updateCustomerAddress: Address;
+  /** Update an existing Address */
+  deleteCustomerAddress: boolean;
   /** Create a new Facet */
   createFacet: Facet;
   /** Update an existing Facet */
@@ -5930,6 +5930,10 @@ export interface UpdateCustomerAddressMutationArgs {
   
   input: UpdateAddressInput;
 }
+export interface DeleteCustomerAddressMutationArgs {
+  
+  id: string;
+}
 export interface CreateFacetMutationArgs {
   
   input: CreateFacetInput;

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff