import { clamp } from 'lodash'; import { Ctx, Field, Float, ID, ObjectType } from 'type-graphql'; import { AfterInsert, AfterRemove, AfterUpdate, BaseEntity, Column, CreateDateColumn, DeleteDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; import AlgoliaSearchIndex, { AlgoliaIndexKind } from '../services/algolia'; import { ContextType } from '../types'; import { AlgoliaPage, PageSource } from './types'; import { Product } from '.'; @ObjectType() @Entity({ name: 'pages' }) @Index(['firebaseId'], { unique: true }) @Index('related_pages_index', ['productFirebaseId', 'retailer'], { background: true, }) export default class Page extends BaseEntity { @Field(() => ID) @PrimaryGeneratedColumn('uuid') id!: string; @Field(() => String) @Column({ type: 'varchar', }) firebaseId!: string; @Field(() => Boolean, { nullable: true, }) @Column({ type: 'boolean', nullable: true, }) availability?: boolean; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) distributor?: string; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) distributorSKU?: string; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) img?: string; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) name?: string; @ManyToOne(() => Product, { onUpdate: 'CASCADE', onDelete: 'CASCADE', }) @JoinColumn({ referencedColumnName: 'firebaseId', }) product?: Product; @Column({ nullable: true }) productFirebaseId?: string; @Field(() => Product, { nullable: true, name: 'product', }) async productResolver( @Ctx() context?: ContextType ): Promise { if (!context) return Product.findOne({ where: { firebaseId: this.productFirebaseId } }); return this.productFirebaseId ? context.dbDataLoader.productsByFirebaseId.load(this.productFirebaseId) : undefined; } @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) productSKU?: string; @Field(() => Float) @Column({ type: 'float', }) price!: number; @Field(() => String) @Column({ type: 'varchar', }) retailer!: string; @Field(() => String) @Column({ type: 'varchar', }) url!: string; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) urlQuery?: string; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) urlOriginal?: string; @Field(() => Page, { nullable: true, name: 'lowestPricePage', }) async lowestPricePageResolver( @Ctx() context: ContextType ): Promise { if (!this.productFirebaseId) { return undefined; } return ( await context.dbDataLoader.pagesByProductFirebaseIdPriceAsc.load( this.productFirebaseId ) )[0]; } @ManyToOne(() => Page, { nullable: true, onUpdate: 'CASCADE', onDelete: 'CASCADE', }) @JoinColumn() lowestPricePage?: Page; @Column({ nullable: true }) lowestPricePageId?: string; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) asin?: string; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) dimensions?: string; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) colorCode?: string; @Field(() => [Page], { nullable: false, }) async relatedPages(@Ctx() context: ContextType): Promise { if (!this.productFirebaseId || !this.retailer) { return []; } return ( await context.dbDataLoader.discoverablePagesByProduct.load( this.productFirebaseId ) ).filter(({ retailer }) => retailer !== this.retailer); } @Field(() => [String], { nullable: true, }) @Column({ type: 'varchar', nullable: true, array: true, }) googleShoppingIDs?: string[]; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) annotations?: string; @Field(() => String, { nullable: true, }) @Column({ type: 'varchar', nullable: true, }) description?: string; @Field(() => Boolean, { nullable: true, }) @Column({ type: 'boolean', nullable: true, }) discoverable?: boolean; @Field(() => Boolean, { nullable: true, }) @Column({ type: 'boolean', nullable: true, }) hasReviews?: boolean; @Field(() => Boolean, { nullable: true, }) @Column({ type: 'boolean', nullable: true, }) featured?: boolean; @Field(() => Number, { nullable: false, }) @Column({ type: 'int', nullable: false, default: 0, }) productScore!: number; @Column({ type: 'enum', enum: PageSource, nullable: true, }) source?: PageSource; async getAlgoliaPage(@Ctx() context?: ContextType): Promise { const lowestPricePage = this.lowestPricePageId ? await (context ? context.dbDataLoader.pages.load(this.lowestPricePageId) : Page.findOne(this.lowestPricePageId)) : undefined; const product = await this.productResolver(context); return { objectID: this.firebaseId, name: this.name, price: this.price, retailer: this.retailer, url: this.url, urlQuery: this.urlQuery, discoverable: this.discoverable ?? false, availability: this.availability, product_id: this.productFirebaseId, featured: this.featured, productScore: this.productScore, img: this.img, featuredImage: product?.featuredImage ?? undefined, ['lowest_price.price']: lowestPricePage?.price, savingRatio: lowestPricePage?.price && this.price ? clamp((this.price - lowestPricePage.price) / this.price, 0, 1) : 0, lastmodified: this.updatedAt.getTime(), }; } @AfterInsert() async afterInsert(@Ctx() context?: ContextType): Promise { // don't add to algolia if not discoverable if (!this.discoverable) { return; } const index = AlgoliaSearchIndex.fromIndex(AlgoliaIndexKind.PAGES); await index.createOrUpdateRecord( this.firebaseId, await this.getAlgoliaPage(context) ); } @AfterUpdate() async afterUpdate(@Ctx() context?: ContextType): Promise { // don't update in algolia for google shopping return this.afterInsert(context); } @AfterRemove() async afterRemove(): Promise { const index = AlgoliaSearchIndex.fromIndex(AlgoliaIndexKind.PAGES); await index.removeRecord(this.firebaseId); } @Column({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', nullable: true, }) lastScrapedAt?: Date | null; @Field(() => Date) @CreateDateColumn({ type: 'timestamp with time zone', }) createdAt!: Date; @Field(() => Date, { nullable: true, }) @UpdateDateColumn({ type: 'timestamp with time zone', nullable: true, default: null, }) updatedAt!: Date; @Field(() => Date, { nullable: true, }) @DeleteDateColumn({ type: 'timestamp with time zone', nullable: true, default: null, }) deletedAt!: Date; }