From e871ada0e16bb0fcda63d2aa5156ef91c050d18d Mon Sep 17 00:00:00 2001 From: RinYato Date: Mon, 23 Sep 2024 17:34:28 +0700 Subject: [PATCH] wut did I even do in this commit? --- .gitignore | 1 + apps/api/src/index.ts | 3 +- apps/api/src/lib/db.ts | 2 +- apps/api/src/lib/lucia/index.ts | 1 - apps/api/src/service/bakong.service.ts | 1 + apps/api/src/service/checkout.service.ts | 29 +++++- apps/api/src/service/token.service.ts | 4 +- apps/api/src/service/webhook.service.ts | 9 +- apps/api/src/task/transaction/queue.ts | 35 ++++--- apps/api/src/task/transaction/tasker.ts | 18 +++- apps/api/src/task/webhook/queue.ts | 33 ++++--- apps/api/src/task/webhook/tasker.ts | 60 +++++++++++- apps/web/src/route/portal/$checkoutId.tsx | 11 ++- .../src/route/portal/-component/invoice.tsx | 44 +++++---- docker-compose.yaml | 97 +++++++++++++++++++ 15 files changed, 280 insertions(+), 68 deletions(-) create mode 100644 docker-compose.yaml diff --git a/.gitignore b/.gitignore index 96fab4f..66eccd7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules .env.development.local .env.test.local .env.production.local +.env.compose # Testing coverage diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 91ae386..32f2af9 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,6 +14,7 @@ import { TransactionTasker } from "@/task/transaction"; import { registerGlobalErrorHandler } from "./setup/error"; import { registerLogger } from "./setup/logger"; import { registerHeaderMiddleware } from "./setup/header"; +import { WebhookTasker } from "./task/webhook"; const app = new OpenAPIHono(); @@ -30,7 +31,7 @@ registerTiming(app); registerGlobalErrorHandler(app); // Register taskers -registerTasker(app, [new TransactionTasker()]); +registerTasker(app, [new TransactionTasker(), new WebhookTasker()]); // Register Auth middleware registerAuthMiddleware(app); diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts index bb79fcf..920e78c 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -5,7 +5,7 @@ import { apiError } from "./error"; // Auto run migration await migrate(); -const client = createDBClient({ url: env.DB_URL }); +const client = createDBClient({ url: env.DB_URL, max: 30 }); export const db = createDB(client); export type DB = typeof db; diff --git a/apps/api/src/lib/lucia/index.ts b/apps/api/src/lib/lucia/index.ts index 836d4a9..1ebeebd 100644 --- a/apps/api/src/lib/lucia/index.ts +++ b/apps/api/src/lib/lucia/index.ts @@ -33,7 +33,6 @@ export const lucia = new Lucia(adapter, { address: attributes.address, phone: attributes.phone, webhookUrl: attributes.webhookUrl, - allowRetry: attributes.allowRetry, waitBeforeRedirect: attributes.waitBeforeRedirect, createdAt: attributes.createdAt, updatedAt: attributes.createdAt, diff --git a/apps/api/src/service/bakong.service.ts b/apps/api/src/service/bakong.service.ts index efb2612..e39e8b2 100644 --- a/apps/api/src/service/bakong.service.ts +++ b/apps/api/src/service/bakong.service.ts @@ -19,6 +19,7 @@ class BakongService { private token = env.BAKONG_TOKEN; private api = ky.extend({ prefixUrl: BAKONG_API_URL, + retry: 2, }); createKHQR(opts: KHQROpts) { diff --git a/apps/api/src/service/checkout.service.ts b/apps/api/src/service/checkout.service.ts index a7981d9..7eb372f 100644 --- a/apps/api/src/service/checkout.service.ts +++ b/apps/api/src/service/checkout.service.ts @@ -112,6 +112,7 @@ export class CheckoutService { displayName: true, username: true, bakongId: true, + webhookUrl: true, }, }, }, @@ -125,7 +126,12 @@ export class CheckoutService { } if (checkout.status === "SUCCESS") { - return ok({ checkout, activeTransaction: null }); + const { + user: { webhookUrl, ...user }, + transactions, + ...checkoutOnly + } = checkout; + return ok({ checkout: checkoutOnly, user, activeTransaction: null }); } let activeTransaction = checkout.transactions.find((t) => t.status === "PENDING"); @@ -153,9 +159,24 @@ export class CheckoutService { } // Add transaction to queue without waiting - withRetry(() => transactionQueue.add(activeTransaction.id, activeTransaction.md5)); - - return ok({ checkout, activeTransaction }); + withRetry(() => + transactionQueue.add({ + userId: checkout.userId, + checkoutId: checkout.id, + webhookUrl: checkout.user.webhookUrl, + transactionId: activeTransaction.id, + md5: activeTransaction.md5, + }), + ); + + // Ugly i know, but bare with me + const { + user: { webhookUrl, ...user }, + transactions, + ...checkoutOnly + } = checkout; + + return ok({ checkout: checkoutOnly, user, activeTransaction }); } findById(id: string) { diff --git a/apps/api/src/service/token.service.ts b/apps/api/src/service/token.service.ts index 4ae1951..a7135f0 100644 --- a/apps/api/src/service/token.service.ts +++ b/apps/api/src/service/token.service.ts @@ -3,7 +3,7 @@ import { nanoid } from "@/lib/id"; import { redis } from "@/lib/redis"; import { withRetry } from "@/lib/retry"; import { omit } from "@/lib/transform"; -import { err, ok, type Result } from "@justmiracle/result"; +import { err, ok, unwrap, type Result } from "@justmiracle/result"; import { TB_token } from "@repo/db/table"; import { and, eq, isNull, sql } from "drizzle-orm"; @@ -42,7 +42,7 @@ class TokenService { .catch(err); if (dbToken.error) return dbToken; - withRetry(() => this.setToken(key, userId)); + withRetry(() => this.setToken(key, userId).then(unwrap)); return dbToken; } diff --git a/apps/api/src/service/webhook.service.ts b/apps/api/src/service/webhook.service.ts index 8ba1f65..0c82de4 100644 --- a/apps/api/src/service/webhook.service.ts +++ b/apps/api/src/service/webhook.service.ts @@ -1,10 +1,17 @@ import { db, takeFirstOrThrow } from "@/lib/db"; +import { err, ok } from "@justmiracle/result"; import type { WebhookInsert } from "@repo/db/schema"; import { TB_webhook } from "@repo/db/table"; class WebhookService { async create(data: WebhookInsert) { - return db.insert(TB_webhook).values(data).returning().then(takeFirstOrThrow); + return db + .insert(TB_webhook) + .values(data) + .returning() + .then(takeFirstOrThrow) + .then(ok) + .catch(err); } } diff --git a/apps/api/src/task/transaction/queue.ts b/apps/api/src/task/transaction/queue.ts index 7f38703..6b44a90 100644 --- a/apps/api/src/task/transaction/queue.ts +++ b/apps/api/src/task/transaction/queue.ts @@ -1,5 +1,5 @@ import { DEFAULT_CONNECTION } from "@/lib/tasker"; -import { Queue } from "bullmq"; +import { type Job, Queue } from "bullmq"; export const TRANSACTION_QUEUE_NAME = "{transaction}"; @@ -9,28 +9,33 @@ const REMOVE_ON_FAIL = { count: 8500 }; const REMOVE_ON_SUCCESS = { count: 5500 }; const BACKOFF = { type: "fixed", delay: 3200 }; +export type TransactionJobData = { + md5: string; + transactionId: string; + checkoutId: string; + userId: string; + webhookUrl: string; +}; +export type TransactionJob = Job; + export class TransactionQueue { queue; constructor() { - this.queue = new Queue(TRANSACTION_QUEUE_NAME, { + this.queue = new Queue(TRANSACTION_QUEUE_NAME, { connection: DEFAULT_CONNECTION, }); } - async add(transactionId: string, md5: string) { - return await this.queue.add( - TRANSACTION_QUEUE_NAME, - { md5, transactionId }, - { - jobId: transactionId, - delay: DELAY, - backoff: BACKOFF, - attempts: MAXIMUM_ATTEMPTS, - removeOnFail: REMOVE_ON_FAIL, - removeOnComplete: REMOVE_ON_SUCCESS, - }, - ); + async add(data: TransactionJobData) { + return await this.queue.add(TRANSACTION_QUEUE_NAME, data, { + jobId: data.transactionId, + delay: DELAY, + backoff: BACKOFF, + attempts: MAXIMUM_ATTEMPTS, + removeOnFail: REMOVE_ON_FAIL, + removeOnComplete: REMOVE_ON_SUCCESS, + }); } } diff --git a/apps/api/src/task/transaction/tasker.ts b/apps/api/src/task/transaction/tasker.ts index d77e495..a32622b 100644 --- a/apps/api/src/task/transaction/tasker.ts +++ b/apps/api/src/task/transaction/tasker.ts @@ -1,15 +1,17 @@ import { DEFAULT_CONNECTION, type Tasker } from "@/lib/tasker"; -import { type Job, UnrecoverableError, Worker } from "bullmq"; -import { TRANSACTION_QUEUE_NAME } from "./queue"; +import { UnrecoverableError, Worker } from "bullmq"; +import { TRANSACTION_QUEUE_NAME, type TransactionJob, type TransactionJobData } from "./queue"; import { bakongService } from "@/service/bakong.service"; import { transactionServcie } from "@/service/transaction.service"; import { logger } from "@/setup/logger"; +import { withRetry } from "@/lib/retry"; +import { webhookQueue } from "../webhook"; export class TransactionTasker implements Tasker { worker; constructor() { - this.worker = new Worker(TRANSACTION_QUEUE_NAME, this.process, { + this.worker = new Worker(TRANSACTION_QUEUE_NAME, this.process, { autorun: false, connection: DEFAULT_CONNECTION, concurrency: 20, @@ -32,7 +34,7 @@ export class TransactionTasker implements Tasker { await this.worker.close(); } - async process(job: Job<{ md5: string; transactionId: string }>) { + async process(job: TransactionJob) { // if the job age exceeds 5mn, it will be considered failed const TIMEOUT = 1000 * 60 * 5; if (job.timestamp + TIMEOUT < Date.now()) { @@ -92,6 +94,14 @@ export class TransactionTasker implements Tasker { throw result.error; } + withRetry(async () => { + await webhookQueue.add({ + userId: job.data.userId, + checkoutId: job.data.checkoutId, + webhookUrl: job.data.webhookUrl, + }); + }); + await job.updateProgress(100); job.log("Transaction is successful"); } diff --git a/apps/api/src/task/webhook/queue.ts b/apps/api/src/task/webhook/queue.ts index 1128ce5..a3c837f 100644 --- a/apps/api/src/task/webhook/queue.ts +++ b/apps/api/src/task/webhook/queue.ts @@ -1,5 +1,6 @@ +import { withRetry } from "@/lib/retry"; import { DEFAULT_CONNECTION } from "@/lib/tasker"; -import type { Webhook } from "@repo/db/schema"; +import { err, ok } from "@justmiracle/result"; import { type Job, Queue } from "bullmq"; export const WEBHOOK_QUEUE_NAME = "{webhook}"; @@ -9,35 +10,41 @@ const REMOVE_ON_SUCCESS = { count: 5500 }; const BACKOFF = { type: "exponential", delay: 500 }; const ATTMEPTS = 5; -export type WebhookJob = Job; +export type WebhookJobData = { checkoutId: string; webhookUrl: string; userId: string }; + +export type WebhookJob = Job; class WebhookQueue { queue; constructor() { - this.queue = new Queue(WEBHOOK_QUEUE_NAME, { + this.queue = new Queue(WEBHOOK_QUEUE_NAME, { connection: DEFAULT_CONNECTION, }); } - async add() { - const job = await this.queue.getJob("h"); + async add(opts: WebhookJobData) { + const job = await this.queue.getJob(opts.checkoutId); if (job) { - await job.retry("failed"); - return; + const isFailed = await job.isFailed().then(ok).catch(err); + if (isFailed.error) return isFailed; + + withRetry(() => job.retry("failed")); + + return ok(job); } - return await this.queue.add( - WEBHOOK_QUEUE_NAME, - {}, - { + return this.queue + .add(WEBHOOK_QUEUE_NAME, opts, { + jobId: opts.checkoutId, attempts: ATTMEPTS, backoff: BACKOFF, removeOnFail: REMOVE_ON_FAIL, removeOnComplete: REMOVE_ON_SUCCESS, - }, - ); + }) + .then(ok) + .catch(err); } } diff --git a/apps/api/src/task/webhook/tasker.ts b/apps/api/src/task/webhook/tasker.ts index 8ef7509..874f38e 100644 --- a/apps/api/src/task/webhook/tasker.ts +++ b/apps/api/src/task/webhook/tasker.ts @@ -1,12 +1,17 @@ import { DEFAULT_CONNECTION, type Tasker } from "@/lib/tasker"; -import { WEBHOOK_QUEUE_NAME } from "./queue"; -import { Worker, type Job } from "bullmq"; +import { WEBHOOK_QUEUE_NAME, type WebhookJob, type WebhookJobData } from "./queue"; +import { Worker } from "bullmq"; +import ky from "ky"; +import { err, ok, unwrap } from "@justmiracle/result"; +import { logger } from "@/setup/logger"; +import { webhookService } from "@/service/webhook.service"; +import { withRetry } from "@/lib/retry"; export class WebhookTasker implements Tasker { worker; constructor() { - this.worker = new Worker(WEBHOOK_QUEUE_NAME, this.process, { + this.worker = new Worker(WEBHOOK_QUEUE_NAME, this.process, { autorun: false, connection: DEFAULT_CONNECTION, concurrency: 20, @@ -29,5 +34,52 @@ export class WebhookTasker implements Tasker { await this.worker.close(); } - async process(job: Job) {} + async process(job: WebhookJob) { + const response = await ky + .post(job.data.webhookUrl, { retry: 0, redirect: "error" }) + .then(ok) + .catch(err); + + if (response.error) { + job.log(`Failed to send webhook: ${response.error.message}`); + logger.error(response.error, "Failed to send webhook"); + webhookService.create({ + status: 500, + userId: job.data.userId, + checkoutId: job.data.checkoutId, + json: { error: response.error.message }, + }); + throw response.error; + } + + const json = await response.value.json>().catch(() => ({ + error: "INVALID_JSON", + })); + + if (!response.value.ok) { + job.log(`Webhook return with http error ${response.value.status}`); + logger.error({ response: response.value, json }, "Failed to send webhook http error"); + webhookService.create({ + json, + status: response.value.status, + userId: job.data.userId, + checkoutId: job.data.checkoutId, + }); + throw new Error(`Webhook return with http error ${response.value.status}`); + } + + job.log("Webhook sent successfully"); + logger.info({ response: response.value, json }, "Webhook sent successfully"); + withRetry(async () => { + await webhookService + .create({ + json, + status: response.value.status, + userId: job.data.userId, + checkoutId: job.data.checkoutId, + }) + .then(unwrap); + }); + job.updateProgress(100); + } } diff --git a/apps/web/src/route/portal/$checkoutId.tsx b/apps/web/src/route/portal/$checkoutId.tsx index 9898802..5b4da38 100644 --- a/apps/web/src/route/portal/$checkoutId.tsx +++ b/apps/web/src/route/portal/$checkoutId.tsx @@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query"; import ky from "ky"; import { Check, PiggyBank, Scan } from "@phosphor-icons/react"; import { env } from "@/lib/env"; +import type { Checkout, CheckoutItem, Transaction, User } from "@repo/db/schema"; export const Route = createFileRoute("/portal/$checkoutId")({ component: CheckoutPage, @@ -19,7 +20,11 @@ function CheckoutPage() { queryFn: () => { return ky .get(`v1/checkout/portal/${checkoutId}`, { retry: 0, prefixUrl: env.VITE_API_URL }) - .json(); + .json<{ + checkout: Checkout & { items: CheckoutItem[] }; + activeTransaction: Transaction; + user: User; + }>(); }, retry: false, refetchInterval: (query) => @@ -40,7 +45,7 @@ function CheckoutPage() {
- + diff --git a/apps/web/src/route/portal/-component/invoice.tsx b/apps/web/src/route/portal/-component/invoice.tsx index e77b578..4bbe1c1 100644 --- a/apps/web/src/route/portal/-component/invoice.tsx +++ b/apps/web/src/route/portal/-component/invoice.tsx @@ -1,7 +1,14 @@ import { formatCurrency } from "@/lib/currency"; import { Box, DataList, Flex, Separator, Text, Theme } from "@radix-ui/themes"; - -export function Invoice({ data }: any) { +import type { Checkout, CheckoutItem, User } from "@repo/db/schema"; + +export function Invoice({ + user, + checkout, +}: { + user: User; + checkout: Checkout & { items: CheckoutItem[] }; +}) { return ( Merchant logo @@ -27,13 +33,13 @@ export function Invoice({ data }: any) { - {data.user.displayName} + {user.displayName} - {data.user.address} + {user.address} - Tel. {data.user.phone} + Tel. {user.phone} @@ -43,21 +49,21 @@ export function Invoice({ data }: any) { > For - {data.clientName} + {checkout.clientName} Tel. - {data.clientPhone} + {checkout.clientPhone} Location - {data.clientAddress ?? "--"} + {checkout.clientAddress ?? "--"} @@ -67,7 +73,7 @@ export function Invoice({ data }: any) { Items - {data.items.map((product: any) => ( + {checkout.items.map((product: any) => ( - {formatCurrency(product.price * product.quantity, data.currency)} + {formatCurrency(product.price * product.quantity, checkout.currency)} ))} @@ -99,11 +105,11 @@ export function Invoice({ data }: any) { Subtotal {formatCurrency( - data.items.reduce( + checkout.items.reduce( (acc: number, product: any) => acc + product.price * product.quantity, 0, ), - data.currency, + checkout.currency, )} @@ -111,7 +117,7 @@ export function Invoice({ data }: any) { Discount - {formatCurrency(data.discount, data.currency)} + {formatCurrency(checkout?.discount ?? 0, checkout.currency)} @@ -119,11 +125,11 @@ export function Invoice({ data }: any) { VAT (10%) {formatCurrency( - data.items.reduce( + checkout.items.reduce( (acc: number, product: any) => acc + product.price * product.quantity, 0, - ) * data.tax, - data.currency, + ) * (checkout?.tax ?? 0), + checkout.currency, )} @@ -138,7 +144,7 @@ export function Invoice({ data }: any) { - {formatCurrency(data.total, data.currency)} + {formatCurrency(checkout.total, checkout.currency)} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..c0c9c82 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,97 @@ +services: + watchtower: + image: containrrr/watchtower + environment: + - REPO_USER=${GHCR_USER} + - REPO_PASS=${GHCR_PASS} + command: + - "--label-enable" + - "--interval" + - "30" + - "--rolling-restart" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + + traefik: + image: "traefik:v3.1" + command: + - "--providers.docker" + - "--providers.docker.exposedbydefault=false" + - "--entryPoints.websecure.address=:443" + - "--certificatesresolvers.myresolver.acme.tlschallenge=true" + - "--certificatesresolvers.myresolver.acme.email=chearithorn@gmail.com" + - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" + - "--entrypoints.web.address=:80" + - "--entrypoints.web.http.redirections.entrypoint.to=websecure" + - "--entrypoints.web.http.redirections.entrypoint.scheme=https" + ports: + - "80:80" + - "443:443" + volumes: + - letsencrypt:/letsencrypt + - /var/run/docker.sock:/var/run/docker.sock + + api: + image: "ghcr.io/rin-yato/checkitout-api:prod" + labels: + - "traefik.enable=true" + - "traefik.http.routers.api.rule=Host(`api-checkitout.rinyato.com`)" + - "traefik.http.routers.api.entrypoints=websecure" + - "traefik.http.routers.api.tls.certresolver=myresolver" + - "com.centurylinklabs.watchtower.enable=true" + env_file: + - .env + deploy: + mode: replicated + replicas: 3 + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + dragonfly: + condition: service_healthy + + postgres: + image: postgres:16-alpine + environment: + - PGUSER=${POSTGRES_USER} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + expose: + - 5432 + ports: + - "5432:5432" + volumes: + - postgres:/var/lib/postgresql/data:rw + healthcheck: + test: ["CMD", "pg_isready"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + dragonfly: + image: "docker.dragonflydb.io/dragonflydb/dragonfly" + ulimits: + memlock: -1 + command: + - "--cluster_mode=emulated" + - "--lock_on_hashtags" + environment: + - DFLY_requirepass=${DFLY_requirepass} + expose: + - 6379 + volumes: + - dragonfly:/data + healthcheck: + test: ["CMD", "redis-cli", "PING"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + postgres: + dragonfly: + letsencrypt: