Преглед на файлове

feat(core): Implement Customer history tracking

Relates to #343

BREAKING CHANGE: A DB migration will be required due to some additions to the schema related to Customer history entries.
Michael Bromley преди 5 години
родител
ревизия
ccedf7c00b
променени са 28 файла, в които са добавени 821 реда и са изтрити 65 реда
  1. 31 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 5 5
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 29 0
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  4. 13 0
      packages/common/src/generated-shop-types.ts
  5. 31 0
      packages/common/src/generated-types.ts
  6. 9 0
      packages/common/src/simple-deep-clone.spec.ts
  7. 60 9
      packages/core/e2e/customer.e2e-spec.ts
  8. 75 0
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  9. 13 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  10. 18 0
      packages/core/e2e/graphql/shared-definitions.ts
  11. 5 9
      packages/core/e2e/order-promotion.e2e-spec.ts
  12. 96 16
      packages/core/e2e/shop-auth.e2e-spec.ts
  13. 83 2
      packages/core/e2e/shop-customer.e2e-spec.ts
  14. 9 1
      packages/core/src/api/resolvers/admin/customer.resolver.ts
  15. 13 2
      packages/core/src/api/resolvers/entity/customer-entity.resolver.ts
  16. 22 3
      packages/core/src/api/resolvers/shop/shop-auth.resolver.ts
  17. 4 4
      packages/core/src/api/resolvers/shop/shop-customer.resolver.ts
  18. 10 0
      packages/core/src/api/schema/admin-api/customer.api.graphql
  19. 13 0
      packages/core/src/api/schema/type/history-entry.type.graphql
  20. 2 0
      packages/core/src/entity/entities.ts
  21. 16 0
      packages/core/src/entity/history-entry/customer-history-entry.entity.ts
  22. 1 0
      packages/core/src/i18n/messages/en.json
  23. 23 0
      packages/core/src/service/helpers/utils/address-to-line.ts
  24. 132 12
      packages/core/src/service/services/customer.service.ts
  25. 79 2
      packages/core/src/service/services/history.service.ts
  26. 29 0
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  27. 0 0
      schema-admin.json
  28. 0 0
      schema-shop.json

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

@@ -14,6 +14,12 @@ export type Scalars = {
 };
 
 
