Browse Source

feat(core): Channel aware assets

Relates to #677

BREAKING CHANGE: New DB relation Asset to Channel, requiring a migration. The Admin API mutations `deleteAsset` and `deleteAssets` have changed their argument signature.
Martijn 4 years ago
parent
commit
4ea74e277f

+ 27 - 6
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -325,6 +325,8 @@ export type Mutation = {
   addNoteToOrder: Order;
   /** Add an OptionGroup to a Product */
   addOptionGroupToProduct: Product;
+  /** Assign assets to channel */
+  assignAssetsToChannel: Array<Asset>;
   /** Assigns ProductVariants to the specified Channel */
   assignProductVariantsToChannel: Array<ProductVariant>;
   /** Assigns all ProductVariants of Product to the specified Channel */
@@ -547,6 +549,11 @@ export type MutationAddOptionGroupToProductArgs = {
 };
 
 
+export type MutationAssignAssetsToChannelArgs = {
+  input: AssignAssetsToChannelInput;
+};
+
+
 export type MutationAssignProductVariantsToChannelArgs = {
   input: AssignProductVariantsToChannelInput;
 };
@@ -697,14 +704,12 @@ export type MutationDeleteAdministratorArgs = {
 
 
 export type MutationDeleteAssetArgs = {
-  id: Scalars['ID'];
-  force?: Maybe<Scalars['Boolean']>;
+  input: DeleteAssetInput;
 };
 
 
 export type MutationDeleteAssetsArgs = {
-  ids: Array<Scalars['ID']>;
-  force?: Maybe<Scalars['Boolean']>;
+  input: DeleteAssetsInput;
 };
 
 
@@ -1144,6 +1149,18 @@ export type CoordinateInput = {
   y: Scalars['Float'];
 };
 
+export type DeleteAssetInput = {
+  assetId: Scalars['ID'];
+  force?: Maybe<Scalars['Boolean']>;
+  deleteFromAllChannels?: Maybe<Scalars['Boolean']>;
+};
+
+export type DeleteAssetsInput = {
+  assetIds: Array<Scalars['ID']>;
+  force?: Maybe<Scalars['Boolean']>;
+  deleteFromAllChannels?: Maybe<Scalars['Boolean']>;
+};
+
 export type UpdateAssetInput = {
   id: Scalars['ID'];
   name?: Maybe<Scalars['String']>;
@@ -1151,6 +1168,11 @@ export type UpdateAssetInput = {
   tags?: Maybe<Array<Scalars['String']>>;
 };
 
+export type AssignAssetsToChannelInput = {
+  assetIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+};
+
 export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
 
 export type AuthenticationResult = CurrentUser | InvalidCredentialsError;
@@ -6423,8 +6445,7 @@ export type UpdateAssetMutation = { updateAsset: (
   ) };
 
 export type DeleteAssetsMutationVariables = Exact<{
-  ids: Array<Scalars['ID']>;
-  force?: Maybe<Scalars['Boolean']>;
+  input: DeleteAssetsInput;
 }>;
 
 

+ 2 - 2
packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts

@@ -439,8 +439,8 @@ export const UPDATE_ASSET = gql`
 `;
 
 export const DELETE_ASSETS = gql`
-    mutation DeleteAssets($ids: [ID!]!, $force: Boolean) {
-        deleteAssets(ids: $ids, force: $force) {
+    mutation DeleteAssets($input: DeleteAssetsInput!) {
+        deleteAssets(input: $input) {
             result
             message
         }

+ 4 - 2
packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts

@@ -348,8 +348,10 @@ export class ProductDataService {
 
     deleteAssets(ids: string[], force: boolean) {
         return this.baseDataService.mutate<DeleteAssets.Mutation, DeleteAssets.Variables>(DELETE_ASSETS, {
-            ids,
-            force,
+            input: {
+                assetIds: ids,
+                force,
+            },
         });
     }
 

+ 7 - 5
packages/asset-server-plugin/e2e/asset-server-plugin.e2e-spec.ts

@@ -7,7 +7,7 @@ import fetch from 'node-fetch';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 import { AssetServerPlugin } from '../src/plugin';
 
 import { CreateAssets, DeleteAsset, DeletionResult } from './graphql/generated-e2e-asset-server-plugin-types';
@@ -172,8 +172,10 @@ describe('AssetServerPlugin', () => {
             const { deleteAsset } = await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(
                 DELETE_ASSET,
                 {
-                    id: asset.id,
-                    force: true,
+                    input: {
+                        assetId: asset.id,
+                        force: true,
+                    },
                 },
             );
 
@@ -254,8 +256,8 @@ export const CREATE_ASSETS = gql`
 `;
 
 export const DELETE_ASSET = gql`
-    mutation DeleteAsset($id: ID!, $force: Boolean!) {
-        deleteAsset(id: $id, force: $force) {
+    mutation DeleteAsset($input: DeleteAssetInput!) {
+        deleteAsset(input: $input) {
             result
         }
     }

+ 26 - 6
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -278,6 +278,8 @@ export type Mutation = {
     deleteAsset: DeletionResponse;
     /** Delete multiple Assets */
     deleteAssets: DeletionResponse;
+    /** Assign assets to channel */
+    assignAssetsToChannel: Array<Asset>;
     /** Authenticates the user using the native authentication strategy. This mutation is an alias for `authenticate({ native: { ... }})` */
     login: NativeAuthenticationResult;
     /** Authenticates the user using a named authentication strategy */
@@ -481,13 +483,15 @@ export type MutationUpdateAssetArgs = {
 };
 
 export type MutationDeleteAssetArgs = {
-    id: Scalars['ID'];
-    force?: Maybe<Scalars['Boolean']>;
+    input: DeleteAssetInput;
 };
 
 export type MutationDeleteAssetsArgs = {
-    ids: Array<Scalars['ID']>;
-    force?: Maybe<Scalars['Boolean']>;
+    input: DeleteAssetsInput;
+};
+
+export type MutationAssignAssetsToChannelArgs = {
+    input: AssignAssetsToChannelInput;
 };
 
 export type MutationLoginArgs = {
@@ -958,6 +962,18 @@ export type CoordinateInput = {
     y: Scalars['Float'];
 };
 
+export type DeleteAssetInput = {
+    assetId: Scalars['ID'];
+    force?: Maybe<Scalars['Boolean']>;
+    deleteFromAllChannels?: Maybe<Scalars['Boolean']>;
+};
+
+export type DeleteAssetsInput = {
+    assetIds: Array<Scalars['ID']>;
+    force?: Maybe<Scalars['Boolean']>;
+    deleteFromAllChannels?: Maybe<Scalars['Boolean']>;
+};
+
 export type UpdateAssetInput = {
     id: Scalars['ID'];
     name?: Maybe<Scalars['String']>;
@@ -965,6 +981,11 @@ export type UpdateAssetInput = {
     tags?: Maybe<Array<Scalars['String']>>;
 };
 
+export type AssignAssetsToChannelInput = {
+    assetIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+};
+
 export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
 
 export type AuthenticationResult = CurrentUser | InvalidCredentialsError;
@@ -4519,8 +4540,7 @@ export type CreateAssetsMutation = {
 };
 
 export type DeleteAssetMutationVariables = Exact<{
-    id: Scalars['ID'];
-    force: Scalars['Boolean'];
+    input: DeleteAssetInput;
 }>;
 
 export type DeleteAssetMutation = { deleteAsset: Pick<DeletionResponse, 'result'> };

+ 26 - 4
packages/common/src/generated-types.ts

@@ -323,6 +323,8 @@ export type Mutation = {
   deleteAsset: DeletionResponse;
   /** Delete multiple Assets */
   deleteAssets: DeletionResponse;
+  /** Assign assets to channel */
+  assignAssetsToChannel: Array<Asset>;
   /** Authenticates the user using the native authentication strategy. This mutation is an alias for `authenticate({ native: { ... }})` */
   login: NativeAuthenticationResult;
   /** Authenticates the user using a named authentication strategy */
@@ -534,14 +536,17 @@ export type MutationUpdateAssetArgs = {
 
 
 export type MutationDeleteAssetArgs = {
-  id: Scalars['ID'];
-  force?: Maybe<Scalars['Boolean']>;
+  input: DeleteAssetInput;
 };
 
 
 export type MutationDeleteAssetsArgs = {
-  ids: Array<Scalars['ID']>;
-  force?: Maybe<Scalars['Boolean']>;
+  input: DeleteAssetsInput;
+};
+
+
+export type MutationAssignAssetsToChannelArgs = {
+  input: AssignAssetsToChannelInput;
 };
 
 
@@ -1107,6 +1112,18 @@ export type CoordinateInput = {
   y: Scalars['Float'];
 };
 
+export type DeleteAssetInput = {
+  assetId: Scalars['ID'];
+  force?: Maybe<Scalars['Boolean']>;
+  deleteFromAllChannels?: Maybe<Scalars['Boolean']>;
+};
+
+export type DeleteAssetsInput = {
+  assetIds: Array<Scalars['ID']>;
+  force?: Maybe<Scalars['Boolean']>;
+  deleteFromAllChannels?: Maybe<Scalars['Boolean']>;
+};
+
 export type UpdateAssetInput = {
   id: Scalars['ID'];
   name?: Maybe<Scalars['String']>;
@@ -1114,6 +1131,11 @@ export type UpdateAssetInput = {
   tags?: Maybe<Array<Scalars['String']>>;
 };
 
+export type AssignAssetsToChannelInput = {
+  assetIds: Array<Scalars['ID']>;
+  channelId: Scalars['ID'];
+};
+
 export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
 
 export type AuthenticationResult = CurrentUser | InvalidCredentialsError;

+ 255 - 0
packages/core/e2e/asset-channel.e2e-spec.ts

@@ -0,0 +1,255 @@
+/* tslint:disable:no-non-null-assertion */
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
+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 { DefaultLogger, LogLevel } from '../src/config';
+
+import { ASSET_FRAGMENT } from './graphql/fragments';
+import {
+    AssignAssetsToChannel,
+    AssignProductsToChannel,
+    CreateAssets,
+    CreateChannel,
+    CurrencyCode,
+    DeleteAsset,
+    DeletionResult,
+    GetAsset,
+    GetProductWithVariants,
+    LanguageCode,
+} from './graphql/generated-e2e-admin-types';
+import {
+    ASSIGN_PRODUCT_TO_CHANNEL,
+    CREATE_ASSETS,
+    CREATE_CHANNEL,
+    DELETE_ASSET,
+    GET_ASSET,
+    GET_PRODUCT_WITH_VARIANTS,
+} from './graphql/shared-definitions';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+
+// FIXME
+testConfig.logger = new DefaultLogger({ level: LogLevel.Debug });
+
+const { server, adminClient } = createTestEnvironment(testConfig);
+const SECOND_CHANNEL_TOKEN = 'second_channel_token';
+let createdAssetId: string;
+let channel2Id: string;
+let featuredAssetId: string;
+
+beforeAll(async () => {
+    await server.init({
+        initialData,
+        productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+        customerCount: 1,
+    });
+    await adminClient.asSuperAdmin();
+    const { createChannel } = await adminClient.query<CreateChannel.Mutation, CreateChannel.Variables>(
+        CREATE_CHANNEL,
+        {
+            input: {
+                code: 'second-channel',
+                token: SECOND_CHANNEL_TOKEN,
+                defaultLanguageCode: LanguageCode.en,
+                currencyCode: CurrencyCode.GBP,
+                pricesIncludeTax: true,
+                defaultShippingZoneId: 'T_1',
+                defaultTaxZoneId: 'T_1',
+            },
+        },
+    );
+    channel2Id = createChannel.id;
+}, TEST_SETUP_TIMEOUT_MS);
+
+afterAll(async () => {
+    await server.destroy();
+});
+
+describe('ChannelAware Assets', () => {
+    it('Create asset in default channel', async () => {
+        const filesToUpload = [path.join(__dirname, 'fixtures/assets/pps2.jpg')];
+        const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
+            mutation: CREATE_ASSETS,
+            filePaths: filesToUpload,
+            mapVariables: filePaths => ({
+                input: filePaths.map(p => ({ file: null })),
+            }),
+        });
+
+        expect(createAssets.length).toBe(1);
+        createdAssetId = createAssets[0].id;
+        expect(createdAssetId).toBeDefined();
+    });
+
+    it('Get asset from default channel', async () => {
+        const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+            id: createdAssetId,
+        });
+        expect(asset?.id).toEqual(createdAssetId);
+    });
+
+    it('Asset is not in channel2', async () => {
+        await adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+        const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+            id: createdAssetId,
+        });
+        expect(asset).toBe(null);
+    });
+
+    it('Add asset to channel2', async () => {
+        await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+        const { assignAssetsToChannel: assets } = await adminClient.query<
+            AssignAssetsToChannel.Mutation,
+            AssignAssetsToChannel.Variables
+        >(ASSIGN_ASSET_TO_CHANNEL, {
+            input: {
+                assetIds: [createdAssetId],
+                channelId: channel2Id,
+            },
+        });
+        expect(assets[0].id).toBe(createdAssetId);
+    });
+
+    it('Get asset from channel2', async () => {
+        await adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+        const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+            id: createdAssetId,
+        });
+        expect(asset?.id).toBe(createdAssetId);
+    });
+
+    it('Delete asset from channel2', async () => {
+        const { deleteAsset } = await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(
+            DELETE_ASSET,
+            {
+                input: {
+                    assetId: createdAssetId,
+                },
+            },
+        );
+        expect(deleteAsset.result).toBe(DeletionResult.DELETED);
+    });
+
+    it('Asset is available in default channel', async () => {
+        await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+        const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+            id: createdAssetId,
+        });
+        expect(asset?.id).toEqual(createdAssetId);
+    });
+
+    it('Add asset to channel2', async () => {
+        await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+        const { assignAssetsToChannel: assets } = await adminClient.query<
+            AssignAssetsToChannel.Mutation,
+            AssignAssetsToChannel.Variables
+        >(ASSIGN_ASSET_TO_CHANNEL, {
+            input: {
+                assetIds: [createdAssetId],
+                channelId: channel2Id,
+            },
+        });
+        expect(assets[0].id).toBe(createdAssetId);
+    });
+
+    it(
+        'Delete asset from all channels with insufficient permission',
+        assertThrowsWithMessage(async () => {
+            await adminClient.asAnonymousUser();
+            const { deleteAsset } = await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(
+                DELETE_ASSET,
+                {
+                    input: {
+                        assetId: createdAssetId,
+                        deleteFromAllChannels: true,
+                    },
+                },
+            );
+        }, `You are not currently authorized to perform this action`),
+    );
+
+    it('Delete asset from all channels as superadmin', async () => {
+        await adminClient.asSuperAdmin();
+        await adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+        const { deleteAsset } = await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(
+            DELETE_ASSET,
+            {
+                input: {
+                    assetId: createdAssetId,
+                    deleteFromAllChannels: true,
+                },
+            },
+        );
+        expect(deleteAsset.result).toEqual(DeletionResult.DELETED);
+    });
+
+    it('Asset is also deleted in default channel', async () => {
+        await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+        const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+            id: createdAssetId,
+        });
+        expect(asset?.id).toBeUndefined();
+    });
+});
+
+describe('Product related assets', () => {
+    it('Featured asset is available in default channel', async () => {
+        await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+        const { product } = await adminClient.query<
+            GetProductWithVariants.Query,
+            GetProductWithVariants.Variables
+        >(GET_PRODUCT_WITH_VARIANTS, {
+            id: 'T_1',
+        });
+        featuredAssetId = product!.featuredAsset!.id;
+        expect(featuredAssetId).toBeDefined();
+
+        await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+        const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+            id: featuredAssetId,
+        });
+        expect(asset?.id).toEqual(featuredAssetId);
+    });
+
+    it('Featured asset is not available in channel2', async () => {
+        await adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+        const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+            id: featuredAssetId,
+        });
+        expect(asset?.id).toBeUndefined();
+    });
+
+    it('Add Product to channel2', async () => {
+        await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+        const { assignProductsToChannel } = await adminClient.query<
+            AssignProductsToChannel.Mutation,
+            AssignProductsToChannel.Variables
+        >(ASSIGN_PRODUCT_TO_CHANNEL, {
+            input: {
+                channelId: channel2Id,
+                productIds: ['T_1'],
+            },
+        });
+        expect(assignProductsToChannel[0].id).toEqual('T_1');
+        expect(assignProductsToChannel[0].channels.map(c => c.id)).toContain(channel2Id);
+    });
+
+    it('Get featured asset from channel2', async () => {
+        await adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+        const { asset } = await adminClient.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+            id: featuredAssetId,
+        });
+        expect(asset?.id).toEqual(featuredAssetId);
+    });
+});
+
+export const ASSIGN_ASSET_TO_CHANNEL = gql`
+    mutation assignAssetsToChannel($input: AssignAssetsToChannelInput!) {
+        assignAssetsToChannel(input: $input) {
+            ...Asset
+        }
+    }
+    ${ASSET_FRAGMENT}
+`;

