diff --git a/.env.example b/.env.example index 7ed74c2..9128e2e 100644 --- a/.env.example +++ b/.env.example @@ -15,15 +15,29 @@ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_P # SERVICES SETTINGS NODE_ENV=development -AUTH_PORT=9095 -ORDER_PORT=9096 +AUTH_PORT=8081 +ORDER_PORT=8082 +PRODUCT_PORT=8083 SENDGRID_API_KEY=SG.your_sendgrid_api_key # REMEMBER TO USE A VALID SENDER EMAIL, more info: https://app.sendgrid.com/settings/sender_auth/senders EMAIL_FROM=support@projectx.com EMAIL_FROM_NAME=ProjectX Team +# PAYMENT SETTINGS +# You can find your secret key in your Stripe account +STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key +STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key +# If you are testing your webhook locally with the Stripe CLI you +# can find the endpoint's secret by running `stripe listen` +# Otherwise, find your endpoint's secret in your webhook settings in the Developer Dashboard +STRIPE_WEBHOOK_SECRET=whsec_your_stripe_webhook_signing_secret + +# DEVELOPMENT ONLY +NGROK_AUTHTOKEN=your_ngrok_auth_token + # WEB SETTINGS SESSION_SECRET=your_secret_key_for_sessions AUTH_API_URL="http://localhost:${AUTH_PORT}" ORDER_API_URL="http://localhost:${ORDER_PORT}" +PRODUCT_API_URL="http://localhost:${PRODUCT_PORT}" JWT_SECRET=your_secret_key_for_jwt \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 85e8eb6..3f3d484 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,7 +3,6 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", - "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], "configurations": [ { "name": "Web", @@ -16,6 +15,81 @@ "console": "internalConsole", "cwd": "${workspaceFolder}", "envFile": "${workspaceFolder}/.env" + }, + { + "name": "Debug Auth Service", + "type": "node", + "request": "attach", + "port": 9229, + "restart": true, + "sourceMaps": true, + "remoteRoot": "/app", + "localRoot": "${workspaceFolder}", + "outFiles": [ + "${workspaceFolder}/dist/apps/auth/**/*.js", + "${workspaceFolder}/dist/libs/**/*.js" + ], + "resolveSourceMapLocations": [ + "${workspaceFolder}/**", + "!**/node_modules/**" + ], + "skipFiles": ["/**"], + "trace": true, + "sourceMapPathOverrides": { + "webpack:///./~/*": "${workspaceFolder}/node_modules/*", + "webpack:///./*": "${workspaceFolder}/*", + "webpack:///*": "*" + } + }, + { + "name": "Debug Order Service", + "type": "node", + "request": "attach", + "port": 9230, + "restart": true, + "sourceMaps": true, + "remoteRoot": "/app", + "localRoot": "${workspaceFolder}", + "outFiles": [ + "${workspaceFolder}/dist/apps/order/**/*.js", + "${workspaceFolder}/dist/libs/**/*.js" + ], + "resolveSourceMapLocations": [ + "${workspaceFolder}/**", + "!**/node_modules/**" + ], + "skipFiles": ["/**"], + "trace": true, + "sourceMapPathOverrides": { + "webpack:///./~/*": "${workspaceFolder}/node_modules/*", + "webpack:///./*": "${workspaceFolder}/*", + "webpack:///*": "*" + } + }, + { + "name": "Debug Product Service", + "type": "node", + "request": "attach", + "port": 9231, + "restart": true, + "sourceMaps": true, + "remoteRoot": "/app", + "localRoot": "${workspaceFolder}", + "outFiles": [ + "${workspaceFolder}/dist/apps/product/**/*.js", + "${workspaceFolder}/dist/libs/**/*.js" + ], + "resolveSourceMapLocations": [ + "${workspaceFolder}/**", + "!**/node_modules/**" + ], + "skipFiles": ["/**"], + "trace": true, + "sourceMapPathOverrides": { + "webpack:///./~/*": "${workspaceFolder}/node_modules/*", + "webpack:///./*": "${workspaceFolder}/*", + "webpack:///*": "*" + } } ] } diff --git a/README.md b/README.md index e02f093..fbac3f5 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,12 @@ Temporal for Durable Executions

