瀏覽代碼

feat(server): Create anonymous sessions and basic Order operations

Michael Bromley 7 年之前
父節點
當前提交
c81e920280

+ 22 - 0
docs/diagrams/customer-session-flow-diagram.puml

@@ -0,0 +1,22 @@
+' This diagram illustrates the logic governing the handling of anonymous sessions
+' for a Customer making an Order
+@startuml
+!include theme.puml
+title Customer Session Flow
+start
+:addItemToOrder();
+if (authToken exists?) then (yes)
+    :get Session from authToken;
+else (no)
+    :create new AnonymousSession;
+    :set authToken cookie;
+endif
+if (Session has associated Order?) then (yes)
+    :get existing Order;
+else
+    :create new Order;
+endif
+:add item to Order;
+:return Order;
+stop
+@enduml

文件差異過大導致無法顯示
+ 0 - 0
schema.json


+ 1 - 0
server/e2e/__snapshots__/administrator.e2e-spec.ts.snap

@@ -25,6 +25,7 @@ Object {
           "DeleteCatalog",
           "DeleteCustomer",
           "DeleteOrder",
+          "Owner",
           "ReadAdministrator",
           "ReadCatalog",
           "ReadCustomer",

+ 11 - 0
server/e2e/__snapshots__/order.e2e-spec.ts.snap

@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Orders as anonymous user addItemToOrder() creates a new Order with an item 1`] = `
+Object {
+  "id": "T_1",
+  "productVariant": Object {
+    "id": "T_1",
+  },
+  "quantity": 1,
+}
+`;

+ 189 - 0
server/e2e/order.e2e-spec.ts

@@ -0,0 +1,189 @@
+import gql from 'graphql-tag';
+
+import { TestClient } from './test-client';
+import { TestServer } from './test-server';
+
+describe('Orders', () => {
+    const client = new TestClient();
+    const server = new TestServer();
+
+    beforeAll(async () => {
+        const token = await server.init({
+            productCount: 10,
+            customerCount: 1,
+        });
+        await client.init();
+    }, 60000);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('as anonymous user', () => {
+        beforeAll(async () => {
+            client.asAnonymousUser();
+        });
+
+        const TEST_ORDER_FRAGMENT = gql`
+            fragment TestOrderFragment on Order {
+                id
+                items {
+                    id
+                    quantity
+                    productVariant {
+                        id
+                    }
+                }
+            }
+        `;
+
+        const ADD_ITEM_TO_ORDER = gql`
+            mutation AddItemToOrder($productVariantId: ID!, $quantity: Int!) {
+                addItemToOrder(productVariantId: $productVariantId, quantity: $quantity) {
+                    ...TestOrderFragment
+                }
+            }
+            ${TEST_ORDER_FRAGMENT}
+        `;
+
+        const ADJUST_ITEM_QUENTITY = gql`
+            mutation AdjustItemQuantity($orderItemId: ID!, $quantity: Int!) {
+                adjustItemQuantity(orderItemId: $orderItemId, quantity: $quantity) {
+                    ...TestOrderFragment
+                }
+            }
+            ${TEST_ORDER_FRAGMENT}
+        `;
+
+        const REMOVE_ITEM_FROM_ORDER = gql`
+            mutation RemoveItemFromOrder($orderItemId: ID!) {
+                removeItemFromOrder(orderItemId: $orderItemId) {
+                    ...TestOrderFragment
+                }
+            }
+            ${TEST_ORDER_FRAGMENT}
+        `;
+
+        it('addItemToOrder() starts with no session token', () => {
+            expect(client.getAuthToken()).toBe('');
+        });
+
+        it('addItemToOrder() creates a new Order with an item', async () => {
+            const result = await client.query(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 1,
+            });
+
+            expect(result.addItemToOrder.items.length).toBe(1);
+            expect(result.addItemToOrder.items[0]).toMatchSnapshot();
+        });
+
+        it('addItemToOrder() creates an anonymous session', () => {
+            expect(client.getAuthToken()).not.toBe('');
+        });
+
+        it('addItemToOrder() errors with an invalid productVariantId', async () => {
+            try {
+                await client.query(ADD_ITEM_TO_ORDER, {
+                    productVariantId: 'T_999',
+                    quantity: 1,
+                });
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining(`No ProductVariant with the id '999' could be found`),
+                );
+            }
+        });
+
+        it('addItemToOrder() errors with a negative quantity', async () => {
+            try {
+                await client.query(ADD_ITEM_TO_ORDER, {
+                    productVariantId: 'T_999',
+                    quantity: -3,
+                });
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining(`-3 is not a valid quantity for an OrderItem`),
+                );
+            }
+        });
+
+        it('addItemToOrder() with an existing productVariantId adds quantity to the existing OrderItem', async () => {
+            const result = await client.query(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 2,
+            });
+
+            expect(result.addItemToOrder.items.length).toBe(1);
+            expect(result.addItemToOrder.items[0].quantity).toBe(3);
+        });
+
+        it('adjustItemQuantity() with an existing productVariantId adds quantity to the existing OrderItem', async () => {
+            const result = await client.query(ADJUST_ITEM_QUENTITY, {
+                orderItemId: 'T_1',
+                quantity: 50,
+            });
+
+            expect(result.adjustItemQuantity.items.length).toBe(1);
+            expect(result.adjustItemQuantity.items[0].quantity).toBe(50);
+        });
+
+        it('adjustItemQuantity() errors with a negative quantity', async () => {
+            try {
+                await client.query(ADJUST_ITEM_QUENTITY, {
+                    orderItemId: 'T_1',
+                    quantity: -3,
+                });
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining(`-3 is not a valid quantity for an OrderItem`),
+                );
+            }
+        });
+
+        it('adjustItemQuantity() errors with an invalid orderItemId', async () => {
+            try {
+                await client.query(ADJUST_ITEM_QUENTITY, {
+                    orderItemId: 'T_999',
+                    quantity: 5,
+                });
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining(`This order does not contain an OrderItem with the id 999`),
+                );
+            }
+        });
+
+        it('removeItemFromOrder() removes the correct item', async () => {
+            const result1 = await client.query(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_3',
+                quantity: 3,
+            });
+            expect(result1.addItemToOrder.items.length).toBe(2);
+            expect(result1.addItemToOrder.items.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
+
+            const result2 = await client.query(REMOVE_ITEM_FROM_ORDER, {
+                orderItemId: 'T_1',
+            });
+            expect(result2.removeItemFromOrder.items.length).toBe(1);
+            expect(result2.removeItemFromOrder.items.map(i => i.productVariant.id)).toEqual(['T_3']);
+        });
+
+        it('removeItemFromOrder() errors with an invalid orderItemId', async () => {
+            try {
+                await client.query(REMOVE_ITEM_FROM_ORDER, {
+                    orderItemId: 'T_999',
+                });
+                fail('Should have thrown');
+            } catch (err) {
+                expect(err.message).toEqual(
+                    expect.stringContaining(`This order does not contain an OrderItem with the id 999`),
+                );
+            }
+        });
+    });
+});

+ 12 - 8
server/mock-data/simple-graphql-client.ts

@@ -27,6 +27,10 @@ export class SimpleGraphQLClient {
         this.setHeaders();
     }
 
+    getAuthToken(): string {
+        return this.authToken;
+    }
+
     setChannelToken(token: string) {
         this.channelToken = token;
         this.setHeaders();
@@ -35,9 +39,15 @@ export class SimpleGraphQLClient {
     /**
      * Performs both query and mutation operations.
      */
-    query<T = any, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<T> {
+    async query<T = any, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<T> {
         const queryString = print(query);
-        return this.client.request(queryString, variables);
+        const result = await this.client.rawRequest<T>(queryString, variables);
+
+        const authToken = result.headers.get(getConfig().authOptions.authTokenHeaderKey);
+        if (authToken) {
+            this.setAuthToken(authToken);
+        }
+        return result.data as T;
     }
 
     async queryStatus<T = any, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<number> {
@@ -101,7 +111,6 @@ export class SimpleGraphQLClient {
             });
         });
     }
-
     async asUserWithCredentials(username: string, password: string) {
         const result = await this.query(
             gql`
@@ -121,11 +130,6 @@ export class SimpleGraphQLClient {
                 password,
             },
         );