+ 13 - 54
packages/core/e2e/asset.e2e-spec.ts

@@ -3,13 +3,11 @@ import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
 import { mergeConfig } from '@vendure/core';
 import { createTestEnvironment } from '@vendure/testing';
-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 { ASSET_FRAGMENT } from './graphql/fragments';
 import {
     AssetFragment,
     CreateAssets,
@@ -24,7 +22,10 @@ import {
     UpdateAsset,
 } from './graphql/generated-e2e-admin-types';
 import {
+    CREATE_ASSETS,
     DELETE_ASSET,
+    GET_ASSET,
+    GET_ASSET_FRAGMENT_FIRST,
     GET_ASSET_LIST,
     GET_PRODUCT_WITH_VARIANTS,
     UPDATE_ASSET,
@@ -440,7 +441,9 @@ describe('Asset resolver', () => {
             const { deleteAsset } = await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(
                 DELETE_ASSET,
                 {
-                    id: createdAssetId,
+                    input: {
+                        assetId: createdAssetId,
+                    },
                 },
             );
 
@@ -456,7 +459,9 @@ describe('Asset resolver', () => {
             const { deleteAsset } = await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(
                 DELETE_ASSET,
                 {
-                    id: firstProduct.featuredAsset!.id,
+                    input: {
+                        assetId: firstProduct.featuredAsset!.id,
+                    },
                 },
             );
 
@@ -481,8 +486,10 @@ describe('Asset resolver', () => {
             const { deleteAsset } = await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(
                 DELETE_ASSET,
                 {
-                    id: firstProduct.featuredAsset!.id,
-                    force: true,
+                    input: {
+                        assetId: firstProduct.featuredAsset!.id,
+                        force: true,
+                    },
                 },
             );
 
@@ -504,51 +511,3 @@ describe('Asset resolver', () => {
         });
     });
 });
-
-export const GET_ASSET = gql`
-    query GetAsset($id: ID!) {
-        asset(id: $id) {
-            ...Asset
-            width
-            height
-        }
-    }
-    ${ASSET_FRAGMENT}
-`;
-
-export const GET_ASSET_FRAGMENT_FIRST = gql`
-    fragment AssetFragFirst on Asset {
-        id
-        preview
-    }
-
-    query GetAssetFragmentFirst($id: ID!) {
-        asset(id: $id) {
-            ...AssetFragFirst
-        }
-    }
-`;
-
-export const CREATE_ASSETS = gql`
-    mutation CreateAssets($input: [CreateAssetInput!]!) {
-        createAssets(input: $input) {
-            ...Asset
-            ... on Asset {
-                focalPoint {
-                    x
-                    y
-                }
-                tags {
-                    id
-                    value
-                }
-            }
-            ... on MimeTypeError {
-                message
-                fileName
-                mimeType
-            }
-        }
-    }
-    ${ASSET_FRAGMENT}
-`;

+ 4 - 2
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -769,8 +769,10 @@ describe('Default search plugin', () => {
                     expect(assetId).toBeTruthy();
 
                     await adminClient.query<DeleteAsset.Mutation, DeleteAsset.Variables>(DELETE_ASSET, {
-                        id: assetId!,
-                        force: true,
+                        input: {
+                            assetId: assetId!,
+                            force: true,
+                        },
                     });
 
                     await awaitRunningJobs(adminClient);

+ 106 - 72
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -278,6 +278,8 @@ export type Mutation = {
     deleteAsset: DeletionResponse;
     /** Delete multiple Assets */
     deleteAssets: DeletionResponse;
+    /** Assign assets to channel */
+    assignAssetsToChannel: Array<Asset>;
     /** Authenticates the user using the native authentication strategy. This mutation is an alias for `authenticate({ native: { ... }})` */
     login: NativeAuthenticationResult;
     /** Authenticates the user using a named authentication strategy */
@@ -481,13 +483,15 @@ export type MutationUpdateAssetArgs = {
 };
 
 export type MutationDeleteAssetArgs = {
-    id: Scalars['ID'];
-    force?: Maybe<Scalars['Boolean']>;
+    input: DeleteAssetInput;
 };
 
 export type MutationDeleteAssetsArgs = {
-    ids: Array<Scalars['ID']>;
-    force?: Maybe<Scalars['Boolean']>;
+    input: DeleteAssetsInput;
+};
+
+export type MutationAssignAssetsToChannelArgs = {
+    input: AssignAssetsToChannelInput;
 };
 
 export type MutationLoginArgs = {
@@ -958,6 +962,18 @@ export type CoordinateInput = {
     y: Scalars['Float'];
 };
 
+export type DeleteAssetInput = {
+    assetId: Scalars['ID'];
+    force?: Maybe<Scalars['Boolean']>;
+    deleteFromAllChannels?: Maybe<Scalars['Boolean']>;
+};
+
+export type DeleteAssetsInput = {
+    assetIds: Array<Scalars['ID']>;
+    force?: Maybe<Scalars['Boolean']>;
+    deleteFromAllChannels?: Maybe<Scalars['Boolean']>;
+};
+
 export type UpdateAssetInput = {
     id: Scalars['ID'];
     name?: Maybe<Scalars['String']>;
@@ -965,6 +981,11 @@ export type UpdateAssetInput = {
     tags?: Maybe<Array<Scalars['String']>>;
 };
 
+export type AssignAssetsToChannelInput = {
+    assetIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+};
+
 export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
 
 export type AuthenticationResult = CurrentUser | InvalidCredentialsError;
@@ -4552,33 +4573,11 @@ export type Q2QueryVariables = Exact<{ [key: string]: never }>;
 
 export type Q2Query = { product?: Maybe<Pick<Product, 'id' | 'name'>> };
 
-export type GetAssetQueryVariables = Exact<{
-    id: Scalars['ID'];
+export type AssignAssetsToChannelMutationVariables = Exact<{
+    input: AssignAssetsToChannelInput;
 }>;
 
-export type GetAssetQuery = { asset?: Maybe<Pick<Asset, 'width' | 'height'> & AssetFragment> };
-
-export type AssetFragFirstFragment = Pick<Asset, 'id' | 'preview'>;
-
-export type GetAssetFragmentFirstQueryVariables = Exact<{
-    id: Scalars['ID'];
-}>;
-
-export type GetAssetFragmentFirstQuery = { asset?: Maybe<AssetFragFirstFragment> };
-
-export type CreateAssetsMutationVariables = Exact<{
-    input: Array<CreateAssetInput>;
-}>;
-
-export type CreateAssetsMutation = {
-    createAssets: Array<
-        | ({
-              focalPoint?: Maybe<Pick<Coordinate, 'x' | 'y'>>;
-              tags: Array<Pick<Tag, 'id' | 'value'>>;
-          } & AssetFragment)
-        | Pick<MimeTypeError, 'message' | 'fileName' | 'mimeType'>
-    >;
-};
+export type AssignAssetsToChannelMutation = { assignAssetsToChannel: Array<AssetFragment> };
 
 export type CanCreateCustomerMutationVariables = Exact<{
     input: CreateCustomerInput;
@@ -5531,8 +5530,7 @@ export type UpdateAssetMutation = {
 };
 
 export type DeleteAssetMutationVariables = Exact<{
-    id: Scalars['ID'];
-    force?: Maybe<Scalars['Boolean']>;
+    input: DeleteAssetInput;
 }>;
 
 export type DeleteAssetMutation = { deleteAsset: Pick<DeletionResponse, 'result' | 'message'> };
@@ -5872,6 +5870,34 @@ export type UpdateShippingMethodMutationVariables = Exact<{
 
 export type UpdateShippingMethodMutation = { updateShippingMethod: ShippingMethodFragment };
 
+export type GetAssetQueryVariables = Exact<{
+    id: Scalars['ID'];
+}>;
+
+export type GetAssetQuery = { asset?: Maybe<Pick<Asset, 'width' | 'height'> & AssetFragment> };
+
+export type AssetFragFirstFragment = Pick<Asset, 'id' | 'preview'>;
+
+export type GetAssetFragmentFirstQueryVariables = Exact<{
+    id: Scalars['ID'];
+}>;
+
+export type GetAssetFragmentFirstQuery = { asset?: Maybe<AssetFragFirstFragment> };
+
+export type CreateAssetsMutationVariables = Exact<{
+    input: Array<CreateAssetInput>;
+}>;
+
+export type CreateAssetsMutation = {
+    createAssets: Array<
+        | ({
+              focalPoint?: Maybe<Pick<Coordinate, 'x' | 'y'>>;
+              tags: Array<Pick<Tag, 'id' | 'value'>>;
+          } & AssetFragment)
+        | Pick<MimeTypeError, 'message' | 'fileName' | 'mimeType'>
+    >;
+};
+
 export type CancelJobMutationVariables = Exact<{
     id: Scalars['ID'];
 }>;
@@ -6559,47 +6585,11 @@ export namespace Q2 {
     export type Product = NonNullable<Q2Query['product']>;
 }
 
-export namespace GetAsset {
-    export type Variables = GetAssetQueryVariables;
-    export type Query = GetAssetQuery;
-    export type Asset = NonNullable<GetAssetQuery['asset']>;
-}
-
-export namespace AssetFragFirst {
-    export type Fragment = AssetFragFirstFragment;
-}
-
-export namespace GetAssetFragmentFirst {
-    export type Variables = GetAssetFragmentFirstQueryVariables;
-    export type Query = GetAssetFragmentFirstQuery;
-    export type Asset = NonNullable<GetAssetFragmentFirstQuery['asset']>;
-}
-
-export namespace CreateAssets {
-    export type Variables = CreateAssetsMutationVariables;
-    export type Mutation = CreateAssetsMutation;
-    export type CreateAssets = NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>;
-    export type AssetInlineFragment = DiscriminateUnion<
-        NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>,
-        { __typename?: 'Asset' }
-    >;
-    export type FocalPoint = NonNullable<
-        DiscriminateUnion<
-            NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>,
-            { __typename?: 'Asset' }
-        >['focalPoint']
-    >;
-    export type Tags = NonNullable<
-        NonNullable<
-            DiscriminateUnion<
-                NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>,
-                { __typename?: 'Asset' }
-            >['tags']
-        >[number]
-    >;
-    export type MimeTypeErrorInlineFragment = DiscriminateUnion<
-        NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>,
-        { __typename?: 'MimeTypeError' }
+export namespace AssignAssetsToChannel {
+    export type Variables = AssignAssetsToChannelMutationVariables;
+    export type Mutation = AssignAssetsToChannelMutation;
+    export type AssignAssetsToChannel = NonNullable<
+        NonNullable<AssignAssetsToChannelMutation['assignAssetsToChannel']>[number]
     >;
 }
 
@@ -8011,6 +8001,50 @@ export namespace UpdateShippingMethod {
     export type UpdateShippingMethod = NonNullable<UpdateShippingMethodMutation['updateShippingMethod']>;
 }
 
+export namespace GetAsset {
+    export type Variables = GetAssetQueryVariables;
+    export type Query = GetAssetQuery;
+    export type Asset = NonNullable<GetAssetQuery['asset']>;
+}
+
+export namespace AssetFragFirst {
+    export type Fragment = AssetFragFirstFragment;
+}
+
+export namespace GetAssetFragmentFirst {
+    export type Variables = GetAssetFragmentFirstQueryVariables;
+    export type Query = GetAssetFragmentFirstQuery;
+    export type Asset = NonNullable<GetAssetFragmentFirstQuery['asset']>;
+}
+
+export namespace CreateAssets {
+    export type Variables = CreateAssetsMutationVariables;
+    export type Mutation = CreateAssetsMutation;
+    export type CreateAssets = NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>;
+    export type AssetInlineFragment = DiscriminateUnion<
+        NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>,
+        { __typename?: 'Asset' }
+    >;
+    export type FocalPoint = NonNullable<
+        DiscriminateUnion<
+            NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>,
+            { __typename?: 'Asset' }
+        >['focalPoint']
+    >;
+    export type Tags = NonNullable<
+        NonNullable<
+            DiscriminateUnion<
+                NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>,
+                { __typename?: 'Asset' }
+            >['tags']
+        >[number]
+    >;
+    export type MimeTypeErrorInlineFragment = DiscriminateUnion<
+        NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>,
+        { __typename?: 'MimeTypeError' }
+    >;
+}
+
 export namespace CancelJob {
     export type Variables = CancelJobMutationVariables;
     export type Mutation = CancelJobMutation;

+ 50 - 2
packages/core/e2e/graphql/shared-definitions.ts

@@ -390,8 +390,8 @@ export const UPDATE_ASSET = gql`
 `;
 
 export const DELETE_ASSET = gql`
-    mutation DeleteAsset($id: ID!, $force: Boolean) {
-        deleteAsset(id: $id, force: $force) {
+    mutation DeleteAsset($input: DeleteAssetInput!) {
+        deleteAsset(input: $input) {
             result
             message
         }
@@ -823,3 +823,51 @@ export const UPDATE_SHIPPING_METHOD = gql`
     }
     ${SHIPPING_METHOD_FRAGMENT}
 `;
+
+export const GET_ASSET = gql`
+    query GetAsset($id: ID!) {
+        asset(id: $id) {
+            ...Asset
+            width
+            height
+        }
+    }
+    ${ASSET_FRAGMENT}
+`;
+
+export const GET_ASSET_FRAGMENT_FIRST = gql`
+    fragment AssetFragFirst on Asset {
+        id
+        preview
+    }
+
+    query GetAssetFragmentFirst($id: ID!) {
+        asset(id: $id) {
+            ...AssetFragFirst
+        }
+    }
+`;
+
+export const CREATE_ASSETS = gql`
+    mutation CreateAssets($input: [CreateAssetInput!]!) {
+        createAssets(input: $input) {
+            ...Asset
+            ... on Asset {
+                focalPoint {
+                    x
+                    y
+                }
+                tags {
+                    id
+                    value
+                }
+            }
+            ... on MimeTypeError {
+                message
+                fileName
+                mimeType
+            }
+        }
+    }
+    ${ASSET_FRAGMENT}
+`;

+ 31 - 4
packages/core/src/api/resolvers/admin/asset.resolver.ts

@@ -1,6 +1,7 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     CreateAssetResult,
+    MutationAssignAssetsToChannelArgs,
     MutationCreateAssetsArgs,
     MutationDeleteAssetArgs,
     MutationDeleteAssetsArgs,
@@ -61,14 +62,40 @@ export class AssetResolver {
     @Transaction()
     @Mutation()
     @Allow(Permission.DeleteCatalog)
-    async deleteAsset(@Ctx() ctx: RequestContext, @Args() { id, force }: MutationDeleteAssetArgs) {
-        return this.assetService.delete(ctx, [id], force || undefined);
+    async deleteAsset(
+        @Ctx() ctx: RequestContext,
+        @Args() { input: { assetId, force, deleteFromAllChannels } }: MutationDeleteAssetArgs,
+    ) {
+        return this.assetService.delete(
+            ctx,
+            [assetId],
+            force || undefined,
+            deleteFromAllChannels || undefined,
+        );
     }
 
     @Transaction()
     @Mutation()
     @Allow(Permission.DeleteCatalog)
-    async deleteAssets(@Ctx() ctx: RequestContext, @Args() { ids, force }: MutationDeleteAssetsArgs) {
-        return this.assetService.delete(ctx, ids, force || undefined);
+    async deleteAssets(
+        @Ctx() ctx: RequestContext,
+        @Args() { input: { assetIds, force, deleteFromAllChannels } }: MutationDeleteAssetsArgs,
+    ) {
+        return this.assetService.delete(
+            ctx,
+            assetIds,
+            force || undefined,
+            deleteFromAllChannels || undefined,
+        );
+    }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdateCatalog)
+    async assignAssetsToChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() { input }: MutationAssignAssetsToChannelArgs,
+    ) {
+        return this.assetService.assignToChannel(ctx, input);
     }
 }

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

@@ -11,9 +11,11 @@ type Mutation {
     "Update an existing Asset"
     updateAsset(input: UpdateAssetInput!): Asset!
     "Delete an Asset"
-    deleteAsset(id: ID!, force: Boolean): DeletionResponse!
+    deleteAsset(input: DeleteAssetInput!): DeletionResponse!
     "Delete multiple Assets"
-    deleteAssets(ids: [ID!]!, force: Boolean): DeletionResponse!
+    deleteAssets(input: DeleteAssetsInput!): DeletionResponse!
+    "Assign assets to channel"
+    assignAssetsToChannel(input: AssignAssetsToChannelInput!): [Asset!]!
 }
 
 type MimeTypeError implements ErrorResult {
@@ -41,9 +43,26 @@ input CoordinateInput {
     y: Float!
 }
 
+input DeleteAssetInput {
+    assetId: ID!
+    force: Boolean
+    deleteFromAllChannels: Boolean
+}
+
+input DeleteAssetsInput {
+    assetIds: [ID!]!
+    force: Boolean
+    deleteFromAllChannels: Boolean
+}
+
 input UpdateAssetInput {
     id: ID!
     name: String
     focalPoint: CoordinateInput
     tags: [String!]
 }
+
+input AssignAssetsToChannelInput {
+    assetIds: [ID!]!
+    channelId: ID!
+}

+ 7 - 2
packages/core/src/entity/asset/asset.entity.ts

@@ -2,7 +2,8 @@ import { AssetType } from '@vendure/common/lib/generated-types';
 import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinColumn, JoinTable, ManyToMany, OneToMany, OneToOne } from 'typeorm';
 
-import { Taggable } from '../../common/types/common-types';
+import { Channel } from '..';
+import { ChannelAware, Taggable } from '../../common/types/common-types';
 import { Address } from '../address/address.entity';
 import { VendureEntity } from '../base/base.entity';
 import { CustomCustomerFields } from '../custom-entity-fields';
@@ -17,7 +18,7 @@ import { User } from '../user/user.entity';
  * @docsCategory entities
  */
 @Entity()
-export class Asset extends VendureEntity implements Taggable {
+export class Asset extends VendureEntity implements Taggable, ChannelAware {
     constructor(input?: DeepPartial<Asset>) {
         super(input);
     }
@@ -44,4 +45,8 @@ export class Asset extends VendureEntity implements Taggable {
     @ManyToMany(type => Tag)
     @JoinTable()
     tags: Tag[];
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
 }

+ 24 - 0
packages/core/src/event-bus/events/asset-channel-event.ts

@@ -0,0 +1,24 @@
+import { ID } from '@vendure/common/lib/shared-types';
+
+import { RequestContext } from '../../api/common/request-context';
+import { Asset } from '../../entity';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * @description
+ * This event is fired whenever an {@link Asset} is assigned or removed
+ * From a channel.
+ *
+ * @docsCategory events
+ * @docsPage Event Types
+ */
+export class AssetChannelEvent extends VendureEvent {
+    constructor(
+        public ctx: RequestContext,
+        public asset: Asset,
+        public channelId: ID,
+        public type: 'assigned' | 'removed',
+    ) {
+        super();
+    }
+}

+ 133 - 27
packages/core/src/service/services/asset.service.ts

@@ -2,27 +2,28 @@ import { Injectable } from '@nestjs/common';
 import {
     AssetListOptions,
     AssetType,
+    AssignAssetsToChannelInput,
     CreateAssetInput,
     CreateAssetResult,
     DeletionResponse,
     DeletionResult,
     LogicalOperator,
+    Permission,
     UpdateAssetInput,
 } from '@vendure/common/lib/generated-types';
 import { omit } from '@vendure/common/lib/omit';
 import { ID, PaginatedList, Type } from '@vendure/common/lib/shared-types';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { unique } from '@vendure/common/lib/unique';
 import { ReadStream } from 'fs-extra';
 import mime from 'mime-types';
 import path from 'path';
 import { Stream } from 'stream';
-import { Brackets } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
 import { isGraphQlErrorResult } from '../../common/error/error-result';
-import { InternalServerError } from '../../common/error/errors';
+import { ForbiddenError, InternalServerError } from '../../common/error/errors';
 import { MimeTypeError } from '../../common/error/generated-graphql-admin-errors';
-import { ListQueryOptions } from '../../common/types/common-types';
 import { getAssetType, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { Logger } from '../../config/logger/vendure-logger';
@@ -33,11 +34,14 @@ import { Collection } from '../../entity/collection/collection.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { Product } from '../../entity/product/product.entity';
 import { EventBus } from '../../event-bus/event-bus';
+import { AssetChannelEvent } from '../../event-bus/events/asset-channel-event';
 import { AssetEvent } from '../../event-bus/events/asset-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { patchEntity } from '../helpers/utils/patch-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
+import { ChannelService } from './channel.service';
+import { RoleService } from './role.service';
 import { TagService } from './tag.service';
 // tslint:disable-next-line:no-var-requires
 const sizeOf = require('image-size');
@@ -62,6 +66,8 @@ export class AssetService {
         private listQueryBuilder: ListQueryBuilder,
         private eventBus: EventBus,
         private tagService: TagService,
+        private channelService: ChannelService,
+        private roleService: RoleService,
     ) {
         this.permittedMimeTypes = this.configService.assetOptions.permittedFileTypes
             .map(val => (/\.[\w]+/.test(val) ? mime.lookup(val) || undefined : val))
@@ -73,13 +79,14 @@ export class AssetService {
     }
 
     findOne(ctx: RequestContext, id: ID): Promise<Asset | undefined> {
-        return this.connection.getRepository(ctx, Asset).findOne(id);
+        return this.connection.findOneInChannel(ctx, Asset, id, ctx.channelId);
     }
 
     findAll(ctx: RequestContext, options?: AssetListOptions): Promise<PaginatedList<Asset>> {
         const qb = this.listQueryBuilder.build(Asset, options, {
             ctx,
-            relations: options?.tags ? ['tags'] : [],
+            relations: options?.tags ? ['tags', 'channels'] : ['channels'],
+            channelId: ctx.channelId,
         });
         const tags = options?.tags;
         if (tags && tags.length) {
@@ -111,11 +118,15 @@ export class AssetService {
         entity: T,
     ): Promise<Asset | undefined> {
         const entityType: Type<EntityWithAssets> = Object.getPrototypeOf(entity).constructor;
-        const entityWithFeaturedAsset = await this.connection
-            .getRepository(ctx, entityType)
-            .findOne(entity.id, {
+        const entityWithFeaturedAsset = await this.connection.findOneInChannel(
+            ctx,
+            entityType,
+            entity.id,
+            ctx.channelId,
+            {
                 relations: ['featuredAsset'],
-            });
+            },
+        );
         return (entityWithFeaturedAsset && entityWithFeaturedAsset.featuredAsset) || undefined;
     }
 
@@ -126,9 +137,15 @@ export class AssetService {
         let assets = entity.assets;
         if (!assets) {
             const entityType: Type<EntityWithAssets> = Object.getPrototypeOf(entity).constructor;
-            const entityWithAssets = await this.connection.getRepository(ctx, entityType).findOne(entity.id, {
-                relations: ['assets'],
-            });
+            const entityWithAssets = await this.connection.findOneInChannel(
+                ctx,
+                entityType,
+                entity.id,
+                ctx.channelId,
+                {
+                    relations: ['assets'],
+                },
+            );
             assets = (entityWithAssets && entityWithAssets.assets) || [];
         }
         return assets.sort((a, b) => a.position - b.position).map(a => a.asset);
@@ -165,9 +182,9 @@ export class AssetService {
         if (!entity.id) {
             throw new InternalServerError('error.entity-must-have-an-id');
         }
-        const { assetIds, featuredAssetId } = input;
+        const { assetIds } = input;
         if (assetIds && assetIds.length) {
-            const assets = await this.connection.getRepository(ctx, Asset).findByIds(assetIds);
+            const assets = await this.connection.findByIdsInChannel(ctx, Asset, assetIds, ctx.channelId, {});
             const sortedAssets = assetIds
                 .map(id => assets.find(a => idsAreEqual(a.id, id)))
                 .filter(notNullOrUndefined);
@@ -214,8 +231,18 @@ export class AssetService {
         return updatedAsset;
     }
 
-    async delete(ctx: RequestContext, ids: ID[], force: boolean = false): Promise<DeletionResponse> {
-        const assets = await this.connection.getRepository(ctx, Asset).findByIds(ids);
+    async delete(
+        ctx: RequestContext,
+        ids: ID[],
+        force: boolean = false,
+        deleteFromAllChannels: boolean = false,
+    ): Promise<DeletionResponse> {
+        const assets = await this.connection.findByIdsInChannel(ctx, Asset, ids, ctx.channelId, {
+            relations: ['channels'],
+        });
+        let channelsOfAssets: ID[] = [];
+        assets.forEach(a => a.channels.forEach(c => channelsOfAssets.push(c.id)));
+        channelsOfAssets = unique(channelsOfAssets);
         const usageCount = {
             products: 0,
             variants: 0,
@@ -239,6 +266,86 @@ export class AssetService {
                 }),
             };
         }
+        const hasDeleteAllPermission = await this.hasDeletePermissionForChannels(ctx, channelsOfAssets);
+        if (deleteFromAllChannels && !hasDeleteAllPermission) {
+            throw new ForbiddenError();
+        }
+        if (!deleteFromAllChannels) {
+            await Promise.all(
+                assets.map(async asset => {
+                    await this.channelService.removeFromChannels(ctx, Asset, asset.id, [ctx.channelId]);
+                    this.eventBus.publish(new AssetChannelEvent(ctx, asset, ctx.channelId, 'removed'));
+                }),
+            );
+            const isOnlyChannel = channelsOfAssets.length === 1;
+            if (isOnlyChannel) {
+                // only channel, so also delete asset
+                await this.deleteUnconditional(ctx, assets);
+            }
+            return {
+                result: DeletionResult.DELETED,
+            };
+        }
+        // This leaves us with deleteFromAllChannels with force or deleteFromAllChannels with no current usages
+        await Promise.all(
+            assets.map(async asset => {
+                await this.channelService.removeFromChannels(ctx, Asset, asset.id, channelsOfAssets);
+                this.eventBus.publish(new AssetChannelEvent(ctx, asset, ctx.channelId, 'removed'));
+            }),
+        );
+        return this.deleteUnconditional(ctx, assets);
+    }
+
+    async assignToChannel(ctx: RequestContext, input: AssignAssetsToChannelInput): Promise<Asset[]> {
+        const hasPermission = await this.roleService.userHasPermissionOnChannel(
+            ctx,
+            input.channelId,
+            Permission.UpdateCatalog,
+        );
+        if (!hasPermission) {
+            throw new ForbiddenError();
+        }
+        const assets = await this.connection.findByIdsInChannel(
+            ctx,
+            Asset,
+            input.assetIds,
+            ctx.channelId,
+            {},
+        );
+        await Promise.all(
+            assets.map(async asset => {
+                await this.channelService.assignToChannels(ctx, Asset, asset.id, [input.channelId]);
+                return this.eventBus.publish(new AssetChannelEvent(ctx, asset, input.channelId, 'assigned'));
+            }),
+        );
+        return this.connection.findByIdsInChannel(
+            ctx,
+            Asset,
+            assets.map(a => a.id),
+            ctx.channelId,
+            {},
+        );
+    }
+
+    /**
+     * Create an Asset from a file stream created during data import.
+     */
+    async createFromFileStream(stream: ReadStream): Promise<CreateAssetResult> {
+        const filePath = stream.path;
+        if (typeof filePath === 'string') {
+            const filename = path.basename(filePath);
+            const mimetype = mime.lookup(filename) || 'application/octet-stream';
+            return this.createAssetInternal(RequestContext.empty(), stream, filename, mimetype);
+        } else {
+            throw new InternalServerError(`error.path-should-be-a-string-got-buffer`);
+        }
+    }
+
+    /**
+     * Unconditionally delete given assets.
+     * Does not remove assets from channels
+     */
+    private async deleteUnconditional(ctx: RequestContext, assets: Asset[]): Promise<DeletionResponse> {
         for (const asset of assets) {
             // Create a new asset so that the id is still available
             // after deletion (the .remove() method sets it to undefined)
@@ -258,17 +365,15 @@ export class AssetService {
     }
 
     /**
-     * Create an Asset from a file stream created during data import.
+     * Check if current user has permissions to delete assets from all channels
      */
-    async createFromFileStream(stream: ReadStream): Promise<CreateAssetResult> {
-        const filePath = stream.path;
-        if (typeof filePath === 'string') {
-            const filename = path.basename(filePath);
-            const mimetype = mime.lookup(filename) || 'application/octet-stream';
-            return this.createAssetInternal(RequestContext.empty(), stream, filename, mimetype);
-        } else {
-            throw new InternalServerError(`error.path-should-be-a-string-got-buffer`);
-        }
+    private async hasDeletePermissionForChannels(ctx: RequestContext, channelIds: ID[]): Promise<boolean> {
+        const permissions = await Promise.all(
+            channelIds.map(async channelId => {
+                return this.roleService.userHasPermissionOnChannel(ctx, channelId, Permission.DeleteCatalog);
+            }),
+        );
+        return !permissions.includes(false);
     }
 
     private async createAssetInternal(
@@ -276,7 +381,7 @@ export class AssetService {
         stream: Stream,
         filename: string,
         mimetype: string,
-    ): Promise<CreateAssetResult> {
+    ): Promise<Asset | MimeTypeError> {
         const { assetOptions } = this.configService;
         if (!this.validateMimeType(mimetype)) {
             return new MimeTypeError(filename, mimetype);
@@ -312,6 +417,7 @@ export class AssetService {
             preview: previewFileIdentifier,
             focalPoint: null,
         });
+        this.channelService.assignToCurrentChannel(asset, ctx);
         return this.connection.getRepository(ctx, Asset).save(asset);
     }
 

+ 3 - 1
packages/core/src/service/services/product-variant.service.ts

@@ -520,7 +520,7 @@ export class ProductVariantService {
         }
         const variants = await this.connection
             .getRepository(ctx, ProductVariant)
-            .findByIds(input.productVariantIds, { relations: ['taxCategory'] });
+            .findByIds(input.productVariantIds, { relations: ['taxCategory', 'assets'] });
         const priceFactor = input.priceFactor != null ? input.priceFactor : 1;
         for (const variant of variants) {
             this.applyChannelPriceAndTax(variant, ctx);
@@ -532,6 +532,8 @@ export class ProductVariantService {
                 variant.price * priceFactor,
                 input.channelId,
             );
+            const assetIds = variant.assets?.map(a => a.assetId) || [];
+            await this.assetService.assignToChannel(ctx, { channelId: input.channelId, assetIds });
         }
         const result = await this.findByIds(
             ctx,

+ 6 - 1
packages/core/src/service/services/product.service.ts

@@ -9,6 +9,7 @@ import {
     UpdateProductInput,
 } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
 import { FindOptionsUtils } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
@@ -219,7 +220,7 @@ export class ProductService {
         const productsWithVariants = await this.connection
             .getRepository(ctx, Product)
             .findByIds(input.productIds, {
-                relations: ['variants'],
+                relations: ['variants', 'assets'],
             });
         await this.productVariantService.assignProductVariantsToChannel(ctx, {
             productVariantIds: ([] as ID[]).concat(
@@ -228,6 +229,10 @@ export class ProductService {
             channelId: input.channelId,
             priceFactor: input.priceFactor,
         });
+        const assetIds: ID[] = unique(
+            ([] as ID[]).concat(...productsWithVariants.map(p => p.assets.map(a => a.id))),
+        );
+        await this.assetService.assignToChannel(ctx, { channelId: input.channelId, assetIds });
         const products = await this.connection.getRepository(ctx, Product).findByIds(input.productIds);
         for (const product of products) {
             this.eventBus.publish(new ProductChannelEvent(ctx, product, input.channelId, 'assigned'));

+ 25 - 4
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -278,6 +278,8 @@ export type Mutation = {
     deleteAsset: DeletionResponse;
     /** Delete multiple Assets */
     deleteAssets: DeletionResponse;
+    /** Assign assets to channel */
+    assignAssetsToChannel: Array<Asset>;
     /** Authenticates the user using the native authentication strategy. This mutation is an alias for `authenticate({ native: { ... }})` */
     login: NativeAuthenticationResult;
     /** Authenticates the user using a named authentication strategy */
@@ -481,13 +483,15 @@ export type MutationUpdateAssetArgs = {
 };
 
 export type MutationDeleteAssetArgs = {
-    id: Scalars['ID'];
-    force?: Maybe<Scalars['Boolean']>;
+    input: DeleteAssetInput;
 };
 
 export type MutationDeleteAssetsArgs = {
-    ids: Array<Scalars['ID']>;
-    force?: Maybe<Scalars['Boolean']>;
+    input: DeleteAssetsInput;
+};
+
+export type MutationAssignAssetsToChannelArgs = {
+    input: AssignAssetsToChannelInput;
 };
 
 export type MutationLoginArgs = {
@@ -958,6 +962,18 @@ export type CoordinateInput = {
     y: Scalars['Float'];
 };
 
+export type DeleteAssetInput = {
+    assetId: Scalars['ID'];
+    force?: Maybe<Scalars['Boolean']>;
+    deleteFromAllChannels?: Maybe<Scalars['Boolean']>;
+};
+
+export type DeleteAssetsInput = {
+    assetIds: Array<Scalars['ID']>;
+    force?: Maybe<Scalars['Boolean']>;
+    deleteFromAllChannels?: Maybe<Scalars['Boolean']>;
+};
+
 export type UpdateAssetInput = {
     id: Scalars['ID'];
     name?: Maybe<Scalars['String']>;
@@ -965,6 +981,11 @@ export type UpdateAssetInput = {
     tags?: Maybe<Array<Scalars['String']>>;
 };
 
+export type AssignAssetsToChannelInput = {
+    assetIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+};
+
 export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
 
 export type AuthenticationResult = CurrentUser | InvalidCredentialsError;

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


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