Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Upgrade to V2 #6

Merged
merged 13 commits into from
May 17, 2023
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__data__
dist
node_modules
node_modules
__admin-ui
5 changes: 3 additions & 2 deletions codegen.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

generates:
./src/generated/graphql-shop-api-types.ts:
./src/ui/generated/graphql-shop-api-types.ts:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Place generated inside ui dir, because Admin UI Angular compilation can't access anything outside the ui directory

schema: http://localhost:3050/shop-api
# documents: 'src/ui/queries.ts'
plugins:
Expand All @@ -11,8 +11,9 @@ generates:
scalars:
DateTime: Date
ID: string | number
./src/generated/graphql-admin-api-types.ts:
./src/ui/generated/graphql-admin-api-types.ts:
schema: http://localhost:3050/admin-api
documents: '**/*.graphql.ts'
plugins:
- typescript
- typescript-operations
Expand Down
22 changes: 11 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,25 @@
"build": "rimraf dist && tsc && copyfiles -u 1 'src/ui/**/*' dist/src/ && copyfiles -u 1 'generated/*' dist/src/generated",
"generate": "graphql-codegen",
"start": "ts-node test/dev-server.ts",
"test": "jest --preset=\"ts-jest\" --forceExit"
"test": "vitest run"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can run yarn vitest to run Vitest in watch mode when developing locally 🦾

},
"devDependencies": {
"@graphql-codegen/cli": "^2.4.0",
"@graphql-codegen/typescript": "^2.4.8",
"@graphql-codegen/typescript-operations": "^2.3.5",
"@types/jest": "^29.5.1",
"@vendure/admin-ui-plugin": "1.9.3",
"@vendure/asset-server-plugin": "1.9.3",
"@vendure/core": "1.9.3",
"@vendure/email-plugin": "^1.9.5",
"@vendure/testing": "1.9.3",
"@vendure/ui-devkit": "1.9.3",
"@swc/core": "^1.3.58",
"@vendure/admin-ui-plugin": "2.0.0-beta.2",
"@vendure/asset-server-plugin": "2.0.0-beta.2",
"@vendure/core": "2.0.0-beta.2",
"@vendure/email-plugin": "2.0.0-beta.2",
"@vendure/testing": "2.0.0-beta.2",
"@vendure/ui-devkit": "2.0.0-beta.2",
"copyfiles": "^2.4.1",
"jest": "^29.5.0",
"rimraf": "^4.1.2",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"typescript": "4.3.5"
"typescript": "4.9.5",
"unplugin-swc": "^1.3.2",
"vitest": "^0.31.0"
},
"dependencies": {}
}
2 changes: 1 addition & 1 deletion src/api/back-in-stock-admin.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
MutationUpdateBackInStockSubscriptionArgs,
QueryActiveBackInStockSubscriptionsForProductVariantArgs,
QueryBackInStockSubscriptionArgs,
} from '../generated/graphql-admin-api-types';
} from '../ui/generated/graphql-admin-api-types';
@Resolver()
export class BackInStockAdminResolver {
constructor(private backInStockService: BackInStockService) {}
Expand Down
4 changes: 2 additions & 2 deletions src/api/back-in-stock.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
MutationCreateBackInStockSubscriptionArgs,
QueryActiveBackInStockSubscriptionForProductVariantWithCustomerArgs,
CreateBackInStockSubscriptionResult,
} from '../generated/graphql-shop-api-types';
} from '../ui/generated/graphql-shop-api-types';
import { BackInStockOptions } from '../back-in-stock.plugin';
import { PLUGIN_INIT_OPTIONS } from '../constants';

