diff --git a/src/lib/twilio/twilio.module.ts b/src/lib/twilio/twilio.module.ts index 98698f58..bf4308b5 100644 --- a/src/lib/twilio/twilio.module.ts +++ b/src/lib/twilio/twilio.module.ts @@ -14,6 +14,32 @@ export const TWILIO_CLIENT = 'TWILIO_CLIENT'; const authToken = process.env.TWILIO_AUTH_TOKEN ?? ''; if (accountSid === '' || authToken === '') { + // In development, allow service to start without Twilio credentials + if ( + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === undefined + ) { + // eslint-disable-next-line no-console + console.warn( + '⚠️ Twilio credentials not found. Twilio features will be disabled.', + ); + // Return a mock Twilio client for development + // Using type assertion since we only need calls.create and messages.create + const mockClient = { + calls: { + create: (): Promise => + Promise.reject(new Error('Twilio not configured')), + list: (): Promise => Promise.resolve([]), + }, + messages: { + create: (): Promise => + Promise.reject(new Error('Twilio not configured')), + list: (): Promise => Promise.resolve([]), + }, + }; + + return mockClient as unknown as Twilio.Twilio; + } throw new Error( 'Twilio credentials not found in environment variables', ); diff --git a/src/main.ts b/src/main.ts index 441889c8..30b17155 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { GlobalExceptionFilter } from '@/common/filters/global-exception.filter' import { setupSwagger } from '@/config/swagger.config'; import { winstonLogger } from '@/logger/winston.logger'; import { AppModule } from '@/modules/app.module'; +import { getCorsOrigin } from '@/utils/app-config'; async function bootstrap(): Promise { const app: INestApplication = await NestFactory.create(AppModule); app.useLogger(winstonLogger); @@ -26,7 +27,7 @@ async function bootstrap(): Promise { }), ); app.enableCors({ - origin: process.env.CORS_ORIGIN, + origin: getCorsOrigin(), credentials: true, // Enable cookies in CORS }); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 3ba51b7b..6ca40161 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -16,10 +16,11 @@ import { EUserRole } from '@/common/constants/user.constant'; import { SkipCSRF } from '@/common/decorators/skip-csrf.decorator'; import { AuthService } from '@/modules/auth/auth.service'; import { LoginDto } from '@/modules/auth/dto/login.dto'; +import { ResetPasswordDto } from '@/modules/auth/dto/reset-password.dto'; import { CreateUserDto } from '@/modules/auth/dto/signup.dto'; import { UserResponseDto } from '@/modules/auth/dto/user-response.dto'; -import { ResetPasswordDto } from '@/modules/auth/dto/reset-password.dto'; import { UserStatus } from '@/modules/user/enum/userStatus.enum'; +import { getAppUrl } from '@/utils/app-config'; import { generateCSRFToken } from '@/utils/csrf.util'; @ApiTags('auth') @@ -173,20 +174,34 @@ export class AuthController { @Get('google/callback') @UseGuards(AuthGuard('google')) googleAuthRedirect(@Req() req: Request, @Res() res: Response): void { + // Check if user is authenticated + if (!req.user) { + const frontendUrl = getAppUrl(); + res.redirect(`${frontendUrl}/login?error=oauth_failed`); + return; + } + const { user, token, csrfToken } = req.user as { user: Record; token: string; csrfToken: string; }; + // Validate required fields + if (!token || !csrfToken) { + const frontendUrl = getAppUrl(); + res.redirect(`${frontendUrl}/login?error=oauth_incomplete`); + return; + } + // Manually construct safe user object to preserve ObjectId const safeUser = { - _id: user._id?.toString() ?? user._id, - email: user.email ?? '', - firstName: user.firstName, - lastName: user.lastName, - role: user.role as EUserRole, - status: user.status as UserStatus, + _id: user._id?.toString() ?? (user._id as string | undefined) ?? '', + email: (user.email as string | undefined) ?? '', + firstName: (user.firstName as string | undefined) ?? '', + lastName: (user.lastName as string | undefined) ?? '', + role: (user.role as EUserRole | undefined) ?? EUserRole.user, + status: (user.status as UserStatus | undefined) ?? UserStatus.active, }; // Set JWT token as httpOnly cookie @@ -208,7 +223,7 @@ export class AuthController { }); // Redirect to frontend with user data (CSRF token is in regular cookie) - const frontendUrl = process.env.APP_URL ?? 'http://localhost:3000'; + const frontendUrl = getAppUrl(); res.redirect( `${frontendUrl}/auth/callback?user=${encodeURIComponent(JSON.stringify(safeUser))}&csrfToken=${encodeURIComponent(csrfToken)}`, ); @@ -216,14 +231,21 @@ export class AuthController { @ApiOperation({ summary: 'Forgot Password', - description: 'Send a password reset link to the user\'s email', + description: "Send a password reset link to the user's email", + }) + @ApiResponse({ + status: 200, + description: 'If that email is registered, a reset link has been sent.', }) - @ApiResponse({ status: 200, description: 'If that email is registered, a reset link has been sent.' }) @Post('forgot-password') @SkipCSRF() - async forgotPassword(@Body('email') email: string): Promise<{ message: string }> { + async forgotPassword( + @Body('email') email: string, + ): Promise<{ message: string }> { await this.authService.forgotPassword(email); - return { message: 'If that email is registered, a reset link has been sent.' }; + return { + message: 'If that email is registered, a reset link has been sent.', + }; } @ApiOperation({ @@ -334,7 +356,9 @@ export class AuthController { @ApiResponse({ status: 400, description: 'Invalid token or password' }) @Post('reset-password') @SkipCSRF() - async resetPassword(@Body() dto: ResetPasswordDto): Promise<{ message: string }> { + async resetPassword( + @Body() dto: ResetPasswordDto, + ): Promise<{ message: string }> { await this.authService.resetPassword(dto); return { message: 'Password reset successful' }; } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 51b6869c..9fc1d44a 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,4 +1,3 @@ -import process from 'process'; import { BadRequestException, ConflictException, @@ -13,6 +12,7 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import { Model } from 'mongoose'; import * as path from 'path'; +import process from 'process'; import { EUserRole } from '@/common/constants/user.constant'; import { SALT_ROUNDS } from '@/modules/auth/auth.config'; diff --git a/src/modules/auth/dto/reset-password.dto.ts b/src/modules/auth/dto/reset-password.dto.ts index efd4faf7..4acf2d49 100644 --- a/src/modules/auth/dto/reset-password.dto.ts +++ b/src/modules/auth/dto/reset-password.dto.ts @@ -15,4 +15,4 @@ export class ResetPasswordDto { @IsString() @MinLength(6) confirmPassword!: string; -} \ No newline at end of file +} diff --git a/src/modules/auth/strategies/google.strategy.ts b/src/modules/auth/strategies/google.strategy.ts index 5d76ec99..943ceb70 100644 --- a/src/modules/auth/strategies/google.strategy.ts +++ b/src/modules/auth/strategies/google.strategy.ts @@ -21,10 +21,30 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { private readonly jwtService: JwtService, @InjectModel(User.name) private readonly userModel: Model, ) { + let clientID = configService.get('GOOGLE_CLIENT_ID') ?? ''; + let clientSecret = configService.get('GOOGLE_CLIENT_SECRET') ?? ''; + let callbackURL = configService.get('GOOGLE_CALLBACK_URL') ?? ''; + + // In development, allow service to start without Google OAuth credentials + if ( + (clientID === '' || clientSecret === '' || callbackURL === '') && + (process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === undefined) + ) { + // eslint-disable-next-line no-console + console.warn( + '⚠️ Google OAuth credentials not found. Google OAuth features will be disabled.', + ); + // Provide dummy values for development + clientID = 'dummy_client_id'; + clientSecret = 'dummy_client_secret'; + callbackURL = 'http://localhost:4000/api/auth/google/callback'; + } + super({ - clientID: configService.get('GOOGLE_CLIENT_ID') ?? '', - clientSecret: configService.get('GOOGLE_CLIENT_SECRET') ?? '', - callbackURL: configService.get('GOOGLE_CALLBACK_URL') ?? '', + clientID, + clientSecret, + callbackURL, scope: ['email', 'profile'], } as StrategyOptions); } @@ -34,19 +54,26 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { refreshToken: string, profile: { id: string; - name: { givenName: string; familyName: string }; - emails: { value: string }[]; - photos: { value: string }[]; + name?: { givenName?: string; familyName?: string }; + emails?: { value: string }[]; + photos?: { value: string }[]; }, done: VerifyCallback, ): Promise { try { + // Validate profile data - profile.emails and profile.name are optional + if (!profile.emails || profile.emails.length === 0 || !profile.name) { + done(new UnauthorizedException('Invalid Google profile data'), false); + return; + } + + // At this point, we know profile.name and profile.emails exist and have items const { name, emails } = profile; const googleUser = { email: emails[0].value, - firstName: name.givenName, - lastName: name.familyName, + firstName: name.givenName ?? '', + lastName: name.familyName ?? '', }; let user = await this.userModel.findOne({ diff --git a/src/modules/company/schema/company.schema.ts b/src/modules/company/schema/company.schema.ts index 37e5e9e1..c05a67dc 100644 --- a/src/modules/company/schema/company.schema.ts +++ b/src/modules/company/schema/company.schema.ts @@ -15,7 +15,7 @@ export class Company { user!: User; @Prop() calendar_access_token?: string; - + _id!: Types.ObjectId; } diff --git a/src/modules/google-calendar/calendar-oauth.controller.ts b/src/modules/google-calendar/calendar-oauth.controller.ts index 4940ff43..6290b5be 100644 --- a/src/modules/google-calendar/calendar-oauth.controller.ts +++ b/src/modules/google-calendar/calendar-oauth.controller.ts @@ -2,6 +2,8 @@ import { Controller, Get, Query, Res } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { getAppUrl } from '@/utils/app-config'; + import { CalendarTokenService } from './calendar-token.service'; import { CalendarOAuthService } from './services/calendar-oauth.service'; @@ -68,7 +70,7 @@ export class CalendarOAuthController { calendarId: 'primary', }); - const frontendUrl = process.env.APP_URL ?? 'http://localhost:3000'; + const frontendUrl: string = getAppUrl(); res.redirect(`${frontendUrl}/settings/calendar?connected=google`); } } diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index 3c211b27..60fe641f 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -18,9 +18,13 @@ export class HealthController { @ApiOperation({ summary: 'API Health Status', - description: 'Endpoint for monitoring service availability and operational status. Returns service metadata, current environment, and timestamp information.', + description: + 'Endpoint for monitoring service availability and operational status. Returns service metadata, current environment, and timestamp information.', + }) + @ApiResponse({ + status: 200, + description: 'Service is operational and healthy', }) - @ApiResponse({ status: 200, description: 'Service is operational and healthy' }) @Get() check(): { status: string; @@ -33,10 +37,17 @@ export class HealthController { @ApiOperation({ summary: 'Database Connectivity Status', - description: 'Validates database connectivity and health. Returns connection status for MongoDB and Redis instances.', + description: + 'Validates database connectivity and health. Returns connection status for MongoDB and Redis instances.', + }) + @ApiResponse({ + status: 200, + description: 'All database connections are healthy', + }) + @ApiResponse({ + status: 503, + description: 'One or more database connections have failed', }) - @ApiResponse({ status: 200, description: 'All database connections are healthy' }) - @ApiResponse({ status: 503, description: 'One or more database connections have failed' }) @Get('db') checkDatabase(): { status: string; @@ -49,7 +60,8 @@ export class HealthController { @ApiOperation({ summary: 'AI Service Chat Integration Test', - description: 'Validates AI service connectivity by sending a test message and measuring response time. Used for integration testing and monitoring.', + description: + 'Validates AI service connectivity by sending a test message and measuring response time. Used for integration testing and monitoring.', }) @ApiBody({ schema: { @@ -96,9 +108,13 @@ export class HealthController { @ApiOperation({ summary: 'MCP Service Health Probe', - description: 'Performs a health check against the MCP (Model Context Protocol) server. Returns connectivity status and response latency.', + description: + 'Performs a health check against the MCP (Model Context Protocol) server. Returns connectivity status and response latency.', + }) + @ApiResponse({ + status: 200, + description: 'MCP service is responding normally', }) - @ApiResponse({ status: 200, description: 'MCP service is responding normally' }) @Get('mcp_ping') mcpPing(): Promise<{ status: string; @@ -112,9 +128,13 @@ export class HealthController { @ApiOperation({ summary: 'AI Service Health Probe', - description: 'Performs a health check against the AI service backend. Returns connectivity status and response latency metrics.', + description: + 'Performs a health check against the AI service backend. Returns connectivity status and response latency metrics.', + }) + @ApiResponse({ + status: 200, + description: 'AI service is responding normally', }) - @ApiResponse({ status: 200, description: 'AI service is responding normally' }) @Get('pingAI') ping(): Promise<{ status: string; @@ -128,9 +148,13 @@ export class HealthController { @ApiOperation({ summary: 'Authentication Error Simulation', - description: 'Test endpoint that simulates an unauthorized access scenario. Used for testing error handling and authentication flows.', + description: + 'Test endpoint that simulates an unauthorized access scenario. Used for testing error handling and authentication flows.', + }) + @ApiResponse({ + status: 401, + description: 'Returns authentication failure response', }) - @ApiResponse({ status: 401, description: 'Returns authentication failure response' }) @Get('unauthorized') unauthorized(): never { throw new UnauthorizedException('JWT token is invalid or expired'); diff --git a/src/modules/onboarding/onboarding.service.ts b/src/modules/onboarding/onboarding.service.ts index a6da4ef9..8ff5670e 100644 --- a/src/modules/onboarding/onboarding.service.ts +++ b/src/modules/onboarding/onboarding.service.ts @@ -15,9 +15,18 @@ import { @Injectable() export class OnboardingService { - AU_ADDR_REGEX = + // More flexible regex patterns for Australian addresses + AU_ADDR_REGEX_STRICT = /^(?[^,]+),\s*(?[^,]+),\s*(?[A-Z]{2,3})\s+(?\d{4})$/; + // Pattern for addresses with state and postcode together: "Street, Suburb, State Postcode" + AU_ADDR_REGEX_FLEXIBLE = + /^(?[^,]+),\s*(?[^,]+),\s*(?[A-Z]{2,3})\s*(?\d{4})$/; + + // Pattern for addresses with state and postcode separated: "Street, Suburb, State, Postcode" + AU_ADDR_REGEX_SEPARATED = + /^(?[^,]+),\s*(?[^,]+),\s*(?[A-Z]{2,3}),\s*(?\d{4})$/; + fieldValidators: Partial< Record< string, @@ -29,12 +38,79 @@ export class OnboardingService { > > = { 'user.address.full': function (answer, update) { - const match = this.AU_ADDR_REGEX.exec(answer.trim()); + const trimmedAnswer = answer.trim(); + + // Try multiple regex patterns + let match = this.AU_ADDR_REGEX_STRICT.exec(trimmedAnswer); if (!match?.groups) { + match = this.AU_ADDR_REGEX_FLEXIBLE.exec(trimmedAnswer); + } + if (!match?.groups) { + match = this.AU_ADDR_REGEX_SEPARATED.exec(trimmedAnswer); + } + + // If regex doesn't match, try to parse manually from comma-separated values + if (!match?.groups) { + const parts = trimmedAnswer + .split(',') + .map(p => p.trim()) + .filter(p => p); + + if (parts.length >= 3) { + // Try to extract state and postcode from the last part + const lastPart = parts[parts.length - 1]; + const statePostcodeMatch = /^([A-Z]{2,3})\s*(\d{4})$/.exec(lastPart); + + if (statePostcodeMatch) { + // Format: "Street, Suburb, State Postcode" (3 parts) or more + // For 3 parts: parts[0] is street, parts[1] is suburb + // For more parts: join all but last 2 as street + const streetAddress = + parts.length === 3 ? parts[0] : parts.slice(0, -2).join(', '); + const suburb = parts[parts.length - 2]; + const state = statePostcodeMatch[1]; + const postcode = statePostcodeMatch[2]; + + update.$set['answers.user.address.streetAddress'] = streetAddress; + update.$set['answers.user.address.suburb'] = suburb; + update.$set['answers.user.address.state'] = state; + update.$set['answers.user.address.postcode'] = postcode; + update.$set['answers.user.address.full'] = answer; + return; + } + + // Try to extract state and postcode separately + if (parts.length >= 4) { + const stateMatch = /^([A-Z]{2,3})$/.exec(parts[parts.length - 2]); + const postcodeMatch = /^(\d{4})$/.exec(parts[parts.length - 1]); + + if (stateMatch && postcodeMatch) { + // Format: "Street, Suburb, State, Postcode" (4 parts) or more + // For 4 parts: parts[0] is street, parts[1] is suburb + // For more parts: join all but last 3 as street + const streetAddress = + parts.length === 4 ? parts[0] : parts.slice(0, -3).join(', '); + const suburb = parts[parts.length - 3]; + const state = stateMatch[1]; + const postcode = postcodeMatch[1]; + + update.$set['answers.user.address.streetAddress'] = streetAddress; + update.$set['answers.user.address.suburb'] = suburb; + update.$set['answers.user.address.state'] = state; + update.$set['answers.user.address.postcode'] = postcode; + update.$set['answers.user.address.full'] = answer; + return; + } + } + } + + // If all parsing attempts fail, throw error throw new BadRequestException( - 'Unable to parse address; please check the format.', + 'Unable to parse address; please check the format. Expected format: "Street Address, Suburb, State Postcode" (e.g., "123 Main St, Sydney, NSW 2000")', ); } + + // Use regex match results update.$set['answers.user.address.streetAddress'] = match.groups.street.trim(); update.$set['answers.user.address.suburb'] = match.groups.suburb.trim(); @@ -98,6 +174,16 @@ export class OnboardingService { nextStep = stepId + 2; // Skip step 5 (custom message input) } + // Special case: if this is the demo step (step 6, field is empty), and user clicks Skip or Demo, + // mark onboarding as completed (nextStep will be beyond the last step) + // Only treat empty field as skip action for the specific demo step (step 6) + const DEMO_STEP_ID = 6; + if (stepId === DEMO_STEP_ID && (!field || field.trim() === '')) { + // This is the demo step (step 6), which is the last step + // After this, onboarding should be completed + nextStep = stepId + 1; // This will be 7, which is beyond the last step (6) + } + const update: UpdateQuery = { $set: { currentStep: nextStep, @@ -110,8 +196,13 @@ export class OnboardingService { const validator = this.fieldValidators[field]; if (validator) { await validator.call(this, answer, update); - } else if (field.trim()) { + } else if (field && field.trim()) { + // Regular field with value - save to answers update.$set[`answers.${field}`] = answer.trim(); + } else { + // Empty field means it's a demo/skip step - just advance to next step + // No need to save anything, just update currentStep + // This is handled by the update.$set.currentStep already set above } await this.sessionModel.updateOne({ userId }, update, { upsert: true }); @@ -122,18 +213,40 @@ export class OnboardingService { !field.includes('address') && !field.includes('greeting') ) { - const [, key] = field.split('.'); - await this.userService.patch(userId, { [key]: answer.trim() }); + const parts = field.split('.'); + if (parts.length >= 2 && parts[1]) { + const key = parts[1]; + await this.userService.patch(userId, { [key]: answer.trim() }); + } } // Handle address update immediately after parsing if (field === 'user.address.full') { + // Ensure all required fields are present after parsing + const streetAddress = update.$set['answers.user.address.streetAddress']; + const suburb = update.$set['answers.user.address.suburb']; + const state = update.$set['answers.user.address.state']; + const postcode = update.$set['answers.user.address.postcode']; + + // Collect missing fields for detailed error message + const missingFields: string[] = []; + if (!streetAddress) missingFields.push('streetAddress'); + if (!suburb) missingFields.push('suburb'); + if (!state) missingFields.push('state'); + if (!postcode) missingFields.push('postcode'); + + if (missingFields.length > 0) { + throw new BadRequestException( + `Address parsing failed: missing required fields - ${missingFields.join(', ')}`, + ); + } + const addressData = { unitAptPOBox: update.$set['answers.user.address.unitAptPOBox'] ?? '', - streetAddress: update.$set['answers.user.address.streetAddress'], - suburb: update.$set['answers.user.address.suburb'], - state: update.$set['answers.user.address.state'], - postcode: update.$set['answers.user.address.postcode'], + streetAddress, + suburb, + state, + postcode, }; await this.userService.patch(userId, { address: addressData }); } diff --git a/src/modules/stripe/stripe.service.ts b/src/modules/stripe/stripe.service.ts index 8d8d68f7..0727ed9f 100644 --- a/src/modules/stripe/stripe.service.ts +++ b/src/modules/stripe/stripe.service.ts @@ -1,9 +1,39 @@ import { Injectable } from '@nestjs/common'; import Stripe from 'stripe'; +import { getAppUrl } from '@/utils/app-config'; + @Injectable() export class StripeService { - private stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? ''); + private stripe: Stripe; + + constructor() { + const stripeKey = process.env.STRIPE_SECRET_KEY ?? ''; + if (stripeKey === '') { + // In development, allow service to start without Stripe credentials + if ( + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === undefined + ) { + // eslint-disable-next-line no-console + console.warn( + '⚠️ Stripe credentials not found. Stripe features will be disabled.', + ); + // Use a dummy key for development (Stripe SDK requires a non-empty string) + this.stripe = new Stripe('sk_test_development_dummy_key_1234567890', { + apiVersion: '2025-08-27.basil', + }); + } else { + throw new Error( + 'Stripe credentials not found in environment variables', + ); + } + } else { + this.stripe = new Stripe(stripeKey, { + apiVersion: '2025-08-27.basil', + }); + } + } get client(): Stripe { return this.stripe; @@ -15,7 +45,7 @@ export class StripeService { planId: string; stripeCustomerId?: string; }): Promise { - const appUrl = process.env.APP_URL ?? 'http://localhost:3000'; + const appUrl = getAppUrl(); const session = await this.stripe.checkout.sessions.create({ mode: 'subscription', payment_method_types: ['card'], @@ -52,7 +82,7 @@ export class StripeService { async createBillingPortalSession(stripeCustomerId: string): Promise { const session = await this.client.billingPortal.sessions.create({ customer: stripeCustomerId, - return_url: process.env.APP_URL ?? 'http://localhost:3000', + return_url: getAppUrl(), }); return session.url; diff --git a/src/utils/app-config.ts b/src/utils/app-config.ts new file mode 100644 index 00000000..00c79a70 --- /dev/null +++ b/src/utils/app-config.ts @@ -0,0 +1,33 @@ +/** + * Get the frontend application URL from environment variables + */ +export function getAppUrl(): string { + const appUrl = process.env.APP_URL; + + if (appUrl) { + return appUrl; + } + + // Only allow localhost fallback in development + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.warn( + '⚠️ Frontend URL not set. Using localhost fallback for development.', + ); + return 'http://localhost:3000'; + } + + // In production, throw error if frontend URL is not configured + throw new Error( + 'Frontend URL is not configured. Please set APP_URL environment variable.', + ); +} + +/** + * Get the CORS origin from environment variables + */ +export function getCorsOrigin(): string | undefined { + const corsOrigin = process.env.CORS_ORIGIN; + + return corsOrigin; +}