-> **ProjectX** is a comprehensive full-stack template designed to simplify the development of scalable and resilient applications using **React** and **Temporal**. By integrating Temporalโ€™s advanced workflow orchestration with Reactโ€™s dynamic frontend framework, ProjectX enables developers to build applications with durable executions and seamless communication between services. +> **ProjectX** is a comprehensive full-stack template designed to simplify the development of scalable and resilient applications using **React** and **Temporal**. By integrating Temporal's advanced workflow orchestration with React's dynamic frontend framework, ProjectX enables developers to build applications with durable executions and seamless communication between services. ## Notable Links ๐Ÿค“ - [Get started with Temporal and TypeScript](https://github.com/temporalio/sdk-typescript) +- [Workflow Messages - TypeScript SDK](https://docs.temporal.io/develop/typescript/message-passing) ### Public Courses @@ -49,45 +50,47 @@ Additionally, workflows support scheduled and time-based executions with configu โ€ข **Batch Processing:** Handling large-scale batch jobs with retry mechanisms and progress monitoring. -## Setting Up ๐Ÿ› ๏ธ +## Getting Started ๐Ÿš€ -### Requirements ๐Ÿงฐ +### Prerequisites ๐Ÿงฐ +- [Docker Compose](https://docs.docker.com/compose/install) - [Node.js LTS Version](https://nodejs.org) - [Git](https://git-scm.com/downloads) -- [Docker Compose](https://docs.docker.com/compose/install) - Code editor: - [VSCode](https://code.visualstudio.com/) - [Cursor](https://www.cursor.com/) -### From Linux/Mac ๏ฃฟ +### Quick Setup ๐Ÿ› ๏ธ -- Install Homebrew -- Install tools using Homebrew: -```sh -brew install node -brew install git -brew install docker-compose -npm add --global nx@latest +1. **Clone and Setup Environment:** +```bash +git clone https://github.com/proyecto26/projectx.git +cd projectx +cp .env.example .env ``` -### Documentation ๐Ÿ“š - -- **FrontEnd:** -Commands used to create the project and frontend structure (Nx, RemixJS, etc) [here](./docs/frontend/README.md). - -- **BackEnd:** -Commands used to create the services (NestJS, Temporal, etc) [here](./docs/backend/README.md). +2. **Start Development Environment:** +```bash +# Build and start all services (db, temporal, backend services) +docker-compose up -d -## Usage ๐Ÿƒ +# View service logs +docker-compose logs -f [service] -### Monorepo +# Start web application +npm install +npm run dev:web +``` -Instructions to use Nx CLI [here](./docs/NX_README.md). +### Documentation ๐Ÿ“š -For more information on using Nx, refer to the [Nx documentation](https://nx.dev/getting-started/intro). +For detailed information about the project, please refer to: +- [Architecture Overview](./docs/architecture/README.md) +- [Frontend Guide](./docs/frontend/README.md) +- [Backend Guide](./docs/backend/README.md) -### Project Structure Overview +## Project Structure Overview image @@ -112,6 +115,10 @@ For more information on using Nx, refer to the [Nx documentation](https://nx.dev - **Purpose**: Manages order processing, checkout, and payment handling. - **Key Features**: Cart management, order tracking, and payment integration. +- **apps/product**: + - **Purpose**: Manages product catalog and inventory. + - **Key Features**: Product listing, details, and inventory management. + - **apps/web**: - **Purpose**: The main web application interface. - **Key Features**: User interaction with the system. @@ -151,65 +158,76 @@ For more information on using Nx, refer to the [Nx documentation](https://nx.dev ``` -### Run the web app -```sh -npm run dev:web -``` -### Run the ui lib (See all the UI components) -```sh -npm run storybook -``` +> [!TIP] +> View the Database diagram [here](./libs/backend/db/README.md). -### Run services with Docker Compose -- Build the images: -```sh -docker-compose build --no-cache -``` -- Run the services: -```sh -docker-compose up -d -``` - -- Delete the services: -```sh -docker-compose down --volumes -``` -## Explore the project ๐Ÿ‘€ +## Development Tools ๐Ÿ”ง -```sh +### Monorepo Management +```bash +# View project structure npx nx show projects npx nx graph -``` - -View the Database diagram [here](./libs/backend/db/README.md). -Do you want to change the path of a project to decide your own organization? No problem: -```sh +# Move project location npx nx g @nx/workspace:move --project core libs/backend/common ``` -## Update project โšก +### UI Development +```bash +# Run Storybook +npm run storybook +``` -```sh +### Project Updates +```bash npx nx migrate latest npx nx migrate --run-migrations ``` -## Docker ๐Ÿšข +## Docker Configuration ๐Ÿณ -- Images: - * https://registry.hub.docker.com/r/postgis/postgis/ - * https://registry.hub.docker.com/r/temporalio/auto-setup - * https://registry.hub.docker.com/r/temporalio/admin-tools - * https://registry.hub.docker.com/r/temporalio/ui +Services defined in [docker-compose.yml](./docker-compose.yml): +- PostgreSQL with PostGIS +- Temporal server and UI +- Auth, Order, and Product services -All the images needed to run this project in development are listed in the [docker-compose.yml](./docker-compose.yml) file. +### Common Commands +```bash +# Build fresh images +docker-compose build --no-cache + +# Start services +docker-compose up -d + +# Remove services and volumes +docker-compose down --volumes +``` + +## Payment Providers + +- Stripe: + - [Webhooks](https://docs.stripe.com/webhooks?lang=node) + - [Stripe Webhook integration](https://docs.stripe.com/api/webhook_endpoints) + - [Stripe Checkout](https://docs.stripe.com/payments/checkout) + - [Webhooks Dashboard](https://dashboard.stripe.com/test/workbench/webhooks) + - [Automatic fulfillment Orders](https://docs.stripe.com/checkout/fulfillment) + - [Interactive webhook endpoint builder](https://docs.stripe.com/webhooks/quickstart) + - [Trigger webhook events with the Stripe CLI](https://docs.stripe.com/stripe-cli/triggers) + - [Testing cards](https://docs.stripe.com/testing#cards) +- Stripe commands for testing webhooks: +```bash +brew install stripe/stripe-cli/stripe +stripe login --api-key ... +stripe trigger payment_intent.succeeded +stripe listen --forward-to localhost:8081/order/webhook +``` ## Supporting ๐Ÿป I believe in Unicorns ๐Ÿฆ„ diff --git a/apps/auth/project.json b/apps/auth/project.json index 80deb1e..d5b2794 100644 --- a/apps/auth/project.json +++ b/apps/auth/project.json @@ -5,10 +5,35 @@ "projectType": "application", "tags": [], "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "target": "node", + "compiler": "tsc", + "outputPath": "dist/apps/auth", + "main": "apps/auth/src/main.ts", + "tsConfig": "apps/auth/tsconfig.app.json", + "assets": [ + "apps/auth/src/assets", + "apps/auth/src/workflows" + ], + "isolatedConfig": true, + "webpackConfig": "apps/auth/webpack.config.js" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, "serve": { "executor": "@nx/js:node", "defaultConfiguration": "development", - "dependsOn": ["build"], "options": { "buildTarget": "auth:build", "runBuildTargetDependencies": false diff --git a/apps/auth/src/app/app.controller.ts b/apps/auth/src/app/app.controller.ts index bab550c..8416028 100644 --- a/apps/auth/src/app/app.controller.ts +++ b/apps/auth/src/app/app.controller.ts @@ -15,6 +15,11 @@ import { AppService } from './app.service'; export class AppController { constructor(private readonly appService: AppService) {} + /** + * Endpoint to initiate the login process by sending a verification email. + * @param body AuthLoginDto containing the user's email. + * @returns A message indicating the email was sent. + */ @ApiOperation({ summary: 'Login with email', description: 'This endpoint allow a user to login with email', @@ -28,9 +33,14 @@ export class AppController { @Post('login') @HttpCode(HttpStatus.CREATED) login(@Body() body: AuthLoginDto) { - return this.appService.sendLoginEmail(body); + return this.appService.login(body); } + /** + * Endpoint to verify the login code and authenticate the user. + * @param body AuthVerifyDto containing the user's email and verification code. + * @returns AuthResponseDto containing the access token and user information. + */ @ApiOperation({ summary: 'Verify login code', description: 'This endpoint allow a user to verify the login code', diff --git a/apps/auth/src/app/app.service.ts b/apps/auth/src/app/app.service.ts index 5e37b25..adabd3c 100644 --- a/apps/auth/src/app/app.service.ts +++ b/apps/auth/src/app/app.service.ts @@ -1,10 +1,4 @@ -import { - HttpException, - HttpStatus, - Injectable, - Logger, - UnauthorizedException, -} from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AuthService, verifyLoginCodeUpdate } from '@projectx/core'; import { AuthLoginDto, AuthResponseDto, AuthVerifyDto } from '@projectx/models'; @@ -13,10 +7,10 @@ import { getWorkflowDescription, isWorkflowRunning, } from '@projectx/workflows'; +import { WorkflowExecutionAlreadyStartedError } from '@temporalio/common'; import { plainToInstance } from 'class-transformer'; import { loginUserWorkflow } from '../workflows'; -import { WorkflowExecutionAlreadyStartedError } from '@temporalio/common'; @Injectable() export class AppService { @@ -25,34 +19,42 @@ export class AppService { constructor( private readonly configService: ConfigService, private readonly clientService: ClientService, - private readonly authService: AuthService, + private readonly authService: AuthService ) {} getWorkflowIdByEmail(email: string) { return `login-${email}`; } - async sendLoginEmail(data: AuthLoginDto) { - this.logger.log(`sendLoginEmail(${data.email}) - sending email`); + /** + * Initiates the login process by sending a verification email. + * @param body AuthLoginDto containing the user's email. + * @returns A message indicating the email was sent. + */ + async login(body: AuthLoginDto) { + this.logger.log(`sendLoginEmail(${body.email}) - sending email`); const taskQueue = this.configService.get('temporal.taskQueue'); try { await this.clientService.client?.workflow.start(loginUserWorkflow, { - args: [data], + args: [body], taskQueue, - workflowId: this.getWorkflowIdByEmail(data.email), + workflowId: this.getWorkflowIdByEmail(body.email), searchAttributes: { - Email: [data.email], + Email: [body.email], }, }); + return { message: 'Login email sent successfully' }; } catch (error) { if (error instanceof WorkflowExecutionAlreadyStartedError) { this.logger.log( - `sendLoginEmail(${data.email}) - workflow already started` + `sendLoginEmail(${body.email}) - workflow already started` ); + return { message: 'Login email already sent' }; } else { throw new HttpException( `Error starting workflow`, - HttpStatus.INTERNAL_SERVER_ERROR, { + HttpStatus.INTERNAL_SERVER_ERROR, + { cause: error, } ); @@ -60,9 +62,14 @@ export class AppService { } } - async verifyLoginCode(data: AuthVerifyDto) { - this.logger.log(`verifyLoginCode(${data.email}) - code: ${data.code}`); - const workflowId = this.getWorkflowIdByEmail(data.email); + /** + * Verifies the login code and returns an access token. + * @param body AuthVerifyDto containing the user's email and verification code. + * @returns AuthResponseDto containing the access token and user information. + */ + async verifyLoginCode(body: AuthVerifyDto) { + this.logger.log(`verifyLoginCode(${body.email}) - code: ${body.code}`); + const workflowId = this.getWorkflowIdByEmail(body.email); const description = await getWorkflowDescription( this.clientService.client?.workflow, @@ -76,10 +83,13 @@ export class AppService { const handle = this.clientService.client?.workflow.getHandle(workflowId); const result = await handle.executeUpdate(verifyLoginCodeUpdate, { - args: [data.code], + args: [body.code], }); if (!result?.user) { - throw new UnauthorizedException(); + throw new HttpException( + 'Invalid verification code', + HttpStatus.UNAUTHORIZED + ); } return plainToInstance(AuthResponseDto, { diff --git a/apps/auth/src/app/user/user.controller.ts b/apps/auth/src/app/user/user.controller.ts index c0eb102..7f34919 100644 --- a/apps/auth/src/app/user/user.controller.ts +++ b/apps/auth/src/app/user/user.controller.ts @@ -14,10 +14,11 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; -import { UserService } from './user.service'; -import { AuthUser, JwtAuthGuard, User } from '@projectx/core'; +import { AuthUser, JwtAuthGuard, AuthenticatedUser } from '@projectx/core'; import { UserDto, UserStatus } from '@projectx/models'; +import { UserService } from './user.service'; + @ApiBearerAuth() @ApiTags('User') @UseGuards(JwtAuthGuard) @@ -38,7 +39,7 @@ export class UserController { }) @Get() @HttpCode(HttpStatus.OK) - async getProfile(@User() userDto: AuthUser) { + async getProfile(@AuthenticatedUser() userDto: AuthUser) { const user = await this.userService.findOne(userDto); if (!user) { throw new NotFoundException('User not found'); diff --git a/apps/auth/src/config/app.config.ts b/apps/auth/src/config/app.config.ts index 1de705e..0b298ed 100644 --- a/apps/auth/src/config/app.config.ts +++ b/apps/auth/src/config/app.config.ts @@ -1,7 +1,7 @@ import { registerAs } from '@nestjs/config'; export default registerAs('app', () => ({ - port: Number(process.env.AUTH_PORT) || 9095, + port: Number(process.env.AUTH_PORT) || 8081, environment: process.env.NODE_ENV, apiPrefix: 'auth', allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') ?? [], diff --git a/apps/auth/src/config/env.config.ts b/apps/auth/src/config/env.config.ts index c9961b3..91d3654 100644 --- a/apps/auth/src/config/env.config.ts +++ b/apps/auth/src/config/env.config.ts @@ -23,4 +23,8 @@ export class EnvironmentVariables { @IsString() @IsNotEmpty() JWT_SECRET: string; + + @IsString() + @IsNotEmpty() + SENDGRID_API_KEY: string; } diff --git a/apps/auth/src/main.ts b/apps/auth/src/main.ts index 83252de..3923557 100644 --- a/apps/auth/src/main.ts +++ b/apps/auth/src/main.ts @@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common'; import { AppModule } from './app/app.module'; +// Export activities to be used in workflows export * from './app/activities/activities.service'; bootstrapApp(AppModule).catch((err) => { diff --git a/apps/auth/src/workflows/login.workflow.ts b/apps/auth/src/workflows/login.workflow.ts index 3f627e5..546ea6b 100644 --- a/apps/auth/src/workflows/login.workflow.ts +++ b/apps/auth/src/workflows/login.workflow.ts @@ -5,6 +5,8 @@ import { log, isCancellation, CancellationScope, + allHandlersFinished, + ApplicationFailure, } from '@temporalio/workflow'; // eslint-disable-next-line @nx/enforce-module-boundaries @@ -16,21 +18,22 @@ import { LoginWorkflowState, LoginWorkflowStatus, verifyLoginCodeUpdate, -} from '../../../../libs/backend/core/src/lib/user/user.workflow'; +} from '../../../../libs/backend/core/src/lib/user/workflow.utils'; import type { ActivitiesService } from '../main'; const { sendLoginEmail } = proxyActivities({ - startToCloseTimeout: '5m', + startToCloseTimeout: '5 seconds', retry: { initialInterval: '2s', maximumInterval: '10s', + maximumAttempts: 10, backoffCoefficient: 1.5, nonRetryableErrorTypes: [LoginWorkflowNonRetryableErrors.UNKNOWN_ERROR], }, }); const { verifyLoginCode } = proxyActivities({ - startToCloseTimeout: '5m', + startToCloseTimeout: '5 seconds', retry: { initialInterval: '2s', maximumInterval: '10s', @@ -68,19 +71,34 @@ export async function loginUserWorkflow( state.codeStatus = LoginWorkflowCodeStatus.SENT; // Wait for user to verify code (human interaction) - if (await condition(() => !!state.user, '10m')) { + await condition(() => !!state.user, '10m') + + // Wait for all handlers to finish before checking the state + await condition(allHandlersFinished); + if (state.user) { state.status = LoginWorkflowStatus.SUCCESS; log.info(`User logged in, user: ${state.user}`); } else { state.status = LoginWorkflowStatus.FAILED; - log.error(`User login failed, email: ${data.email}`); + log.error(`User login code expired, email: ${data.email}`); + throw ApplicationFailure.nonRetryable( + 'User login code expired', + LoginWorkflowNonRetryableErrors.LOGIN_CODE_EXPIRED, + ); } + return; } catch (error) { + // If the error is an application failure, throw it + if (error instanceof ApplicationFailure) { + throw error; + } + // Otherwise, update the state and log the error state.status = LoginWorkflowStatus.FAILED; - log.error(`Login workflow failed, email: ${data.email}, error: ${error}`); - - if (isCancellation(error)) { - return await CancellationScope.nonCancellable(async () => { + if (!isCancellation(error)) { + log.error(`Login workflow failed, email: ${data.email}, error: ${error}`); + } else { + log.warn(`Login workflow cancelled, email: ${data.email}`); + await CancellationScope.nonCancellable(async () => { // TODO: Handle workflow cancellation }); } diff --git a/apps/auth/webpack.config.js b/apps/auth/webpack.config.js index 9819407..8a2eb71 100644 --- a/apps/auth/webpack.config.js +++ b/apps/auth/webpack.config.js @@ -5,6 +5,7 @@ module.exports = { output: { path: join(__dirname, '../../dist/apps/auth'), }, + devtool: 'source-map', plugins: [ new NxAppWebpackPlugin({ target: 'node', @@ -15,6 +16,7 @@ module.exports = { optimization: false, outputHashing: 'none', generatePackageJson: true, + sourceMap: true }), ], }; diff --git a/apps/order/project.json b/apps/order/project.json index cada50c..c0eaba8 100644 --- a/apps/order/project.json +++ b/apps/order/project.json @@ -5,10 +5,35 @@ "projectType": "application", "tags": [], "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "target": "node", + "compiler": "tsc", + "outputPath": "dist/apps/order", + "main": "apps/order/src/main.ts", + "tsConfig": "apps/order/tsconfig.app.json", + "assets": [ + "apps/order/src/assets", + "apps/order/src/workflows" + ], + "isolatedConfig": true, + "webpackConfig": "apps/order/webpack.config.js" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, "serve": { "executor": "@nx/js:node", "defaultConfiguration": "development", - "dependsOn": ["build"], "options": { "buildTarget": "order:build", "runBuildTargetDependencies": false diff --git a/apps/order/src/app/activities/activities.module.ts b/apps/order/src/app/activities/activities.module.ts index bf2d1bc..d391541 100644 --- a/apps/order/src/app/activities/activities.module.ts +++ b/apps/order/src/app/activities/activities.module.ts @@ -2,10 +2,10 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { ActivitiesService } from './activities.service'; +import { OrderModule } from '../order/order.module'; @Module({ - imports: [HttpModule], - controllers: [], + imports: [HttpModule, OrderModule], providers: [ActivitiesService], exports: [ActivitiesService], }) diff --git a/apps/order/src/app/activities/activities.service.ts b/apps/order/src/app/activities/activities.service.ts index c044457..ebc4742 100644 --- a/apps/order/src/app/activities/activities.service.ts +++ b/apps/order/src/app/activities/activities.service.ts @@ -1,11 +1,23 @@ -import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; +import { OrderWorkflowData } from '@projectx/core'; + +import { OrderService } from '../order/order.service'; @Injectable() export class ActivitiesService { - constructor(public readonly httpService: HttpService) {} + constructor( + public readonly orderService: OrderService + ) {} + + async createOrder(data: OrderWorkflowData) { + return await this.orderService.createOrder(data); + } + + async reportPaymentFailed(orderId: number) { + return this.orderService.reportPaymentFailed(orderId); + } - async getHelloMessage() { - return 'Hello World'; + async reportPaymentConfirmed(orderId: number) { + return this.orderService.reportPaymentConfirmed(orderId); } } \ No newline at end of file diff --git a/apps/order/src/app/app.controller.ts b/apps/order/src/app/app.controller.ts index d73d75c..3c020d7 100644 --- a/apps/order/src/app/app.controller.ts +++ b/apps/order/src/app/app.controller.ts @@ -1,13 +1,105 @@ -import { Controller, Get } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Post, + Headers, + UseGuards, + Param, + HttpStatus, + HttpCode, + Delete, + Req, + RawBodyRequest, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, + ApiHeader, + ApiResponse, +} from '@nestjs/swagger'; +import { AuthenticatedUser, AuthUser, JwtAuthGuard } from '@projectx/core'; +import { CreateOrderDto, OrderStatusResponseDto } from '@projectx/models'; import { AppService } from './app.service'; +@ApiTags('Order') @Controller() export class AppController { constructor(private readonly appService: AppService) {} - @Get() - findAll() { - return this.appService.findAll(); + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Create a new order and initialize payment', + }) + @Post() + async createOrder( + @AuthenticatedUser() userDto: AuthUser, + @Body() orderDto: CreateOrderDto + ) { + return this.appService.createOrder(userDto, orderDto); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Check status of the order workflow', + }) + @ApiParam({ + name: 'referenceId', + required: true, + type: String, + description: 'Reference ID of the order', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiOkResponse({ + description: 'Returns the status of the order workflow', + type: OrderStatusResponseDto, + }) + @HttpCode(HttpStatus.OK) + @Get(':referenceId') + async getOrderStatus(@Param('referenceId') referenceId: string) { + return this.appService.getOrderStatus(referenceId); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Cancel an order', + }) + @Delete(':referenceId') + async cancelOrder(@Param('referenceId') referenceId: string) { + return this.appService.cancelOrder(referenceId); + } + + @ApiOperation({ + summary: 'Handle Stripe webhook events', + description: 'Endpoint for receiving webhook events from Stripe for payment processing', + }) + @ApiHeader({ + name: 'stripe-signature', + description: 'Stripe signature for webhook event verification', + required: true, + }) + @ApiResponse({ + status: 200, + description: 'Webhook event processed successfully', + }) + @ApiResponse({ + status: 400, + description: 'Invalid payload or signature', + }) + @HttpCode(HttpStatus.OK) + @Post('/webhook') + async handleStripeWebhook( + @Req() request: RawBodyRequest, + @Headers('stripe-signature') signature: string + ) { + // Validate and process the webhook + return this.appService.handleWebhook(request.rawBody, signature); } } diff --git a/apps/order/src/app/app.module.ts b/apps/order/src/app/app.module.ts index 9799392..6b4ee52 100644 --- a/apps/order/src/app/app.module.ts +++ b/apps/order/src/app/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { CoreModule, validateConfiguration } from '@projectx/core'; import { DbModule } from '@projectx/db'; import { EmailModule } from '@projectx/email'; +import { PaymentModule } from '@projectx/payment'; import { WorkflowsModule } from '@projectx/workflows'; import path from 'path'; @@ -37,6 +38,7 @@ import { ActivitiesModule } from './activities/activities.module'; }), inject: [ActivitiesService], }), + PaymentModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/order/src/app/app.service.ts b/apps/order/src/app/app.service.ts index 9538447..b7d85aa 100644 --- a/apps/order/src/app/app.service.ts +++ b/apps/order/src/app/app.service.ts @@ -1,11 +1,197 @@ -import { Injectable } from '@nestjs/common'; -import { OrderRepositoryService } from '@projectx/db'; +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CreateOrderDto } from '@projectx/models'; +import { + ClientService, + getWorkflowDescription, + isWorkflowRunning, + WORKFLOW_TTL, +} from '@projectx/workflows'; +import { StripeService } from '@projectx/payment'; +import { + OrderWorkflowData, + paymentWebHookEventSignal, + PaymentWebhookEvent, + AuthUser, + createOrderUpdate, + getOrderStateQuery, + cancelWorkflowSignal, +} from '@projectx/core'; +import { + WorkflowExecutionAlreadyStartedError, + WorkflowIdConflictPolicy, +} from '@temporalio/common'; +import { WithStartWorkflowOperation } from '@temporalio/client'; + +import { createOrder } from '../workflows/order.workflow'; @Injectable() export class AppService { - constructor(private readonly OrderService: OrderRepositoryService) {} - - async findAll() { - return this.OrderService.findAll(); + private readonly logger = new Logger(AppService.name); + + constructor( + private readonly configService: ConfigService, + private readonly clientService: ClientService, + private readonly stripeService: StripeService + ) {} + + getWorkflowIdByReferenceId(referenceId: string) { + return `order-${referenceId}`; + } + + async createOrder(user: AuthUser, orderDto: CreateOrderDto) { + this.logger.log(`createOrder(${user.email}) - creating order`); + const taskQueue = this.configService.get('temporal.taskQueue'); + try { + // Start workflow with order data + const workflowData: OrderWorkflowData = { + user, + order: orderDto, + }; + + const startWorkflowOperation = new WithStartWorkflowOperation( + createOrder, + { + workflowId: this.getWorkflowIdByReferenceId(orderDto.referenceId), + args: [workflowData], + taskQueue, + workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL, + searchAttributes: { + UserId: [user.id], + Email: [user.email], + }, + } + ); + + const state = + await this.clientService.client?.workflow.executeUpdateWithStart( + createOrderUpdate, + { + startWorkflowOperation, + } + ); + + return { + orderId: state.orderId, + referenceId: state.referenceId, + clientSecret: state.clientSecret, + message: 'Order created successfully', + }; + } catch (error) { + if (error instanceof WorkflowExecutionAlreadyStartedError) { + this.logger.warn( + `createOrder(${user.email}) - workflow already started` + ); + throw new HttpException( + 'Order already in progress', + HttpStatus.CONFLICT + ); + } else { + this.logger.error(`createOrder(${user.email}) - Error creating order`, error); + throw new BadRequestException( + 'Error creating order', + { + cause: error, + } + ); + } + } + } + + async getOrderStatus(referenceId: string) { + this.logger.log(`getOrderStatus(${referenceId}) - getting status`); + const workflowId = this.getWorkflowIdByReferenceId(referenceId); + + const description = await getWorkflowDescription( + this.clientService.client?.workflow, + workflowId + ); + + if (!isWorkflowRunning(description)) { + throw new HttpException('No active order found', HttpStatus.NOT_FOUND); + } + + if (Date.now() - description.startTime.getTime() >= WORKFLOW_TTL) { + throw new HttpException('Order has expired', HttpStatus.GONE); + } + + const handle = this.clientService.client?.workflow.getHandle(workflowId); + const state = await handle.query(getOrderStateQuery); + return state; + } + + async cancelOrder(referenceId: string) { + this.logger.log(`cancelOrder(${referenceId}) - cancelling order`); + const workflowId = this.getWorkflowIdByReferenceId(referenceId); + const handle = this.clientService.client?.workflow.getHandle(workflowId); + await handle.signal(cancelWorkflowSignal); + } + + async handleWebhook(payload: string | Buffer, signature: string) { + if (!payload || !signature) { + this.logger.error(`handleWebhook(${signature}) - No payload received`); + throw new BadRequestException('No payload received'); + } + this.logger.log(`handleWebhook(${signature}) - Processing webhook event`); + try { + // Verify and construct the webhook event + const event = this.stripeService.constructWebhookEvent( + payload, + signature + ); + + // Extract payment intent data + const paymentIntent = this.stripeService.handleWebhookEvent(event); + if (!paymentIntent?.metadata) { + this.logger.error(`handleWebhook(${signature}) - Unhandled event type: ${event.type}`); + return { received: true }; + } + const { userId, referenceId } = paymentIntent.metadata; + + if (!userId || !referenceId) { + this.logger.error( + 'Missing userId or referenceId in payment intent metadata' + ); + return { received: true }; + } + + // Get workflow handle + const workflowId = this.getWorkflowIdByReferenceId(referenceId); + const handle = this.clientService.client?.workflow.getHandle(workflowId); + + // Convert Stripe event to PaymentWebhookEvent + const webhookEvent: PaymentWebhookEvent = { + id: event.id, + type: event.type, + provider: 'Stripe', + data: { + id: paymentIntent.id, + amount: paymentIntent.amount, + currency: paymentIntent.currency, + status: paymentIntent.status, + metadata: { + userId: Number(userId), + referenceId: referenceId, + }, + }, + }; + + // Signal the workflow + await handle.signal(paymentWebHookEventSignal, webhookEvent); + + // Return true to indicate the webhook was received + return { received: true }; + } catch (err) { + this.logger.error(`handleWebhook(${signature}) - Webhook Error: ${err.message}`); + throw new BadRequestException('Webhook Error', { + cause: err, + }); + } } } diff --git a/apps/order/src/app/order/order.module.ts b/apps/order/src/app/order/order.module.ts new file mode 100644 index 0000000..b58c90c --- /dev/null +++ b/apps/order/src/app/order/order.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { OrderSchemaModule } from '@projectx/db'; +import { PaymentModule } from '@projectx/payment'; + +import { OrderService } from './order.service'; + +@Module({ + imports: [PaymentModule, OrderSchemaModule], + providers: [OrderService], + exports: [OrderService], +}) +export class OrderModule {} diff --git a/apps/order/src/app/order/order.service.spec.ts b/apps/order/src/app/order/order.service.spec.ts new file mode 100644 index 0000000..873a0cd --- /dev/null +++ b/apps/order/src/app/order/order.service.spec.ts @@ -0,0 +1,38 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; +import { UserRepositoryService } from '@projectx/db'; +import type { AuthUser } from '@projectx/core'; + +import { UserService } from './order.service'; + +describe('UserService', () => { + let service: UserService; + let userRepositoryService: UserRepositoryService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserService, + { provide: UserRepositoryService, useValue: createMock() }, + { provide: Logger, useValue: createMock() }, + ], + }).compile(); + + service = module.get(UserService); + userRepositoryService = module.get(UserRepositoryService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findOne', () => { + it('should call findOneByEmail method of UserRepositoryService correctly', async () => { + const mockUser: AuthUser = { email: 'test@test.com', id: 1 } as AuthUser; + await service.findOne(mockUser); + + expect(userRepositoryService.findOneByEmail).toHaveBeenCalledWith(mockUser.email); + }); + }); +}); \ No newline at end of file diff --git a/apps/order/src/app/order/order.service.ts b/apps/order/src/app/order/order.service.ts new file mode 100644 index 0000000..49e8b05 --- /dev/null +++ b/apps/order/src/app/order/order.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OrderWorkflowData } from '@projectx/core'; +import { OrderRepositoryService } from '@projectx/db'; +import { OrderStatus } from '@projectx/models'; +import { StripeService } from '@projectx/payment'; + +@Injectable() +export class OrderService { + readonly logger = new Logger(OrderService.name); + constructor( + public readonly stripeService: StripeService, + public readonly orderRepositoryService: OrderRepositoryService + ) {} + + async createOrder({ user, order }: OrderWorkflowData) { + this.logger.log(`createOrder(${user.id})`, order); + // Create order in database with referenceId + const newOrder = await this.orderRepositoryService.createOrder(user.id, { + ...order, + referenceId: order.referenceId, + }); + + // Create payment intent with Stripe + const paymentIntent = await this.stripeService.createPaymentIntent( + Math.round(newOrder.totalPrice.toNumber() * 100), // Convert to cents + 'usd', + { + userId: user.id.toString(), + referenceId: order.referenceId, + orderId: String(newOrder.id), + } + ); + + return { + order: newOrder, + clientSecret: paymentIntent.client_secret, + }; + } + + async reportPaymentFailed(orderId: number) { + this.logger.log(`reportPaymentFailed(${orderId})`); + const updatedOrder = await this.orderRepositoryService.updateOrderStatus( + orderId, + OrderStatus.Failed + ); + // TODO: Send email notification to user about payment failure + return updatedOrder; + } + + async reportPaymentConfirmed(orderId: number) { + this.logger.log(`reportPaymentConfirmed(${orderId})`); + const updatedOrder = await this.orderRepositoryService.updateOrderStatus( + orderId, + OrderStatus.Confirmed + ); + // TODO: Send email notification to user about payment confirmation + return updatedOrder; + } +} diff --git a/apps/order/src/config/app.config.ts b/apps/order/src/config/app.config.ts index 549af26..b827560 100644 --- a/apps/order/src/config/app.config.ts +++ b/apps/order/src/config/app.config.ts @@ -1,7 +1,7 @@ import { registerAs } from '@nestjs/config'; export default registerAs('app', () => ({ - port: Number(process.env.ORDER_PORT) || 9096, + port: Number(process.env.ORDER_PORT) || 8082, environment: process.env.NODE_ENV, apiPrefix: 'order', allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') ?? [], diff --git a/apps/order/src/config/env.config.ts b/apps/order/src/config/env.config.ts index 6c61711..7057af6 100644 --- a/apps/order/src/config/env.config.ts +++ b/apps/order/src/config/env.config.ts @@ -23,4 +23,16 @@ export class EnvironmentVariables { @IsString() @IsNotEmpty() JWT_SECRET: string; + + @IsString() + @IsNotEmpty() + STRIPE_SECRET_KEY: string; + + @IsString() + @IsNotEmpty() + STRIPE_WEBHOOK_SECRET: string; + + @IsString() + @IsNotEmpty() + SENDGRID_API_KEY: string; } diff --git a/apps/order/src/config/swagger.config.ts b/apps/order/src/config/swagger.config.ts index 056eeb5..27cad39 100644 --- a/apps/order/src/config/swagger.config.ts +++ b/apps/order/src/config/swagger.config.ts @@ -2,6 +2,6 @@ import { registerAs } from '@nestjs/config'; export default registerAs('swagger', () => ({ title: 'Order API', - description: 'Provides the endpoints for orders', + description: 'Provides the endpoints for order management', version: '1.0', })); diff --git a/apps/order/src/main.ts b/apps/order/src/main.ts index 84853b5..e94661c 100644 --- a/apps/order/src/main.ts +++ b/apps/order/src/main.ts @@ -3,7 +3,11 @@ import { bootstrapApp } from '@projectx/core'; import { AppModule } from './app/app.module'; -bootstrapApp(AppModule).catch((err) => { +// Export activities to be used in workflows +export * from './app/activities/activities.service'; + +// Enable raw body parsing for webhook events +bootstrapApp(AppModule, { rawBody: true }).catch((err) => { Logger.error( `โš ๏ธ Application failed to start: ${err}` ) diff --git a/apps/order/src/workflows/index.ts b/apps/order/src/workflows/index.ts index 5166c42..c0bd195 100644 --- a/apps/order/src/workflows/index.ts +++ b/apps/order/src/workflows/index.ts @@ -1 +1,2 @@ export * from './order.workflow'; +export * from './process-payment.workflow'; diff --git a/apps/order/src/workflows/long-running.workflow.ts b/apps/order/src/workflows/long-running.workflow.ts new file mode 100644 index 0000000..a3f84ae --- /dev/null +++ b/apps/order/src/workflows/long-running.workflow.ts @@ -0,0 +1,12 @@ +import { continueAsNew, workflowInfo } from "@temporalio/workflow"; + +const MAX_NUMBER_OF_EVENTS = 10000; +// It's just an example of a long running workflow +export async function longRunningWorkflow(n: number): Promise { + // Long-duration workflow + while (workflowInfo().historyLength < MAX_NUMBER_OF_EVENTS) { + //... + } + + await continueAsNew(n + 1); +} diff --git a/apps/order/src/workflows/order.workflow.ts b/apps/order/src/workflows/order.workflow.ts index a2c05cb..83dc1f0 100644 --- a/apps/order/src/workflows/order.workflow.ts +++ b/apps/order/src/workflows/order.workflow.ts @@ -1,13 +1,123 @@ -import { continueAsNew, sleep, workflowInfo } from "@temporalio/workflow"; +/* eslint-disable @nx/enforce-module-boundaries */ +import { + allHandlersFinished, + ApplicationFailure, + ChildWorkflowHandle, + condition, + proxyActivities, + setHandler, + startChild, + log, +} from '@temporalio/workflow'; -const MAX_NUMBER_OF_EVENTS = 10000; +// Typescript alias issue while importing files from other libraries from workflows. +import { + OrderProcessPaymentStatus, + OrderWorkflowData, + OrderWorkflowNonRetryableErrors, + createOrderUpdate, + getOrderStateQuery, + getWorkflowIdByPaymentOrder, + paymentWebHookEventSignal, +} from '../../../../libs/backend/core/src/lib/order/workflow.utils'; +import { cancelWorkflowSignal } from '../../../../libs/backend/core/src/lib/workflows'; +import type { OrderStatusResponseDto } from '../../../../libs/models/src/order/order.dto'; +import type { ActivitiesService } from '../main'; +import { processPayment } from './process-payment.workflow'; -export async function createOrder(email?: string): Promise { - - // Long-duration workflow - while (workflowInfo().historyLength < MAX_NUMBER_OF_EVENTS) { - await sleep(1000); +const { + createOrder: createOrderActivity, + reportPaymentFailed, + reportPaymentConfirmed, +} = proxyActivities({ + startToCloseTimeout: '5 seconds', + retry: { + initialInterval: '2s', + maximumInterval: '10s', + maximumAttempts: 10, + backoffCoefficient: 1.5, + nonRetryableErrorTypes: [OrderWorkflowNonRetryableErrors.UNKNOWN_ERROR], + }, +}); + +export enum OrderStatus { + Pending = 'Pending', + Confirmed = 'Confirmed', + Shipped = 'Shipped', + Delivered = 'Delivered', + Cancelled = 'Cancelled', + Failed = 'Failed', +} + +const initialState: OrderStatusResponseDto = { + status: OrderStatus.Pending, + orderId: undefined, + referenceId: '', + clientSecret: undefined, +}; + +export async function createOrder( + data: OrderWorkflowData, + state = initialState +): Promise { + state.referenceId = data.order.referenceId; + // Define references to child workflows + let processPaymentWorkflow: ChildWorkflowHandle; + + // Attach queries, signals and updates + setHandler(getOrderStateQuery, () => state); + setHandler(cancelWorkflowSignal, () => { + log.info('Requesting order cancellation'); + if (!state?.orderId) { + throw ApplicationFailure.nonRetryable( + OrderWorkflowNonRetryableErrors.CANCELLED, + 'Order cancelled' + ); + } + if (processPaymentWorkflow) { + processPaymentWorkflow.signal(cancelWorkflowSignal); + } else { + log.error('The payment process has already finished, cannot cancel'); + } + }); + setHandler(paymentWebHookEventSignal, (e) => + processPaymentWorkflow?.signal(paymentWebHookEventSignal, e) + ); + // Create the order and the payment intent with the payment provider + setHandler(createOrderUpdate, async () => { + const { order, clientSecret } = await createOrderActivity(data); + state.orderId = order.id; + state.referenceId = order.referenceId; + state.clientSecret = clientSecret; + return state; + }); + + // Wait the order to be ready to be processed + await condition(() => !!state?.orderId); + + // First step - Process payment + if (state.status === OrderStatus.Pending) { + processPaymentWorkflow = await startChild(processPayment, { + args: [data], + workflowId: getWorkflowIdByPaymentOrder(state.referenceId), + }); + const processPaymentResult = await processPaymentWorkflow.result(); + if (processPaymentResult.status !== OrderProcessPaymentStatus.SUCCESS) { + // Report payment failure before throwing the error + await reportPaymentFailed(state.orderId); + state.status = OrderStatus.Failed; + throw ApplicationFailure.nonRetryable( + OrderWorkflowNonRetryableErrors.UNKNOWN_ERROR, + 'Payment failed' + ); + } + processPaymentWorkflow = undefined; + state.status = OrderStatus.Confirmed; + await reportPaymentConfirmed(state.orderId); } - await continueAsNew(email); -} \ No newline at end of file + // TODO: Second step - Ship the order + + // Wait for all handlers to finish before workflow completion + await condition(allHandlersFinished); +} diff --git a/apps/order/src/workflows/process-payment.workflow.ts b/apps/order/src/workflows/process-payment.workflow.ts new file mode 100644 index 0000000..8fc01d4 --- /dev/null +++ b/apps/order/src/workflows/process-payment.workflow.ts @@ -0,0 +1,84 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +import { + log, + condition, + setHandler, + allHandlersFinished, +} from '@temporalio/workflow'; + +import { + OrderWorkflowData, + PROCESS_PAYMENT_TIMEOUT, + OrderProcessPaymentState, + OrderProcessPaymentStatus, + paymentWebHookEventSignal, + PaymentWebhookEvent, +} from '../../../../libs/backend/core/src/lib/order/workflow.utils'; +import { cancelWorkflowSignal } from '../../../../libs/backend/core/src/lib/workflows'; + +export const finalPaymentStatuses = [ + OrderProcessPaymentStatus.SUCCESS, + OrderProcessPaymentStatus.FAILURE, + OrderProcessPaymentStatus.DECLINED, + OrderProcessPaymentStatus.CANCELLED, +]; + +const initiatedWebhookEvents = [ + 'payment_intent.created', + 'payment_intent.processing', + 'payment_method.attached', +]; + +const confirmedWebhookEvents = [ + 'checkout.session.completed', + 'checkout.session.async_payment_succeeded', + 'payment_intent.succeeded', +]; + +const failedWebhookEvents = [ + 'payment_intent.payment_failed', + 'payment_intent.canceled', +]; + +export async function processPayment( + data: OrderWorkflowData +): Promise { + const state: OrderProcessPaymentState = { + status: OrderProcessPaymentStatus.PENDING, + }; + log.info('Processing payment', { data }); + + // Attach queries, signals and updates + setHandler(cancelWorkflowSignal, async () => { + if (finalPaymentStatuses.includes(state.status)) { + log.warn('Payment already completed, cannot cancel'); + return; + } + log.warn('Cancelling payment'); + state.status = OrderProcessPaymentStatus.CANCELLED; + }); + setHandler(paymentWebHookEventSignal, async (event: PaymentWebhookEvent) => { + log.info('Received payment webhook event', { type: event.type }); + + if (initiatedWebhookEvents.includes(event.type)) { + state.status = OrderProcessPaymentStatus.INITIATED; + } else if (confirmedWebhookEvents.includes(event.type)) { + state.status = OrderProcessPaymentStatus.SUCCESS; + } else if (failedWebhookEvents.includes(event.type)) { + state.status = OrderProcessPaymentStatus.FAILURE; + } + + log.info('Updated payment status', { status: state.status }); + }); + + // Wait for payment to complete or timeout + await condition( + () => finalPaymentStatuses.includes(state.status), + PROCESS_PAYMENT_TIMEOUT + ); + + // Wait for all handlers to finish before workflow completion + await condition(allHandlersFinished); + + return state; +} diff --git a/apps/order/webpack.config.js b/apps/order/webpack.config.js index 102f385..a27a9f0 100644 --- a/apps/order/webpack.config.js +++ b/apps/order/webpack.config.js @@ -5,6 +5,7 @@ module.exports = { output: { path: join(__dirname, '../../dist/apps/order'), }, + devtool: 'source-map', plugins: [ new NxAppWebpackPlugin({ target: 'node', @@ -15,6 +16,7 @@ module.exports = { optimization: false, outputHashing: 'none', generatePackageJson: true, + sourceMap: true }), ], }; diff --git a/apps/product-e2e/eslint.config.js b/apps/product-e2e/eslint.config.js new file mode 100644 index 0000000..df7cfc2 --- /dev/null +++ b/apps/product-e2e/eslint.config.js @@ -0,0 +1,3 @@ +const baseConfig = require('../../eslint.config.js'); + +module.exports = [...baseConfig]; diff --git a/apps/product-e2e/jest.config.ts b/apps/product-e2e/jest.config.ts new file mode 100644 index 0000000..03e7107 --- /dev/null +++ b/apps/product-e2e/jest.config.ts @@ -0,0 +1,18 @@ +export default { + displayName: 'product-e2e', + preset: '../../jest.preset.js', + globalSetup: '/src/support/global-setup.ts', + globalTeardown: '/src/support/global-teardown.ts', + setupFiles: ['/src/support/test-setup.ts'], + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/product-e2e', +}; diff --git a/apps/product-e2e/project.json b/apps/product-e2e/project.json new file mode 100644 index 0000000..1b31b44 --- /dev/null +++ b/apps/product-e2e/project.json @@ -0,0 +1,17 @@ +{ + "name": "product-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "implicitDependencies": ["product"], + "targets": { + "e2e": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{e2eProjectRoot}"], + "options": { + "jestConfig": "apps/product-e2e/jest.config.ts", + "passWithNoTests": true + }, + "dependsOn": ["product:build"] + } + } +} diff --git a/apps/product-e2e/src/product/product.spec.ts b/apps/product-e2e/src/product/product.spec.ts new file mode 100644 index 0000000..e8ac2a6 --- /dev/null +++ b/apps/product-e2e/src/product/product.spec.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; + +describe('GET /api', () => { + it('should return a message', async () => { + const res = await axios.get(`/api`); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ message: 'Hello API' }); + }); +}); diff --git a/apps/product-e2e/src/support/global-setup.ts b/apps/product-e2e/src/support/global-setup.ts new file mode 100644 index 0000000..c1f5144 --- /dev/null +++ b/apps/product-e2e/src/support/global-setup.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +var __TEARDOWN_MESSAGE__: string; + +module.exports = async function () { + // Start services that that the app needs to run (e.g. database, docker-compose, etc.). + console.log('\nSetting up...\n'); + + // Hint: Use `globalThis` to pass variables to global teardown. + globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n'; +}; diff --git a/apps/product-e2e/src/support/global-teardown.ts b/apps/product-e2e/src/support/global-teardown.ts new file mode 100644 index 0000000..32ea345 --- /dev/null +++ b/apps/product-e2e/src/support/global-teardown.ts @@ -0,0 +1,7 @@ +/* eslint-disable */ + +module.exports = async function () { + // Put clean up logic here (e.g. stopping services, docker-compose, etc.). + // Hint: `globalThis` is shared between setup and teardown. + console.log(globalThis.__TEARDOWN_MESSAGE__); +}; diff --git a/apps/product-e2e/src/support/test-setup.ts b/apps/product-e2e/src/support/test-setup.ts new file mode 100644 index 0000000..07f2870 --- /dev/null +++ b/apps/product-e2e/src/support/test-setup.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ + +import axios from 'axios'; + +module.exports = async function () { + // Configure axios for tests to use. + const host = process.env.HOST ?? 'localhost'; + const port = process.env.PORT ?? '3000'; + axios.defaults.baseURL = `http://${host}:${port}`; +}; diff --git a/apps/product-e2e/tsconfig.json b/apps/product-e2e/tsconfig.json new file mode 100644 index 0000000..ed633e1 --- /dev/null +++ b/apps/product-e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/apps/product-e2e/tsconfig.spec.json b/apps/product-e2e/tsconfig.spec.json new file mode 100644 index 0000000..d7f9cf2 --- /dev/null +++ b/apps/product-e2e/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.ts"] +} diff --git a/apps/product/eslint.config.js b/apps/product/eslint.config.js new file mode 100644 index 0000000..df7cfc2 --- /dev/null +++ b/apps/product/eslint.config.js @@ -0,0 +1,3 @@ +const baseConfig = require('../../eslint.config.js'); + +module.exports = [...baseConfig]; diff --git a/apps/product/jest.config.ts b/apps/product/jest.config.ts new file mode 100644 index 0000000..fe84261 --- /dev/null +++ b/apps/product/jest.config.ts @@ -0,0 +1,10 @@ +export default { + displayName: 'product', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/product', +}; diff --git a/apps/product/project.json b/apps/product/project.json new file mode 100644 index 0000000..a011aa8 --- /dev/null +++ b/apps/product/project.json @@ -0,0 +1,51 @@ +{ + "name": "product", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/product/src", + "projectType": "application", + "tags": [], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "target": "node", + "compiler": "tsc", + "outputPath": "dist/apps/product", + "main": "apps/product/src/main.ts", + "tsConfig": "apps/product/tsconfig.app.json", + "assets": [ + "apps/product/src/assets" + ], + "isolatedConfig": true, + "webpackConfig": "apps/product/webpack.config.js" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, + "serve": { + "executor": "@nx/js:node", + "defaultConfiguration": "development", + "dependsOn": ["build"], + "options": { + "buildTarget": "product:build", + "runBuildTargetDependencies": false + }, + "configurations": { + "development": { + "buildTarget": "product:build:development" + }, + "production": { + "buildTarget": "product:build:production" + } + } + } + } +} diff --git a/apps/product/src/app/app.controller.spec.ts b/apps/product/src/app/app.controller.spec.ts new file mode 100644 index 0000000..de8007e --- /dev/null +++ b/apps/product/src/app/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let app: TestingModule; + + beforeAll(async () => { + app = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + }); + + describe('getData', () => { + it('should return "Hello API"', () => { + const appController = app.get(AppController); + expect(appController.getData()).toEqual({ message: 'Hello API' }); + }); + }); +}); diff --git a/apps/product/src/app/app.controller.ts b/apps/product/src/app/app.controller.ts new file mode 100644 index 0000000..9347e93 --- /dev/null +++ b/apps/product/src/app/app.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; +import { + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ProductDto } from '@projectx/models'; + +import { AppService } from './app.service'; + +@ApiTags('Product') +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + /** + * Endpoint to retrieve all available products. + * @returns Array of ProductDto containing the product information. + */ + @ApiOperation({ + summary: 'Get all products', + description: 'This endpoint returns all available products', + }) + @ApiOkResponse({ + description: 'Products retrieved successfully', + type: ProductDto, + isArray: true, + }) + @Get() + @HttpCode(HttpStatus.OK) + getProducts() { + return this.appService.getProducts(); + } +} diff --git a/apps/product/src/app/app.module.ts b/apps/product/src/app/app.module.ts new file mode 100644 index 0000000..8ef4e9f --- /dev/null +++ b/apps/product/src/app/app.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { CoreModule, validateConfiguration } from '@projectx/core'; +import { DbModule } from '@projectx/db'; + +import { EnvironmentVariables } from '../config/env.config'; +import appConfig from '../config/app.config'; +import swaggerConfig from '../config/swagger.config'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +@Module({ + imports: [ + DbModule, + CoreModule, + ConfigModule.forRoot({ + isGlobal: true, + load: [ + appConfig, + swaggerConfig, + ], + validate: (config) => validateConfiguration(config, EnvironmentVariables), + }), + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/apps/product/src/app/app.service.spec.ts b/apps/product/src/app/app.service.spec.ts new file mode 100644 index 0000000..42cf0a2 --- /dev/null +++ b/apps/product/src/app/app.service.spec.ts @@ -0,0 +1,21 @@ +import { Test } from '@nestjs/testing'; + +import { AppService } from './app.service'; + +describe('AppService', () => { + let service: AppService; + + beforeAll(async () => { + const app = await Test.createTestingModule({ + providers: [AppService], + }).compile(); + + service = app.get(AppService); + }); + + describe('getData', () => { + it('should return "Hello API"', () => { + expect(service.getData()).toEqual({ message: 'Hello API' }); + }); + }); +}); diff --git a/apps/product/src/app/app.service.ts b/apps/product/src/app/app.service.ts new file mode 100644 index 0000000..d0a3327 --- /dev/null +++ b/apps/product/src/app/app.service.ts @@ -0,0 +1,19 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ProductRepositoryService } from '@projectx/db'; +import { ProductDto } from '@projectx/models'; + +@Injectable() +export class AppService { + readonly logger = new Logger(AppService.name); + + constructor(private readonly productRepository: ProductRepositoryService) {} + + /** + * Retrieves all available products. + * @returns Array of ProductDto containing the product information. + */ + async getProducts(): Promise { + this.logger.log('getProducts() - retrieving all products'); + return await this.productRepository.findProducts(); + } +} diff --git a/apps/web/app/services/auth/queries.ts b/apps/product/src/assets/.gitkeep similarity index 100% rename from apps/web/app/services/auth/queries.ts rename to apps/product/src/assets/.gitkeep diff --git a/apps/product/src/config/app.config.ts b/apps/product/src/config/app.config.ts new file mode 100644 index 0000000..4cf9c33 --- /dev/null +++ b/apps/product/src/config/app.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('app', () => ({ + port: Number(process.env.PRODUCT_PORT) || 8083, + environment: process.env.NODE_ENV, + apiPrefix: 'product', + allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') ?? [], + logLevel: process.env.LOG_LEVEL ?? 'info', + jwtSecret: process.env.JWT_SECRET, +})); \ No newline at end of file diff --git a/apps/product/src/config/env.config.ts b/apps/product/src/config/env.config.ts new file mode 100644 index 0000000..d034b8d --- /dev/null +++ b/apps/product/src/config/env.config.ts @@ -0,0 +1,26 @@ +import { Environment } from '@projectx/models'; +import { + IsDefined, + IsEnum, + IsNotEmpty, + IsInt, + IsString, + Max, + Min, +} from 'class-validator'; + +export class EnvironmentVariables { + @IsEnum(Environment) + @IsDefined() + NODE_ENV: Environment; + + @IsInt() + @Min(0) + @Max(65535) + @IsDefined() + PRODUCT_PORT: number; + + @IsString() + @IsNotEmpty() + JWT_SECRET: string; +} diff --git a/apps/product/src/config/swagger.config.ts b/apps/product/src/config/swagger.config.ts new file mode 100644 index 0000000..740add7 --- /dev/null +++ b/apps/product/src/config/swagger.config.ts @@ -0,0 +1,7 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('swagger', () => ({ + title: 'Product API', + description: 'Provides the endpoints for product management', + version: '1.0', +})); \ No newline at end of file diff --git a/apps/product/src/main.ts b/apps/product/src/main.ts new file mode 100644 index 0000000..e9ee4c8 --- /dev/null +++ b/apps/product/src/main.ts @@ -0,0 +1,11 @@ +import { bootstrapApp } from '@projectx/core'; +import { Logger } from '@nestjs/common'; + +import { AppModule } from './app/app.module'; + +bootstrapApp(AppModule).catch((err) => { + Logger.error( + `โš ๏ธ Application failed to start: ${err}` + ) + process.exit(1); +}); diff --git a/apps/product/tsconfig.app.json b/apps/product/tsconfig.app.json new file mode 100644 index 0000000..eecd9b6 --- /dev/null +++ b/apps/product/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["node"], + "emitDecoratorMetadata": true, + "target": "es2021" + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/apps/product/tsconfig.json b/apps/product/tsconfig.json new file mode 100644 index 0000000..c1e2dd4 --- /dev/null +++ b/apps/product/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/apps/product/tsconfig.spec.json b/apps/product/tsconfig.spec.json new file mode 100644 index 0000000..9b2a121 --- /dev/null +++ b/apps/product/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/apps/product/webpack.config.js b/apps/product/webpack.config.js new file mode 100644 index 0000000..8a9aa23 --- /dev/null +++ b/apps/product/webpack.config.js @@ -0,0 +1,20 @@ +const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { join } = require('path'); + +module.exports = { + output: { + path: join(__dirname, '../../dist/apps/product'), + }, + plugins: [ + new NxAppWebpackPlugin({ + target: 'node', + compiler: 'tsc', + main: './src/main.ts', + tsConfig: './tsconfig.app.json', + assets: ['./src/assets'], + optimization: false, + outputHashing: 'none', + generatePackageJson: true, + }), + ], +}; diff --git a/apps/web/app/config/app.config.server.ts b/apps/web/app/config/app.config.server.ts index 8cea9f2..d9b774f 100644 --- a/apps/web/app/config/app.config.server.ts +++ b/apps/web/app/config/app.config.server.ts @@ -1,4 +1,4 @@ -import { Environment } from '@projectx/models'; +import type { Environment } from '@projectx/models'; import { getRequiredServerEnvVar } from './utils.server'; @@ -7,5 +7,7 @@ export const sessionSecret = getRequiredServerEnvVar( 'SESSION_SECRET', 'MY_SECRET_KEY' ); -export const authAPIUrl = getRequiredServerEnvVar('AUTH_API_URL'); -export const orderAPIUrl = getRequiredServerEnvVar('ORDER_API_URL'); +export const stripePublishableKey = getRequiredServerEnvVar('STRIPE_PUBLISHABLE_KEY'); +export const authAPIUrl = getRequiredServerEnvVar('AUTH_API_URL', 'http://localhost:8081'); +export const orderAPIUrl = getRequiredServerEnvVar('ORDER_API_URL', 'http://localhost:8082'); +export const productAPIUrl = getRequiredServerEnvVar('PRODUCT_API_URL', 'http://localhost:8083'); \ No newline at end of file diff --git a/apps/web/app/config/env.server.ts b/apps/web/app/config/env.server.ts index 241e6b9..f868aff 100644 --- a/apps/web/app/config/env.server.ts +++ b/apps/web/app/config/env.server.ts @@ -1,11 +1,18 @@ -import { authAPIUrl, environment, orderAPIUrl } from './app.config.server'; +import { + authAPIUrl, + environment, + orderAPIUrl, + productAPIUrl, + stripePublishableKey, +} from './app.config.server'; export function getEnv() { - console.log(authAPIUrl); return { NODE_ENV: environment, AUTH_API_URL: authAPIUrl, ORDER_API_URL: orderAPIUrl, + PRODUCT_API_URL: productAPIUrl, + STRIPE_PUBLISHABLE_KEY: stripePublishableKey, }; } diff --git a/apps/web/app/config/security.server.ts b/apps/web/app/config/security.server.ts index 3a083ad..329ae99 100755 --- a/apps/web/app/config/security.server.ts +++ b/apps/web/app/config/security.server.ts @@ -12,10 +12,12 @@ const trustedDomains = [appDomains, localDomains].filter(Boolean).join(' '); export const defaultSrc = replaceNewLinesWithSpaces(` ${trustedDomains} + https://*.stripe.com `); export const scriptSrc = replaceNewLinesWithSpaces(` ${defaultSrc} + `); export const frameSrc = replaceNewLinesWithSpaces(` @@ -34,6 +36,8 @@ export const imgSrc = replaceNewLinesWithSpaces(` https://*.unsplash.com https://placehold.co https://gravatar.com + https://*.githubusercontent.com\ + https://tailwindui.com `); export const contentSecurityPolicy = replaceNewLinesWithSpaces(` diff --git a/apps/web/app/constants/api.server.ts b/apps/web/app/constants/api.server.ts deleted file mode 100644 index a54399d..0000000 --- a/apps/web/app/constants/api.server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { canUseDOM } from '@projectx/ui'; - -import { authAPIUrl, environment, orderAPIUrl } from '~/config/app.config.server'; - -export const NODE_ENV = canUseDOM ? window?.ENV?.NODE_ENV : environment; -export const AUTH_API_URL = canUseDOM ? window?.ENV?.AUTH_API_URL : authAPIUrl; -export const ORDER_API_URL = canUseDOM - ? window?.ENV?.ORDER_API_URL - : orderAPIUrl; diff --git a/apps/web/app/constants/index.ts b/apps/web/app/constants/index.ts index 9c188ea..86f7257 100644 --- a/apps/web/app/constants/index.ts +++ b/apps/web/app/constants/index.ts @@ -1,4 +1,3 @@ -export * from './api.server'; export * from './links'; export * from './navigation'; export * from './theme'; diff --git a/apps/web/app/hooks/useProducts.ts b/apps/web/app/hooks/useProducts.ts new file mode 100644 index 0000000..96f4c28 --- /dev/null +++ b/apps/web/app/hooks/useProducts.ts @@ -0,0 +1,36 @@ +import type { ProductDto } from '@projectx/models'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +const PRODUCTS_QUERY_KEY = 'products'; +const MAX_RETRY_ATTEMPTS = 3; + +export const useProducts = ({ + initialData = [] as ProductDto[], + size = 10, +}) => { + return useInfiniteQuery({ + queryKey: [PRODUCTS_QUERY_KEY], + queryFn: async ({ pageParam = 1 }) => { + // TODO: Use limit and offset to load more products from the endpoint + const response = await axios.get(`${window.ENV.PRODUCT_API_URL}/product`); + return response.data; + }, + enabled: true, + refetchOnWindowFocus: true, + retry: (failureCount) => failureCount <= MAX_RETRY_ATTEMPTS, + getNextPageParam: (lastPage: ProductDto[], pages: ProductDto[][]) => { + if (lastPage.length === size) { + return pages.length + 1; + } + return undefined; + }, + initialPageParam: 1, + ...(!!initialData?.length && { + initialData: { + pages: [initialData], + pageParams: [null], + }, + }), + }); +}; diff --git a/apps/web/app/pages/CheckoutPage.tsx b/apps/web/app/pages/CheckoutPage.tsx new file mode 100644 index 0000000..4147eff --- /dev/null +++ b/apps/web/app/pages/CheckoutPage.tsx @@ -0,0 +1,151 @@ +import { canUseDOM } from '@projectx/ui'; +import { Elements } from '@stripe/react-stripe-js'; +import type { Appearance } from '@stripe/stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import { motion } from 'framer-motion'; + +import { OrderWorkflow } from '~/providers/workflows/types'; +import { CheckoutForm } from './checkout/CheckoutForm'; + +const STRIPE_SECRET_KEY = canUseDOM + ? window?.ENV.STRIPE_PUBLISHABLE_KEY + : (process.env.STRIPE_PUBLISHABLE_KEY as string); + +// Make sure to call loadStripe outside of a component's render to avoid +// recreating the Stripe object on every render. +// This is your test publishable API key. +const stripePromise = loadStripe(STRIPE_SECRET_KEY); + +interface CheckoutPageProps { + error?: Error; + isLoading: boolean; + currentCheckoutWorkflow?: OrderWorkflow; +} + +const appearance: Appearance = { + theme: 'stripe', + variables: { + colorPrimary: '#9333ea', // purple-600 + colorBackground: '#ffffff', + colorText: '#111827', // gray-900 + colorDanger: '#dc2626', // red-600 + fontFamily: 'system-ui, sans-serif', + spacingUnit: '4px', + borderRadius: '8px', + }, + rules: { + '.Input': { + backgroundColor: '#f3f4f6', // gray-100 + color: '#111827', // gray-900 + }, + '.Input--invalid': { + color: '#dc2626', // red-600 + }, + '.Label': { + color: '#374151', // gray-700 + }, + '.Tab': { + backgroundColor: '#f3f4f6', // gray-100 + color: '#374151', // gray-700 + }, + '.Tab--selected': { + backgroundColor: '#9333ea', // purple-600 + color: '#ffffff', + }, + }, +}; + +export const CheckoutPage = ({ + error, + isLoading, + currentCheckoutWorkflow, +}: CheckoutPageProps) => { + if (isLoading) { + // Skeleton for payment form + return ( +
+
+

