178 lines
5.6 KiB
TypeScript
178 lines
5.6 KiB
TypeScript
|
|
import { Controller, Post, Body, UseGuards, Res, Req, HttpCode } from "@nestjs/common";
|
||
|
|
import type { Request, Response } from "express";
|
||
|
|
import { createZodDto } from "nestjs-zod";
|
||
|
|
|
||
|
|
import { RateLimitGuard, RateLimit, getRequestFingerprint } from "@bff/core/rate-limiting/index.js";
|
||
|
|
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
||
|
|
import { Public } from "../../decorators/public.decorator.js";
|
||
|
|
|
||
|
|
import {
|
||
|
|
sendVerificationCodeRequestSchema,
|
||
|
|
sendVerificationCodeResponseSchema,
|
||
|
|
verifyCodeRequestSchema,
|
||
|
|
verifyCodeResponseSchema,
|
||
|
|
quickEligibilityRequestSchema,
|
||
|
|
quickEligibilityResponseSchema,
|
||
|
|
completeAccountRequestSchema,
|
||
|
|
maybeLaterRequestSchema,
|
||
|
|
maybeLaterResponseSchema,
|
||
|
|
} from "@customer-portal/domain/get-started";
|
||
|
|
|
||
|
|
import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js";
|
||
|
|
|
||
|
|
// DTO classes using Zod schemas
|
||
|
|
class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRequestSchema) {}
|
||
|
|
class SendVerificationCodeResponseDto extends createZodDto(sendVerificationCodeResponseSchema) {}
|
||
|
|
class VerifyCodeRequestDto extends createZodDto(verifyCodeRequestSchema) {}
|
||
|
|
class VerifyCodeResponseDto extends createZodDto(verifyCodeResponseSchema) {}
|
||
|
|
class QuickEligibilityRequestDto extends createZodDto(quickEligibilityRequestSchema) {}
|
||
|
|
class QuickEligibilityResponseDto extends createZodDto(quickEligibilityResponseSchema) {}
|
||
|
|
class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {}
|
||
|
|
class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {}
|
||
|
|
class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {}
|
||
|
|
|
||
|
|
const ACCESS_COOKIE_PATH = "/api";
|
||
|
|
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
||
|
|
const TOKEN_TYPE = "Bearer" as const;
|
||
|
|
|
||
|
|
const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
||
|
|
const expiresAt = Date.parse(isoTimestamp);
|
||
|
|
if (Number.isNaN(expiresAt)) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
return Math.max(0, expiresAt - Date.now());
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get Started Controller
|
||
|
|
*
|
||
|
|
* Handles the unified "Get Started" flow:
|
||
|
|
* - Email verification via OTP
|
||
|
|
* - Account status detection
|
||
|
|
* - Quick eligibility check (guest)
|
||
|
|
* - Account completion (SF-only users)
|
||
|
|
*
|
||
|
|
* All endpoints are public (no authentication required)
|
||
|
|
*/
|
||
|
|
@Controller("auth/get-started")
|
||
|
|
export class GetStartedController {
|
||
|
|
constructor(private readonly workflow: GetStartedWorkflowService) {}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Send OTP verification code to email
|
||
|
|
*
|
||
|
|
* Rate limit: 5 codes per 5 minutes per IP
|
||
|
|
*/
|
||
|
|
@Public()
|
||
|
|
@Post("send-code")
|
||
|
|
@HttpCode(200)
|
||
|
|
@UseGuards(RateLimitGuard)
|
||
|
|
@RateLimit({ limit: 5, ttl: 300 })
|
||
|
|
async sendVerificationCode(
|
||
|
|
@Body() body: SendVerificationCodeRequestDto,
|
||
|
|
@Req() req: Request
|
||
|
|
): Promise<SendVerificationCodeResponseDto> {
|
||
|
|
const fingerprint = getRequestFingerprint(req);
|
||
|
|
return this.workflow.sendVerificationCode(body, fingerprint);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Verify OTP code and determine account status
|
||
|
|
*
|
||
|
|
* Rate limit: 10 attempts per 5 minutes per IP
|
||
|
|
*/
|
||
|
|
@Public()
|
||
|
|
@Post("verify-code")
|
||
|
|
@HttpCode(200)
|
||
|
|
@UseGuards(RateLimitGuard)
|
||
|
|
@RateLimit({ limit: 10, ttl: 300 })
|
||
|
|
async verifyCode(
|
||
|
|
@Body() body: VerifyCodeRequestDto,
|
||
|
|
@Req() req: Request
|
||
|
|
): Promise<VerifyCodeResponseDto> {
|
||
|
|
const fingerprint = getRequestFingerprint(req);
|
||
|
|
return this.workflow.verifyCode(body, fingerprint);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Quick eligibility check for guests
|
||
|
|
* Creates SF Account + eligibility case
|
||
|
|
*
|
||
|
|
* Rate limit: 5 per 15 minutes per IP
|
||
|
|
*/
|
||
|
|
@Public()
|
||
|
|
@Post("quick-check")
|
||
|
|
@HttpCode(200)
|
||
|
|
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||
|
|
@RateLimit({ limit: 5, ttl: 900 })
|
||
|
|
async quickEligibilityCheck(
|
||
|
|
@Body() body: QuickEligibilityRequestDto
|
||
|
|
): Promise<QuickEligibilityResponseDto> {
|
||
|
|
return this.workflow.quickEligibilityCheck(body);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* "Maybe Later" flow
|
||
|
|
* Creates SF Account + eligibility case, sends confirmation email
|
||
|
|
*
|
||
|
|
* Rate limit: 3 per 10 minutes per IP
|
||
|
|
*/
|
||
|
|
@Public()
|
||
|
|
@Post("maybe-later")
|
||
|
|
@HttpCode(200)
|
||
|
|
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||
|
|
@RateLimit({ limit: 3, ttl: 600 })
|
||
|
|
async maybeLater(@Body() body: MaybeLaterRequestDto): Promise<MaybeLaterResponseDto> {
|
||
|
|
return this.workflow.maybeLater(body);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Complete account for SF-only users
|
||
|
|
* Creates WHMCS client and Portal user, links to existing SF account
|
||
|
|
*
|
||
|
|
* Returns auth tokens (sets httpOnly cookies)
|
||
|
|
*
|
||
|
|
* Rate limit: 5 per 15 minutes per IP
|
||
|
|
*/
|
||
|
|
@Public()
|
||
|
|
@Post("complete-account")
|
||
|
|
@HttpCode(200)
|
||
|
|
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||
|
|
@RateLimit({ limit: 5, ttl: 900 })
|
||
|
|
async completeAccount(
|
||
|
|
@Body() body: CompleteAccountRequestDto,
|
||
|
|
@Res({ passthrough: true }) res: Response
|
||
|
|
) {
|
||
|
|
const result = await this.workflow.completeAccount(body);
|
||
|
|
|
||
|
|
// Set auth cookies (same pattern as signup)
|
||
|
|
const accessExpires = result.tokens.expiresAt;
|
||
|
|
const refreshExpires = result.tokens.refreshExpiresAt;
|
||
|
|
|
||
|
|
res.cookie("access_token", result.tokens.accessToken, {
|
||
|
|
httpOnly: true,
|
||
|
|
secure: process.env.NODE_ENV === "production",
|
||
|
|
sameSite: "lax",
|
||
|
|
path: ACCESS_COOKIE_PATH,
|
||
|
|
maxAge: calculateCookieMaxAge(accessExpires),
|
||
|
|
});
|
||
|
|
|
||
|
|
res.cookie("refresh_token", result.tokens.refreshToken, {
|
||
|
|
httpOnly: true,
|
||
|
|
secure: process.env.NODE_ENV === "production",
|
||
|
|
sameSite: "lax",
|
||
|
|
path: REFRESH_COOKIE_PATH,
|
||
|
|
maxAge: calculateCookieMaxAge(refreshExpires),
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
user: result.user,
|
||
|
|
session: {
|
||
|
|
expiresAt: accessExpires,
|
||
|
|
refreshExpiresAt: refreshExpires,
|
||
|
|
tokenType: TOKEN_TYPE,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|