-        if (result && result.login) {
-            this.setAuthToken(result.login.token);
-        } else {
-            console.error(result);
-        }
     }
 
     async asSuperAdmin() {

+ 2 - 0
server/package.json

@@ -43,6 +43,7 @@
     "i18next-node-fs-backend": "^2.0.0",
     "ms": "^2.1.1",
     "mysql": "^2.16.0",
+    "nanoid": "^1.2.4",
     "reflect-metadata": "^0.1.12",
     "rxjs": "^6.2.0",
     "sharp": "^0.20.8",
@@ -58,6 +59,7 @@
     "@types/i18next": "^8.4.3",
     "@types/i18next-express-middleware": "^0.0.33",
     "@types/jest": "^23.3.1",
+    "@types/nanoid": "^1.2.0",
     "@types/node": "^9.3.0",
     "@types/sharp": "^0.17.10",
     "faker": "^4.1.0",

+ 31 - 35
server/src/api/common/auth-guard.ts

@@ -1,16 +1,16 @@
 import { CanActivate, ExecutionContext, Injectable, ReflectMetadata } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { GqlExecutionContext } from '@nestjs/graphql';
-import { Request } from 'express';
+import { Request, Response } from 'express';
 import { Permission } from 'shared/generated-types';
 
-import { idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
-import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
 import { Session } from '../../entity/session/session.entity';
+import { AuthService } from '../../service/providers/auth.service';
 
-import { RequestContext } from './request-context';
+import { extractAuthToken } from './extract-auth-token';
 import { REQUEST_CONTEXT_KEY, RequestContextService } from './request-context.service';
+import { setAuthToken } from './set-auth-token';
 
 export const PERMISSIONS_METADATA_KEY = '__permissions__';
 
@@ -40,50 +40,46 @@ export class AuthGuard implements CanActivate {
     constructor(
         private reflector: Reflector,
         private configService: ConfigService,
+        private authService: AuthService,
         private requestContextService: RequestContextService,
     ) {}
 
     async canActivate(context: ExecutionContext): Promise<boolean> {
-        const req: Request = GqlExecutionContext.create(context).getContext().req;
+        const ctx = GqlExecutionContext.create(context).getContext();
+        const req: Request = ctx.req;
+        const res: Response = ctx.res;
         const authDisabled = this.configService.authOptions.disableAuth;
-        const permissions = this.reflector.get<string[]>(
-            PERMISSIONS_METADATA_KEY,
-            context.getHandler(),
-        ) as Permission[];
-        const requestContext = await this.requestContextService.fromRequest(req);
+        const permissions = this.reflector.get<Permission[]>(PERMISSIONS_METADATA_KEY, context.getHandler());
+        const hasOwnerPermission = !!permissions && permissions.includes(Permission.Owner);
+        const session = await this.getSession(req, res, hasOwnerPermission);
+        const requestContext = await this.requestContextService.fromRequest(req, permissions, session);
         req[REQUEST_CONTEXT_KEY] = requestContext;
 
         if (authDisabled || !permissions) {
             return true;
         } else {
-            return this.userHasRequiredPermissionsOnChannel(permissions, requestContext);
+            return requestContext.isAuthorized || requestContext.authorizedAsOwnerOnly;
         }
     }
 
-    private isAuthenticatedSession(session?: Session): session is AuthenticatedSession {
-        return !!session && !!(session as AuthenticatedSession).user;
-    }
-
-    private userHasRequiredPermissionsOnChannel(
-        permissions: Permission[],
-        requestContext: RequestContext,
-    ): boolean {
-        const user = requestContext.user;
-        if (!user) {
-            return false;
+    private async getSession(
+        req: Request,
+        res: Response,
+        hasOwnerPermission: boolean,
+    ): Promise<Session | undefined> {
+        const authToken = extractAuthToken(req, this.configService.authOptions.tokenMethod);
+        if (authToken) {
+            return await this.authService.validateSession(authToken);
+        } else if (hasOwnerPermission) {
+            const session = await this.authService.createAnonymousSession();
+            setAuthToken({
+                authToken: session.token,
+                rememberMe: true,
+                authOptions: this.configService.authOptions,
+                req,
+                res,
+            });
+            return session;
         }
-        const permissionsOnChannel = user.roles
-            .filter(role => role.channels.find(c => idsAreEqual(c.id, requestContext.channel.id)))
-            .reduce((output, role) => [...output, ...role.permissions], [] as Permission[]);
-        return arraysIntersect(permissions, permissionsOnChannel);
     }
 }
-
-/**
- * Returns true if any element of arr1 appears in arr2.
- */
-function arraysIntersect<T>(arr1: T[], arr2: T[]): boolean {
-    return arr1.reduce((intersects, role) => {
-        return intersects || arr2.includes(role);
-    }, false);
-}

+ 46 - 33
server/src/api/common/request-context.service.ts

@@ -1,16 +1,15 @@
 import { Injectable } from '@nestjs/common';
 import { Request } from 'express';
-import { LanguageCode } from 'shared/generated-types';
+import { LanguageCode, Permission } from 'shared/generated-types';
 
+import { idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
+import { Channel } from '../../entity/channel/channel.entity';
 import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
 import { Session } from '../../entity/session/session.entity';
 import { User } from '../../entity/user/user.entity';
-import { I18nError } from '../../i18n/i18n-error';
-import { AuthService } from '../../service/providers/auth.service';
 import { ChannelService } from '../../service/providers/channel.service';
 
-import { extractAuthToken } from './extract-auth-token';
 import { RequestContext } from './request-context';
 
 export const REQUEST_CONTEXT_KEY = 'vendureRequestContext';
@@ -20,33 +19,31 @@ export const REQUEST_CONTEXT_KEY = 'vendureRequestContext';
  */
 @Injectable()
 export class RequestContextService {
-    constructor(
-        private channelService: ChannelService,
-        private authService: AuthService,
-        private configService: ConfigService,
-    ) {}
+    constructor(private channelService: ChannelService, private configService: ConfigService) {}
 
     /**
      * Creates a new RequestContext based on an Express request object.
      */
-    async fromRequest(req: Request): Promise<RequestContext> {
+    async fromRequest(
+        req: Request,
+        requiredPermissions?: Permission[],
+        session?: Session,
+    ): Promise<RequestContext> {
         const channelToken = this.getChannelToken(req);
-        const channel = channelToken && this.channelService.getChannelFromToken(channelToken);
-        if (channel) {
-            const session = await this.getSession(req);
-            let user: User | undefined;
-            if (this.isAuthenticatedSession(session)) {
-                user = session.user;
-            }
-            const languageCode = this.getLanguageCode(req);
-            return new RequestContext({
-                channel,
-                languageCode,
-                user,
-                session,
-            });
-        }
-        throw new I18nError(`error.unexpected-request-context`);
+        const channel = (channelToken && this.channelService.getChannelFromToken(channelToken)) || undefined;
+
+        const hasOwnerPermission = !!requiredPermissions && requiredPermissions.includes(Permission.Owner);
+        const languageCode = this.getLanguageCode(req);
+        const user = session && (session as AuthenticatedSession).user;
+        const isAuthorized = this.userHasRequiredPermissionsOnChannel(requiredPermissions, channel, user);
+        const authorizedAsOwnerOnly = !isAuthorized && hasOwnerPermission;
+        return new RequestContext({
+            channel,
+            languageCode,
+            session,
+            isAuthorized,
+            authorizedAsOwnerOnly,
+        });
     }
 
     private getChannelToken(req: Request): string | undefined {
@@ -61,13 +58,6 @@ export class RequestContextService {
         return channelToken;
     }
 
-    private async getSession(req: Request): Promise<Session | undefined> {
-        const authToken = extractAuthToken(req, this.configService.authOptions.tokenMethod);
-        if (authToken) {
-            return await this.authService.validateSession(authToken);
-        }
-    }
-
     private getLanguageCode(req: Request): LanguageCode | undefined {
         return req.body && req.body.variables && req.body.variables.languageCode;
     }
@@ -75,4 +65,27 @@ export class RequestContextService {
     private isAuthenticatedSession(session?: Session): session is AuthenticatedSession {
         return !!session && !!(session as AuthenticatedSession).user;
     }
+
+    private userHasRequiredPermissionsOnChannel(
+        permissions: Permission[] = [],
+        channel?: Channel,
+        user?: User,
+    ): boolean {
+        if (!user || !channel) {
+            return false;
+        }
+        const permissionsOnChannel = user.roles
+            .filter(role => role.channels.find(c => idsAreEqual(c.id, channel.id)))
+            .reduce((output, role) => [...output, ...role.permissions], [] as Permission[]);
+        return this.arraysIntersect(permissions, permissionsOnChannel);
+    }
+
+    /**
+     * Returns true if any element of arr1 appears in arr2.
+     */
+    private arraysIntersect<T>(arr1: T[], arr2: T[]): boolean {
+        return arr1.reduce((intersects, role) => {
+            return intersects || arr2.includes(role);
+        }, false);
+    }
 }

+ 42 - 15
server/src/api/common/request-context.ts

@@ -5,17 +5,43 @@ import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
 import { Channel } from '../../entity/channel/channel.entity';
 import { Session } from '../../entity/session/session.entity';
 import { User } from '../../entity/user/user.entity';
+import { I18nError } from '../../i18n/i18n-error';
 
 /**
  * The RequestContext is intended to hold information relevant to the current request, which may be
  * required at various points of the stack.
  */
 export class RequestContext {
-    get channel(): Channel {
+    private readonly _languageCode: LanguageCode;
+    private readonly _channel?: Channel;
+    private readonly _session?: Session;
+    private readonly _isAuthorized: boolean;
+    private readonly _authorizedAsOwnerOnly: boolean;
+
+    constructor(options: {
+        channel?: Channel;
+        session?: Session;
+        languageCode?: LanguageCode;
+        isAuthorized: boolean;
+        authorizedAsOwnerOnly: boolean;
+    }) {
+        const { channel, session, languageCode } = options;
+        this._channel = channel;
+        this._session = session;
+        this._languageCode =
+            languageCode || (channel && channel.defaultLanguageCode) || DEFAULT_LANGUAGE_CODE;
+        this._isAuthorized = options.isAuthorized;
+        this._authorizedAsOwnerOnly = options.authorizedAsOwnerOnly;
+    }
+
+    get channel(): Channel | undefined {
         return this._channel;
     }
 
-    get channelId(): ID | undefined {
+    get channelId(): ID {
+        if (!this._channel) {
+            throw new I18nError('error.no-valid-channel-specified');
+        }
         return this._channel.id;
     }
 
@@ -23,21 +49,22 @@ export class RequestContext {
         return this._languageCode;
     }
 
-    get user(): User | undefined {
-        return this._user;
+    get session(): Session | undefined {
+        return this._session;
     }
 
-    private readonly _languageCode: LanguageCode;
-    private readonly _channel: Channel;
-    private readonly _session?: Session;
-    private readonly _user?: User;
+    /**
+     * True if the current session is authorized to access the current resolver method.
+     */
+    get isAuthorized(): boolean {
+        return this._isAuthorized;
+    }
 
-    constructor(options: { channel: Channel; user?: User; session?: Session; languageCode?: LanguageCode }) {
-        const { channel, session, languageCode, user } = options;
-        this._channel = channel;
-        this._session = session;
-        this._user = user;
-        this._languageCode =
-            languageCode || (channel && channel.defaultLanguageCode) || DEFAULT_LANGUAGE_CODE;
+    /**
+     * True if the current anonymous session is only authorized to operate on entities that
+     * are owned by the current session.
+     */
+    get authorizedAsOwnerOnly(): boolean {
+        return this._authorizedAsOwnerOnly;
     }
 }

+ 28 - 0
server/src/api/common/set-auth-token.ts

@@ -0,0 +1,28 @@
+import { Request, Response } from 'express';
+import * as ms from 'ms';
+
+import { AuthOptions } from '../../config/vendure-config';
+
+/**
+ * Sets the authToken either as a cookie or as a response header, depending on the
+ * config settings.
+ */
+export function setAuthToken(options: {
+    authToken: string;
+    rememberMe: boolean;
+    authOptions: Required<AuthOptions>;
+    req: Request;
+    res: Response;
+}) {
+    const { authToken, rememberMe, authOptions, req, res } = options;
+    if (authOptions.tokenMethod === 'cookie') {
+        if (req.session) {
+            if (rememberMe) {
+                req.sessionOptions.maxAge = ms('1y');
+            }
+            req.session.token = authToken;
+        }
+    } else {
+        res.set(authOptions.authTokenHeaderKey, authToken);
+    }
+}

+ 14 - 15
server/src/api/resolvers/auth.resolver.ts

@@ -1,6 +1,5 @@
 import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
-import { Request } from 'express';
-import * as ms from 'ms';
+import { Request, Response } from 'express';
 import { AttemptLoginVariables, Permission } from 'shared/generated-types';
 
 import { ConfigService } from '../../config/config.service';
@@ -9,6 +8,7 @@ import { AuthService } from '../../service/providers/auth.service';
 import { ChannelService } from '../../service/providers/channel.service';
 import { Allow } from '../common/auth-guard';
 import { extractAuthToken } from '../common/extract-auth-token';
+import { setAuthToken } from '../common/set-auth-token';
 
 @Resolver('Auth')
 export class AuthResolver {
@@ -23,24 +23,23 @@ export class AuthResolver {
      * the user data and returns the token either in a cookie or in the response body.
      */
     @Mutation()
-    async login(@Args() args: AttemptLoginVariables, @Context('req') request: Request) {
+    async login(
+        @Args() args: AttemptLoginVariables,
+        @Context('req') req: Request,
+        @Context('res') res: Response,
+    ) {
         const session = await this.authService.authenticate(args.username, args.password);
 
         if (session) {
-            let token: string | null = null;
-            if (this.configService.authOptions.tokenMethod === 'cookie') {
-                if (request.session) {
-                    if (args.rememberMe) {
-                        request.sessionOptions.maxAge = ms('1y');
-                    }
-                    request.session.token = session.token;
-                }
-            } else {
-                token = session.token;
-            }
+            setAuthToken({
+                req,
+                res,
+                authOptions: this.configService.authOptions,
+                rememberMe: args.rememberMe,
+                authToken: session.token,
+            });
             return {
                 user: this.publiclyAccessibleUser(session.user),
-                token,
             };
         }
     }

+ 73 - 0
server/src/api/resolvers/order.resolver.ts

@@ -0,0 +1,73 @@
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+import { Permission } from 'shared/generated-types';
+import { PaginatedList } from 'shared/shared-types';
+
+import { Order } from '../../entity/order/order.entity';
+import { I18nError } from '../../i18n/i18n-error';
+import { AuthService } from '../../service/providers/auth.service';
+import { OrderService } from '../../service/providers/order.service';
+import { Allow } from '../common/auth-guard';
+import { Decode } from '../common/id-interceptor';
+import { RequestContext } from '../common/request-context';
+import { Ctx } from '../common/request-context.decorator';
+
+@Resolver('Order')
+export class OrderResolver {
+    constructor(private orderService: OrderService, private authService: AuthService) {}
+
+    @Query()
+    @Allow(Permission.ReadOrder)
+    orders(@Args() args): Promise<PaginatedList<Order>> {
+        return {} as any;
+    }
+
+    @Query()
+    @Allow(Permission.ReadOrder, Permission.Owner)
+    async order(@Ctx() ctx: RequestContext, @Args() args): Promise<Order | undefined> {
+        const order = await this.orderService.findOne(ctx, args.id);
+        if (order && ctx.authorizedAsOwnerOnly) {
+            if (ctx.session && ctx.session.activeOrder && ctx.session.activeOrder.id === order.id) {
+                return order;
+            } else {
+                return;
+            }
+        }
+        return order;
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateOrder, Permission.Owner)
+    @Decode('productVariantId')
+    async addItemToOrder(@Ctx() ctx: RequestContext, @Args() args): Promise<Order> {
+        const order = await this.getOrderFromContext(ctx);
+        return this.orderService.addItemToOrder(ctx, order.id, args.productVariantId, args.quantity);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateOrder, Permission.Owner)
+    @Decode('orderItemId')
+    async adjustItemQuantity(@Ctx() ctx: RequestContext, @Args() args): Promise<Order> {
+        const order = await this.getOrderFromContext(ctx);
+        return this.orderService.adjustItemQuantity(ctx, order.id, args.orderItemId, args.quantity);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateOrder, Permission.Owner)
+    @Decode('orderItemId')
+    async removeItemFromOrder(@Ctx() ctx: RequestContext, @Args() args): Promise<Order> {
+        const order = await this.getOrderFromContext(ctx);
+        return this.orderService.removeItemFromOrder(ctx, order.id, args.orderItemId);
+    }
+
+    private async getOrderFromContext(ctx: RequestContext): Promise<Order> {
+        if (!ctx.session) {
+            throw new I18nError(`error.no-active-session`);
+        }
+        let order = ctx.session.activeOrder;
+        if (!order) {
+            order = await this.orderService.create();
+            await this.authService.setActiveOrder(ctx.session, order);
+        }
+        return order;
+    }
+}

+ 35 - 0
server/src/api/types/order.api.graphql

@@ -0,0 +1,35 @@
+type Query {
+    order(id: ID!): Order
+    orders(options: OrderListOptions): OrderList
+}
+
+type Mutation {
+    addItemToOrder(productVariantId: ID!, quantity: Int!): Order
+    removeItemFromOrder(orderItemId: ID!): Order
+    adjustItemQuantity(orderItemId: ID!, quantity: Int!): Order
+}
+
+type OrderList implements PaginatedList {
+    items: [Order!]!
+    totalItems: Int!
+}
+
+input OrderListOptions {
+    take: Int
+    skip: Int
+    sort: OrderSortParameter
+    filter: OrderFilterParameter
+}
+
+input OrderSortParameter {
+    id: SortOrder
+    createdAt: SortOrder
+    updatedAt: SortOrder
+    code: SortOrder
+}
+
+input OrderFilterParameter {
+    code: StringOperators
+    createdAt: DateOperators
+    updatedAt: DateOperators
+}

+ 20 - 0
server/src/common/generate-public-id.ts

@@ -0,0 +1,20 @@
+import generate = require('nanoid/generate');
+
+/**
+ * Generates a random, human-readable string of numbers and upper-case letters
+ * for use as public-facing identifiers for things like order or customers.
+ *
+ * The restriction to only uppercase letters and numbers is intended to make
+ * reading and reciting the generated string easier and less error-prone for people.
+ * Note that the letters "O" and "I" are also omitted because they are easily
+ * confused with 0 and 1 respectively.
+ *
+ * There is a trade-off between the length of the string and the probability
+ * of collisions (the same ID being generated twice). We are using a length of
+ * 16, which according to calculations (https://zelark.github.io/nano-id-cc/)
+ * would require IDs to be generated at a rate of 1000/hour for 29k years to
+ * reach a probability of 1% that a collision would occur.
+ */
+export function generatePublicId(): string {
+    return generate('0123456789ABCDEFGHJKLMNPQRSTUVWXYZ', 16);
+}

+ 3 - 1
server/src/common/types/permission.graphql

@@ -1,9 +1,11 @@
-" Permissions for administrators "
+" Permissions for administrators and customers "
 enum Permission {
     " The Authenticated role means simply that the user is logged in "
     Authenticated
     " SuperAdmin can perform the most sensitive tasks "
     SuperAdmin
+    " Owner means the user owns this entity, e.g. a Customer's own Order"
+    Owner
 
     CreateCatalog
     ReadCatalog

+ 2 - 2
server/src/config/config.service.ts

@@ -28,8 +28,8 @@ export class ConfigService implements VendureConfig {
         }
     }
 
-    get authOptions(): AuthOptions {
-        return this.activeConfig.authOptions;
+    get authOptions(): Required<AuthOptions> {
+        return this.activeConfig.authOptions as Required<AuthOptions>;
     }
 
     get channelTokenKey(): string {

+ 1 - 0
server/src/config/default-config.ts

@@ -25,6 +25,7 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
         disableAuth: false,
         tokenMethod: 'cookie',
         sessionSecret: 'session-secret',
+        authTokenHeaderKey: 'vendure-auth-token',
         sessionDuration: '7d',
     },
     apiPath: API_PATH,

+ 4 - 0
server/src/config/vendure-config.ts

@@ -41,6 +41,10 @@ export interface AuthOptions {
      * See https://stackoverflow.com/a/30090120/772859
      */
     sessionSecret?: string;
+    /**
+     * Sets the header property which will be used to send the auth token when using the "bearer" method.
+     */
+    authTokenHeaderKey?: string;
     /**
      * Session duration, i.e. the time which must elapse from the last authenticted request
      * after which the user must re-authenticate.

+ 0 - 3
server/src/entity/session/anonymous-session.entity.ts

@@ -10,7 +10,4 @@ export class AnonymousSession extends Session {
     constructor(input: DeepPartial<AnonymousSession>) {
         super(input);
     }
-
-    @ManyToOne(type => Order)
-    order: Order;
 }

+ 4 - 0
server/src/entity/session/session.entity.ts

@@ -3,6 +3,7 @@ import { Column, Entity, Index, ManyToOne, TableInheritance } from 'typeorm';
 
 import { VendureEntity } from '../base/base.entity';
 import { Customer } from '../customer/customer.entity';
+import { Order } from '../order/order.entity';
 import { User } from '../user/user.entity';
 
 @Entity()
@@ -15,4 +16,7 @@ export abstract class Session extends VendureEntity {
     @Column() expires: Date;
 
     @Column() invalidated: boolean;
+
+    @ManyToOne(type => Order)
+    activeOrder?: Order;
 }

+ 3 - 1
server/src/i18n/messages/en.json

@@ -3,6 +3,8 @@
     "cannot-modify-role": "The role '{ roleCode }' cannot be modified",
     "entity-has-no-translation-in-language": "Translatable entity '{ entityName }' has not been translated into the requested language ({ languageCode })",
     "entity-with-id-not-found": "No { entityName } with the id '{ id }' could be found",
-    "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }"
+    "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }",
+    "order-does-not-contain-item-with-id": "This order does not contain an OrderItem with the id { id }",
+    "order-item-quantity-must-be-positive": "{ quantity } is not a valid quantity for an OrderItem"
   }
 }

+ 33 - 7
server/src/service/providers/auth.service.ts

@@ -6,12 +6,17 @@ import { ID } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
 import { ConfigService } from '../../config/config.service';
+import { Order } from '../../entity/order/order.entity';
+import { AnonymousSession } from '../../entity/session/anonymous-session.entity';
 import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
 import { Session } from '../../entity/session/session.entity';
 import { User } from '../../entity/user/user.entity';
 
 import { PasswordService } from './password.service';
 
+/**
+ * The AuthService manages both authenticated and anonymous sessions.
+ */
 @Injectable()
 export class AuthService {
     private readonly sessionDurationInMs;
@@ -37,12 +42,28 @@ export class AuthService {
         const session = new AuthenticatedSession({
             token,
             user,
-            expires: this.getExpiryDate(),
+            expires: this.getExpiryDate(this.sessionDurationInMs),
             invalidated: false,
         });
         await this.invalidateUserSessions(user);
         // save the new session
-        const newSession = await this.connection.getRepository(Session).save(session);
+        const newSession = await this.connection.getRepository(AuthenticatedSession).save(session);
+        return newSession;
+    }
+
+    /**
+     * Create an anonymous session.
+     */
+    async createAnonymousSession(): Promise<AnonymousSession> {
+        const token = await this.generateSessionToken();
+        const anonymousSessionDurationInMs = ms('1y');
+        const session = new AnonymousSession({
+            token,
+            expires: this.getExpiryDate(anonymousSessionDurationInMs),
+            invalidated: false,
+        });
+        // save the new session
+        const newSession = await this.connection.getRepository(AnonymousSession).save(session);
         return newSession;
     }
 
@@ -52,7 +73,7 @@ export class AuthService {
     async validateSession(token: string): Promise<Session | undefined> {
         const session = await this.connection.getRepository(Session).findOne({
             where: { token, invalidated: false },
-            relations: ['user', 'user.roles', 'user.roles.channels'],
+            relations: ['user', 'user.roles', 'user.roles.channels', 'activeOrder'],
         });
         if (session && session.expires > new Date()) {
             await this.updateSessionExpiry(session);
@@ -60,6 +81,11 @@ export class AuthService {
         }
     }
 
+    async setActiveOrder<T extends Session>(session: T, order: Order): Promise<T> {
+        session.activeOrder = order;
+        return this.connection.getRepository(Session).save(session);
+    }
+
     /**
      * Invalidates all existing sessions for the given user.
      */
@@ -122,14 +148,14 @@ export class AuthService {
         if (session.expires.getTime() - now < this.sessionDurationInMs / 2) {
             await this.connection
                 .getRepository(Session)
-                .update({ id: session.id }, { expires: this.getExpiryDate() });
+                .update({ id: session.id }, { expires: this.getExpiryDate(this.sessionDurationInMs) });
         }
     }
 
     /**
-     * Returns a future expiry date according to the configured sessionDuration.
+     * Returns a future expiry date according timeToExpireInMs in the future.
      */
-    private getExpiryDate(): Date {
-        return new Date(Date.now() + this.sessionDurationInMs);
+    private getExpiryDate(timeToExpireInMs: number): Date {
+        return new Date(Date.now() + timeToExpireInMs);
     }
 }

+ 135 - 0
server/src/service/providers/order.service.ts

@@ -0,0 +1,135 @@
+import { InjectConnection } from '@nestjs/typeorm';
+import { ID, PaginatedList } from 'shared/shared-types';
+import { Connection } from 'typeorm';
+
+import { RequestContext } from '../../api/common/request-context';
+import { generatePublicId } from '../../common/generate-public-id';
+import { ListQueryOptions } from '../../common/types/common-types';
+import { assertFound, idsAreEqual } from '../../common/utils';
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { Order } from '../../entity/order/order.entity';
+import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
+import { I18nError } from '../../i18n/i18n-error';
+import { buildListQuery } from '../helpers/build-list-query';
+
+import { ProductVariantService } from './product-variant.service';
+
+export class OrderService {
+    constructor(
+        @InjectConnection() private connection: Connection,
+        private productVariantService: ProductVariantService,
+    ) {}
+
+    findAll(ctx: RequestContext, options?: ListQueryOptions<Order>): Promise<PaginatedList<Order>> {
+        return buildListQuery(this.connection, Order, options, [])
+            .getManyAndCount()
+            .then(([items, totalItems]) => {
+                return {
+                    items,
+                    totalItems,
+                };
+            });
+    }
+
+    findOne(ctx: RequestContext, orderId: ID): Promise<Order | undefined> {
+        return this.connection.getRepository(Order).findOne(orderId, {
+            relations: ['items', 'items.productVariant'],
+        });
+    }
+
+    create(): Promise<Order> {
+        const newOrder = new Order({
+            code: generatePublicId(),
+            items: [],
+        });
+        return this.connection.getRepository(Order).save(newOrder);
+    }
+
+    async addItemToOrder(
+        ctx: RequestContext,
+        orderId: ID,
+        productVariantId: ID,
+        quantity: number,
+    ): Promise<Order> {
+        this.assertQuantityIsPositive(quantity);
+        const order = await this.getOrderOrThrow(ctx, orderId);
+        const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId);
+        const existingItem = order.items.find(item => idsAreEqual(item.productVariant.id, productVariantId));
+
+        if (existingItem) {
+            existingItem.quantity += quantity;
+            await this.connection.getRepository(OrderItem).save(existingItem);
+        } else {
+            const orderItem = new OrderItem({
+                quantity,
+                productVariant,
+                unitPrice: productVariant.price,
+            });
+            const newOrderItem = await this.connection.getRepository(OrderItem).save(orderItem);
+            order.items.push(newOrderItem);
+            await this.connection.getRepository(Order).save(order);
+        }
+        return assertFound(this.findOne(ctx, order.id));
+    }
+
+    async adjustItemQuantity(
+        ctx: RequestContext,
+        orderId: ID,
+        orderItemId: ID,
+        quantity: number,
+    ): Promise<Order> {
+        this.assertQuantityIsPositive(quantity);
+        const order = await this.getOrderOrThrow(ctx, orderId);
+        const orderItem = this.getOrderItemOrThrow(order, orderItemId);
+        orderItem.quantity = quantity;
+        await this.connection.getRepository(OrderItem).save(orderItem);
+        return assertFound(this.findOne(ctx, order.id));
+    }
+
+    async removeItemFromOrder(ctx: RequestContext, orderId: ID, orderItemId: ID): Promise<Order> {
+        const order = await this.getOrderOrThrow(ctx, orderId);
+        const orderItem = this.getOrderItemOrThrow(order, orderItemId);
+        order.items = order.items.filter(item => !idsAreEqual(item.id, orderItemId));
+        await this.connection.getRepository(Order).save(order);
+        return assertFound(this.findOne(ctx, order.id));
+    }
+
+    private async getOrderOrThrow(ctx: RequestContext, orderId: ID): Promise<Order> {
+        const order = await this.findOne(ctx, orderId);
+        if (!order) {
+            throw new I18nError('error.entity-with-id-not-found', { entityName: 'Order', id: orderId });
+        }
+        return order;
+    }
+
+    private async getProductVariantOrThrow(
+        ctx: RequestContext,
+        productVariantId: ID,
+    ): Promise<ProductVariant> {
+        const productVariant = await this.productVariantService.findOne(ctx, productVariantId);
+        if (!productVariant) {
+            throw new I18nError('error.entity-with-id-not-found', {
+                entityName: 'ProductVariant',
+                id: productVariantId,
+            });
+        }
+        return productVariant;
+    }
+
+    private getOrderItemOrThrow(order: Order, orderItemId: ID): OrderItem {
+        const orderItem = order.items.find(item => idsAreEqual(item.id, orderItemId));
+        if (!orderItem) {
+            throw new I18nError(`error.order-does-not-contain-item-with-id`, { id: orderItemId });
+        }
+        return orderItem;
+    }
+
+    /**
+     * Throws if quantity is negative.
+     */
+    private assertQuantityIsPositive(quantity: number) {
+        if (quantity < 0) {
+            throw new I18nError(`error.order-item-quantity-must-be-positive`, { quantity });
+        }
+    }
+}

+ 24 - 0
server/src/service/providers/product-variant.service.ts

@@ -30,6 +30,18 @@ export class ProductVariantService {
         private translationUpdaterService: TranslationUpdaterService,
     ) {}
 
+    findOne(ctx: RequestContext, productVariantId: ID): Promise<Translated<ProductVariant> | undefined> {
+        const relations = [];
+        return this.connection
+            .getRepository(ProductVariant)
+            .findOne(productVariantId, { relations })
+            .then(result => {
+                if (result) {
+                    return translateDeep(this.applyChannelPrice(result, ctx.channelId), ctx.languageCode);
+                }
+            });
+    }
+
     async create(
         ctx: RequestContext,
         product: Product,
@@ -130,6 +142,18 @@ export class ProductVariantService {
         return variants.map(v => translateDeep(v, DEFAULT_LANGUAGE_CODE, ['options', 'facetValues']));
     }
 
+    /**
+     * Populates the `price` field with the price for the specified channel.
+     */
+    applyChannelPrice(variant: ProductVariant, channelId: ID): ProductVariant {
+        const channelPrice = variant.productVariantPrices.find(p => idsAreEqual(p.channelId, channelId));
+        if (!channelPrice) {
+            throw new I18nError(`error.no-price-found-for-channel`);
+        }
+        variant.price = channelPrice.price;
+        return variant;
+    }
+
     private createVariantName(productName: string, options: ProductOption[]): string {
         const optionsSuffix = options
             .map(option => {

+ 5 - 7
server/src/service/providers/product.service.ts

@@ -20,6 +20,7 @@ import { updateTranslatable } from '../helpers/update-translatable';
 
 import { AssetService } from './asset.service';
 import { ChannelService } from './channel.service';
+import { ProductVariantService } from './product-variant.service';
 
 @Injectable()
 export class ProductService {
@@ -28,6 +29,7 @@ export class ProductService {
         private translationUpdaterService: TranslationUpdaterService,
         private channelService: ChannelService,
         private assetService: AssetService,
+        private productVariantService: ProductVariantService,
     ) {}
 
     findAll(
@@ -155,13 +157,9 @@ export class ProductService {
     }
 
     private applyChannelPriceToVariants<T extends Product>(product: T, ctx: RequestContext): T {
-        product.variants.forEach(v => {
-            const channelPrice = v.productVariantPrices.find(p => idsAreEqual(p.channelId, ctx.channelId));
-            if (!channelPrice) {
-                throw new I18nError(`error.no-price-found-for-channel`);
-            }
-            v.price = channelPrice.price;
-        });
+        product.variants = product.variants.map(v =>
+            this.productVariantService.applyChannelPrice(v, ctx.channelId),
+        );
         return product;
     }
 

+ 2 - 0
server/src/service/service.module.ts

@@ -12,6 +12,7 @@ import { ChannelService } from './providers/channel.service';
 import { CustomerService } from './providers/customer.service';
 import { FacetValueService } from './providers/facet-value.service';
 import { FacetService } from './providers/facet.service';
+import { OrderService } from './providers/order.service';
 import { PasswordService } from './providers/password.service';
 import { ProductOptionGroupService } from './providers/product-option-group.service';
 import { ProductOptionService } from './providers/product-option.service';
@@ -27,6 +28,7 @@ const exportedProviders = [
     CustomerService,
     FacetService,
     FacetValueService,
+    OrderService,
     ProductOptionService,
     ProductOptionGroupService,
     ProductService,

+ 10 - 0
server/yarn.lock

@@ -249,6 +249,12 @@
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b"
 
+"@types/nanoid@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@types/nanoid/-/nanoid-1.2.0.tgz#c0141cfec5b4818016355389b0bd539097d872ff"
+  dependencies:
+    "@types/node" "*"
+
 "@types/node@*":
   version "10.5.2"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-10.5.2.tgz#f19f05314d5421fe37e74153254201a7bf00a707"
@@ -3934,6 +3940,10 @@ nan@2.11.0, nan@^2.11.0:
   version "2.11.0"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.0.tgz#574e360e4d954ab16966ec102c0c049fd961a099"
 
+nanoid@^1.2.4:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-1.2.4.tgz#4f0e19e7e63341e9fe9adaa8d96ba594f5dcc6db"
+
 nanomatch@^1.2.9:
   version "1.2.13"
   resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"

+ 2 - 1
shared/generated-types.ts

@@ -2119,7 +2119,7 @@ export enum LanguageCode {
 }
 
 /**
- *  Permissions for administrators 
+ *  Permissions for administrators and customers 
  */
 export enum Permission {
   Authenticated = "Authenticated",
@@ -2131,6 +2131,7 @@ export enum Permission {
   DeleteCatalog = "DeleteCatalog",
   DeleteCustomer = "DeleteCustomer",
   DeleteOrder = "DeleteOrder",
+  Owner = "Owner",
   ReadAdministrator = "ReadAdministrator",
   ReadCatalog = "ReadCatalog",
   ReadCustomer = "ReadCustomer",

部分文件因文件數量過多而無法顯示