Expand All @@ -22,7 +22,7 @@ export class BackInStockResolver {
async activeBackInStockSubscriptionForProductVariantWithCustomer(
@Ctx() ctx: RequestContext,
@Args() args: QueryActiveBackInStockSubscriptionForProductVariantWithCustomerArgs,
): Promise<BackInStock | undefined> {
): Promise<BackInStock | null> {
return this.backInStockService.findActiveForProductVariantWithCustomer(
ctx,
args.input.productVariantId,
Expand Down
95 changes: 13 additions & 82 deletions src/back-in-stock.plugin.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
import {
EntityHydrator,
PluginCommonModule,
ProductVariant,
ProductVariantService,
RequestContextService,
TransactionalConnection,
VendurePlugin,
VendurePlugin
} from '@vendure/core';
import { PLUGIN_INIT_OPTIONS } from './constants';
import { EmailEventListener } from '@vendure/email-plugin';
import { AdminUiExtension } from '@vendure/ui-devkit/compiler';
import path from 'path';
import { adminApiExtensions, shopApiExtensions } from './api/api-extensions';
import { BackInStockAdminResolver } from './api/back-in-stock-admin.resolver';
import { BackInStockResolver } from './api/back-in-stock.resolver';
import { UnionResolver } from './api/union.resolver';
import { PLUGIN_INIT_OPTIONS } from './constants';
import { BackInStock } from './entity/back-in-stock.entity';
import { BackInStockService } from './service/back-in-stock.service';
import { EmailEventListener } from '@vendure/email-plugin';
import { BackInStockEvent } from './events/back-in-stock.event';
import { BackInStockSubscriptionStatus } from './types';
import { UnionResolver } from './api/union.resolver';
import { BackInStockAdminResolver } from './api/back-in-stock-admin.resolver';
import { AdminUiExtension } from '@vendure/ui-devkit/compiler';
import path from 'path';
import { EntitySubscriberInterface, EventSubscriber, UpdateEvent } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { getApiType } from '@vendure/core/dist/api/common/get-api-type';
import { SortOrder } from './generated/graphql-shop-api-types';
import { BackInStockService } from './service/back-in-stock.service';

export interface BackInStockOptions {
enableEmail: boolean;
Expand All @@ -40,66 +31,6 @@ export interface BackInStockOptions {
allowSubscriptionWithoutSession?: boolean;
}

/**
* @description
* Subscribes to {@link ProductVariant} inventory changes
* and FIFO updates BackInStock {@link BackInStock} to be notified
* to the amount of saleable stock with plugin init option
* limitEmailToStock = true or false to notify all subscribers
*
*/
@Injectable()
@EventSubscriber()
export class ProductVariantSubscriber implements EntitySubscriberInterface<ProductVariant> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProductVariantSubscriber has been replaced by eventBus.ofType(StockMovementEvent), because in V2 multiple entities (StockLocations) can be responsible for the final saleableStock

constructor(
private connection: TransactionalConnection,
private backInStockService: BackInStockService,
private productVariantService: ProductVariantService,
private requestContextService: RequestContextService,
) {
this.connection.rawConnection.subscribers.push(this);
}
listenTo() {
return ProductVariant;
}

// set subscriptions to be notified only on replenishment event
async afterUpdate(event: UpdateEvent<ProductVariant>) {
if (
event.entity?.stockOnHand > event.databaseEntity?.stockOnHand &&
BackInStockPlugin.options.enableEmail
) {
const ctx = await this.requestContextService.create({ apiType: getApiType() });
const productVariant = await this.productVariantService.findOne(ctx, event.entity?.id);
//! calculate saleable manually as this context is not aware of the current db transaction
const saleableStock =
event.entity?.stockOnHand -
productVariant!.stockAllocated -
productVariant!.outOfStockThreshold;

const backInStockSubscriptions = await this.backInStockService.findActiveForProductVariant(
ctx,
productVariant!.id,
{
take: BackInStockPlugin.options.limitEmailToStock ? saleableStock : undefined,
sort: {
createdAt: SortOrder.Asc,
},
},
);

if (saleableStock >= 1 && backInStockSubscriptions.totalItems >= 1) {
for (const subscription of backInStockSubscriptions.items) {
this.backInStockService.update(ctx, {
id: subscription.id,
status: BackInStockSubscriptionStatus.Notified,
});
}
}
}
}
}

@VendurePlugin({
imports: [PluginCommonModule],
entities: [BackInStock],
Expand All @@ -109,7 +40,6 @@ export class ProductVariantSubscriber implements EntitySubscriberInterface<Produ
useFactory: () => BackInStockPlugin.options,
},
BackInStockService,
ProductVariantSubscriber,
],
shopApiExtensions: {
schema: shopApiExtensions,
Expand All @@ -124,13 +54,14 @@ export class BackInStockPlugin {
static options: BackInStockOptions = {
enableEmail: true,
limitEmailToStock: true,
allowSubscriptionWithoutSession: true,
};

static init(options: BackInStockOptions): typeof BackInStockPlugin {
if (options.allowSubscriptionWithoutSession === undefined) {
options.allowSubscriptionWithoutSession = true;
}
this.options = options;
this.options = {
...this.options,
...options
}; // Only override whats passed in, leave the other defaults
return BackInStockPlugin;
}

Expand Down
87 changes: 71 additions & 16 deletions src/service/back-in-stock.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,32 @@ import {
InternalServerError,
EventBus,
TranslatorService,
StockMovementEvent,
StockLevelService,
Logger,
EntityHydrator,
} from '@vendure/core';
import { BackInStock } from '../entity/back-in-stock.entity';
import { BackInStockSubscriptionStatus } from '../types';
import {
CreateBackInStockInput,
CreateBackInStockSubscriptionResult,
ErrorCode,
SortOrder,
UpdateBackInStockInput,
} from '../generated/graphql-shop-api-types';
import { BackInStockPlugin } from '../back-in-stock.plugin';
} from '../ui/generated/graphql-shop-api-types';
import { BackInStockOptions } from '../back-in-stock.plugin';
import { BackInStockEvent } from '../events/back-in-stock.event';
import { OnApplicationBootstrap, Inject } from '@nestjs/common';
import { PLUGIN_INIT_OPTIONS, loggerCtx } from '../constants';

/**
* @description
* Contains methods relating to {@link BackInStock} entities.
*
*/
@Injectable()
export class BackInStockService {
export class BackInStockService implements OnApplicationBootstrap {
private readonly relations = ['productVariant', 'channel', 'customer'];

constructor(
Expand All @@ -47,11 +54,54 @@ export class BackInStockService {
private productVariantService: ProductVariantService,
private translatorService: TranslatorService,
private eventBus: EventBus,
) {}
@Inject(PLUGIN_INIT_OPTIONS) private options: BackInStockOptions
Copy link
Contributor Author

@martijnvdbrug martijnvdbrug May 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

- BackInStockPlugin.options
+ @Inject(PLUGIN_INIT_OPTIONS) private options: BackInStockOptions

BackInStockPlugin.optionsusually works, but you could end up with weird bootstrap scenarios, where BackInStockPlugin.options is undefined. With @Inject we make NestJS responsible for resolving, which is safer.

) { }

async findOne(ctx: RequestContext, id: ID): Promise<BackInStock | undefined> {
return this.connection.getRepository(ctx, BackInStock).findOne(id, {
relations: this.relations,

onApplicationBootstrap() {
// Listen for stock movements and update subscriptions
this.eventBus.ofType(StockMovementEvent).subscribe(async event => {
// Refetch variants, because variants in event does not have all properties fetched from DB
const variants = await this.productVariantService.findByIds(event.ctx, event.stockMovements.map(sm => sm.productVariant.id));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

event.stockMovements[0].productVariant only has a partial variant: {id: 1}, so we refetch the entire variant to be able to resolve saleableStockLevel

// Check new stockLevel of each variant in the event
Promise.all(variants.map(async (productVariant) => {
const saleableStock = await this.productVariantService.getSaleableStockLevel(event.ctx, productVariant);
if (isNaN(saleableStock)) {
// This can happen when an event is fired during bootstrap,
// so Vendure can't yet resolve saleable stock for some reason
return;
}
if (saleableStock < 1) {
return; // Still not in stock
}
const backInStockSubscriptions = await this.findActiveForProductVariant(
event.ctx,
productVariant!.id,
{
take: this.options.limitEmailToStock ? saleableStock : undefined,
sort: {
createdAt: SortOrder.Asc,
},
},
);
if (backInStockSubscriptions.totalItems < 1) {
return; // No subscriptions to notify
}
await Promise.all(backInStockSubscriptions.items.map(async subscription =>
this.update(event.ctx, {
id: subscription.id,
status: BackInStockSubscriptionStatus.Notified,
})));
}));
});
}

async findOne(ctx: RequestContext, id: ID): Promise<BackInStock | null> {
return this.connection.getRepository(ctx, BackInStock).findOne({
where: {
id
},
relations: { productVariant: true, channel: true, customer: true },
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New way of fetching relations in TypeOrm 0.3.0.

relations: ['productVariant'] is deprecated: https://github.com/typeorm/typeorm/releases/tag/0.3.0

});
}

Expand Down Expand Up @@ -80,16 +130,14 @@ export class BackInStockService {
options?: ListQueryOptions<BackInStock>,
relations?: RelationPaths<Channel> | RelationPaths<Customer>,
): Promise<PaginatedList<BackInStock>> {
const productVariant = await this.productVariantService.findOne(ctx, productVariantId);

return this.listQueryBuilder
.build(BackInStock, options, {
relations: relations || this.relations,
ctx,
where: {
productVariant,
status: BackInStockSubscriptionStatus.Created,
},
// where: {
// productVariant: { id: productVariantId },
// status: BackInStockSubscriptionStatus.Created,
// },
})
.getManyAndCount()
.then(async ([items, totalItems]) => {
Expand All @@ -104,7 +152,7 @@ export class BackInStockService {
ctx: RequestContext,
productVariantId: ID,
email: string,
): Promise<BackInStock | undefined> {
): Promise<BackInStock | null> {
const { channelId } = ctx;
const status = BackInStockSubscriptionStatus.Created;
const queryBuilder = this.connection
Expand Down Expand Up @@ -140,6 +188,13 @@ export class BackInStockService {
};
}

if (!productVariant) {
return {
errorCode: ErrorCode.UnknownError,
message: `No variant found with ID ${input.productVariantId}`,
};
}

const existingSubscription = await this.findActiveForProductVariantWithCustomer(
ctx,
productVariantId,
Expand Down Expand Up @@ -169,14 +224,14 @@ export class BackInStockService {
if (!subscription) {
throw new InternalServerError('Subscription not found');
}

const updatedSubscription = patchEntity(subscription, input);
if (input.status === 'Notified') {
if (BackInStockPlugin.options.enableEmail) {
if (this.options.enableEmail) {
const translatedVariant = this.translatorService.translate(subscription.productVariant, ctx);
this.eventBus.publish(
new BackInStockEvent(ctx, subscription, translatedVariant, 'updated', subscription.email),
);
Logger.info(`Publish "BackInStockEvent" for variant ${translatedVariant.sku} to notify "${subscription.email}"`, loggerCtx);
} else {
throw new InternalServerError('Email notification disabled');
}
Expand Down
Loading