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 @@
-> **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
@@ -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() {