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;
Comment on lines +28 to +41
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock Twilio client provides incomplete type coverage. The Twilio.Twilio type includes many more properties and methods beyond just calls and messages (e.g., accounts, addresses, applications, etc.). Code that attempts to use other Twilio features will fail at runtime even though TypeScript won't catch it due to the type assertion. Consider either implementing a more complete mock object or using a proper mocking library, or at minimum document this limitation clearly.

Copilot uses AI. Check for mistakes.
}
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)}`,
);
Comment on lines 227 to 229
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSRF token is being included in both an httpOnly cookie (line 217) and as a query parameter in the redirect URL (line 228). This creates a security issue: the CSRF token should not be exposed in the URL where it could be logged in browser history, server logs, or referrer headers. The httpOnly cookie approach is secure, but including it in the URL query parameter defeats this protection. Consider removing the csrfToken from the query parameters and relying solely on the cookie-based approach.

Copilot uses AI. Check for mistakes.
}

@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;
}
Comment on lines +64 to +68
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic incorrectly rejects profiles where name.givenName or name.familyName is missing. The check on line 65 verifies that profile.name exists, but doesn't account for the fact that name.givenName and name.familyName are optional fields (as indicated by the type definition). However, lines 75-76 correctly handle these optional fields with nullish coalescing operators. The validation should only verify that profile.name exists, not require both givenName and familyName to be present. Consider changing the validation to check only for emails, since names can legitimately be missing from some Google profiles.

Copilot uses AI. Check for mistakes.

// 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