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
29 changes: 22 additions & 7 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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,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)}`,
);
Expand Down
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`);
}
}
133 changes: 123 additions & 10 deletions src/modules/onboarding/onboarding.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,18 @@ import {

@Injectable()
export class OnboardingService {
AU_ADDR_REGEX =
// More flexible regex patterns for Australian addresses
AU_ADDR_REGEX_STRICT =
/^(?<street>[^,]+),\s*(?<suburb>[^,]+),\s*(?<state>[A-Z]{2,3})\s+(?<postcode>\d{4})$/;

// Pattern for addresses with state and postcode together: "Street, Suburb, State Postcode"
AU_ADDR_REGEX_FLEXIBLE =
/^(?<street>[^,]+),\s*(?<suburb>[^,]+),\s*(?<state>[A-Z]{2,3})\s*(?<postcode>\d{4})$/;

// Pattern for addresses with state and postcode separated: "Street, Suburb, State, Postcode"
AU_ADDR_REGEX_SEPARATED =
/^(?<street>[^,]+),\s*(?<suburb>[^,]+),\s*(?<state>[A-Z]{2,3}),\s*(?<postcode>\d{4})$/;

fieldValidators: Partial<
Record<
string,
Expand All @@ -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();
Expand Down Expand Up @@ -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<OnboardingSessionDocument> = {
$set: {
currentStep: nextStep,
Expand All @@ -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 });
Expand All @@ -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 });
}
Expand Down
Loading
Loading