From 887c5097394090557b6c7aa3f1d830c6c2db9933 Mon Sep 17 00:00:00 2001 From: HungLV Date: Wed, 14 Aug 2024 09:41:37 +0700 Subject: [PATCH] feat: product API --- docker-compose.yaml | 24 +-- package.json | 1 + .../migration.sql | 17 ++ .../migration.sql | 17 ++ .../migration.sql | 41 ++++ prisma/schema.prisma | 179 ++++++++++-------- prisma/seeds/seeds.ts | 58 +++--- src/apis/index.ts | 30 +++ src/apis/routes/exmaples/codeium-api.ts | 44 ----- src/apis/routes/index.ts | 6 +- src/apis/routes/products/create.ts | 112 +++++++++++ .../example-get-api.ts => products/get.ts} | 4 +- src/apis/routes/products/update.ts | 127 +++++++++++++ src/apis/routes/utils/upload.ts | 38 ++++ src/elastic-search/indexes/products/index.ts | 16 +- 15 files changed, 532 insertions(+), 182 deletions(-) create mode 100644 prisma/migrations/20240814021956_trigger_updated_at/migration.sql create mode 100644 prisma/migrations/20240814021957_optional_json_fields/migration.sql create mode 100644 prisma/migrations/20240814021958_product_collection_relation/migration.sql delete mode 100644 src/apis/routes/exmaples/codeium-api.ts create mode 100644 src/apis/routes/products/create.ts rename src/apis/routes/{exmaples/example-get-api.ts => products/get.ts} (86%) create mode 100644 src/apis/routes/products/update.ts create mode 100644 src/apis/routes/utils/upload.ts diff --git a/docker-compose.yaml b/docker-compose.yaml index 85c2418..9c98288 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -42,18 +42,18 @@ services: ports: - 9200:9200 - be: - image: ipscanbe - depends_on: - postgres: - condition: service_healthy - ports: - - '3000:3000' - environment: - REDIS_URL: redis://redis:6379/1 - DATABASE_URL: 'postgresql://postgres:password@postgres:5432/ip-scan?schema=public' - networks: - - local +# be: +# image: ipscanbe +# depends_on: +# postgres: +# condition: service_healthy +# ports: +# - '3000:3000' +# environment: +# REDIS_URL: redis://redis:6379/1 +# DATABASE_URL: 'postgresql://postgres:password@postgres:5432/ip-scan?schema=public' +# networks: +# - local networks: local: diff --git a/package.json b/package.json index 1d99eb0..881cf99 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@bull-board/hapi": "^5.21.1", "@elastic/elasticsearch": "^8.14.0", + "@hapi/boom": "^10.0.1", "@hapi/hapi": "^21.3.10", "@hapi/inert": "^7.1.0", "@hapi/vision": "^7.0.3", diff --git a/prisma/migrations/20240814021956_trigger_updated_at/migration.sql b/prisma/migrations/20240814021956_trigger_updated_at/migration.sql new file mode 100644 index 0000000..fc31672 --- /dev/null +++ b/prisma/migrations/20240814021956_trigger_updated_at/migration.sql @@ -0,0 +1,17 @@ +CREATE OR REPLACE FUNCTION update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER products_updated_at_timestamp_trigger +BEFORE UPDATE ON products +FOR EACH ROW +EXECUTE FUNCTION update_timestamp(); + +CREATE TRIGGER collections_updated_at_timestamp_trigger +BEFORE UPDATE ON collections +FOR EACH ROW +EXECUTE FUNCTION update_timestamp(); diff --git a/prisma/migrations/20240814021957_optional_json_fields/migration.sql b/prisma/migrations/20240814021957_optional_json_fields/migration.sql new file mode 100644 index 0000000..2b85e35 --- /dev/null +++ b/prisma/migrations/20240814021957_optional_json_fields/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "collections" ALTER COLUMN "metadata" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "ipassets" ALTER COLUMN "metadata" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "licenses" ALTER COLUMN "metadata" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "nfts" ALTER COLUMN "metadata" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "products" ALTER COLUMN "metadata" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "users" ALTER COLUMN "additional_info" DROP NOT NULL; diff --git a/prisma/migrations/20240814021958_product_collection_relation/migration.sql b/prisma/migrations/20240814021958_product_collection_relation/migration.sql new file mode 100644 index 0000000..5f437e1 --- /dev/null +++ b/prisma/migrations/20240814021958_product_collection_relation/migration.sql @@ -0,0 +1,41 @@ +/* + Warnings: + + - You are about to drop the column `product_id` on the `collections` table. All the data in the column will be lost. + - Made the column `metadata` on table `collections` required. This step will fail if there are existing NULL values in that column. + - Made the column `metadata` on table `products` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE "collections" DROP CONSTRAINT "collections_product_id_fkey"; + +-- DropIndex +DROP INDEX "collections_product_id_idx"; + +-- AlterTable +ALTER TABLE "collections" DROP COLUMN "product_id", +ALTER COLUMN "metadata" SET NOT NULL; + +-- AlterTable +ALTER TABLE "products" ALTER COLUMN "metadata" SET NOT NULL; + +-- CreateTable +CREATE TABLE "product_collections" ( + "id" SERIAL NOT NULL, + "product_id" INTEGER NOT NULL, + "collection_id" INTEGER NOT NULL, + + CONSTRAINT "product_collections_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "product_collections_product_id_idx" ON "product_collections"("product_id"); + +-- CreateIndex +CREATE INDEX "product_collections_collection_id_idx" ON "product_collections"("collection_id"); + +-- AddForeignKey +ALTER TABLE "product_collections" ADD CONSTRAINT "product_collections_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "product_collections" ADD CONSTRAINT "product_collections_collection_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c89a3f9..af1ff32 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,7 +5,7 @@ // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" binaryTargets = ["native", "debian-openssl-3.0.x"] } @@ -15,17 +15,17 @@ datasource db { } model User { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) - name String - bio String - email String - wallet_address String - avatar_img String - banner_img String - additional_info Json + name String + bio String + email String + wallet_address String + avatar_img String + banner_img String + additional_info Json? - products Product[] + products Product[] @@index(name) @@index(wallet_address) @@ -33,35 +33,35 @@ model User { } model Product { - id Int @id @default(autoincrement()) - name String - category String - description String - avatar_img String - banner_img String - metadata Json - owner_id Int - featured_at DateTime? - created_at DateTime @default(now()) - updated_at DateTime @default(now()) @updatedAt - - stat_total_collection Int? - stat_total_items Int? - stat_total_activities BigInt? - - stat_total_volume_all BigInt? - stat_total_volume_12m BigInt? - stat_total_volume_30d BigInt? - stat_total_volume_7d BigInt? - - stat_floor_price_all BigInt? - stat_floor_price_12m BigInt? - stat_floor_price_30d BigInt? - stat_floor_price_7d BigInt? - - owner User @relation(fields: [owner_id], references: [id]) - collections Collection[] - attributes ProductAttribute[] + id Int @id @default(autoincrement()) + name String + category String + description String + avatar_img String + banner_img String + metadata Json + owner_id Int + featured_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + + stat_total_collection Int? + stat_total_items Int? + stat_total_activities BigInt? + + stat_total_volume_all BigInt? + stat_total_volume_12m BigInt? + stat_total_volume_30d BigInt? + stat_total_volume_7d BigInt? + + stat_floor_price_all BigInt? + stat_floor_price_12m BigInt? + stat_floor_price_30d BigInt? + stat_floor_price_7d BigInt? + + owner User @relation(fields: [owner_id], references: [id]) + attributes ProductAttribute[] + product_collections ProductCollection[] @@unique(name) @@index(category) @@ -71,12 +71,12 @@ model Product { } model ProductAttribute { - id Int @id @default(autoincrement()) - name String - value String - product_id Int + id Int @id @default(autoincrement()) + name String + value String + product_id Int - product Product @relation(fields: [product_id], references: [id]) + product Product @relation(fields: [product_id], references: [id]) @@unique([name, value, product_id]) @@index([name, value]) @@ -85,34 +85,45 @@ model ProductAttribute { } model Collection { - id Int @id @default(autoincrement()) - name String? - chain_id String - contract_address String - product_id Int - metadata Json - - nfts Nft[] - product Product @relation(fields: [product_id], references: [id]) - created_at DateTime @default(now()) - updated_at DateTime @default(now()) @updatedAt - + id Int @id @default(autoincrement()) + name String? + chain_id String + contract_address String + metadata Json + + nfts Nft[] + product_collections ProductCollection[] + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + @@unique([chain_id, contract_address]) - @@index(product_id) @@index(name) @@map("collections") } +model ProductCollection { + id Int @id @default(autoincrement()) + product_id Int + collection_id Int + + product Product @relation(fields: [product_id], references: [id]) + collection Collection @relation(fields: [collection_id], references: [id]) + + @@index([product_id]) + @@index([collection_id]) + @@map("product_collections") +} + model Nft { - id Int @id @default(autoincrement()) - chain_id String - collection_id Int - contract_address String - token_id String - metadata Json + id Int @id @default(autoincrement()) + chain_id String + collection_id Int + contract_address String + token_id String + metadata Json? - collection Collection @relation(fields: [collection_id], references: [id]) - ipassets Ipasset[] + collection Collection @relation(fields: [collection_id], references: [id]) + ipassets Ipasset[] @@unique([collection_id, token_id]) @@unique([chain_id, contract_address, token_id]) @@ -121,17 +132,17 @@ model Nft { } model Ipasset { - id Int @id @default(autoincrement()) - chain_id String - contract_address String - parent_ipasset_id Int? - nft_id Int - metadata Json - - nft Nft @relation(fields: [nft_id], references: [id]) - parent_ipassets Ipasset? @relation("ParentChildIpasset", fields: [parent_ipasset_id], references: [id]) - child_ipassets Ipasset[] @relation("ParentChildIpasset") - licenses License[] + id Int @id @default(autoincrement()) + chain_id String + contract_address String + parent_ipasset_id Int? + nft_id Int + metadata Json? + + nft Nft @relation(fields: [nft_id], references: [id]) + parent_ipassets Ipasset? @relation("ParentChildIpasset", fields: [parent_ipasset_id], references: [id]) + child_ipassets Ipasset[] @relation("ParentChildIpasset") + licenses License[] @@unique([chain_id, contract_address]) @@index(parent_ipasset_id) @@ -139,14 +150,14 @@ model Ipasset { } model License { - id Int @id @default(autoincrement()) - chain_id String - contract_address String - token_id String - ipasset_id Int - metadata Json - - ipasset Ipasset @relation(fields: [ipasset_id], references: [id]) + id Int @id @default(autoincrement()) + chain_id String + contract_address String + token_id String + ipasset_id Int + metadata Json? + + ipasset Ipasset @relation(fields: [ipasset_id], references: [id]) @@unique([chain_id, contract_address, token_id]) @@map("licenses") diff --git a/prisma/seeds/seeds.ts b/prisma/seeds/seeds.ts index 735f581..a0d3579 100644 --- a/prisma/seeds/seeds.ts +++ b/prisma/seeds/seeds.ts @@ -62,30 +62,14 @@ async function main() { data: [ ..._.range(0, faker.number.int({ min: 0, max: 4 })).map( (index: number) => ({ - name: 'Chain', - value: [ - 'Aura network', - 'Ethereum', - 'Arbitrum One', - 'Avalanche C-Chain', - 'Blast', - ][index], + name: 'status', + value: ['Prototype', 'Alpha', 'Beta', 'Final Product'][index], product_id: product.id, }), ), - { - name: 'Game status', - value: faker.helpers.arrayElement([ - 'Prototype', - 'Alpha', - 'Beta', - 'Final Product', - ]), - product_id: product.id, - }, ..._.range(0, faker.number.int({ min: 0, max: 2 })).map( (index: number) => ({ - name: 'Player info', + name: 'player info', value: ['Singleplayer', 'Multiplayer', 'Massive Multiplayer'][ index ], @@ -94,14 +78,14 @@ async function main() { ), ..._.range(0, faker.number.int({ min: 0, max: 3 })).map( (index: number) => ({ - name: 'Genre', + name: 'genre', value: ['Action', 'Romance', 'Survival', 'Telltale'][index], product_id: product.id, }), ), ..._.range(0, faker.number.int({ min: 0, max: 2 })).map( (index: number) => ({ - name: 'Game mode', + name: 'game mode', value: ['PvE', 'PvP', 'Cooperative'][index], product_id: product.id, }), @@ -118,18 +102,28 @@ async function main() { const collectionAddresses = Object.keys(collectionAddressToIpassets); // Create Collections const collections = await Promise.all( - products.map((product, index: number) => { - return prisma.collection.create({ - data: { - chain_id: '11155111', - contract_address: collectionAddresses[index], - product_id: product.id, - metadata: { - rarity: faker.helpers.arrayElement(['common', 'rare', 'epic']), - releaseDate: faker.date.future(), + products.map(async (product: any, index: number) => { + return prisma.collection + .create({ + data: { + chain_id: '11155111', + contract_address: collectionAddresses[index], + metadata: { + rarity: faker.helpers.arrayElement(['common', 'rare', 'epic']), + releaseDate: faker.date.future(), + }, }, - }, - }); + }) + .then(async (response) => { + await prisma.productCollection.create({ + data: { + product_id: product.id, + collection_id: response.id, + }, + }); + + return response; + }); }), ); diff --git a/src/apis/index.ts b/src/apis/index.ts index 29944ef..37e0628 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -14,6 +14,8 @@ import * as routes from './routes'; import { getAllQueues } from '#jobs/index'; import * as elasticSearchJob from '#jobs/elastic-search/elastic-search-job'; import { initIndexes } from 'elastic-search/indexes'; +import { Prisma } from '@prisma/client'; +import Boom from '@hapi/boom'; async function registerPlugins(server: Hapi.Server): Promise { // log @@ -47,6 +49,23 @@ export function routeApis(server: Hapi.Server): void { Object.values(routes).forEach((route) => server.route(route)); } +function setupServerResponseTransformation(server: Hapi.Server): void { + server.ext('onPreResponse', (request, h) => { + const response = request.response; + + // DB: unique constraint + if (response instanceof Prisma.PrismaClientKnownRequestError) { + if (response.code === 'P2002') { + throw Boom.badRequest( + `${response.meta?.modelName} (${((response.meta?.target as Array) || []).join(',')}) must be unique!`, + ); + } + } + + return h.continue; + }); +} + async function registerAdapters(server: Hapi.Server) { // bull board const serverAdapter = new HapiAdapter(); @@ -69,6 +88,16 @@ async function initServer(): Promise { port: config.port, host: config.host, debug: false, + routes: { + validate: { + failAction: async (request, h, err) => { + // https://github.com/hapijs/hapi/issues/3658 + // as mentioned in above "Input validation errors are no longer passed directly from joi to the client." + // So a general message will be returned, which doesn't sufficient to debug API call + if (err) throw Boom.boomify(err); // TODO check if resposne error is HTML escaped + }, + }, + }, }); await registerPlugins(server); @@ -77,6 +106,7 @@ async function initServer(): Promise { await registerJobs(); routeApis(server); + setupServerResponseTransformation(server); await server.start(); } diff --git a/src/apis/routes/exmaples/codeium-api.ts b/src/apis/routes/exmaples/codeium-api.ts deleted file mode 100644 index c0682b6..0000000 --- a/src/apis/routes/exmaples/codeium-api.ts +++ /dev/null @@ -1,44 +0,0 @@ -import Hapi from '@hapi/hapi'; -import { Prisma } from '@prisma/client'; - -import { prisma } from '#common/db'; -import Joi from 'joi'; - -export const updateProductRoute: Hapi.ServerRoute = { - method: 'POST', - path: '/productions/{id}', - options: { - description: 'Update product by its ID', - notes: 'Just an example demonstrating how to set up an API.', - tags: ['api', 'Product'], - plugins: { - 'hapi-swagger': {}, - }, - validate: { - params: Joi.object({ - id: Joi.number().integer().min(1).description('ID of production'), - }), - payload: Joi.object({ - name: Joi.string().allow(''), - category: Joi.string().allow(''), - description: Joi.string().allow(''), - avatar_img: Joi.string().allow(''), - banner_img: Joi.string().allow(''), - metadata: Joi.object().allow(null), - }), - }, - }, - /** - * Handles the update product request. - * - * @param {Hapi.Request} request - The incoming request object. - * @return {Promise} The updated product data. - */ - handler: async (request: Hapi.Request): Promise => { - const { id } = request.params; - const { payload } = request as { payload: Prisma.ProductUpdateInput }; - const where = { id: parseInt(id) }; - const updated = await prisma.product.update({ data: payload, where }); - return updated; - }, -}; diff --git a/src/apis/routes/index.ts b/src/apis/routes/index.ts index 39b3eb3..2b2342a 100644 --- a/src/apis/routes/index.ts +++ b/src/apis/routes/index.ts @@ -1 +1,5 @@ -export * from '#apis/routes/exmaples/example-get-api'; +export * from '#apis/routes/products/update'; +export * from '#apis/routes/products/create'; +export * from '#apis/routes/products/get'; + +export * from '#apis/routes/utils/upload'; diff --git a/src/apis/routes/products/create.ts b/src/apis/routes/products/create.ts new file mode 100644 index 0000000..d23e5eb --- /dev/null +++ b/src/apis/routes/products/create.ts @@ -0,0 +1,112 @@ +import Hapi from '@hapi/hapi'; +import { prisma } from '#common/db'; +import Joi from 'joi'; +import { Prisma } from '@prisma/client'; + +export const createProductionRoute: Hapi.ServerRoute = { + method: 'POST', + path: '/products', + options: { + description: 'Create a new product', + notes: 'Create product, its attributes and collections', + tags: ['api', 'product', 'create'], + plugins: { 'hapi-swagger': {} }, + validate: { + payload: Joi.object({ + name: Joi.string().required().example('Product name'), + owner_id: Joi.number().required().example(1), + avatar_img: Joi.string() + .required() + .example('https://loremflickr.com/640/480?lock=1572275828555776'), + banner_img: Joi.string() + .required() + .example('https://loremflickr.com/640/480?lock=1572275828555776'), + category: Joi.string().required().example('game'), + description: Joi.string().required().example('description of product'), + featured: Joi.boolean().default(false), + attributes: Joi.array() + .items( + Joi.object({ + name: Joi.string().required().example('attribute name'), + value: Joi.string().required().example('attribute value'), + }), + ) + .default([]), + metadata: Joi.object({ + previews: Joi.array() + .items(Joi.string()) + .default([]) + .example([ + 'https://loremflickr.com/640/480?lock=1572275828555776', + 'https://loremflickr.com/640/480?lock=1572275828555776', + ]), + cta_url: Joi.string().default('').example('https://www.google.com'), + }).required(), + collections: Joi.array() + .items( + Joi.object({ + chain_id: Joi.string().required(), + contract_address: Joi.string().required(), + }), + ) + .default([]) + .example([ + { chain_id: '1', contract_address: '0x1234x' }, + { chain_id: '2', contract_address: '0x1233x' }, + ]), + }), + }, + // TODO validate response: { schema: Joi.object({}) } + }, + handler: async (request: Hapi.Request) => { + const payload = request.payload as any; + + // create new product + const productData = { + name: payload.name, + owner: { connect: { id: payload.owner_id } }, + avatar_img: payload.avatar_img, + banner_img: payload.banner_img, + category: payload.category, + description: payload.description, + metadata: payload.metadata, + attributes: { create: payload.attributes }, + featured_at: payload.featured ? new Date() : undefined, + } as Prisma.ProductCreateInput; + const product = await prisma.product.create({ data: productData }); + + // create new collections + const existingCollections = await prisma.collection.findMany({ + where: { + OR: payload.collections as any, // TODO change to where in + }, + }); + const newCollectionData = payload.collections.filter((item: any) => { + return !existingCollections.find( + // exceptable with low number of existing collections + (col: any) => + col.chain_id === item.chain_id && + col.contract_address === item.contract_address, + ); + }); + let newCollections = [] as Prisma.CollectionGetPayload[]; + if (newCollectionData.length > 0) { + newCollections = await prisma.collection.createManyAndReturn({ + data: newCollectionData.map((col: any) => ({ + chain_id: col.chain_id, + contract_address: col.contract_address, + })) as Prisma.CollectionCreateManyInput[], + }); + } + + // create new product - collection relation + await prisma.productCollection.createMany({ + data: [...existingCollections, ...newCollections].map((col: any) => ({ + product_id: product.id, + collection_id: col.id, + })) as Prisma.ProductCollectionCreateManyInput[], + }); + + return { data: { id: product.id } }; + }, +}; diff --git a/src/apis/routes/exmaples/example-get-api.ts b/src/apis/routes/products/get.ts similarity index 86% rename from src/apis/routes/exmaples/example-get-api.ts rename to src/apis/routes/products/get.ts index 6c715a3..c6d5102 100644 --- a/src/apis/routes/exmaples/example-get-api.ts +++ b/src/apis/routes/products/get.ts @@ -1,11 +1,10 @@ import Hapi from '@hapi/hapi'; import { prisma } from '#common/db'; import Joi from 'joi'; -import { addToQueue } from '#jobs/examples/example-job'; export const getProductionRoute: Hapi.ServerRoute = { method: 'GET', - path: '/productions/{id}', + path: '/products/{id}', options: { description: 'Get product by its ID', notes: 'Just an example demonstrating how to set up an API.', @@ -26,7 +25,6 @@ export const getProductionRoute: Hapi.ServerRoute = { // response: { schema: Joi.object({}) } }, handler: (request: Hapi.Request) => { - addToQueue({ id: Date.now() }); return prisma.product.findFirst(request.params.id); }, }; diff --git a/src/apis/routes/products/update.ts b/src/apis/routes/products/update.ts new file mode 100644 index 0000000..86c17d8 --- /dev/null +++ b/src/apis/routes/products/update.ts @@ -0,0 +1,127 @@ +import Hapi from '@hapi/hapi'; +import { prisma } from '#common/db'; +import { Prisma } from '@prisma/client'; +import Joi from 'joi'; + +export const updateProductionRoute: Hapi.ServerRoute = { + method: 'PUT', + path: '/products/{id}', + options: { + description: 'Update product by its ID', + notes: 'Update product, its attributes and collections', + tags: ['api', 'product', 'update'], + plugins: { 'hapi-swagger': {} }, + validate: { + params: Joi.object({ + id: Joi.number().required().example(1), + }), + payload: Joi.object({ + name: Joi.string().required().example('Product name'), + owner_id: Joi.number().required().example(1), + avatar_img: Joi.string() + .required() + .example('https://loremflickr.com/640/480?lock=1572275828555776'), + banner_img: Joi.string() + .required() + .example('https://loremflickr.com/640/480?lock=1572275828555776'), + category: Joi.string().required().example('game'), + description: Joi.string().required().example('description of product'), + featured: Joi.boolean().default(false), + attributes: Joi.array() + .items( + Joi.object({ + name: Joi.string().required().example('attribute name'), + value: Joi.string().required().example('attribute value'), + }), + ) + .default([]), + metadata: Joi.object({ + previews: Joi.array() + .items(Joi.string()) + .default([]) + .example([ + 'https://loremflickr.com/640/480?lock=1572275828555776', + 'https://loremflickr.com/640/480?lock=1572275828555776', + ]), + cta_link: Joi.string().example('https://www.google.com'), + }).required(), + collections: Joi.array() + .items( + Joi.object({ + chain_id: Joi.string().required(), + contract_address: Joi.string().required(), + }), + ) + .default([]) + .example([ + { chain_id: '1', contract_address: '0x1234x' }, + { chain_id: '2', contract_address: '0x1233x' }, + ]), + }), + }, + // response: { schema: Joi.object({}) } + }, + handler: async (request: Hapi.Request) => { + const payload = request.payload as any; + const product = { + name: payload.name, + owner: { connect: { id: payload.owner_id } }, + avatar_img: payload.avatar_img, + banner_img: payload.banner_img, + category: payload.category, + description: payload.description, + metadata: payload.metadata, + featured_at: payload.featured ? new Date() : undefined, + } as Prisma.ProductUpdateInput; + const id = request.params.id; + + // create new collections + const existingCollections = await prisma.collection.findMany({ + where: { OR: payload.collections as any /** TODO use where in */ }, + }); + const newCollectionData = payload.collections.filter((item: any) => { + return !existingCollections.find( + (col: any) => + col.chain_id === item.chain_id && + col.contract_address === item.contract_address, + ); + }); + let newCollections = [] as Prisma.CollectionGetPayload[]; + if (newCollectionData.length > 0) { + newCollections = await prisma.collection.createManyAndReturn({ + data: newCollectionData.map((col: any) => ({ + chain_id: col.chain_id, + contract_address: col.contract_address, + })) as Prisma.CollectionCreateManyInput[], + }); + } + + // update product & create new product - collection relation + await Promise.all([ + prisma.product.update({ + where: { id: Number(request.params.id) }, + data: product, + }), + prisma.$transaction([ + prisma.productAttribute.deleteMany({ where: { product_id: id } }), + prisma.productAttribute.createMany({ + data: payload.attributes.map((att: any) => ({ + ...att, + product_id: id, + })) as Prisma.ProductAttributeCreateManyInput[], + }), + ]), + prisma.$transaction([ + prisma.productCollection.deleteMany({ where: { product_id: id } }), + prisma.productCollection.createMany({ + data: [...existingCollections, ...newCollections].map((col: any) => ({ + product_id: id, + collection_id: col.id, + })) as Prisma.ProductCollectionCreateManyInput[], + }), + ]), + ]); + + return { data: { id } }; + }, +}; diff --git a/src/apis/routes/utils/upload.ts b/src/apis/routes/utils/upload.ts new file mode 100644 index 0000000..8de6989 --- /dev/null +++ b/src/apis/routes/utils/upload.ts @@ -0,0 +1,38 @@ +import { logger } from '#common/loggger'; +import Hapi from '@hapi/hapi'; +import Joi from 'joi'; + +export const uploadFileRoute: Hapi.ServerRoute = { + method: 'POST', + path: '/upload', + options: { + description: 'File upload', + notes: 'Upload file', + tags: ['api', 'upload'], + plugins: { + 'hapi-swagger': { + payloadType: 'form', + }, + }, + validate: { + payload: Joi.object({ + file: Joi.any() + .meta({ swaggerType: 'file' }) + .description('file to upload'), + }), + }, + payload: { + multipart: true, + maxBytes: 1048576, + parse: true, + output: 'file', + }, + }, + handler: async (request: Hapi.Request) => { + const file = request.payload; + logger.warn(file); + return { + message: 'Uploaded', + }; + }, +}; diff --git a/src/elastic-search/indexes/products/index.ts b/src/elastic-search/indexes/products/index.ts index 2061c90..097cb94 100644 --- a/src/elastic-search/indexes/products/index.ts +++ b/src/elastic-search/indexes/products/index.ts @@ -197,12 +197,16 @@ export const syncDataMissing = async (): Promise => { additional_info: true, }, }, - collections: { - select: { - id: true, - name: true, - chain_id: true, - contract_address: true, + product_collections: { + include: { + collection: { + select: { + id: true, + name: true, + chain_id: true, + contract_address: true, + }, + }, }, }, attributes: { select: { id: true, name: true, value: true } },