Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(order): implement order api and workflow #7

Draft
wants to merge 3 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ 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
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 protected]
EMAIL_FROM_NAME=ProjectX Team

# DEVELOPMENT ONLY
NGROK_AUTHTOKEN=your_ngrok_auth_token

# WEB SETTINGS
SESSION_SECRET=your_secret_key_for_sessions
AUTH_API_URL="http://localhost:${AUTH_PORT}"
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ npx nx show projects
npx nx graph
```

View the Database diagram [here](./libs/backend/db/README.md).
> [!TIP]
> 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
Expand Down
12 changes: 11 additions & 1 deletion apps/auth/src/app/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
52 changes: 31 additions & 21 deletions apps/auth/src/app/app.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand All @@ -25,44 +19,57 @@ 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<string>('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,
}
);
}
}
}

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,
Expand All @@ -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, {
Expand Down
7 changes: 4 additions & 3 deletions apps/auth/src/app/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion apps/auth/src/config/app.config.ts
Original file line number Diff line number Diff line change
@@ -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(',') ?? [],
Expand Down
34 changes: 25 additions & 9 deletions apps/auth/src/workflows/login.workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
log,
isCancellation,
CancellationScope,
allHandlersFinished,
ApplicationFailure,
} from '@temporalio/workflow';

// eslint-disable-next-line @nx/enforce-module-boundaries
Expand All @@ -16,11 +18,11 @@ 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<ActivitiesService>({
startToCloseTimeout: '5m',
startToCloseTimeout: '5 seconds',
retry: {
initialInterval: '2s',
maximumInterval: '10s',
Expand All @@ -30,7 +32,7 @@ const { sendLoginEmail } = proxyActivities<ActivitiesService>({
});

const { verifyLoginCode } = proxyActivities<ActivitiesService>({
startToCloseTimeout: '5m',
startToCloseTimeout: '5 seconds',
retry: {
initialInterval: '2s',
maximumInterval: '10s',
Expand Down Expand Up @@ -68,19 +70,33 @@ 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
});
}
Expand Down
2 changes: 2 additions & 0 deletions apps/order/src/app/app.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';

import { AppService } from './app.service';

@ApiTags('Order')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
Expand Down
2 changes: 1 addition & 1 deletion apps/order/src/config/app.config.ts
Original file line number Diff line number Diff line change
@@ -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(',') ?? [],
Expand Down
12 changes: 12 additions & 0 deletions apps/order/src/workflows/long-running.workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { continueAsNew, workflowInfo } from "@temporalio/workflow";

const MAX_NUMBER_OF_EVENTS = 10000;

export async function longRunningWorkflow(n: number): Promise<void> {
// Long-duration workflow
while (workflowInfo().historyLength < MAX_NUMBER_OF_EVENTS) {
//...
}

await continueAsNew<typeof longRunningWorkflow>(n + 1);
}
65 changes: 55 additions & 10 deletions apps/order/src/workflows/order.workflow.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,58 @@
import { continueAsNew, sleep, workflowInfo } from "@temporalio/workflow";
/* eslint-disable @nx/enforce-module-boundaries */
import {
allHandlersFinished,
ChildWorkflowHandle,
condition,
setHandler,
startChild,
} from '@temporalio/workflow';

const MAX_NUMBER_OF_EVENTS = 10000;
import {
OrderProcessPaymentStatus,
OrderWorkflowData,
OrderWorkflowState,
OrderWorkflowStatus,
getOrderStateQuery,
getWorkflowIdByPaymentOrder,
} from '../../../../libs/backend/core/src/lib/order/workflow.utils';
import {
cancelWorkflowSignal,
} from '../../../../libs/backend/core/src/lib/workflows';
import { processPayment } from './process-payment.workflow';

export async function createOrder(email?: string): Promise<void> {

// Long-duration workflow
while (workflowInfo().historyLength < MAX_NUMBER_OF_EVENTS) {
await sleep(1000);
}
const initialState: OrderWorkflowState = {
status: OrderWorkflowStatus.PENDING,
orderId: 0,
};

export async function createOrder(
data: OrderWorkflowData,
state = initialState
): Promise<void> {
let processPaymentWorkflow: ChildWorkflowHandle<typeof processPayment>;
// Attach queries, signals and updates
setHandler(getOrderStateQuery, () => state);
setHandler(
cancelWorkflowSignal,
() => processPaymentWorkflow?.signal(cancelWorkflowSignal)
);
// TODO: Create the order in the database

await continueAsNew<typeof createOrder>(email);
}
state.status = OrderWorkflowStatus.PROCESSING_PAYMENT;
processPaymentWorkflow = await startChild(processPayment, {
args: [data],
workflowId: getWorkflowIdByPaymentOrder(state.orderId),
});
const processPaymentResult = await processPaymentWorkflow.result();
if (processPaymentResult.status === OrderProcessPaymentStatus.SUCCESS) {
state.status = OrderWorkflowStatus.PAYMENT_COMPLETED;
} else {
state.status = OrderWorkflowStatus.FAILED;
return;
}
processPaymentWorkflow = undefined;
//...
state.status = OrderWorkflowStatus.COMPLETED;
// Wait for all handlers to finish before workflow completion
await condition(allHandlersFinished);
}
Loading
Loading