diff --git a/examples/primary-sales-backend-api/.env.example b/examples/primary-sales-backend-api/.env.example new file mode 100644 index 0000000000..028f05c759 --- /dev/null +++ b/examples/primary-sales-backend-api/.env.example @@ -0,0 +1,9 @@ +PORT=3000 + +# Environment variables declared in this file are automatically made available to Prisma. +# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema + +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings + +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/primarysales?schema=public" diff --git a/examples/primary-sales-backend-api/.gitignore b/examples/primary-sales-backend-api/.gitignore new file mode 100644 index 0000000000..12a6c18dc0 --- /dev/null +++ b/examples/primary-sales-backend-api/.gitignore @@ -0,0 +1,4 @@ +node_modules +# Keep environment variables out of version control +.env +build \ No newline at end of file diff --git a/examples/primary-sales-backend-api/.nvmrc b/examples/primary-sales-backend-api/.nvmrc new file mode 100644 index 0000000000..b427e2ae2f --- /dev/null +++ b/examples/primary-sales-backend-api/.nvmrc @@ -0,0 +1 @@ +v20.16.0 \ No newline at end of file diff --git a/examples/primary-sales-backend-api/README.md b/examples/primary-sales-backend-api/README.md new file mode 100644 index 0000000000..0dee9cc627 --- /dev/null +++ b/examples/primary-sales-backend-api/README.md @@ -0,0 +1,40 @@ +# Example Primary Sales Webhook API + +This example shows how to implement the webhooks required for the [primary sales backend config](https://docs.immutable.com/products/zkEVM/checkout/widgets/primary-sales/backend/byo). + +## Pre-requisites + +* [NodeJS >= v20](https://nodejs.org/en) +* [Docker](https://www.docker.com/) + +### Install dependencies + +Run `npm i` + +### Set environment variables + +Copy the `.env.example` file and rename it to `.env`. + +## Running the app + +1. Run `docker-compose up -d` to start the postgres DB at port 5432. +2. Run `npx prisma migrate dev` and `npm run seed` to initialise the DB schema and seed it with data. +3. `npm run dev` to start your server on port 3000 + +## Webhook endpoints + +To see the list of endpoints this example serves, go to [the Swagger UI](http://localhost:3000/docs). + +Apart from the `/api/v1/products` endpoint which is used to list the products available in the DB, the rest of the endpoints correspond to the [Primary Sales backend config documentation](https://docs.immutable.com/products/zkEVM/checkout/widgets/primary-sales/backend/byo). + + +## Example requests + +For your convenience, we have also added a postman collection under the `postman` folder. These contain sample requests for each endpoint, using the seeded products data. + +To run the requests, download [Postman](https://www.postman.com/) and import the collection. + + +## TO-DO list + +* Add authentication for each endpoint, as per the [webhook authentication section](https://docs.immutable.com/products/zkEVM/checkout/widgets/primary-sales/backend/byo#webhook-authentication) \ No newline at end of file diff --git a/examples/primary-sales-backend-api/docker-compose.yml b/examples/primary-sales-backend-api/docker-compose.yml new file mode 100644 index 0000000000..b15f216412 --- /dev/null +++ b/examples/primary-sales-backend-api/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + primary-sales-db: + image: postgres:14 + ports: + - 5432:5432 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=primarysales + restart: always + volumes: + - primary-sales-db-data:/data/postgres + +volumes: + primary-sales-db-data: + diff --git a/examples/primary-sales-backend-api/package.json b/examples/primary-sales-backend-api/package.json new file mode 100644 index 0000000000..459bf355ea --- /dev/null +++ b/examples/primary-sales-backend-api/package.json @@ -0,0 +1,29 @@ +{ + "devDependencies": { + "@types/node": "^22.2.0", + "fastify-tsconfig": "^2.0.0", + "nodemon": "^3.1.4", + "prisma": "^5.18.0", + "ts-node": "^10.9.2", + "typescript": "^5.5.4" + }, + "dependencies": { + "@fastify/autoload": "^5.10.0", + "@fastify/env": "^4.4.0", + "@fastify/swagger": "^8.15.0", + "@fastify/swagger-ui": "^4.1.0", + "@fastify/type-provider-typebox": "^4.0.0", + "@paralleldrive/cuid2": "^2.2.2", + "@prisma/client": "^5.18.0", + "fastify": "^4.28.1" + }, + "prisma": { + "seed": "ts-node prisma/seed.ts" + }, + "scripts": { + "build": "rm -rf build ; tsc", + "start": "node build/src/server.js", + "dev": "nodemon --exec ts-node src/server.ts", + "seed": "prisma db seed" + } +} diff --git a/examples/primary-sales-backend-api/postman/Primary sales BE.postman_collection.json b/examples/primary-sales-backend-api/postman/Primary sales BE.postman_collection.json new file mode 100644 index 0000000000..4df0afbcdd --- /dev/null +++ b/examples/primary-sales-backend-api/postman/Primary sales BE.postman_collection.json @@ -0,0 +1,189 @@ +{ + "info": { + "_postman_id": "c754b503-fb61-4722-8fac-47fecc2efb3e", + "name": "Primary sales BE", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "25754861" + }, + "item": [ + { + "name": "Quote", + "request": { + "method": "POST", + "header": [ + { + "key": "QUOTE_API_KEY", + "value": "test_api_key", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"recipient_address\": \"0xdd9AAE1C317eE6EFEb0F3DB0A068e9Ed952a6CEB\",\n \"products\": [\n {\n \"product_id\": \"vi7age4ku18qynwbk4wx90ge\",\n \"quantity\": 1\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:3000/api/v1/orders/quotes", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "v1", + "orders", + "quotes" + ] + } + }, + "response": [] + }, + { + "name": "Create order", + "request": { + "method": "POST", + "header": [ + { + "key": "QUOTE_API_KEY", + "value": "test_api_key", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"recipient_address\": \"0xdd9AAE1C317eE6EFEb0F3DB0A068e9Ed952a6CEB\",\n \"currency\": \"USDC\",\n \"products\": [\n {\n \"product_id\": \"vi7age4ku18qynwbk4wx90ge\",\n \"quantity\": 1\n },\n {\n \"product_id\": \"jtwrclpj0v1zab865ne893hb\",\n \"quantity\": 1\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:3000/api/v1/sale-authorization", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "v1", + "sale-authorization" + ] + } + }, + "response": [] + }, + { + "name": "Expire an order", + "request": { + "method": "POST", + "header": [ + { + "key": "QUOTE_API_KEY", + "value": "test_api_key", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reference\": \"cm02a70000001updhnudm7bop\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:3000/api/v1/expire", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "v1", + "expire" + ] + } + }, + "response": [] + }, + { + "name": "Confirm", + "request": { + "method": "POST", + "header": [ + { + "key": "QUOTE_API_KEY", + "value": "test_api_key", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reference\": \"cm02apig000035o3ipwszr02z\",\n \"tx_hash\": \"test\",\n \"recipient_address\": \"0xdd9AAE1C317eE6EFEb0F3DB0A068e9Ed952a6CEB\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:3000/api/v1/confirm", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "v1", + "confirm" + ] + } + }, + "response": [] + }, + { + "name": "Products", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "QUOTE_API_KEY", + "value": "test_api_key", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reference\": \"cm02a70000001updhnudm7bop\",\n \"tx_hash\": \"test\",\n \"recipient_address\": \"0xdd9AAE1C317eE6EFEb0F3DB0A068e9Ed952a6CEB\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:3000/api/v1/products", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "v1", + "products" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/examples/primary-sales-backend-api/prisma/migrations/20240820102407_init/migration.sql b/examples/primary-sales-backend-api/prisma/migrations/20240820102407_init/migration.sql new file mode 100644 index 0000000000..00a238fd17 --- /dev/null +++ b/examples/primary-sales-backend-api/prisma/migrations/20240820102407_init/migration.sql @@ -0,0 +1,65 @@ +-- CreateEnum +CREATE TYPE "CurrencyType" AS ENUM ('crypto', 'fiat'); + +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('reserved', 'completed', 'expired', 'failed'); + +-- CreateTable +CREATE TABLE "Product" ( + "id" TEXT NOT NULL, + "collectionAddress" TEXT NOT NULL, + "contractType" TEXT NOT NULL, + "stockQuantity" INTEGER NOT NULL, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Currency" ( + "name" TEXT NOT NULL, + "type" "CurrencyType" NOT NULL, + + CONSTRAINT "Currency_pkey" PRIMARY KEY ("name") +); + +-- CreateTable +CREATE TABLE "ProductPrice" ( + "product_id" TEXT NOT NULL, + "currency_name" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + + CONSTRAINT "ProductPrice_pkey" PRIMARY KEY ("product_id","currency_name") +); + +-- CreateTable +CREATE TABLE "Order" ( + "id" TEXT NOT NULL, + "status" "OrderStatus" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "transactionHash" TEXT, + "recipientAddress" TEXT NOT NULL, + + CONSTRAINT "Order_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderLineItem" ( + "order_id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + + CONSTRAINT "OrderLineItem_pkey" PRIMARY KEY ("order_id","product_id") +); + +-- AddForeignKey +ALTER TABLE "ProductPrice" ADD CONSTRAINT "ProductPrice_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductPrice" ADD CONSTRAINT "ProductPrice_currency_name_fkey" FOREIGN KEY ("currency_name") REFERENCES "Currency"("name") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderLineItem" ADD CONSTRAINT "OrderLineItem_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderLineItem" ADD CONSTRAINT "OrderLineItem_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/examples/primary-sales-backend-api/prisma/migrations/migration_lock.toml b/examples/primary-sales-backend-api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..fbffa92c2b --- /dev/null +++ b/examples/primary-sales-backend-api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/examples/primary-sales-backend-api/prisma/schema.prisma b/examples/primary-sales-backend-api/prisma/schema.prisma new file mode 100644 index 0000000000..3beb46bab9 --- /dev/null +++ b/examples/primary-sales-backend-api/prisma/schema.prisma @@ -0,0 +1,72 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Product { + id String @id @default(cuid()) + productPrices ProductPrice[] + collectionAddress String + contractType String + stockQuantity Int + orders OrderLineItem[] +} + +enum CurrencyType { + crypto + fiat +} + +model Currency { + name String @id + type CurrencyType + productPrices ProductPrice[] +} + +model ProductPrice { + product Product @relation(fields: [product_id], references: [id]) + product_id String + currency Currency @relation(fields: [currency_name], references: [name]) + currency_name String + amount Float + + @@id([product_id, currency_name]) +} + +enum OrderStatus { + reserved + completed + expired + failed +} + +model Order { + id String @id @default(cuid()) + status OrderStatus + lineItems OrderLineItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + transactionHash String? + recipientAddress String +} + +model OrderLineItem { + order_id String + product_id String + quantity Int + order Order @relation(fields: [order_id], references: [id]) + product Product @relation(fields: [product_id], references: [id]) + + @@id([order_id, product_id]) +} diff --git a/examples/primary-sales-backend-api/prisma/seed.ts b/examples/primary-sales-backend-api/prisma/seed.ts new file mode 100644 index 0000000000..c0400387c0 --- /dev/null +++ b/examples/primary-sales-backend-api/prisma/seed.ts @@ -0,0 +1,86 @@ +import { PrismaClient } from "@prisma/client"; +import 'dotenv/config' + +const prisma = new PrismaClient(); + +// For this seed script, we will create a few products and currencies +// that we will use to demonstrate the API functionality + +// This is a fake collection address. In a real scenario, this would be the address of the product's collection address. +const collectionAddress = '0x00'; + +async function main() { + const usdc = await prisma.currency.upsert({ + where: { name: 'USDC' }, + update: {}, + create: { + name: 'USDC', + type: 'crypto' + } + }); + + const eth = await prisma.currency.upsert({ + where: { name: 'ETH' }, + update: {}, + create: { + name: 'ETH', + type: 'crypto' + } + }); + + const productId1 = 'vi7age4ku18qynwbk4wx90ge'; + + await prisma.product.upsert({ + where: { id: productId1 }, + update: {}, + create: { + id: productId1, + collectionAddress: collectionAddress, + contractType: 'ERC721', + stockQuantity: 100, + productPrices: { + create: [ + { + currency_name: usdc.name, + amount: 1000 + }, + { + currency_name: eth.name, + amount: 0.205 + }, + ] + } + } + }) + + const productId2 = 'jtwrclpj0v1zab865ne893hb'; + + await prisma.product.upsert({ + where: { id: productId2 }, + update: {}, + create: { + id: productId2, + collectionAddress: collectionAddress, + contractType: 'ERC721', + stockQuantity: 50, + productPrices: { + create: [ + { + currency_name: usdc.name, + amount: 20 + }, + ] + } + } + }) +} + +main() + .then(async () => { + await prisma.$disconnect() + }) + .catch(async (e) => { + console.error(e) + await prisma.$disconnect() + process.exit(1) + }) \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/errors.ts b/examples/primary-sales-backend-api/src/errors.ts new file mode 100644 index 0000000000..825fe2083a --- /dev/null +++ b/examples/primary-sales-backend-api/src/errors.ts @@ -0,0 +1,8 @@ +export class ApiError extends Error { + constructor(readonly statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + + Error.captureStackTrace(this, this.constructor); + } +} \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/plugins/errorHander.ts b/examples/primary-sales-backend-api/src/plugins/errorHander.ts new file mode 100644 index 0000000000..309db96f7a --- /dev/null +++ b/examples/primary-sales-backend-api/src/plugins/errorHander.ts @@ -0,0 +1,45 @@ +import { Prisma } from '@prisma/client'; +import { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; + +const handlePrismaError = (err: Prisma.PrismaClientKnownRequestError): { + statusCode: number, + message: string +} => { + switch (err.code) { + case 'P2025': + return { + statusCode: 400, + message: `Not found: ${err.meta?.modelName}` + }; + default: + // handling all other errors + return { + statusCode: 500, + message: 'Internal Server Error' + }; + + } +}; + +const errorHandlerPlugin: FastifyPluginAsync = fp(async (fastify: FastifyInstance) => { + fastify.setErrorHandler(function (error, request, reply) { + this.log.error(error); + + if (error instanceof Prisma.PrismaClientKnownRequestError) { + const mappedError = handlePrismaError(error); + + reply.status(mappedError.statusCode).send({ message: mappedError.message }); + return; + } + + if (!error.statusCode) { + reply.status(500).send({ message: 'Internal Server Error' }); + return; + } + + reply.status(error.statusCode).send({ message: error.message }); + }); +}); + +export default errorHandlerPlugin; \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/plugins/prisma.ts b/examples/primary-sales-backend-api/src/plugins/prisma.ts new file mode 100644 index 0000000000..97da5f1229 --- /dev/null +++ b/examples/primary-sales-backend-api/src/plugins/prisma.ts @@ -0,0 +1,17 @@ +import { PrismaClient } from "@prisma/client"; +import { FastifyPluginAsync } from "fastify"; +import fp from 'fastify-plugin' + +const PrismaPlugin: FastifyPluginAsync = fp(async (fastify) => { + const prisma = new PrismaClient(); + + await prisma.$connect(); + + fastify.decorate('prisma', prisma) + + fastify.addHook('onClose', async () => { + await prisma.$disconnect(); + }); +}) + +export default PrismaPlugin; \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/plugins/swagger.ts b/examples/primary-sales-backend-api/src/plugins/swagger.ts new file mode 100644 index 0000000000..428651e679 --- /dev/null +++ b/examples/primary-sales-backend-api/src/plugins/swagger.ts @@ -0,0 +1,33 @@ +import fp from 'fastify-plugin' +import SwaggerUI from '@fastify/swagger-ui' +import Swagger, { type FastifyDynamicSwaggerOptions } from '@fastify/swagger' + +export default fp(async (fastify, opts) => { + await fastify.register(Swagger, { + openapi: { + info: { + title: 'Primary Sales Webhooks Backend', + description: 'Example API endpoints for the Primary Sales Webhooks Backend', + version: '0.1.0', + }, + servers: [ + { + url: `http://localhost:${fastify.config.PORT}`, + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + }, + }) + + await fastify.register(SwaggerUI, { + routePrefix: '/docs', + }) +}) \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/routes/authorize.ts b/examples/primary-sales-backend-api/src/routes/authorize.ts new file mode 100644 index 0000000000..d2a1a988ac --- /dev/null +++ b/examples/primary-sales-backend-api/src/routes/authorize.ts @@ -0,0 +1,127 @@ +import { FastifyPluginAsyncTypebox, Type } from "@fastify/type-provider-typebox"; +import { OrderLineItem, OrderStatus, PrismaClient } from '@prisma/client'; +import { ApiError } from '../errors'; +import { ProductRequestSchema, ProductRequestType } from "../schemas/product"; + +const createOrder = async (prisma: PrismaClient, recipientAddress: string, orderProducts: ProductRequestType[]) => { + return await prisma.$transaction(async tx => { + const updatedProducts = []; + + for (const orderProduct of orderProducts) { + + // For each product in the order, we decrement the stock quantity by the order quantity since we are reserving stock for the order. + const updatedProduct = await tx.product.update({ + where: { id: orderProduct.product_id }, + data: { + stockQuantity: { decrement: orderProduct.quantity } + }, + include: { + productPrices: true + } + }) + + if (updatedProduct.stockQuantity < 0) { + throw new ApiError(400, `Product with id ${orderProduct.product_id} has insufficient stock for this order`); + } + updatedProducts.push(updatedProduct); + } + + // Create an order with a 'reserved' status alongside the order line items. + const order = await tx.order.create({ + data: { + status: OrderStatus.reserved, + recipientAddress: recipientAddress, + lineItems: { + create: updatedProducts.map((product) => ({ + product_id: product.id, + quantity: orderProducts.find((orderProduct) => orderProduct.product_id === product.id)?.quantity ?? 0 + })), + } + }, + include: { + lineItems: { + include: { + product: { + include: { + productPrices: true + } + } + } + } + } + }) + return order; + }) +} + +const SaleAuthorizationRoutes: FastifyPluginAsyncTypebox = async (fastify) => { + fastify.post('/orders/authorize', { + schema: { + description: 'Authorize a sale and reserve stock for the order', + body: Type.Object({ + products: Type.Array(ProductRequestSchema), + currency: Type.String(), + recipient_address: Type.String() + }), + response: { + 200: Type.Object({ + reference: Type.String(), + currency: Type.String(), + products: Type.Array(Type.Object({ + product_id: Type.String(), + collection_address: Type.String(), + contract_type: Type.String(), + detail: Type.Array(Type.Object({ + token_id: Type.String(), + amount: Type.Number() + }) + ) + })) + }), + 400: Type.Object({ + message: Type.String() + }), + 404: Type.Object({ + message: Type.String() + }), + } + } + }, async (request, _) => { + const order = await createOrder(fastify.prisma, request.body.recipient_address, request.body.products); + + const populateDetails = (amount: number, lineItem: OrderLineItem) => { + const details = []; + for (let i = 0; i < lineItem.quantity; i++) { + details.push({ + // We don't persist token ID in this example, so we generate a random one. + // For real life use cases, consider whether you need to persist token_id or not during order creation. + token_id: String(Math.floor(Math.random() * 10000000000)), + amount + }) + } + + return details; + } + + return { + reference: order.id, + currency: request.body.currency, + products: order.lineItems.map(lineItem => { + const pricing = lineItem.product.productPrices.find((productPrice) => productPrice.currency_name === request.body.currency); + if (!pricing) { + throw new ApiError(404, `Product with id ${lineItem.product_id} does not have pricing for currency ${request.body.currency}`); + } + const productDetails = populateDetails(pricing.amount, lineItem); + + return { + product_id: lineItem.product_id, + collection_address: lineItem.product.collectionAddress, + contract_type: lineItem.product.contractType, + detail: productDetails + } + }) + } + }); +} + +export default SaleAuthorizationRoutes; diff --git a/examples/primary-sales-backend-api/src/routes/confirm.ts b/examples/primary-sales-backend-api/src/routes/confirm.ts new file mode 100644 index 0000000000..0dfa2e722f --- /dev/null +++ b/examples/primary-sales-backend-api/src/routes/confirm.ts @@ -0,0 +1,45 @@ +import { FastifyPluginAsyncTypebox, Type } from "@fastify/type-provider-typebox"; +import { OrderStatus } from "@prisma/client"; + +const ConfirmOrderRequestSchema = Type.Object({ + // This example doesn't need the rest of the fields in the request + // Consider what fields you'll need for your own implementation and adjust accordingly + reference: Type.String(), + tx_hash: Type.String(), +}) + + +const OrderConfirmationRoutes: FastifyPluginAsyncTypebox = async (fastify) => { + fastify.post('/orders/confirm', { + schema: { + description: 'Endpoint that will be called after a successful transation.', + body: ConfirmOrderRequestSchema, + response: { + 200: Type.Null(), + 404: Type.Object({ + message: Type.String() + }), + 400: Type.Object({ + message: Type.String() + }), + } + } + }, async (request, reply) => { + const { reference, tx_hash } = request.body; + + // We only update the order status to completed and store the transaction hash + // In real life scenarios you can use this endpoint for randomised metadata generation (such as lootboxes), to transfer the NFTs to the user in-game, etc. + await fastify.prisma.order.update({ + where: { + id: reference + }, + data: { + status: OrderStatus.completed, + transactionHash: tx_hash, + } + }); + return reply.status(200).send(); + }); +} + +export default OrderConfirmationRoutes; \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/routes/expire.ts b/examples/primary-sales-backend-api/src/routes/expire.ts new file mode 100644 index 0000000000..3d5fd54d7f --- /dev/null +++ b/examples/primary-sales-backend-api/src/routes/expire.ts @@ -0,0 +1,78 @@ +import { FastifyPluginAsyncTypebox, Type } from "@fastify/type-provider-typebox"; +import { OrderStatus, PrismaClient } from "@prisma/client"; +import { ApiError } from "../errors"; + +const expireOrder = async (prisma: PrismaClient, reference: string) => { + return await prisma.$transaction(async tx => { + const order = await tx.order.findUnique({ + where: { + id: reference + } + }); + + if (!order) { + throw new ApiError(404, 'Order not found'); + } + + if (order.status !== OrderStatus.reserved) { + throw new ApiError(400, 'Order is not reserved'); + } + + await tx.order.update({ + where: { + id: reference + }, + data: { + status: OrderStatus.expired + } + }); + + const lineItems = await tx.orderLineItem.findMany({ + where: { + order_id: reference + } + }); + + for (const lineItem of lineItems) { + await tx.product.update({ + where: { + id: lineItem.product_id + }, + // Since the order is expired, the quantity reserved by that order should be released back + // so we increase the stock back by the original order quantity + data: { + stockQuantity: { + increment: lineItem.quantity + } + } + }); + } + }); +} + + +const OrderExpiryRoutes: FastifyPluginAsyncTypebox = async (fastify) => { + fastify.post('/orders/expire', { + schema: { + description: 'Expire an order and release the reserved stock back to the product', + body: Type.Object({ + reference: Type.String(), + }), + response: { + 200: Type.Null(), + 404: Type.Object({ + message: Type.String() + }), + 400: Type.Object({ + message: Type.String() + }), + } + } + }, async (request, reply) => { + await expireOrder(fastify.prisma, request.body.reference); + + return reply.status(200).send(); + }); +} + +export default OrderExpiryRoutes; \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/routes/products.ts b/examples/primary-sales-backend-api/src/routes/products.ts new file mode 100644 index 0000000000..e3a1106c3c --- /dev/null +++ b/examples/primary-sales-backend-api/src/routes/products.ts @@ -0,0 +1,45 @@ +import { FastifyPluginAsyncTypebox, Type } from "@fastify/type-provider-typebox"; + +const OrderConfirmationRoutes: FastifyPluginAsyncTypebox = async (fastify) => { + fastify.get('/products', { + schema: { + description: 'Get a list of products with their pricing', + response: { + 200: Type.Array(Type.Object({ + product_id: Type.String(), + quantity: Type.Number(), + pricing: Type.Array(Type.Object({ + currency: Type.String(), + amount: Type.Number() + })) + })), + 404: Type.Object({ + message: Type.String() + }), + 400: Type.Object({ + message: Type.String() + }), + } + } + }, async () => { + const products = await fastify.prisma.product.findMany({ + include: { + productPrices: true + } + }); + return products.map(p => { + return { + product_id: p.id, + quantity: p.stockQuantity, + pricing: p.productPrices.map(pp => { + return { + currency: pp.currency_name, + amount: pp.amount + } + }) + } + }) + }); +} + +export default OrderConfirmationRoutes; \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/routes/quotes.ts b/examples/primary-sales-backend-api/src/routes/quotes.ts new file mode 100644 index 0000000000..88decc4b24 --- /dev/null +++ b/examples/primary-sales-backend-api/src/routes/quotes.ts @@ -0,0 +1,124 @@ +import { FastifyPluginAsyncTypebox, Type } from "@fastify/type-provider-typebox"; +import { PrismaClient, Currency, CurrencyType } from "@prisma/client"; +import { CurrencySchema } from "../schemas/currency"; +import { ProductsResponseSchema, ProductsResponseType } from "../schemas/product"; +import { ApiError } from "../errors"; + +const getProductsWithPrices = (ids: string[], prisma: PrismaClient) => { + return prisma.product.findMany({ + where: { + id: { + in: ids + } + }, + include: { + productPrices: { + include: { + currency: true + } + } + } + }); +} + +const calculateTotals = (products: ProductsResponseType): Map => { + const currencyTotalsMap = new Map(); + + products.forEach((product) => { + product.pricing.forEach((productPrice) => { + const { currency, amount, currency_type } = productPrice; + + const currentTotal = currencyTotalsMap.get(currency)?.total || 0; + + currencyTotalsMap.set(currency, { + type: currency_type as CurrencyType, + name: currency, + total: currentTotal + amount + }); + }); + }); + + return currencyTotalsMap; +} + +const QuoteRoutes: FastifyPluginAsyncTypebox = async (fastify) => { + fastify.post('/quotes', { + schema: { + description: 'Get a quote for a list of products. Used to calculate the total cost of an order and show it to the user.', + body: Type.Object({ + recipient_address: Type.String(), + products: Type.Array(Type.Object({ + product_id: Type.String(), + quantity: Type.Integer() + })), + }), + response: { + 200: Type.Object({ + products: ProductsResponseSchema, + totals: Type.Array(CurrencySchema) + }), + 404: Type.Object({ + message: Type.String() + }), + 400: Type.Object({ + message: Type.String() + }), + } + } + }, async (request, _) => { + const { products: requestedProducts } = request.body; + + const products = await getProductsWithPrices(requestedProducts.map((product) => product.product_id), fastify.prisma); + + if (!products.length) { + return { products: [], totals: [] } + } + + const productsResponse: ProductsResponseType = []; + + for (const requestedProduct of requestedProducts) { + const product = products.find(p => p.id === requestedProduct.product_id); + + if (!product) { + throw new ApiError(404, `Product with id ${requestedProduct.product_id} not found`); + } + + if (requestedProduct.quantity > product.stockQuantity) { + throw new ApiError(400, `Not enough stock for product with id ${requestedProduct.product_id}`); + } + + // Get the pricing for the product, separated by currency + const productPrices = product.productPrices.map((productPrice) => ({ + currency: productPrice.currency.name, + amount: productPrice.amount * requestedProduct.quantity, + currency_type: String(productPrice.currency.type), + })); + + productsResponse.push({ + product_id: product.id, + quantity: requestedProduct.quantity, + pricing: productPrices + }); + } + + // Calculate totals for each currency + const currencyTotalsMap = calculateTotals(productsResponse); + + const totalsResponse = Array.from(currencyTotalsMap.values()).map(total => ({ + currency: total.name, + currency_type: String(total.type), + amount: total.total + })); + + return { + products: productsResponse, + totals: totalsResponse + } + }); +} + +export default QuoteRoutes; \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/schemas/currency.ts b/examples/primary-sales-backend-api/src/schemas/currency.ts new file mode 100644 index 0000000000..f6539dafd9 --- /dev/null +++ b/examples/primary-sales-backend-api/src/schemas/currency.ts @@ -0,0 +1,7 @@ +import { Type } from "@fastify/type-provider-typebox"; + +export const CurrencySchema = Type.Object({ + currency: Type.String(), + amount: Type.Number(), + currency_type: Type.String(), +}) \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/schemas/product.ts b/examples/primary-sales-backend-api/src/schemas/product.ts new file mode 100644 index 0000000000..f1d005d170 --- /dev/null +++ b/examples/primary-sales-backend-api/src/schemas/product.ts @@ -0,0 +1,17 @@ +import { Type, Static } from "@fastify/type-provider-typebox"; +import { CurrencySchema } from "./currency"; + +export const ProductRequestSchema = Type.Object({ + product_id: Type.String(), + quantity: Type.Integer() +}); + +export type ProductRequestType = Static; + +export const ProductsResponseSchema = Type.Array(Type.Object({ + product_id: Type.String(), + quantity: Type.Number(), + pricing: Type.Array(CurrencySchema) +})); + +export type ProductsResponseType = Static; \ No newline at end of file diff --git a/examples/primary-sales-backend-api/src/server.ts b/examples/primary-sales-backend-api/src/server.ts new file mode 100644 index 0000000000..6ec408ff6c --- /dev/null +++ b/examples/primary-sales-backend-api/src/server.ts @@ -0,0 +1,60 @@ +import fastify from "fastify"; +import fastifyEnv from "@fastify/env"; +import AutoLoad from '@fastify/autoload'; +import { PrismaClient } from "@prisma/client"; +import { Static, Type } from "@fastify/type-provider-typebox"; + +export const ConfigSchema = Type.Object({ + PORT: Type.String({ default: '3000' }), +}); + +export type Config = Static; + +declare module 'fastify' { + interface FastifyInstance { + config: Config; + prisma: PrismaClient; + } +} + +const options = { + schema: ConfigSchema, + dotenv: true, + data: process.env +} + +const server = fastify({ logger: true }); + +const initialize = async () => { + await server + .register(fastifyEnv, options); + + server + .register(AutoLoad, { + dir: `${__dirname}/plugins`, + ignorePattern: /.test.(t|j)s/, + }) + .register(AutoLoad, { + dir: `${__dirname}/routes`, + ignorePattern: /.test.(t|j)s/, + dirNameRoutePrefix: true, + routeParams: true, + options: { prefix: "/api/v1" }, + }); + + await server.after(); +} + +(async () => { + try { + await initialize(); + await server.ready(); + await server.listen({ + port: Number(server.config.PORT), + host: '0.0.0.0' + }); + } catch (error) { + server.log.error(error); + process.exit(1); + } +})(); diff --git a/examples/primary-sales-backend-api/tsconfig.json b/examples/primary-sales-backend-api/tsconfig.json new file mode 100644 index 0000000000..96b6b0e58f --- /dev/null +++ b/examples/primary-sales-backend-api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "fastify-tsconfig", + "compilerOptions": { + "outDir": "build", + "lib": ["es2018"], + "target": "es2018", + "strict": true, + "forceConsistentCasingInFileNames": true, + "typeRoots": ["./node_modules/@types", "./src/types"] + } +} \ No newline at end of file