+ 404 +

+

+ Loading... +

+

+ Please wait while we load the payment form. +

+
+
+ ); + } + + if (error) { + return ( +
+
+

+ 404 +

+

+ Payment not found +

+

+ The payment session you're looking for doesn't exist or has expired. +

+
+
+ ); + } + + const clientSecret = currentCheckoutWorkflow?.data?.response?.clientSecret; + + if (!clientSecret) { + return ( +
+ +
+

+ 404 +

+

+ Payment not found +

+

+ The payment session you're looking for doesn't exist or has expired. +

+
+
+
+ ); + } + + const options = { + clientSecret, + appearance, + }; + + return ( +
+ +

+ Complete Payment +

+
+ + + +
+
+
+ ); +}; diff --git a/apps/web/app/pages/LoginPage.tsx b/apps/web/app/pages/LoginPage.tsx index a5097cc..c48dbf4 100644 --- a/apps/web/app/pages/LoginPage.tsx +++ b/apps/web/app/pages/LoginPage.tsx @@ -122,7 +122,7 @@ export function LoginPage() {
+

+ We've sent a 6-character code to {formData.email}. The code expires shortly, so please enter it soon. +

+ {/* Show any error or success messages */} + {message && ( + +

{message}

+
+ )} +
+ ); +}; diff --git a/apps/web/app/providers/workflows/constants.ts b/apps/web/app/providers/workflows/constants.ts index 873213a..29c8bbe 100644 --- a/apps/web/app/providers/workflows/constants.ts +++ b/apps/web/app/providers/workflows/constants.ts @@ -1,2 +1,3 @@ // HTTP STATUS GONE -export const EXPIRED_STATUS_CODE = '410'; +export const EXPIRED_STATUS_CODE = 410; +export const NOT_FOUND_STATUS_CODE = 404; \ No newline at end of file diff --git a/apps/web/app/providers/workflows/internal/useOrderWorkflow.ts b/apps/web/app/providers/workflows/internal/useOrderWorkflow.ts index d6483c6..f0ccf51 100644 --- a/apps/web/app/providers/workflows/internal/useOrderWorkflow.ts +++ b/apps/web/app/providers/workflows/internal/useOrderWorkflow.ts @@ -1,30 +1,28 @@ import { useLocation } from '@remix-run/react'; import { UseQueryOptions, useQueries } from '@tanstack/react-query'; -import _ from 'lodash'; import { toast } from 'react-toastify'; +import { AxiosError } from 'axios'; -import { EXPIRED_STATUS_CODE } from '../constants'; -import { cancelOrder, getOrderStatus, startOrder } from '../services/order'; +import { EXPIRED_STATUS_CODE, NOT_FOUND_STATUS_CODE } from '../constants'; +import { cancelOrder, createOrder, getOrderStatus } from '../services/order'; import { useWorkflowActions } from '../useWorkflowActions'; -import { useWorkflowExpiration } from './useWorkflowExpiration'; import { OrderFailureStatus, OrderStatus, - OrderStatusResponse, OrderSuccessStatus, OrderWorkflow, WorkflowStep, WorkflowTypes, } from '../types'; +import { useWorkflowExpiration } from './useWorkflowExpiration'; export type WorkflowProps = { - accessToken?: string; + accessToken: string; workflows: Array; }; -const VALID_HTTP_STATUS_ERRORS = ['404', '409', '422', '503']; const END_STATUS = [...OrderSuccessStatus, ...OrderFailureStatus]; -const QUERY_KEY = 'checkout-workflow'; +const QUERY_KEY = 'order-workflow'; const WORKFLOW_TYPE = WorkflowTypes.ORDER; export const useOrderWorkflow = ({ accessToken, workflows }: WorkflowProps) => { @@ -42,37 +40,53 @@ export const useOrderWorkflow = ({ accessToken, workflows }: WorkflowProps) => { workflowType: WORKFLOW_TYPE, }); - const handleRequestError = (workflow: OrderWorkflow, error: Error) => { - if (!VALID_HTTP_STATUS_ERRORS.includes(error.message)) { - handleError({ workflow, error }); - return null; - } - throw error; - }; - + // Run the pending workflows return useQueries({ queries: workflows .filter((w) => !w.error) .map((workflow) => { - return { + return { cacheTime: 0, refetchIntervalInBackground: true, refetchOnWindowFocus: true, + enabled: !!accessToken, queryKey: [ QUERY_KEY, workflow.step, - workflow.data?.productId, + workflow.data?.referenceId, workflow.isInitialized, ], + retry: true, + useErrorBoundary: () => false, queryFn: async () => { switch (workflow.step) { case WorkflowStep.STATUS: try { - const orderState: OrderStatusResponse = await getOrderStatus( - workflow.referenceId, - accessToken + const orderState = await getOrderStatus( + accessToken as string, + workflow.referenceId ); if (END_STATUS.includes(orderState.status)) { + // Handle completed status + handleUpsert({ + referenceId: workflow.referenceId, + workflow: { + data: Object.assign({}, workflow.data, orderState), + }, + }); + const isOrderPage = location.pathname.includes('/checkout'); + if (OrderSuccessStatus.includes(orderState?.status)) { + toast.success('Order completed successfully'); + } else if ( + !isOrderPage && + OrderFailureStatus.includes(orderState?.status) + ) { + toast.error( + orderState.status === OrderStatus.Cancelled + ? 'The transaction was cancelled.' + : 'The transaction has failed, please try again.' + ); + } return orderState; } if (!workflow.isInitialized) { @@ -81,7 +95,13 @@ export const useOrderWorkflow = ({ accessToken, workflows }: WorkflowProps) => { } return Promise.reject('Order status is loading'); } catch (error) { - if (error.message === EXPIRED_STATUS_CODE) { + if ( + error instanceof AxiosError && + error.status && + [EXPIRED_STATUS_CODE, NOT_FOUND_STATUS_CODE].includes( + error.status + ) + ) { handleClear({ workflow }); } throw error; @@ -90,7 +110,14 @@ export const useOrderWorkflow = ({ accessToken, workflows }: WorkflowProps) => { } case WorkflowStep.CANCEL: try { - await cancelOrder(workflow.referenceId, accessToken); + await cancelOrder(accessToken, workflow.referenceId); + handleUpsert({ + referenceId: workflow.referenceId, + workflow: { + step: WorkflowStep.STATUS, + retries: 0, + }, + }); return true; } catch { return false; @@ -98,79 +125,39 @@ export const useOrderWorkflow = ({ accessToken, workflows }: WorkflowProps) => { case WorkflowStep.START: default: try { - const referenceId = await startOrder( - workflow.data.productId, - workflow.referenceId, - accessToken + const response = await createOrder( + accessToken, + workflow.data ); - return referenceId; + if (response) { + handleUpdate({ + workflow: { + ...workflow, + step: WorkflowStep.STATUS, + // Possible new referenceId decided by the server + referenceId: response.referenceId, + retries: 0, + data: Object.assign({}, workflow.data, { + response: { + referenceId: response.referenceId, + clientSecret: response.clientSecret, + orderId: response.orderId, + status: OrderStatus.Pending, + }, + }), + }, + // Current referenceId + referenceId: workflow.referenceId, + }); + } + return response; } catch (error) { - return handleRequestError(workflow, error as Error); - } - } - }, - enabled: !!accessToken, - retry: true, - useErrorBoundary: () => false, - onSuccess: async (response: OrderStatusResponse | string) => { - // Choose the next step of the workflow - const checkoutResponse = response as OrderStatusResponse; - const isOrderPage = location.pathname.includes('/checkout'); - switch (workflow.step) { - case WorkflowStep.STATUS: - if (!checkoutResponse) { - return; - } - handleUpsert({ - referenceId: workflow.referenceId, - workflow: { - data: { - ...workflow.data, - response: checkoutResponse, - }, - }, - }); - if (OrderSuccessStatus.includes(checkoutResponse?.status)) { - toast.success('Order completed successfully'); - } else if ( - !isOrderPage && - OrderFailureStatus.includes(checkoutResponse?.status) - ) { - toast.error( - checkoutResponse.orderStatus === OrderStatus.CANCELLED - ? 'The transaction was cancelled.' - : 'The transaction has failed, please try again.' - ); - } - break; - case WorkflowStep.CANCEL: - handleUpsert({ - referenceId: workflow.referenceId, - workflow: { - step: WorkflowStep.STATUS, - retries: 0, - }, - }); - break; - case WorkflowStep.START: - default: - if (_.isString(response)) { - handleUpdate({ - workflow: { - ...workflow, - step: WorkflowStep.STATUS, - // New referenceId - referenceId: response, - retries: 0, - }, - // Current referenceId - referenceId: workflow.referenceId, - }); + handleError({ workflow, error: error as Error }); + return null; } - break; } }, - } as UseQueryOptions; + }; }), }); }; diff --git a/apps/web/app/providers/workflows/internal/useWorkflowExpiration.ts b/apps/web/app/providers/workflows/internal/useWorkflowExpiration.ts index fc96aa2..45f0b52 100644 --- a/apps/web/app/providers/workflows/internal/useWorkflowExpiration.ts +++ b/apps/web/app/providers/workflows/internal/useWorkflowExpiration.ts @@ -6,6 +6,11 @@ export type WorkflowExpirationProps = { workflowType: WorkflowType; }; +/** + * Manage the expiration of the workflows. + * If the expiration time is reached, the workflow is cleared + * If the maximum retries are reached, the workflow is marked as failed + */ export const useWorkflowExpiration = ({ workflowType, }: WorkflowExpirationProps) => { diff --git a/apps/web/app/providers/workflows/services/order.ts b/apps/web/app/providers/workflows/services/order.ts index 160fe8a..c0e4b12 100644 --- a/apps/web/app/providers/workflows/services/order.ts +++ b/apps/web/app/providers/workflows/services/order.ts @@ -1,20 +1,13 @@ +import type { CreateOrderDto, OrderStatusResponseDto, OrderCreateResponseDto } from '@projectx/models'; import axios from 'axios'; -import { v4 as uuidv4 } from 'uuid'; -import { ORDER_API_URL } from '~/constants'; -import { OrderStartResponse, OrderStatusResponse } from '../types'; - -export async function startOrder( - productId: unknown, - referenceId = uuidv4(), - accessToken?: string, -): Promise { - const response = await axios.post( - ORDER_API_URL, - { - referenceId, - productId, - }, +export async function createOrder( + accessToken: string, + orderDto: CreateOrderDto, +): Promise { + const response = await axios.post( + `${window.ENV.ORDER_API_URL}/order`, + orderDto, { headers: { Authorization: `Bearer ${accessToken}`, @@ -25,11 +18,11 @@ export async function startOrder( } export async function getOrderStatus( + accessToken: string, referenceId: string, - accessToken?: string, -): Promise { - const response = await axios.get( - `${ORDER_API_URL}/${referenceId}`, +): Promise { + const response = await axios.get( + `${window.ENV.ORDER_API_URL}/order/${referenceId}`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -40,10 +33,10 @@ export async function getOrderStatus( } export async function cancelOrder( + accessToken: string, referenceId: string, - accessToken?: string, ): Promise { - await axios.delete(`${ORDER_API_URL}/${referenceId}`, { + await axios.delete(`${window.ENV.ORDER_API_URL}/order/${referenceId}`, { headers: { Authorization: `Bearer ${accessToken}`, }, diff --git a/apps/web/app/providers/workflows/store/reducer.ts b/apps/web/app/providers/workflows/store/reducer.ts index 74c2257..a2de4a9 100644 --- a/apps/web/app/providers/workflows/store/reducer.ts +++ b/apps/web/app/providers/workflows/store/reducer.ts @@ -12,11 +12,8 @@ function getInitialState(): StoreState { return savedState ? JSON.parse(savedState) : { - isWalletDialogOpen: false, - isWalletInProgress: false, - workflows: {}, - checkoutNftAddress: undefined, - }; + workflows: {} as StoreState['workflows'], + } as StoreState; } export const initialState = getInitialState(); @@ -29,17 +26,14 @@ export const reducer = ( action: StoreAction, ): StoreState => { const nextState = produce(state, (draftState) => { + draftState.workflows = draftState.workflows || getInitialState().workflows; switch (action.type) { case StoreActions.RunWorkflow: - draftState.workflows = - draftState.workflows || getInitialState().workflows; draftState.workflows[action.workflowType] = draftState.workflows[action.workflowType] || []; draftState.workflows[action.workflowType].push(action.payload); break; case StoreActions.ClearWorkflow: - draftState.workflows = - draftState.workflows || getInitialState().workflows; const workflowsToClear = draftState.workflows[action.workflowType] || []; draftState.workflows[action.workflowType] = workflowsToClear.filter( @@ -47,8 +41,6 @@ export const reducer = ( ); break; case StoreActions.UpdateWorkflow: - draftState.workflows = - draftState.workflows || getInitialState().workflows; const workflowsToUpdate = draftState.workflows[action.workflowType] || []; // Clear the workflows using the current referenceId @@ -59,8 +51,6 @@ export const reducer = ( draftState.workflows[action.workflowType].push(action.payload); break; case StoreActions.UpsertWorkflow: - draftState.workflows = - draftState.workflows || getInitialState().workflows; draftState.workflows[action.workflowType] = draftState.workflows[action.workflowType] || []; const workflowIndex = draftState.workflows[ diff --git a/apps/web/app/providers/workflows/store/store.tsx b/apps/web/app/providers/workflows/store/store.tsx index 0cbc85f..1771411 100644 --- a/apps/web/app/providers/workflows/store/store.tsx +++ b/apps/web/app/providers/workflows/store/store.tsx @@ -14,17 +14,13 @@ export const GlobalContext = createContext( ); export const StoreConsumer = GlobalContext.Consumer; -export type StoreProviderProps = PropsWithChildren<{ - accessToken?: string; -}>; +export type StoreProviderProps = PropsWithChildren; export const StoreProvider: React.FC = ({ children, - accessToken, }) => { const [state, dispatch] = useReducer(reducer, { ...initialState, - accessToken, }); const value = React.useMemo( @@ -37,16 +33,12 @@ export const StoreProvider: React.FC = ({ ); }; -export type StoreProviderType = { - accessToken?: string; -}; - export function withStoreProvider( WrappedComponent: ComponentType, -): ComponentType { - return function (props: T & StoreProviderType) { +): ComponentType { + return function (props: T & StoreProviderProps) { return ( - + ); diff --git a/apps/web/app/providers/workflows/store/types.ts b/apps/web/app/providers/workflows/store/types.ts index 9946292..e963c76 100644 --- a/apps/web/app/providers/workflows/store/types.ts +++ b/apps/web/app/providers/workflows/store/types.ts @@ -10,33 +10,29 @@ export enum StoreActions { } export type StoreState = { - isWalletDialogOpen: boolean; - isWalletInProgress: boolean; workflows: Record>>; - accessToken?: string; - checkoutNftAddress: string | undefined; }; export type StoreAction = | { - type: StoreActions.RunWorkflow; - payload: Workflow; + type: StoreActions.RunWorkflow; + payload: Workflow; workflowType: WorkflowType; - } +} | { - type: StoreActions.ClearWorkflow; + type: StoreActions.ClearWorkflow; referenceId: Workflow['referenceId']; - workflowType: WorkflowType; - } + workflowType: WorkflowType; +} | { - type: StoreActions.UpdateWorkflow; - payload: Workflow; + type: StoreActions.UpdateWorkflow; + payload: Workflow; workflowType: WorkflowType; referenceId: Workflow['referenceId']; - } +} | { - type: StoreActions.UpsertWorkflow; - payload: Partial>; + type: StoreActions.UpsertWorkflow; + payload: Partial>; workflowType: WorkflowType; referenceId: Workflow['referenceId']; }; diff --git a/apps/web/app/providers/workflows/types/order.ts b/apps/web/app/providers/workflows/types/order.ts index 7e78f88..6f97854 100644 --- a/apps/web/app/providers/workflows/types/order.ts +++ b/apps/web/app/providers/workflows/types/order.ts @@ -1,45 +1,29 @@ +import type { CreateOrderDto, OrderStatusResponseDto } from "@projectx/models"; import { Workflow } from "./workflow"; export enum OrderStatus { - PENDING = 'Pending', - FAILED = 'Failed', - EXPIRED = 'Expired', - PROCESSED = 'Processed', - COMPLETED = 'Completed', - CANCELLED = 'Cancelled', - INITIATED = 'Initiated', - UNKOWN_FAILURE = 'UnkownFailure', -} - -export interface OrderStartResponse { - referenceId: string; -} - -export interface OrderStatusResponse { - referenceId: string; - status: OrderStatus; - productId?: unknown; - userId?: unknown; - userEmail?: string; - price?: number; - orderStatus?: OrderStatus; + Pending = 'Pending', + Confirmed = 'Confirmed', + Shipped = 'Shipped', + Delivered = 'Delivered', + Cancelled = 'Cancelled', + Failed = 'Failed', } export const OrderSuccessStatus = [ - OrderStatus.COMPLETED, - OrderStatus.PROCESSED, + OrderStatus.Confirmed, + OrderStatus.Shipped, + OrderStatus.Delivered, ]; export const OrderFailureStatus = [ - OrderStatus.FAILED, - OrderStatus.CANCELLED, - OrderStatus.UNKOWN_FAILURE, + OrderStatus.Failed, + OrderStatus.Cancelled, ]; -export type OrderWorkflowData = { - productId: unknown; - response?: unknown; +export type OrderWorkflowData = CreateOrderDto & { + response?: OrderStatusResponseDto; }; export type OrderWorkflow = Workflow; diff --git a/apps/web/app/providers/workflows/types/workflow.ts b/apps/web/app/providers/workflows/types/workflow.ts index 726d5ec..7d4eb66 100644 --- a/apps/web/app/providers/workflows/types/workflow.ts +++ b/apps/web/app/providers/workflows/types/workflow.ts @@ -1,5 +1,6 @@ export enum WorkflowTypes { ORDER = 'order', + // Add other workflows here... } export type WorkflowType = WorkflowTypes | `${WorkflowTypes}`; @@ -10,15 +11,23 @@ export enum WorkflowStep { } export interface Workflow { + // The email is used to filter the workflows by the current user email?: string; + // The referenceId is used to identify the workflow in the backend referenceId: string; + // We use the step to identify the current state of the workflow and check if the workflow is completed step: WorkflowStep | `${WorkflowStep}`; + // The data includes the data/state of the workflow from the backend data: T; + // The error indicates if there was an error running this process error?: Error; + // Determines if the workflow was created from the backend and there's a process running + isInitialized?: boolean; + // Can can request for a cancellation of the workflow, this flag is used to determine if the workflow is being canceled isCanceling?: boolean; + // The below properties are used to manage the expiration of the workflow retries?: number; maxRetries?: number; expirationTimeInMilliseconds?: number; - isInitialized?: boolean; startDate?: number; } diff --git a/apps/web/app/providers/workflows/useCurrentWorkflow.ts b/apps/web/app/providers/workflows/useCurrentWorkflow.ts index dc4169a..1cb24ff 100644 --- a/apps/web/app/providers/workflows/useCurrentWorkflow.ts +++ b/apps/web/app/providers/workflows/useCurrentWorkflow.ts @@ -2,6 +2,10 @@ import { useMemo } from 'react'; import { Workflow, WorkflowType } from './types'; import { useStoreState } from './store'; +/** + * Find a workflow by its type and predicate + * It's used to determine if there is a existing workflow for a specific task + */ export const useCurrentWorkflow = >( workflowType: WorkflowType, predicate: (value: T, index: number, obj: T[]) => unknown, diff --git a/apps/web/app/providers/workflows/useWorkflowActions.ts b/apps/web/app/providers/workflows/useWorkflowActions.ts index b52d62c..c1fd4c1 100644 --- a/apps/web/app/providers/workflows/useWorkflowActions.ts +++ b/apps/web/app/providers/workflows/useWorkflowActions.ts @@ -15,6 +15,11 @@ type WorkflowActionsProps = { workflowType: WorkflowType; }; +/** + * Actions to manage the workflows, we use a referenceId to identify the workflows running in the backend + * The referenceId is generated by the frontend in order to identify a transaction + * The referenceId is also used to update the workflow in the store + */ export const useWorkflowActions = >({ workflowType, }: WorkflowActionsProps) => { @@ -72,7 +77,7 @@ export const useWorkflowActions = >({ ...workflow, }, // Use a new referenceId if provided - referenceId: referenceId ?? workflow.referenceId, + referenceId: (referenceId ?? workflow.referenceId) || '', }); }, [dispatch, workflowType], diff --git a/apps/web/app/providers/workflows/useWorkflows.ts b/apps/web/app/providers/workflows/useWorkflows.ts index 704a98f..ecf5258 100644 --- a/apps/web/app/providers/workflows/useWorkflows.ts +++ b/apps/web/app/providers/workflows/useWorkflows.ts @@ -1,28 +1,21 @@ -import { useMemo } from 'react'; - import { useOrderWorkflow } from './internal/useOrderWorkflow'; -import { StoreState, useStoreState } from './store'; -import { OrderWorkflow, Workflow, WorkflowType, WorkflowTypes } from './types'; +import { useStoreState } from './store'; +import { OrderWorkflow, WorkflowTypes } from './types'; +import { useWorkflowsByType } from './utils'; export type WorkflowProps = { - accessToken?: string; + accessToken: string; email?: string; }; -export const useWorkflowsByType = >( - store: StoreState, - workflowType: WorkflowType, - email?: string, -) => { - return useMemo(() => { - return ((store?.workflows[workflowType] as T[]) || []).filter( - (w) => !w.email || `${w.email}` === `${email}`, - ); - }, [store?.workflows[workflowType], email]); -}; - +/** + * Connect the backend workflows to the frontend + */ export const useWorkflows = ({ accessToken, email }: WorkflowProps) => { + // The store has all the existing workflows and the actions to update them const store = useStoreState(); + + // Connect the workflow to manage the order transactions useOrderWorkflow({ accessToken, workflows: useWorkflowsByType( @@ -31,4 +24,6 @@ export const useWorkflows = ({ accessToken, email }: WorkflowProps) => { email, ), }); + + // Connect other workflows here... }; diff --git a/apps/web/app/providers/workflows/utils.ts b/apps/web/app/providers/workflows/utils.ts new file mode 100644 index 0000000..2df3aa4 --- /dev/null +++ b/apps/web/app/providers/workflows/utils.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; + +import { StoreState } from './store'; +import { Workflow, WorkflowType } from './types'; + +export const useWorkflowsByType = >( + store: StoreState, + workflowType: WorkflowType, + email?: string, +) => { + return useMemo(() => { + return ((store?.workflows[workflowType] as T[]) || []).filter( + (w) => !w.email || `${w.email}` === `${email}`, + ); + }, [store?.workflows[workflowType], email]); +}; diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx index 4c03163..66fd147 100644 --- a/apps/web/app/root.tsx +++ b/apps/web/app/root.tsx @@ -1,8 +1,9 @@ -import { UserDto } from '@projectx/models'; +import type { UserDto } from '@projectx/models'; import type { MetaFunction, LinksFunction, LoaderFunction, + LoaderFunctionArgs, } from '@remix-run/node'; import { isRouteErrorResponse, @@ -18,6 +19,7 @@ import { } from '@remix-run/react'; import { PropsWithChildren } from 'react'; import { AuthenticityTokenProvider } from 'remix-utils/csrf/react'; +import { ToastContainer } from 'react-toastify'; import { getEnv } from '~/config/env.server'; import { csrf } from '~/cookies/session.server'; @@ -61,7 +63,7 @@ type LoaderData = { ENV: ReturnType; }; -export const loader: LoaderFunction = async ({ request }) => { +export const loader: LoaderFunction = async ({ request }: LoaderFunctionArgs) => { const [csrfToken, cookieHeader] = await csrf.commitToken(); const theme = request.headers.get('Cookie')?.includes('theme=dark') ? THEME.DARK @@ -71,12 +73,12 @@ export const loader: LoaderFunction = async ({ request }) => { const user = getAuthUser(); return json( { + user, theme, csrfToken, + accessToken, ENV: getEnv(), isAuthenticated: !!accessToken, - user, - accessToken, }, { headers: { @@ -90,7 +92,9 @@ export type AppProps = PropsWithChildren< Omit >; function App({ csrfToken, theme, user, accessToken, ENV }: AppProps) { + // Connect Temporal workflows to your app useWorkflows({ accessToken, email: user?.email }); + return ( @@ -105,6 +109,7 @@ function App({ csrfToken, theme, user, accessToken, ENV }: AppProps) { +