Skip to content
Closed
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
26 changes: 26 additions & 0 deletions src/lib/twilio/twilio.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never> =>
Promise.reject(new Error('Twilio not configured')),
list: (): Promise<never[]> => Promise.resolve([]),
},
messages: {
create: (): Promise<never> =>
Promise.reject(new Error('Twilio not configured')),
list: (): Promise<never[]> => Promise.resolve([]),
},
};

return mockClient as unknown as Twilio.Twilio;
}
throw new Error(
'Twilio credentials not found in environment variables',
);
Expand Down
3 changes: 2 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const app: INestApplication = await NestFactory.create(AppModule);
app.useLogger(winstonLogger);
Expand All @@ -26,7 +27,7 @@ async function bootstrap(): Promise<void> {
}),
);
app.enableCors({
origin: process.env.CORS_ORIGIN,
origin: getCorsOrigin(),
credentials: true, // Enable cookies in CORS
});

Expand Down
50 changes: 37 additions & 13 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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<string, unknown>;
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
Expand All @@ -208,22 +223,29 @@ 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)}`,
);
}

@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({
Expand Down Expand Up @@ -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' };
}
Expand Down
2 changes: 1 addition & 1 deletion src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import process from 'process';
import {
BadRequestException,
ConflictException,
Expand All @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/modules/auth/dto/reset-password.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ export class ResetPasswordDto {
@IsString()
@MinLength(6)
confirmPassword!: string;
}
}
43 changes: 35 additions & 8 deletions src/modules/auth/strategies/google.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,30 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
private readonly jwtService: JwtService,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
) {
let clientID = configService.get<string>('GOOGLE_CLIENT_ID') ?? '';
let clientSecret = configService.get<string>('GOOGLE_CLIENT_SECRET') ?? '';
let callbackURL = configService.get<string>('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<string>('GOOGLE_CLIENT_ID') ?? '',
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') ?? '',
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL') ?? '',
clientID,
clientSecret,
callbackURL,
scope: ['email', 'profile'],
} as StrategyOptions);
}
Expand All @@ -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<void> {
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({
Expand Down
2 changes: 1 addition & 1 deletion src/modules/company/schema/company.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class Company {
user!: User;
@Prop()
calendar_access_token?: string;

_id!: Types.ObjectId;
}

Expand Down
4 changes: 3 additions & 1 deletion src/modules/google-calendar/calendar-oauth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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`);
}
}
48 changes: 36 additions & 12 deletions src/modules/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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: {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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');
Expand Down
Loading
Loading