+export type AddNoteToCustomerInput = {
+  id: Scalars['ID'];
+  note: Scalars['String'];
+  isPublic: Scalars['Boolean'];
+};
+
 export type AddNoteToOrderInput = {
   id: Scalars['ID'];
   note: Scalars['String'];
@@ -928,6 +934,7 @@ export type CurrentUserChannelInput = {
 export type Customer = Node & {
    __typename?: 'Customer';
   groups: Array<CustomerGroup>;
+  history: HistoryEntryList;
   id: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
@@ -943,6 +950,11 @@ export type Customer = Node & {
 };
 
 
+export type CustomerHistoryArgs = {
+  options?: Maybe<HistoryEntryListOptions>;
+};
+
+
 export type CustomerOrdersArgs = {
   options?: Maybe<OrderListOptions>;
 };
@@ -1269,6 +1281,19 @@ export type HistoryEntrySortParameter = {
 };
 
 export enum HistoryEntryType {
+  CUSTOMER_REGISTERED = 'CUSTOMER_REGISTERED',
+  CUSTOMER_VERIFIED = 'CUSTOMER_VERIFIED',
+  CUSTOMER_DETAIL_UPDATED = 'CUSTOMER_DETAIL_UPDATED',
+  CUSTOMER_ADDRESS_CREATED = 'CUSTOMER_ADDRESS_CREATED',
+  CUSTOMER_ADDRESS_UPDATED = 'CUSTOMER_ADDRESS_UPDATED',
+  CUSTOMER_ADDRESS_DELETED = 'CUSTOMER_ADDRESS_DELETED',
+  CUSTOMER_ORDER_PLACED = 'CUSTOMER_ORDER_PLACED',
+  CUSTOMER_PASSWORD_UPDATED = 'CUSTOMER_PASSWORD_UPDATED',
+  CUSTOMER_PASSWORD_RESET_REQUESTED = 'CUSTOMER_PASSWORD_RESET_REQUESTED',
+  CUSTOMER_PASSWORD_RESET_VERIFIED = 'CUSTOMER_PASSWORD_RESET_VERIFIED',
+  CUSTOMER_EMAIL_UPDATE_REQUESTED = 'CUSTOMER_EMAIL_UPDATE_REQUESTED',
+  CUSTOMER_EMAIL_UPDATE_VERIFIED = 'CUSTOMER_EMAIL_UPDATE_VERIFIED',
+  CUSTOMER_NOTE = 'CUSTOMER_NOTE',
   ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
   ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
   ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
@@ -1781,6 +1806,7 @@ export type Mutation = {
   addCustomersToGroup: CustomerGroup;
   /** Add members to a Zone */
   addMembersToZone: Zone;
+  addNoteToCustomer: Customer;
   addNoteToOrder: Order;
   /** Add an OptionGroup to a Product */
   addOptionGroupToProduct: Product;
@@ -1945,6 +1971,11 @@ export type MutationAddMembersToZoneArgs = {
 };
 
 
+export type MutationAddNoteToCustomerArgs = {
+  input: AddNoteToCustomerInput;
+};
+
+
 export type MutationAddNoteToOrderArgs = {
   input: AddNoteToOrderInput;
 };

+ 5 - 5
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -69,6 +69,9 @@ const result: IntrospectionResultData = {
                     {
                         name: 'CustomerGroup',
                     },
+                    {
+                        name: 'HistoryEntry',
+                    },
                     {
                         name: 'Address',
                     },
@@ -84,9 +87,6 @@ const result: IntrospectionResultData = {
                     {
                         name: 'ShippingMethod',
                     },
-                    {
-                        name: 'HistoryEntry',
-                    },
                     {
                         name: 'Cancellation',
                     },
@@ -139,10 +139,10 @@ const result: IntrospectionResultData = {
                         name: 'CustomerList',
                     },
                     {
-                        name: 'OrderList',
+                        name: 'HistoryEntryList',
                     },
                     {
-                        name: 'HistoryEntryList',
+                        name: 'OrderList',
                     },
                     {
                         name: 'CollectionList',

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

@@ -13,6 +13,12 @@ export type Scalars = {
     Upload: any;
 };
 
+export type AddNoteToCustomerInput = {
+    id: Scalars['ID'];
+    note: Scalars['String'];
+    isPublic: Scalars['Boolean'];
+};
+
 export type AddNoteToOrderInput = {
     id: Scalars['ID'];
     note: Scalars['String'];
@@ -920,6 +926,7 @@ export type CurrentUserChannel = {
 export type Customer = Node & {
     __typename?: 'Customer';
     groups: Array<CustomerGroup>;
+    history: HistoryEntryList;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -934,6 +941,10 @@ export type Customer = Node & {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CustomerHistoryArgs = {
+    options?: Maybe<HistoryEntryListOptions>;
+};
+
 export type CustomerOrdersArgs = {
     options?: Maybe<OrderListOptions>;
 };
@@ -1264,6 +1275,19 @@ export type HistoryEntrySortParameter = {
 };
 
 export enum HistoryEntryType {
+    CUSTOMER_REGISTERED = 'CUSTOMER_REGISTERED',
+    CUSTOMER_VERIFIED = 'CUSTOMER_VERIFIED',
+    CUSTOMER_DETAIL_UPDATED = 'CUSTOMER_DETAIL_UPDATED',
+    CUSTOMER_ADDRESS_CREATED = 'CUSTOMER_ADDRESS_CREATED',
+    CUSTOMER_ADDRESS_UPDATED = 'CUSTOMER_ADDRESS_UPDATED',
+    CUSTOMER_ADDRESS_DELETED = 'CUSTOMER_ADDRESS_DELETED',
+    CUSTOMER_ORDER_PLACED = 'CUSTOMER_ORDER_PLACED',
+    CUSTOMER_PASSWORD_UPDATED = 'CUSTOMER_PASSWORD_UPDATED',
+    CUSTOMER_PASSWORD_RESET_REQUESTED = 'CUSTOMER_PASSWORD_RESET_REQUESTED',
+    CUSTOMER_PASSWORD_RESET_VERIFIED = 'CUSTOMER_PASSWORD_RESET_VERIFIED',
+    CUSTOMER_EMAIL_UPDATE_REQUESTED = 'CUSTOMER_EMAIL_UPDATE_REQUESTED',
+    CUSTOMER_EMAIL_UPDATE_VERIFIED = 'CUSTOMER_EMAIL_UPDATE_VERIFIED',
+    CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
     ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
@@ -1827,6 +1851,7 @@ export type Mutation = {
     updateCustomerAddress: Address;
     /** Update an existing Address */
     deleteCustomerAddress: Scalars['Boolean'];
+    addNoteToCustomer: Customer;
     /** Create a new Facet */
     createFacet: Facet;
     /** Update an existing Facet */
@@ -2039,6 +2064,10 @@ export type MutationDeleteCustomerAddressArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationAddNoteToCustomerArgs = {
+    input: AddNoteToCustomerInput;
+};
+
 export type MutationCreateFacetArgs = {
     input: CreateFacetInput;
 };

+ 13 - 0
packages/common/src/generated-shop-types.ts

@@ -916,6 +916,19 @@ export type HistoryEntrySortParameter = {
 };
 
 export enum HistoryEntryType {
+    CUSTOMER_REGISTERED = 'CUSTOMER_REGISTERED',
+    CUSTOMER_VERIFIED = 'CUSTOMER_VERIFIED',
+    CUSTOMER_DETAIL_UPDATED = 'CUSTOMER_DETAIL_UPDATED',
+    CUSTOMER_ADDRESS_CREATED = 'CUSTOMER_ADDRESS_CREATED',
+    CUSTOMER_ADDRESS_UPDATED = 'CUSTOMER_ADDRESS_UPDATED',
+    CUSTOMER_ADDRESS_DELETED = 'CUSTOMER_ADDRESS_DELETED',
+    CUSTOMER_ORDER_PLACED = 'CUSTOMER_ORDER_PLACED',
+    CUSTOMER_PASSWORD_UPDATED = 'CUSTOMER_PASSWORD_UPDATED',
+    CUSTOMER_PASSWORD_RESET_REQUESTED = 'CUSTOMER_PASSWORD_RESET_REQUESTED',
+    CUSTOMER_PASSWORD_RESET_VERIFIED = 'CUSTOMER_PASSWORD_RESET_VERIFIED',
+    CUSTOMER_EMAIL_UPDATE_REQUESTED = 'CUSTOMER_EMAIL_UPDATE_REQUESTED',
+    CUSTOMER_EMAIL_UPDATE_VERIFIED = 'CUSTOMER_EMAIL_UPDATE_VERIFIED',
+    CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
     ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',

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

@@ -13,6 +13,12 @@ export type Scalars = {
   Upload: any;
 };
 
+export type AddNoteToCustomerInput = {
+  id: Scalars['ID'];
+  note: Scalars['String'];
+  isPublic: Scalars['Boolean'];
+};
+
 export type AddNoteToOrderInput = {
   id: Scalars['ID'];
   note: Scalars['String'];
@@ -920,6 +926,7 @@ export type CurrentUserChannel = {
 export type Customer = Node & {
    __typename?: 'Customer';
   groups: Array<CustomerGroup>;
+  history: HistoryEntryList;
   id: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
@@ -935,6 +942,11 @@ export type Customer = Node & {
 };
 
 
+export type CustomerHistoryArgs = {
+  options?: Maybe<HistoryEntryListOptions>;
+};
+
+
 export type CustomerOrdersArgs = {
   options?: Maybe<OrderListOptions>;
 };
@@ -1261,6 +1273,19 @@ export type HistoryEntrySortParameter = {
 };
 
 export enum HistoryEntryType {
+  CUSTOMER_REGISTERED = 'CUSTOMER_REGISTERED',
+  CUSTOMER_VERIFIED = 'CUSTOMER_VERIFIED',
+  CUSTOMER_DETAIL_UPDATED = 'CUSTOMER_DETAIL_UPDATED',
+  CUSTOMER_ADDRESS_CREATED = 'CUSTOMER_ADDRESS_CREATED',
+  CUSTOMER_ADDRESS_UPDATED = 'CUSTOMER_ADDRESS_UPDATED',
+  CUSTOMER_ADDRESS_DELETED = 'CUSTOMER_ADDRESS_DELETED',
+  CUSTOMER_ORDER_PLACED = 'CUSTOMER_ORDER_PLACED',
+  CUSTOMER_PASSWORD_UPDATED = 'CUSTOMER_PASSWORD_UPDATED',
+  CUSTOMER_PASSWORD_RESET_REQUESTED = 'CUSTOMER_PASSWORD_RESET_REQUESTED',
+  CUSTOMER_PASSWORD_RESET_VERIFIED = 'CUSTOMER_PASSWORD_RESET_VERIFIED',
+  CUSTOMER_EMAIL_UPDATE_REQUESTED = 'CUSTOMER_EMAIL_UPDATE_REQUESTED',
+  CUSTOMER_EMAIL_UPDATE_VERIFIED = 'CUSTOMER_EMAIL_UPDATE_VERIFIED',
+  CUSTOMER_NOTE = 'CUSTOMER_NOTE',
   ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
   ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
   ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
@@ -1825,6 +1850,7 @@ export type Mutation = {
   updateCustomerAddress: Address;
   /** Update an existing Address */
   deleteCustomerAddress: Scalars['Boolean'];
+  addNoteToCustomer: Customer;
   /** Create a new Facet */
   createFacet: Facet;
   /** Update an existing Facet */
@@ -2066,6 +2092,11 @@ export type MutationDeleteCustomerAddressArgs = {
 };
 
 
+export type MutationAddNoteToCustomerArgs = {
+  input: AddNoteToCustomerInput;
+};
+
+
 export type MutationCreateFacetArgs = {
   input: CreateFacetInput;
 };

+ 9 - 0
packages/common/src/simple-deep-clone.spec.ts

@@ -67,6 +67,15 @@ describe('simpleDeepClone()', () => {
         expect(result).not.toBe(target);
         expect(result.a).toBe(target.a);
     });
+
+    it('clone does not share references with original', () => {
+        const original = { user: { name: 'mike' } };
+        const clone = simpleDeepClone(original);
+
+        original.user.name = 'pete';
+
+        expect(clone.user.name).toEqual('mike');
+    });
 });
 
 class Foo {}

+ 60 - 9
packages/core/e2e/customer.e2e-spec.ts

@@ -1,5 +1,7 @@
 import { OnModuleInit } from '@nestjs/common';
+import { HistoryEntryType } from '@vendure/common/lib/generated-types';
 import { omit } from '@vendure/common/lib/omit';
+import { pick } from '@vendure/common/lib/pick';
 import {
     AccountRegistrationEvent,
     EventBus,
@@ -12,16 +14,18 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { CUSTOMER_FRAGMENT } from './graphql/fragments';
 import {
+    AddNoteToCustomer,
     CreateAddress,
     CreateCustomer,
     DeleteCustomer,
     DeleteCustomerAddress,
     DeletionResult,
     GetCustomer,
+    GetCustomerHistory,
     GetCustomerList,
     GetCustomerOrders,
     GetCustomerWithUser,
@@ -29,7 +33,7 @@ import {
     UpdateCustomer,
 } from './graphql/generated-e2e-admin-types';
 import { AddItemToOrder } from './graphql/generated-e2e-shop-types';
-import { GET_CUSTOMER, GET_CUSTOMER_LIST } from './graphql/shared-definitions';
+import { GET_CUSTOMER, GET_CUSTOMER_HISTORY, GET_CUSTOMER_LIST } from './graphql/shared-definitions';
 import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
@@ -46,7 +50,7 @@ let sendEmailFn: jest.Mock;
 class TestEmailPlugin implements OnModuleInit {
     constructor(private eventBus: EventBus) {}
     onModuleInit() {
-        this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
+        this.eventBus.ofType(AccountRegistrationEvent).subscribe((event) => {
             sendEmailFn(event);
         });
     }
@@ -164,7 +168,7 @@ describe('Customer resolver', () => {
             });
 
             expect(result.customer!.addresses!.length).toBe(2);
-            firstCustomerAddressIds = result.customer!.addresses!.map(a => a.id).sort();
+            firstCustomerAddressIds = result.customer!.addresses!.map((a) => a.id).sort();
         });
 
         it('updateCustomerAddress updates the country', async () => {
@@ -203,7 +207,7 @@ describe('Customer resolver', () => {
                 id: firstCustomer.id,
             });
             const otherAddress = result2.customer!.addresses!.filter(
-                a => a.id !== firstCustomerAddressIds[1],
+                (a) => a.id !== firstCustomerAddressIds[1],
             )[0]!;
             expect(otherAddress.defaultShippingAddress).toBe(false);
             expect(otherAddress.defaultBillingAddress).toBe(false);
@@ -227,7 +231,7 @@ describe('Customer resolver', () => {
                 id: firstCustomer.id,
             });
             const otherAddress2 = result4.customer!.addresses!.filter(
-                a => a.id !== firstCustomerAddressIds[0],
+                (a) => a.id !== firstCustomerAddressIds[0],
             )[0]!;
             expect(otherAddress2.defaultShippingAddress).toBe(false);
             expect(otherAddress2.defaultBillingAddress).toBe(false);
@@ -330,10 +334,10 @@ describe('Customer resolver', () => {
             );
             expect(customer!.addresses!.length).toBe(2);
             const defaultAddress = customer!.addresses!.filter(
-                a => a.defaultBillingAddress && a.defaultShippingAddress,
+                (a) => a.defaultBillingAddress && a.defaultShippingAddress,
             );
             const otherAddress = customer!.addresses!.filter(
-                a => !a.defaultBillingAddress && !a.defaultShippingAddress,
+                (a) => !a.defaultBillingAddress && !a.defaultShippingAddress,
             );
             expect(defaultAddress.length).toBe(1);
             expect(otherAddress.length).toBe(1);
@@ -442,7 +446,7 @@ describe('Customer resolver', () => {
                 GET_CUSTOMER_LIST,
             );
 
-            expect(result.customers.items.map(c => c.id).includes(thirdCustomer.id)).toBe(false);
+            expect(result.customers.items.map((c) => c.id).includes(thirdCustomer.id)).toBe(false);
         });
 
         it(
@@ -491,6 +495,44 @@ describe('Customer resolver', () => {
             expect(createCustomer.user?.identifier).toBe(thirdCustomer.emailAddress);
         });
     });
+
+    describe('customer notes', () => {
+        it('addNoteToCustomer', async () => {
+            const { addNoteToCustomer } = await adminClient.query<
+                AddNoteToCustomer.Mutation,
+                AddNoteToCustomer.Variables
+            >(ADD_NOTE_TO_CUSTOMER, {
+                input: {
+                    id: firstCustomer.id,
+                    isPublic: false,
+                    note: 'Test note',
+                },
+            });
+
+            const { customer } = await adminClient.query<
+                GetCustomerHistory.Query,
+                GetCustomerHistory.Variables
+            >(GET_CUSTOMER_HISTORY, {
+                id: firstCustomer.id,
+                options: {
+                    filter: {
+                        type: {
+                            eq: HistoryEntryType.CUSTOMER_NOTE,
+                        },
+                    },
+                },
+            });
+
+            expect(customer?.history.items.map(pick(['type', 'data']))).toEqual([
+                {
+                    type: HistoryEntryType.CUSTOMER_NOTE,
+                    data: {
+                        note: 'Test note',
+                    },
+                },
+            ]);
+        });
+    });
 });
 
 const GET_CUSTOMER_WITH_USER = gql`
@@ -580,3 +622,12 @@ const DELETE_CUSTOMER = gql`
         }
     }
 `;
+
+const ADD_NOTE_TO_CUSTOMER = gql`
+    mutation AddNoteToCustomer($input: AddNoteToCustomerInput!) {
+        addNoteToCustomer(input: $input) {
+            ...Customer
+        }
+    }
+    ${CUSTOMER_FRAGMENT}
+`;

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

@@ -13,6 +13,12 @@ export type Scalars = {
     Upload: any;
 };
 
+export type AddNoteToCustomerInput = {
+    id: Scalars['ID'];
+    note: Scalars['String'];
+    isPublic: Scalars['Boolean'];
+};
+
 export type AddNoteToOrderInput = {
     id: Scalars['ID'];
     note: Scalars['String'];
@@ -920,6 +926,7 @@ export type CurrentUserChannel = {
 export type Customer = Node & {
     __typename?: 'Customer';
     groups: Array<CustomerGroup>;
+    history: HistoryEntryList;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -934,6 +941,10 @@ export type Customer = Node & {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CustomerHistoryArgs = {
+    options?: Maybe<HistoryEntryListOptions>;
+};
+
 export type CustomerOrdersArgs = {
     options?: Maybe<OrderListOptions>;
 };
@@ -1264,6 +1275,19 @@ export type HistoryEntrySortParameter = {
 };
 
 export enum HistoryEntryType {
+    CUSTOMER_REGISTERED = 'CUSTOMER_REGISTERED',
+    CUSTOMER_VERIFIED = 'CUSTOMER_VERIFIED',
+    CUSTOMER_DETAIL_UPDATED = 'CUSTOMER_DETAIL_UPDATED',
+    CUSTOMER_ADDRESS_CREATED = 'CUSTOMER_ADDRESS_CREATED',
+    CUSTOMER_ADDRESS_UPDATED = 'CUSTOMER_ADDRESS_UPDATED',
+    CUSTOMER_ADDRESS_DELETED = 'CUSTOMER_ADDRESS_DELETED',
+    CUSTOMER_ORDER_PLACED = 'CUSTOMER_ORDER_PLACED',
+    CUSTOMER_PASSWORD_UPDATED = 'CUSTOMER_PASSWORD_UPDATED',
+    CUSTOMER_PASSWORD_RESET_REQUESTED = 'CUSTOMER_PASSWORD_RESET_REQUESTED',
+    CUSTOMER_PASSWORD_RESET_VERIFIED = 'CUSTOMER_PASSWORD_RESET_VERIFIED',
+    CUSTOMER_EMAIL_UPDATE_REQUESTED = 'CUSTOMER_EMAIL_UPDATE_REQUESTED',
+    CUSTOMER_EMAIL_UPDATE_VERIFIED = 'CUSTOMER_EMAIL_UPDATE_VERIFIED',
+    CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
     ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
@@ -1827,6 +1851,7 @@ export type Mutation = {
     updateCustomerAddress: Address;
     /** Update an existing Address */
     deleteCustomerAddress: Scalars['Boolean'];
+    addNoteToCustomer: Customer;
     /** Create a new Facet */
     createFacet: Facet;
     /** Update an existing Facet */
@@ -2039,6 +2064,10 @@ export type MutationDeleteCustomerAddressArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationAddNoteToCustomerArgs = {
+    input: AddNoteToCustomerInput;
+};
+
 export type MutationCreateFacetArgs = {
     input: CreateFacetInput;
 };
@@ -4013,6 +4042,14 @@ export type DeleteCustomerMutation = { __typename?: 'Mutation' } & {
     deleteCustomer: { __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result'>;
 };
 
+export type AddNoteToCustomerMutationVariables = {
+    input: AddNoteToCustomerInput;
+};
+
+export type AddNoteToCustomerMutation = { __typename?: 'Mutation' } & {
+    addNoteToCustomer: { __typename?: 'Customer' } & CustomerFragment;
+};
+
 export type ReindexMutationVariables = {};
 
 export type ReindexMutation = { __typename?: 'Mutation' } & {
@@ -4841,6 +4878,27 @@ export type UpdateChannelMutation = { __typename?: 'Mutation' } & {
     >;
 };
 
+export type GetCustomerHistoryQueryVariables = {
+    id: Scalars['ID'];
+    options?: Maybe<HistoryEntryListOptions>;
+};
+
+export type GetCustomerHistoryQuery = { __typename?: 'Query' } & {
+    customer?: Maybe<
+        { __typename?: 'Customer' } & Pick<Customer, 'id'> & {
+                history: { __typename?: 'HistoryEntryList' } & {
+                    items: Array<
+                        { __typename?: 'HistoryEntry' } & Pick<HistoryEntry, 'id' | 'type' | 'data'> & {
+                                administrator?: Maybe<
+                                    { __typename?: 'Administrator' } & Pick<Administrator, 'id'>
+                                >;
+                            }
+                    >;
+                };
+            }
+    >;
+};
+
 export type UpdateOptionGroupMutationVariables = {
     input: UpdateProductOptionGroupInput;
 };
@@ -5885,6 +5943,12 @@ export namespace DeleteCustomer {
     export type DeleteCustomer = DeleteCustomerMutation['deleteCustomer'];
 }
 
+export namespace AddNoteToCustomer {
+    export type Variables = AddNoteToCustomerMutationVariables;
+    export type Mutation = AddNoteToCustomerMutation;
+    export type AddNoteToCustomer = CustomerFragment;
+}
+
 export namespace Reindex {
     export type Variables = ReindexMutationVariables;
     export type Mutation = ReindexMutation;
@@ -6423,6 +6487,17 @@ export namespace UpdateChannel {
     export type UpdateChannel = UpdateChannelMutation['updateChannel'];
 }
 
+export namespace GetCustomerHistory {
+    export type Variables = GetCustomerHistoryQueryVariables;
+    export type Query = GetCustomerHistoryQuery;
+    export type Customer = NonNullable<GetCustomerHistoryQuery['customer']>;
+    export type History = NonNullable<GetCustomerHistoryQuery['customer']>['history'];
+    export type Items = NonNullable<NonNullable<GetCustomerHistoryQuery['customer']>['history']['items'][0]>;
+    export type Administrator = NonNullable<
+        NonNullable<NonNullable<GetCustomerHistoryQuery['customer']>['history']['items'][0]>['administrator']
+    >;
+}
+
 export namespace UpdateOptionGroup {
     export type Variables = UpdateOptionGroupMutationVariables;
     export type Mutation = UpdateOptionGroupMutation;

+ 13 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -916,6 +916,19 @@ export type HistoryEntrySortParameter = {
 };
 
 export enum HistoryEntryType {
+    CUSTOMER_REGISTERED = 'CUSTOMER_REGISTERED',
+    CUSTOMER_VERIFIED = 'CUSTOMER_VERIFIED',
+    CUSTOMER_DETAIL_UPDATED = 'CUSTOMER_DETAIL_UPDATED',
+    CUSTOMER_ADDRESS_CREATED = 'CUSTOMER_ADDRESS_CREATED',
+    CUSTOMER_ADDRESS_UPDATED = 'CUSTOMER_ADDRESS_UPDATED',
+    CUSTOMER_ADDRESS_DELETED = 'CUSTOMER_ADDRESS_DELETED',
+    CUSTOMER_ORDER_PLACED = 'CUSTOMER_ORDER_PLACED',
+    CUSTOMER_PASSWORD_UPDATED = 'CUSTOMER_PASSWORD_UPDATED',
+    CUSTOMER_PASSWORD_RESET_REQUESTED = 'CUSTOMER_PASSWORD_RESET_REQUESTED',
+    CUSTOMER_PASSWORD_RESET_VERIFIED = 'CUSTOMER_PASSWORD_RESET_VERIFIED',
+    CUSTOMER_EMAIL_UPDATE_REQUESTED = 'CUSTOMER_EMAIL_UPDATE_REQUESTED',
+    CUSTOMER_EMAIL_UPDATE_VERIFIED = 'CUSTOMER_EMAIL_UPDATE_VERIFIED',
+    CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
     ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',

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

@@ -375,3 +375,21 @@ export const UPDATE_CHANNEL = gql`
         }
     }
 `;
+
+export const GET_CUSTOMER_HISTORY = gql`
+    query GetCustomerHistory($id: ID!, $options: HistoryEntryListOptions) {
+        customer(id: $id) {
+            id
+            history(options: $options) {
+                items {
+                    id
+                    administrator {
+                        id
+                    }
+                    type
+                    data
+                }
+            }
+        }
+    }
+`;

+ 5 - 9
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -1,4 +1,5 @@
 /* tslint:disable:no-non-null-assertion */
+import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
 import {
     atLeastNWithFacets,
@@ -161,9 +162,8 @@ describe('Promotions applied to Orders', () => {
         it('order history records application', async () => {
             const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
 
-            expect(activeOrder!.history.items).toEqual([
+            expect(activeOrder!.history.items.map((i) => omit(i, ['id']))).toEqual([
                 {
-                    id: 'T_1',
                     type: HistoryEntryType.ORDER_COUPON_APPLIED,
                     data: {
                         couponCode: TEST_COUPON_CODE,
@@ -199,9 +199,8 @@ describe('Promotions applied to Orders', () => {
         it('order history records removal', async () => {
             const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
 
-            expect(activeOrder!.history.items).toEqual([
+            expect(activeOrder!.history.items.map((i) => omit(i, ['id']))).toEqual([
                 {
-                    id: 'T_1',
                     type: HistoryEntryType.ORDER_COUPON_APPLIED,
                     data: {
                         couponCode: TEST_COUPON_CODE,
@@ -209,7 +208,6 @@ describe('Promotions applied to Orders', () => {
                     },
                 },
                 {
-                    id: 'T_2',
                     type: HistoryEntryType.ORDER_COUPON_REMOVED,
                     data: {
                         couponCode: TEST_COUPON_CODE,
@@ -226,9 +224,8 @@ describe('Promotions applied to Orders', () => {
                 couponCode: 'NOT_THERE',
             });
 
-            expect(removeCouponCode!.history.items).toEqual([
+            expect(removeCouponCode!.history.items.map((i) => omit(i, ['id']))).toEqual([
                 {
-                    id: 'T_1',
                     type: HistoryEntryType.ORDER_COUPON_APPLIED,
                     data: {
                         couponCode: TEST_COUPON_CODE,
@@ -236,7 +233,6 @@ describe('Promotions applied to Orders', () => {
                     },
                 },
                 {
-                    id: 'T_2',
                     type: HistoryEntryType.ORDER_COUPON_REMOVED,
                     data: {
                         couponCode: TEST_COUPON_CODE,
@@ -644,7 +640,7 @@ describe('Promotions applied to Orders', () => {
     function getVariantBySlug(
         slug: 'item-1' | 'item-12' | 'item-60' | 'item-sale-1' | 'item-sale-12',
     ): GetPromoProducts.Variants {
-        return products.find(p => p.slug === slug)!.variants[0];
+        return products.find((p) => p.slug === slug)!.variants[0];
     }
 
     async function deletePromotion(promotionId: string) {

+ 96 - 16
packages/core/e2e/shop-auth.e2e-spec.ts

@@ -24,6 +24,8 @@ import {
     CreateAdministrator,
     CreateRole,
     GetCustomer,
+    GetCustomerHistory,
+    HistoryEntryType,
     Permission,
 } from './graphql/generated-e2e-admin-types';
 import {
@@ -36,7 +38,12 @@ import {
     UpdateEmailAddress,
     Verify,
 } from './graphql/generated-e2e-shop-types';
-import { CREATE_ADMINISTRATOR, CREATE_ROLE, GET_CUSTOMER } from './graphql/shared-definitions';
+import {
+    CREATE_ADMINISTRATOR,
+    CREATE_ROLE,
+    GET_CUSTOMER,
+    GET_CUSTOMER_HISTORY,
+} from './graphql/shared-definitions';
 import {
     GET_ACTIVE_CUSTOMER,
     REFRESH_TOKEN,
@@ -61,16 +68,16 @@ let sendEmailFn: jest.Mock;
 class TestEmailPlugin implements OnModuleInit {
     constructor(private eventBus: EventBus) {}
     onModuleInit() {
-        this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
+        this.eventBus.ofType(AccountRegistrationEvent).subscribe((event) => {
             sendEmailFn(event);
         });
-        this.eventBus.ofType(PasswordResetEvent).subscribe(event => {
+        this.eventBus.ofType(PasswordResetEvent).subscribe((event) => {
             sendEmailFn(event);
         });
-        this.eventBus.ofType(IdentifierChangeRequestEvent).subscribe(event => {
+        this.eventBus.ofType(IdentifierChangeRequestEvent).subscribe((event) => {
             sendEmailFn(event);
         });
-        this.eventBus.ofType(IdentifierChangeEvent).subscribe(event => {
+        this.eventBus.ofType(IdentifierChangeEvent).subscribe((event) => {
             sendEmailFn(event);
         });
     }
@@ -100,6 +107,7 @@ describe('Shop auth & accounts', () => {
         const password = 'password';
         const emailAddress = 'test1@test.com';
         let verificationToken: string;
+        let newCustomerId: string;
 
         beforeEach(() => {
             sendEmailFn = jest.fn();
@@ -140,7 +148,7 @@ describe('Shop auth & accounts', () => {
         });
 
         it('issues a new token if attempting to register a second time', async () => {
-            const sendEmail = new Promise<string>(resolve => {
+            const sendEmail = new Promise<string>((resolve) => {
                 sendEmailFn.mockImplementation((event: AccountRegistrationEvent) => {
                     resolve(event.user.verificationToken!);
                 });
@@ -164,7 +172,7 @@ describe('Shop auth & accounts', () => {
         });
 
         it('refreshCustomerVerification issues a new token', async () => {
-            const sendEmail = new Promise<string>(resolve => {
+            const sendEmail = new Promise<string>((resolve) => {
                 sendEmailFn.mockImplementation((event: AccountRegistrationEvent) => {
                     resolve(event.user.verificationToken!);
                 });
@@ -223,6 +231,8 @@ describe('Shop auth & accounts', () => {
             });
 
             expect(result.verifyCustomerAccount.user.identifier).toBe('test1@test.com');
+            const { activeCustomer } = await shopClient.query<GetActiveCustomer.Query>(GET_ACTIVE_CUSTOMER);
+            newCustomerId = activeCustomer!.id;
         });
 
         it('registration silently fails if attempting to register an email already verified', async () => {
@@ -250,6 +260,31 @@ describe('Shop auth & accounts', () => {
                 `Verification token not recognized`,
             ),
         );
+
+        it('customer history contains entries for registration & verification', async () => {
+            const { customer } = await adminClient.query<
+                GetCustomerHistory.Query,
+                GetCustomerHistory.Variables
+            >(GET_CUSTOMER_HISTORY, {
+                id: newCustomerId,
+            });
+
+            expect(customer?.history.items.map(pick(['type', 'data']))).toEqual([
+                {
+                    type: HistoryEntryType.CUSTOMER_REGISTERED,
+                    data: {},
+                },
+                {
+                    // second entry because we register twice above
+                    type: HistoryEntryType.CUSTOMER_REGISTERED,
+                    data: {},
+                },
+                {
+                    type: HistoryEntryType.CUSTOMER_VERIFIED,
+                    data: {},
+                },
+            ]);
+        });
     });
 
     describe('password reset', () => {
@@ -321,6 +356,30 @@ describe('Shop auth & accounts', () => {
             const loginResult = await shopClient.asUserWithCredentials(customer.emailAddress, 'newPassword');
             expect(loginResult.user.identifier).toBe(customer.emailAddress);
         });
+
+        it('customer history for password reset', async () => {
+            const result = await adminClient.query<GetCustomerHistory.Query, GetCustomerHistory.Variables>(
+                GET_CUSTOMER_HISTORY,
+                {
+                    id: customer.id,
+                    options: {
+                        // skip CUSTOMER_ADDRESS_CREATED entry
+                        skip: 1,
+                    },
+                },
+            );
+
+            expect(result.customer?.history.items.map(pick(['type', 'data']))).toEqual([
+                {
+                    type: HistoryEntryType.CUSTOMER_PASSWORD_RESET_REQUESTED,
+                    data: {},
+                },
+                {
+                    type: HistoryEntryType.CUSTOMER_PASSWORD_RESET_REQUESTED,
+                    data: {},
+                },
+            ]);
+        });
     });
 
     describe('updating emailAddress', () => {
@@ -456,6 +515,29 @@ describe('Shop auth & accounts', () => {
                 expect(getErrorCode(err)).toBe('UNAUTHORIZED');
             }
         });
+
+        it('customer history for email update', async () => {
+            const result = await adminClient.query<GetCustomerHistory.Query, GetCustomerHistory.Variables>(
+                GET_CUSTOMER_HISTORY,
+                {
+                    id: customer.id,
+                    options: {
+                        skip: 3,
+                    },
+                },
+            );
+
+            expect(result.customer?.history.items.map(pick(['type', 'data']))).toEqual([
+                {
+                    type: HistoryEntryType.CUSTOMER_EMAIL_UPDATE_REQUESTED,
+                    data: {},
+                },
+                {
+                    type: HistoryEntryType.CUSTOMER_EMAIL_UPDATE_VERIFIED,
+                    data: {},
+                },
+            ]);
+        });
     });
 
     async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
@@ -499,9 +581,7 @@ describe('Shop auth & accounts', () => {
 
         const role = roleResult.createRole;
 
-        const identifier = `${code}@${Math.random()
-            .toString(16)
-            .substr(2, 8)}`;
+        const identifier = `${code}@${Math.random().toString(16).substr(2, 8)}`;
         const password = `test`;
 
         const adminResult = await shopClient.query<
@@ -528,7 +608,7 @@ describe('Shop auth & accounts', () => {
      * A "sleep" function which allows the sendEmailFn time to get called.
      */
     function waitForSendEmailFn() {
-        return new Promise(resolve => setTimeout(resolve, 10));
+        return new Promise((resolve) => setTimeout(resolve, 10));
     }
 });
 
@@ -578,7 +658,7 @@ describe('Expiring tokens', () => {
             expect(sendEmailFn).toHaveBeenCalledTimes(1);
             expect(verificationToken).toBeDefined();
 
-            await new Promise(resolve => setTimeout(resolve, 3));
+            await new Promise((resolve) => setTimeout(resolve, 3));
 
             return shopClient.query(VERIFY_EMAIL, {
                 password: 'test',
@@ -609,7 +689,7 @@ describe('Expiring tokens', () => {
             expect(sendEmailFn).toHaveBeenCalledTimes(1);
             expect(passwordResetToken).toBeDefined();
 
-            await new Promise(resolve => setTimeout(resolve, 3));
+            await new Promise((resolve) => setTimeout(resolve, 3));
 
             return shopClient.query<ResetPassword.Mutation, ResetPassword.Variables>(RESET_PASSWORD, {
                 password: 'test',
@@ -744,7 +824,7 @@ describe('Updating email address without email verification', () => {
 });
 
 function getVerificationTokenPromise(): Promise<string> {
-    return new Promise<any>(resolve => {
+    return new Promise<any>((resolve) => {
         sendEmailFn.mockImplementation((event: AccountRegistrationEvent) => {
             resolve(event.user.verificationToken);
         });
@@ -752,7 +832,7 @@ function getVerificationTokenPromise(): Promise<string> {
 }
 
 function getPasswordResetTokenPromise(): Promise<string> {
-    return new Promise<any>(resolve => {
+    return new Promise<any>((resolve) => {
         sendEmailFn.mockImplementation((event: PasswordResetEvent) => {
             resolve(event.user.passwordResetToken);
         });
@@ -763,7 +843,7 @@ function getEmailUpdateTokenPromise(): Promise<{
     identifierChangeToken: string | null;
     pendingIdentifier: string | null;
 }> {
-    return new Promise(resolve => {
+    return new Promise((resolve) => {
         sendEmailFn.mockImplementation((event: IdentifierChangeRequestEvent) => {
             resolve(pick(event.user, ['identifierChangeToken', 'pendingIdentifier']));
         });

+ 83 - 2
packages/core/e2e/shop-customer.e2e-spec.ts

@@ -1,12 +1,20 @@
 /* tslint:disable:no-non-null-assertion */
+import { pick } from '@vendure/common/lib/pick';
 import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
+import { skip } from 'rxjs/operators';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
-import { AttemptLogin, GetCustomer, GetCustomerIds } from './graphql/generated-e2e-admin-types';
+import {
+    AttemptLogin,
+    GetCustomer,
+    GetCustomerHistory,
+    GetCustomerIds,
+    HistoryEntryType,
+} from './graphql/generated-e2e-admin-types';
 import {
     CreateAddressInput,
     CreateAddressShop,
@@ -17,7 +25,7 @@ import {
     UpdateCustomerInput,
     UpdatePassword,
 } from './graphql/generated-e2e-shop-types';
-import { ATTEMPT_LOGIN, GET_CUSTOMER } from './graphql/shared-definitions';
+import { ATTEMPT_LOGIN, GET_CUSTOMER, GET_CUSTOMER_HISTORY } from './graphql/shared-definitions';
 import {
     CREATE_ADDRESS,
     DELETE_ADDRESS,
@@ -149,6 +157,28 @@ describe('Shop customers', () => {
             addressId = createCustomerAddress.id;
         });
 
+        it('customer history for CUSTOMER_ADDRESS_CREATED', async () => {
+            const result = await adminClient.query<GetCustomerHistory.Query, GetCustomerHistory.Variables>(
+                GET_CUSTOMER_HISTORY,
+                {
+                    id: customer.id,
+                    options: {
+                        // skip populated CUSTOMER_ADDRESS_CREATED entry
+                        skip: 1,
+                    },
+                },
+            );
+
+            expect(result.customer?.history.items.map(pick(['type', 'data']))).toEqual([
+                {
+                    type: HistoryEntryType.CUSTOMER_ADDRESS_CREATED,
+                    data: {
+                        address: '1 Test Street, United Kingdom',
+                    },
+                },
+            ]);
+        });
+
         it('updateCustomerAddress works', async () => {
             const input: UpdateAddressInput = {
                 id: addressId,
@@ -164,6 +194,27 @@ describe('Shop customers', () => {
             expect(result.updateCustomerAddress.country.code).toEqual('AT');
         });
 
+        it('customer history for CUSTOMER_ADDRESS_UPDATED', async () => {
+            const result = await adminClient.query<GetCustomerHistory.Query, GetCustomerHistory.Variables>(
+                GET_CUSTOMER_HISTORY,
+                { id: customer.id, options: { skip: 2 } },
+            );
+
+            expect(result.customer?.history.items.map(pick(['type', 'data']))).toEqual([
+                {
+                    type: HistoryEntryType.CUSTOMER_ADDRESS_UPDATED,
+                    data: {
+                        address: '5 Test Street, Austria',
+                        input: {
+                            id: addressId,
+                            streetLine1: '5 Test Street',
+                            countryCode: 'AT',
+                        },
+                    },
+                },
+            ]);
+        });
+
         it(
             'updateCustomerAddress fails for address not owned by Customer',
             assertThrowsWithMessage(async () => {
@@ -187,6 +238,22 @@ describe('Shop customers', () => {
             expect(result.deleteCustomerAddress).toBe(true);
         });
 
+        it('customer history for CUSTOMER_ADDRESS_DELETED', async () => {
+            const result = await adminClient.query<GetCustomerHistory.Query, GetCustomerHistory.Variables>(
+                GET_CUSTOMER_HISTORY,
+                { id: customer.id, options: { skip: 3 } },
+            );
+
+            expect(result.customer?.history.items.map(pick(['type', 'data']))).toEqual([
+                {
+                    type: HistoryEntryType.CUSTOMER_ADDRESS_DELETED,
+                    data: {
+                        address: '5 Test Street, Austria',
+                    },
+                },
+            ]);
+        });
+
         it(
             'deleteCustomerAddress fails for address not owned by Customer',
             assertThrowsWithMessage(async () => {
@@ -219,5 +286,19 @@ describe('Shop customers', () => {
             const loginResult = await shopClient.asUserWithCredentials(customer.emailAddress, 'test2');
             expect(loginResult.user.identifier).toBe(customer.emailAddress);
         });
+
+        it('customer history for CUSTOMER_PASSWORD_UPDATED', async () => {
+            const result = await adminClient.query<GetCustomerHistory.Query, GetCustomerHistory.Variables>(
+                GET_CUSTOMER_HISTORY,
+                { id: customer.id, options: { skip: 4 } },
+            );
+
+            expect(result.customer?.history.items.map(pick(['type', 'data']))).toEqual([
+                {
+                    type: HistoryEntryType.CUSTOMER_PASSWORD_UPDATED,
+                    data: {},
+                },
+            ]);
+        });
     });
 });

+ 9 - 1
packages/core/src/api/resolvers/admin/customer.resolver.ts

@@ -1,6 +1,8 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     DeletionResponse,
+    MutationAddNoteToCustomerArgs,
+    MutationAddNoteToOrderArgs,
     MutationCreateCustomerAddressArgs,
     MutationCreateCustomerArgs,
     MutationDeleteCustomerAddressArgs,
@@ -81,7 +83,7 @@ export class CustomerResolver {
         @Args() args: MutationDeleteCustomerAddressArgs,
     ): Promise<boolean> {
         const { id } = args;
-        return this.customerService.deleteAddress(id);
+        return this.customerService.deleteAddress(ctx, id);
     }
 
     @Mutation()
@@ -89,4 +91,10 @@ export class CustomerResolver {
     async deleteCustomer(@Args() args: MutationDeleteCustomerArgs): Promise<DeletionResponse> {
         return this.customerService.softDelete(args.id);
     }
+
+    @Mutation()
+    @Allow(Permission.UpdateCustomer)
+    async addNoteToCustomer(@Ctx() ctx: RequestContext, @Args() args: MutationAddNoteToCustomerArgs) {
+        return this.customerService.addNoteToCustomer(ctx, args.input);
+    }
 }

+ 13 - 2
packages/core/src/api/resolvers/entity/customer-entity.resolver.ts

@@ -1,11 +1,12 @@
 import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql';
-import { QueryOrdersArgs } from '@vendure/common/lib/generated-types';
+import { HistoryEntryListOptions, QueryOrdersArgs, SortOrder } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { Address } from '../../../entity/address/address.entity';
 import { Customer } from '../../../entity/customer/customer.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { CustomerService } from '../../../service/services/customer.service';
+import { HistoryService } from '../../../service/services/history.service';
 import { OrderService } from '../../../service/services/order.service';
 import { UserService } from '../../../service/services/user.service';
 import { ApiType } from '../../common/get-api-type';
@@ -59,7 +60,7 @@ export class CustomerEntityResolver {
 
 @Resolver('Customer')
 export class CustomerAdminEntityResolver {
-    constructor(private customerService: CustomerService) {}
+    constructor(private customerService: CustomerService, private historyService: HistoryService) {}
 
     @ResolveField()
     groups(@Ctx() ctx: RequestContext, @Parent() customer: Customer) {
@@ -68,4 +69,14 @@ export class CustomerAdminEntityResolver {
         }
         return this.customerService.getCustomerGroups(customer.id);
     }
+
+    @ResolveField()
+    async history(@Api() apiType: ApiType, @Parent() order: Order, @Args() args: any) {
+        const publicOnly = apiType === 'shop';
+        const options: HistoryEntryListOptions = { ...args.options };
+        if (!options.sort) {
+            options.sort = { createdAt: SortOrder.ASC };
+        }
+        return this.historyService.getHistoryForCustomer(order.id, publicOnly, options);
+    }
 }

+ 22 - 3
packages/core/src/api/resolvers/shop/shop-auth.resolver.ts

@@ -12,6 +12,7 @@ import {
     MutationVerifyCustomerAccountArgs,
     Permission,
 } from '@vendure/common/lib/generated-shop-types';
+import { HistoryEntryType } from '@vendure/common/lib/generated-types';
 import { Request, Response } from 'express';
 
 import {
@@ -23,6 +24,7 @@ import { ConfigService } from '../../../config/config.service';
 import { AdministratorService } from '../../../service/services/administrator.service';
 import { AuthService } from '../../../service/services/auth.service';
 import { CustomerService } from '../../../service/services/customer.service';
+import { HistoryService } from '../../../service/services/history.service';
 import { UserService } from '../../../service/services/user.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
@@ -37,6 +39,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         administratorService: AdministratorService,
         configService: ConfigService,
         protected customerService: CustomerService,
+        protected historyService: HistoryService,
     ) {
         super(authService, userService, administratorService, configService);
     }
@@ -85,7 +88,11 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Context('req') req: Request,
         @Context('res') res: Response,
     ) {
-        const customer = await this.customerService.verifyCustomerEmailAddress(args.token, args.password);
+        const customer = await this.customerService.verifyCustomerEmailAddress(
+            ctx,
+            args.token,
+            args.password,
+        );
         if (customer && customer.user) {
             return super.createAuthenticatedSession(
                 ctx,
@@ -126,7 +133,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Context('res') res: Response,
     ) {
         const { token, password } = args;
-        const customer = await this.customerService.resetPassword(token, password);
+        const customer = await this.customerService.resetPassword(ctx, token, password);
         if (customer && customer.user) {
             return super.createAuthenticatedSession(
                 ctx,
@@ -149,7 +156,19 @@ export class ShopAuthResolver extends BaseAuthResolver {
         @Ctx() ctx: RequestContext,
         @Args() args: MutationUpdateCustomerPasswordArgs,
     ): Promise<boolean> {
-        return super.updatePassword(ctx, args.currentPassword, args.newPassword);
+        const result = await super.updatePassword(ctx, args.currentPassword, args.newPassword);
+        if (result && ctx.activeUserId) {
+            const customer = await this.customerService.findOneByUserId(ctx.activeUserId);
+            if (customer) {
+                await this.historyService.createHistoryEntryForCustomer({
+                    ctx,
+                    customerId: customer.id,
+                    type: HistoryEntryType.CUSTOMER_PASSWORD_UPDATED,
+                    data: {},
+                });
+            }
+        }
+        return result;
     }
 
     @Mutation()

+ 4 - 4
packages/core/src/api/resolvers/shop/shop-customer.resolver.ts

@@ -2,9 +2,9 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import { MutationDeleteCustomerAddressArgs } from '@vendure/common/lib/generated-shop-types';
 import {
     MutationCreateCustomerAddressArgs,
-    Permission,
     MutationUpdateCustomerAddressArgs,
     MutationUpdateCustomerArgs,
+    Permission,
 } from '@vendure/common/lib/generated-types';
 
 import { ForbiddenError, InternalServerError } from '../../../common/error/errors';
@@ -74,7 +74,7 @@ export class ShopCustomerResolver {
     ): Promise<Address> {
         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))) {
+        if (!customerAddresses.find((address) => idsAreEqual(address.id, args.input.id))) {
             throw new ForbiddenError();
         }
         return this.customerService.updateAddress(ctx, args.input);
@@ -88,10 +88,10 @@ export class ShopCustomerResolver {
     ): 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))) {
+        if (!customerAddresses.find((address) => idsAreEqual(address.id, args.id))) {
             throw new ForbiddenError();
         }
-        return this.customerService.deleteAddress(args.id);
+        return this.customerService.deleteAddress(ctx, args.id);
     }
 
     /**

+ 10 - 0
packages/core/src/api/schema/admin-api/customer.api.graphql

@@ -21,10 +21,13 @@ type Mutation {
 
     "Update an existing Address"
     deleteCustomerAddress(id: ID!): Boolean!
+
+    addNoteToCustomer(input: AddNoteToCustomerInput!): Customer!
 }
 
 type Customer implements Node {
     groups: [CustomerGroup!]!
+    history(options: HistoryEntryListOptions): HistoryEntryList!
 }
 
 input UpdateCustomerInput {
@@ -38,3 +41,10 @@ input UpdateCustomerInput {
 
 # generated by generateListOptions function
 input CustomerListOptions
+
+
+input AddNoteToCustomerInput {
+    id: ID!
+    note: String!
+    isPublic: Boolean!
+}

+ 13 - 0
packages/core/src/api/schema/type/history-entry.type.graphql

@@ -9,6 +9,19 @@ type HistoryEntry implements Node {
 }
 
 enum HistoryEntryType {
+    CUSTOMER_REGISTERED
+    CUSTOMER_VERIFIED
+    CUSTOMER_DETAIL_UPDATED
+    CUSTOMER_ADDRESS_CREATED
+    CUSTOMER_ADDRESS_UPDATED
+    CUSTOMER_ADDRESS_DELETED
+    CUSTOMER_ORDER_PLACED
+    CUSTOMER_PASSWORD_UPDATED
+    CUSTOMER_PASSWORD_RESET_REQUESTED
+    CUSTOMER_PASSWORD_RESET_VERIFIED
+    CUSTOMER_EMAIL_UPDATE_REQUESTED
+    CUSTOMER_EMAIL_UPDATE_VERIFIED
+    CUSTOMER_NOTE
     ORDER_STATE_TRANSITION
     ORDER_PAYMENT_TRANSITION
     ORDER_FULLFILLMENT

+ 2 - 0
packages/core/src/entity/entities.ts

@@ -15,6 +15,7 @@ import { FacetTranslation } from './facet/facet-translation.entity';
 import { Facet } from './facet/facet.entity';
 import { Fulfillment } from './fulfillment/fulfillment.entity';
 import { GlobalSettings } from './global-settings/global-settings.entity';
+import { CustomerHistoryEntry } from './history-entry/customer-history-entry.entity';
 import { HistoryEntry } from './history-entry/history-entry.entity';
 import { OrderHistoryEntry } from './history-entry/order-history-entry.entity';
 import { OrderItem } from './order-item/order-item.entity';
@@ -67,6 +68,7 @@ export const coreEntitiesMap = {
     CountryTranslation,
     Customer,
     CustomerGroup,
+    CustomerHistoryEntry,
     Facet,
     FacetTranslation,
     FacetValue,

+ 16 - 0
packages/core/src/entity/history-entry/customer-history-entry.entity.ts

@@ -0,0 +1,16 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { ChildEntity, ManyToOne } from 'typeorm';
+
+import { Customer } from '../customer/customer.entity';
+
+import { HistoryEntry } from './history-entry.entity';
+
+@ChildEntity()
+export class CustomerHistoryEntry extends HistoryEntry {
+    constructor(input: DeepPartial<CustomerHistoryEntry>) {
+        super(input);
+    }
+
+    @ManyToOne((type) => Customer)
+    customer: Customer;
+}

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

@@ -4,6 +4,7 @@
     "cancel-order-lines-nothing-to-cancel": "Nothing to cancel",
     "cancel-order-lines-quantity-too-high": "Quantity to cancel is greater than existing OrderLine quantity",
     "cannot-delete-role": "The role '{ roleCode }' cannot be deleted",
+    "cannot-locate-customer-for-user": "Cannot locate a Customer for the user",
     "cannot-modify-role": "The role '{ roleCode }' cannot be modified",
     "cannot-create-sales-for-active-order": "Cannot create a Sale for an Order which is still active",
     "cannot-move-collection-into-self": "Cannot move a Collection into itself",

+ 23 - 0
packages/core/src/service/helpers/utils/address-to-line.ts

@@ -0,0 +1,23 @@
+import { OrderAddress } from '@vendure/common/lib/generated-types';
+
+import { Address } from '../../../entity/address/address.entity';
+
+/**
+ * Given an Address object, this function converts it into a single line
+ * consisting of streetLine1, (postalCode), (countryCode).
+ */
+export function addressToLine(address: Address | OrderAddress): string {
+    const propsToInclude: Array<keyof (Address | OrderAddress)> = ['streetLine1', 'postalCode', 'country'];
+    let result = address.streetLine1 || '';
+    if (address.postalCode) {
+        result += ', ' + address.postalCode;
+    }
+    if (address.country) {
+        if (typeof address.country === 'string') {
+            result += ', ' + address.country;
+        } else {
+            result += ', ' + address.country.name;
+        }
+    }
+    return result;
+}

+ 132 - 12
packages/core/src/service/services/customer.service.ts

@@ -2,10 +2,12 @@ import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { RegisterCustomerInput } from '@vendure/common/lib/generated-shop-types';
 import {
+    AddNoteToCustomerInput,
     CreateAddressInput,
     CreateCustomerInput,
     DeletionResponse,
     DeletionResult,
+    HistoryEntryType,
     UpdateAddressInput,
     UpdateCustomerInput,
 } from '@vendure/common/lib/generated-types';
@@ -13,13 +15,19 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
-import { EntityNotFoundError, IllegalOperationError, UserInputError } from '../../common/error/errors';
+import {
+    EntityNotFoundError,
+    IllegalOperationError,
+    InternalServerError,
+    UserInputError,
+} from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound, idsAreEqual, normalizeEmailAddress } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { Address } from '../../entity/address/address.entity';
 import { CustomerGroup } from '../../entity/customer-group/customer-group.entity';
 import { Customer } from '../../entity/customer/customer.entity';
+import { Order } from '../../entity/order/order.entity';
 import { User } from '../../entity/user/user.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { AccountRegistrationEvent } from '../../event-bus/events/account-registration-event';
@@ -27,11 +35,13 @@ import { IdentifierChangeEvent } from '../../event-bus/events/identifier-change-
 import { IdentifierChangeRequestEvent } from '../../event-bus/events/identifier-change-request-event';
 import { PasswordResetEvent } from '../../event-bus/events/password-reset-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
+import { addressToLine } from '../helpers/utils/address-to-line';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { patchEntity } from '../helpers/utils/patch-entity';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { CountryService } from './country.service';
+import { HistoryService } from './history.service';
 import { UserService } from './user.service';
 
 @Injectable()
@@ -43,6 +53,7 @@ export class CustomerService {
         private countryService: CountryService,
         private listQueryBuilder: ListQueryBuilder,
         private eventBus: EventBus,
+        private historyService: HistoryService,
     ) {}
 
     findAll(options: ListQueryOptions<Customer> | undefined): Promise<PaginatedList<Customer>> {
@@ -148,6 +159,12 @@ export class CustomerService {
             firstName: input.firstName || '',
             lastName: input.lastName || '',
         });
+        await this.historyService.createHistoryEntryForCustomer({
+            customerId: customer.id,
+            ctx,
+            type: HistoryEntryType.CUSTOMER_REGISTERED,
+            data: {},
+        });
         if (!user) {
             user = await this.userService.createCustomerUser(input.emailAddress, input.password || undefined);
         } else if (!user.verified) {
@@ -157,6 +174,13 @@ export class CustomerService {
         await this.connection.getRepository(Customer).save(customer, { reload: false });
         if (!user.verified) {
             this.eventBus.publish(new AccountRegistrationEvent(ctx, user));
+        } else {
+            await this.historyService.createHistoryEntryForCustomer({
+                customerId: customer.id,
+                ctx,
+                type: HistoryEntryType.CUSTOMER_VERIFIED,
+                data: {},
+            });
         }
         return true;
     }
@@ -172,11 +196,22 @@ export class CustomerService {
     }
 
     async verifyCustomerEmailAddress(
+        ctx: RequestContext,
         verificationToken: string,
         password: string,
     ): Promise<Customer | undefined> {
         const user = await this.userService.verifyUserByToken(verificationToken, password);
         if (user) {
+            const customer = await this.findOneByUserId(user.id);
+            if (!customer) {
+                throw new InternalServerError('error.cannot-locate-customer-for-user');
+            }
+            await this.historyService.createHistoryEntryForCustomer({
+                customerId: customer.id,
+                ctx,
+                type: HistoryEntryType.CUSTOMER_VERIFIED,
+                data: {},
+            });
             return this.findOneByUserId(user.id);
         }
     }
@@ -185,13 +220,37 @@ export class CustomerService {
         const user = await this.userService.setPasswordResetToken(emailAddress);
         if (user) {
             this.eventBus.publish(new PasswordResetEvent(ctx, user));
+            const customer = await this.findOneByUserId(user.id);
+            if (!customer) {
+                throw new InternalServerError('error.cannot-locate-customer-for-user');
+            }
+            await this.historyService.createHistoryEntryForCustomer({
+                customerId: customer.id,
+                ctx,
+                type: HistoryEntryType.CUSTOMER_PASSWORD_RESET_REQUESTED,
+                data: {},
+            });
         }
     }
 
-    async resetPassword(passwordResetToken: string, password: string): Promise<Customer | undefined> {
+    async resetPassword(
+        ctx: RequestContext,
+        passwordResetToken: string,
+        password: string,
+    ): Promise<Customer | undefined> {
         const user = await this.userService.resetPasswordByToken(passwordResetToken, password);
         if (user) {
-            return this.findOneByUserId(user.id);
+            const customer = await this.findOneByUserId(user.id);
+            if (!customer) {
+                throw new InternalServerError('error.cannot-locate-customer-for-user');
+            }
+            await this.historyService.createHistoryEntryForCustomer({
+                customerId: customer.id,
+                ctx,
+                type: HistoryEntryType.CUSTOMER_PASSWORD_RESET_REQUESTED,
+                data: {},
+            });
+            return customer;
         }
     }
 
@@ -208,22 +267,34 @@ export class CustomerService {
         if (!user) {
             return false;
         }
+        const customer = await this.findOneByUserId(user.id);
+        if (!customer) {
+            return false;
+        }
+        await this.historyService.createHistoryEntryForCustomer({
+            customerId: customer.id,
+            ctx,
+            type: HistoryEntryType.CUSTOMER_EMAIL_UPDATE_REQUESTED,
+            data: {},
+        });
         if (this.configService.authOptions.requireVerification) {
             user.pendingIdentifier = newEmailAddress;
             await this.userService.setIdentifierChangeToken(user);
             this.eventBus.publish(new IdentifierChangeRequestEvent(ctx, user));
             return true;
         } else {
-            const customer = await this.findOneByUserId(user.id);
-            if (!customer) {
-                return false;
-            }
             const oldIdentifier = user.identifier;
             user.identifier = newEmailAddress;
             customer.emailAddress = newEmailAddress;
             await this.connection.getRepository(User).save(user, { reload: false });
             await this.connection.getRepository(Customer).save(customer, { reload: false });
             this.eventBus.publish(new IdentifierChangeEvent(ctx, user, oldIdentifier));
+            await this.historyService.createHistoryEntryForCustomer({
+                customerId: customer.id,
+                ctx,
+                type: HistoryEntryType.CUSTOMER_EMAIL_UPDATE_VERIFIED,
+                data: {},
+            });
             return true;
         }
     }
@@ -240,6 +311,12 @@ export class CustomerService {
         this.eventBus.publish(new IdentifierChangeEvent(ctx, user, oldIdentifier));
         customer.emailAddress = user.identifier;
         await this.connection.getRepository(Customer).save(customer, { reload: false });
+        await this.historyService.createHistoryEntryForCustomer({
+            customerId: customer.id,
+            ctx,
+            type: HistoryEntryType.CUSTOMER_EMAIL_UPDATE_VERIFIED,
+            data: {},
+        });
         return true;
     }
 
@@ -299,27 +376,54 @@ export class CustomerService {
         customer.addresses.push(createdAddress);
         await this.connection.manager.save(customer, { reload: false });
         await this.enforceSingleDefaultAddress(createdAddress.id, input);
+        await this.historyService.createHistoryEntryForCustomer({
+            customerId: customer.id,
+            ctx,
+            type: HistoryEntryType.CUSTOMER_ADDRESS_CREATED,
+            data: { address: addressToLine(createdAddress) },
+        });
         return createdAddress;
     }
 
     async updateAddress(ctx: RequestContext, input: UpdateAddressInput): Promise<Address> {
         const address = await getEntityOrThrow(this.connection, Address, input.id, {
-            relations: ['country'],
+            relations: ['customer', 'country'],
         });
         if (input.countryCode && input.countryCode !== address.country.code) {
             address.country = await this.countryService.findOneByCode(ctx, input.countryCode);
         } else {
             address.country = translateDeep(address.country, ctx.languageCode);
         }
-        const updatedAddress = patchEntity(address, input);
-        await this.connection.getRepository(Address).save(updatedAddress, { reload: false });
+        let updatedAddress = patchEntity(address, input);
+        updatedAddress = await this.connection.getRepository(Address).save(updatedAddress);
         await this.enforceSingleDefaultAddress(input.id, input);
+
+        await this.historyService.createHistoryEntryForCustomer({
+            customerId: address.customer.id,
+            ctx,
+            type: HistoryEntryType.CUSTOMER_ADDRESS_UPDATED,
+            data: {
+                address: addressToLine(updatedAddress),
+                input,
+            },
+        });
         return updatedAddress;
     }
 
-    async deleteAddress(id: ID): Promise<boolean> {
-        const address = await getEntityOrThrow(this.connection, Address, id);
+    async deleteAddress(ctx: RequestContext, id: ID): Promise<boolean> {
+        const address = await getEntityOrThrow(this.connection, Address, id, {
+            relations: ['customer', 'country'],
+        });
+        address.country = translateDeep(address.country, ctx.languageCode);
         await this.reassignDefaultsForDeletedAddress(address);
+        await this.historyService.createHistoryEntryForCustomer({
+            customerId: address.customer.id,
+            ctx,
+            type: HistoryEntryType.CUSTOMER_ADDRESS_DELETED,
+            data: {
+                address: addressToLine(address),
+            },
+        });
         await this.connection.getRepository(Address).remove(address);
         return true;
     }
@@ -334,6 +438,22 @@ export class CustomerService {
         };
     }
 
+    async addNoteToCustomer(ctx: RequestContext, input: AddNoteToCustomerInput): Promise<Order> {
+        const customer = await getEntityOrThrow(this.connection, Customer, input.id);
+        await this.historyService.createHistoryEntryForCustomer(
+            {
+                ctx,
+                customerId: customer.id,
+                type: HistoryEntryType.CUSTOMER_NOTE,
+                data: {
+                    note: input.note,
+                },
+            },
+            input.isPublic,
+        );
+        return customer;
+    }
+
     private async enforceSingleDefaultAddress(addressId: ID, input: CreateAddressInput | UpdateAddressInput) {
         const result = await this.connection
             .getRepository(Address)

+ 79 - 2
packages/core/src/service/services/history.service.ts

@@ -1,10 +1,16 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import { HistoryEntryListOptions, HistoryEntryType } from '@vendure/common/lib/generated-types';
+import {
+    HistoryEntryListOptions,
+    HistoryEntryType,
+    UpdateAddressInput,
+    UpdateCustomerInput,
+} from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList, Type } from '@vendure/common/lib/shared-types';
-import { Connection, FindConditions } from 'typeorm';
+import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
+import { CustomerHistoryEntry } from '../../entity/history-entry/customer-history-entry.entity';
 import { HistoryEntry } from '../../entity/history-entry/history-entry.entity';
 import { OrderHistoryEntry } from '../../entity/history-entry/order-history-entry.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
@@ -14,6 +20,32 @@ import { RefundState } from '../helpers/refund-state-machine/refund-state';
 
 import { AdministratorService } from './administrator.service';
 
+export type CustomerHistoryEntryData = {
+    [HistoryEntryType.CUSTOMER_REGISTERED]: {};
+    [HistoryEntryType.CUSTOMER_VERIFIED]: {};
+    [HistoryEntryType.CUSTOMER_DETAIL_UPDATED]: {
+        input: UpdateCustomerInput;
+    };
+    [HistoryEntryType.CUSTOMER_ADDRESS_CREATED]: {
+        address: string;
+    };
+    [HistoryEntryType.CUSTOMER_ADDRESS_UPDATED]: {
+        address: string;
+        input: UpdateAddressInput;
+    };
+    [HistoryEntryType.CUSTOMER_ADDRESS_DELETED]: {
+        address: string;
+    };
+    [HistoryEntryType.CUSTOMER_PASSWORD_UPDATED]: {};
+    [HistoryEntryType.CUSTOMER_PASSWORD_RESET_REQUESTED]: {};
+    [HistoryEntryType.CUSTOMER_PASSWORD_RESET_VERIFIED]: {};
+    [HistoryEntryType.CUSTOMER_EMAIL_UPDATE_REQUESTED]: {};
+    [HistoryEntryType.CUSTOMER_EMAIL_UPDATE_VERIFIED]: {};
+    [HistoryEntryType.CUSTOMER_NOTE]: {
+        note: string;
+    };
+};
+
 export type OrderHistoryEntryData = {
     [HistoryEntryType.ORDER_STATE_TRANSITION]: {
         from: OrderState;
@@ -49,6 +81,13 @@ export type OrderHistoryEntryData = {
     };
 };
 
+export interface CreateCustomerHistoryEntryArgs<T extends keyof CustomerHistoryEntryData> {
+    customerId: ID;
+    ctx: RequestContext;
+    type: T;
+    data: CustomerHistoryEntryData[T];
+}
+
 export interface CreateOrderHistoryEntryArgs<T extends keyof OrderHistoryEntryData> {
     orderId: ID;
     ctx: RequestContext;
@@ -104,4 +143,42 @@ export class HistoryService {
         });
         return this.connection.getRepository(OrderHistoryEntry).save(entry);
     }
+
+    async getHistoryForCustomer(
+        customerId: ID,
+        publicOnly: boolean,
+        options?: HistoryEntryListOptions,
+    ): Promise<PaginatedList<CustomerHistoryEntry>> {
+        return this.listQueryBuilder
+            .build((HistoryEntry as any) as Type<CustomerHistoryEntry>, options, {
+                where: {
+                    customer: { id: customerId } as any,
+                    ...(publicOnly ? { isPublic: true } : {}),
+                },
+                relations: ['administrator'],
+            })
+            .getManyAndCount()
+            .then(([items, totalItems]) => ({
+                items,
+                totalItems,
+            }));
+    }
+
+    async createHistoryEntryForCustomer<T extends keyof CustomerHistoryEntryData>(
+        args: CreateCustomerHistoryEntryArgs<T>,
+        isPublic = false,
+    ): Promise<CustomerHistoryEntry> {
+        const { ctx, data, customerId, type } = args;
+        const administrator = ctx.activeUserId
+            ? await this.administratorService.findOneByUserId(ctx.activeUserId)
+            : undefined;
+        const entry = new CustomerHistoryEntry({
+            type,
+            isPublic,
+            data: data as any,
+            customer: { id: customerId },
+            administrator,
+        });
+        return this.connection.getRepository(CustomerHistoryEntry).save(entry);
+    }
 }

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

@@ -13,6 +13,12 @@ export type Scalars = {
     Upload: any;
 };
 
+export type AddNoteToCustomerInput = {
+    id: Scalars['ID'];
+    note: Scalars['String'];
+    isPublic: Scalars['Boolean'];
+};
+
 export type AddNoteToOrderInput = {
     id: Scalars['ID'];
     note: Scalars['String'];
@@ -920,6 +926,7 @@ export type CurrentUserChannel = {
 export type Customer = Node & {
     __typename?: 'Customer';
     groups: Array<CustomerGroup>;
+    history: HistoryEntryList;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -934,6 +941,10 @@ export type Customer = Node & {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CustomerHistoryArgs = {
+    options?: Maybe<HistoryEntryListOptions>;
+};
+
 export type CustomerOrdersArgs = {
     options?: Maybe<OrderListOptions>;
 };
@@ -1264,6 +1275,19 @@ export type HistoryEntrySortParameter = {
 };
 
 export enum HistoryEntryType {
+    CUSTOMER_REGISTERED = 'CUSTOMER_REGISTERED',
+    CUSTOMER_VERIFIED = 'CUSTOMER_VERIFIED',
+    CUSTOMER_DETAIL_UPDATED = 'CUSTOMER_DETAIL_UPDATED',
+    CUSTOMER_ADDRESS_CREATED = 'CUSTOMER_ADDRESS_CREATED',
+    CUSTOMER_ADDRESS_UPDATED = 'CUSTOMER_ADDRESS_UPDATED',
+    CUSTOMER_ADDRESS_DELETED = 'CUSTOMER_ADDRESS_DELETED',
+    CUSTOMER_ORDER_PLACED = 'CUSTOMER_ORDER_PLACED',
+    CUSTOMER_PASSWORD_UPDATED = 'CUSTOMER_PASSWORD_UPDATED',
+    CUSTOMER_PASSWORD_RESET_REQUESTED = 'CUSTOMER_PASSWORD_RESET_REQUESTED',
+    CUSTOMER_PASSWORD_RESET_VERIFIED = 'CUSTOMER_PASSWORD_RESET_VERIFIED',
+    CUSTOMER_EMAIL_UPDATE_REQUESTED = 'CUSTOMER_EMAIL_UPDATE_REQUESTED',
+    CUSTOMER_EMAIL_UPDATE_VERIFIED = 'CUSTOMER_EMAIL_UPDATE_VERIFIED',
+    CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
     ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
@@ -1827,6 +1851,7 @@ export type Mutation = {
     updateCustomerAddress: Address;
     /** Update an existing Address */
     deleteCustomerAddress: Scalars['Boolean'];
+    addNoteToCustomer: Customer;
     /** Create a new Facet */
     createFacet: Facet;
     /** Update an existing Facet */
@@ -2039,6 +2064,10 @@ export type MutationDeleteCustomerAddressArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationAddNoteToCustomerArgs = {
+    input: AddNoteToCustomerInput;
+};
+
 export type MutationCreateFacetArgs = {
     input: CreateFacetInput;
 };

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
schema-admin.json


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
schema-shop.json


Някои файлове не бяха показани, защото твърде много файлове са промени