Skip to content
Merged
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
66 changes: 55 additions & 11 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,47 @@ import * as bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { SessionService } from '../session/session.service';
import { TransactionService } from '../common/database/transaction.service';
import { UserRole } from '../users/entities/user.entity';

interface JwtTokenPayload {
sub: string;
email: string;
role: UserRole;
sid: string;
}

interface AuthTokens {
accessToken: string;
refreshToken: string;
}

interface AuthUserResponse {
id: string;
email: string;
firstName: string;
lastName: string;
role: UserRole;
isEmailVerified: boolean;
}

interface RegisterResponse {
user: AuthUserResponse;
accessToken: string;
refreshToken: string;
message: string;
}

interface LoginResponse {
user: AuthUserResponse;
accessToken: string;
refreshToken: string;
}

interface TokenUser {
id: string;
email: string;
role: UserRole;
}

@Injectable()
export class AuthService {
Expand All @@ -18,7 +59,7 @@ export class AuthService {
private readonly transactionService: TransactionService,
) {}

async register(registerDto: RegisterDto) {
async register(registerDto: RegisterDto): Promise<RegisterResponse> {
return await this.transactionService.runInTransaction(async (_manager) => {
// Create user
const user = await this.usersService.create(registerDto);
Expand Down Expand Up @@ -59,7 +100,7 @@ export class AuthService {
});
}

async login(loginDto: LoginDto) {
async login(loginDto: LoginDto): Promise<LoginResponse> {
// Find user
const user = await this.usersService.findByEmail(loginDto.email);
if (!user) {
Expand Down Expand Up @@ -101,7 +142,7 @@ export class AuthService {
};
}

async refreshToken(refreshToken: string) {
async refreshToken(refreshToken: string): Promise<AuthTokens> {
try {
// Verify refresh token
const payload = this.jwtService.verify(refreshToken, {
Expand Down Expand Up @@ -148,7 +189,7 @@ export class AuthService {
}
}

async logout(userId: string, sessionId?: string) {
async logout(userId: string, sessionId?: string): Promise<{ message: string }> {
await this.sessionService.withLock(`logout:${userId}`, async () => {
if (sessionId) {
await this.sessionService.removeSession(sessionId);
Expand All @@ -159,7 +200,7 @@ export class AuthService {
return { message: 'Logout successful' };
}

async forgotPassword(email: string) {
async forgotPassword(email: string): Promise<{ message: string }> {
const user = await this.usersService.findByEmail(email);
if (!user) {
// Don't reveal if user exists
Expand All @@ -178,7 +219,7 @@ export class AuthService {
return { message: 'If the email exists, a password reset link has been sent.' };
}

async resetPassword(resetPasswordDto: ResetPasswordDto) {
async resetPassword(resetPasswordDto: ResetPasswordDto): Promise<{ message: string }> {
// Find user by reset token
const user = await this.usersService.findByPasswordResetToken(resetPasswordDto.token);

Expand All @@ -200,7 +241,10 @@ export class AuthService {
return { message: 'Password has been reset successfully' };
}

async changePassword(userId: string, changePasswordDto: ChangePasswordDto) {
async changePassword(
userId: string,
changePasswordDto: ChangePasswordDto,
): Promise<{ message: string }> {
const user = await this.usersService.findOne(userId);

// Verify current password
Expand All @@ -215,7 +259,7 @@ export class AuthService {
return { message: 'Password changed successfully' };
}

async verifyEmail(token: string) {
async verifyEmail(token: string): Promise<{ message: string }> {
// Find user by verification token
const user = await this.usersService.findByEmailVerificationToken(token);

Expand All @@ -229,16 +273,16 @@ export class AuthService {
}

// Update user as verified
await this.usersService.update(user.id, { isEmailVerified: true } as any);
await this.usersService.update(user.id, { isEmailVerified: true });

// Clear verification token
await this.usersService.updateEmailVerificationToken(user.id, null, null);

return { message: 'Email verified successfully' };
}

private async generateTokens(user: any, sessionId: string) {
const payload = {
private async generateTokens(user: TokenUser, sessionId: string): Promise<AuthTokens> {
const payload: JwtTokenPayload = {
sub: user.id,
email: user.email,
role: user.role,
Expand Down
21 changes: 18 additions & 3 deletions src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserRole } from '../users/entities/user.entity';

interface JwtPayload {
sub: string;
email: string;
role: UserRole;
sid: string;
}

interface ValidatedUser {
sub: string;
email: string;
role: UserRole;
sid: string;
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
secretOrKey: process.env.JWT_SECRET || 'your-secret-key',
});
}

async validate(payload: any) {
return { sub: payload.sub, email: payload.email };
async validate(payload: JwtPayload): Promise<ValidatedUser> {
return { sub: payload.sub, email: payload.email, role: payload.role, sid: payload.sid };
}
}
8 changes: 4 additions & 4 deletions src/common/database/transaction.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class TransactionService {
metrics.endTime = endTime;
metrics.duration = duration;
metrics.status = 'ROLLED_BACK';
metrics.error = error.message;
metrics.error = error instanceof Error ? error.message : String(error);

this.logger.error(`Transaction ${transactionId} rolled back after ${duration}ms:`, error);
throw error;
Expand Down Expand Up @@ -132,7 +132,7 @@ export class TransactionService {
maxRetries: number = 3,
retryDelay: number = 1000,
): Promise<T> {
let lastError: Error;
let lastError: Error | undefined;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
Expand Down Expand Up @@ -196,7 +196,7 @@ export class TransactionService {
/**
* Check if error is retryable
*/
private isRetryableError(error: any): boolean {
private isRetryableError(error: unknown): boolean {
const retryableErrors = [
'deadlock',
'serialization failure',
Expand All @@ -205,7 +205,7 @@ export class TransactionService {
'connection',
];

const errorMessage = error.message?.toLowerCase() || '';
const errorMessage = (error instanceof Error ? error.message : String(error)).toLowerCase();
return retryableErrors.some((msg) => errorMessage.includes(msg));
}

Expand Down
2 changes: 1 addition & 1 deletion src/payments/dto/create-payment.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ export class CreatePaymentDto {
provider?: string = 'stripe';

@IsOptional()
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
}
14 changes: 4 additions & 10 deletions src/payments/dto/create-subscription.dto.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,20 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { PaymentMethod } from '../entities/payment.entity';

export enum SubscriptionInterval {
MONTHLY = 'monthly',
YEARLY = 'yearly',
QUARTERLY = 'quarterly',
WEEKLY = 'weekly',
}
import { SubscriptionInterval } from '../entities/subscription.entity';

export class CreateSubscriptionDto {
@IsString()
courseId: string;

@IsString()
@IsEnum(SubscriptionInterval)
interval: SubscriptionInterval;

@IsEnum(PaymentMethod)
provider: string;
provider: PaymentMethod;

@IsString()
priceId: string;

@IsOptional()
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
}
14 changes: 11 additions & 3 deletions src/payments/entities/invoice.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import {
import { Payment } from './payment.entity';
import { User } from '../../users/entities/user.entity';

export enum InvoiceStatus {
DRAFT = 'draft',
SENT = 'sent',
PAID = 'paid',
OVERDUE = 'overdue',
CANCELLED = 'cancelled',
}

interface InvoiceItem {
description: string;
amount: number;
Expand Down Expand Up @@ -42,10 +50,10 @@ export class Invoice {

@Column({
type: 'enum',
enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled'],
default: 'draft',
enum: InvoiceStatus,
default: InvoiceStatus.DRAFT,
})
status: string;
status: InvoiceStatus;

@Column({ type: 'date', nullable: true })
issuedDate: Date;
Expand Down
2 changes: 1 addition & 1 deletion src/payments/entities/subscription.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class Subscription {
@Column({ type: 'timestamp', nullable: true })
currentPeriodEnd: Date;

@Column({ type: 'timestamp', nullable: true })
@Column({ type: 'boolean', default: false })
cancelAtPeriodEnd: boolean;

@Column({ type: 'timestamp', nullable: true })
Expand Down
61 changes: 61 additions & 0 deletions src/payments/interfaces/payment-provider.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export interface PaymentIntentResult {
paymentIntentId: string;
clientSecret: string;
requiresAction: boolean;
}

export interface RefundResult {
refundId: string;
status: string;
}

export interface PaymentMetadata {
userId: string;
courseId: string;
[key: string]: string | number | boolean;
}

export interface PaymentProvider {
createPaymentIntent(
amount: number,
currency: string,
metadata: PaymentMetadata,
): Promise<PaymentIntentResult>;
refundPayment(paymentId: string, amount?: number): Promise<RefundResult>;
handleWebhook(
payload: Record<string, unknown>,
signature: string,
): Promise<Record<string, unknown>>;
}

export interface SubscriptionWebhookEvent {
data: {
object: {
id: string;
status: string;
};
};
}

export interface RefundWebhookData {
id: string;
amount: number;
}

export interface CreatePaymentIntentResult {
paymentId: string;
clientSecret: string;
requiresAction: boolean;
}

export interface CreateSubscriptionResult {
subscriptionId: string;
status: string;
currentPeriodEnd: Date;
}

export interface ProcessRefundResult {
refundId: string;
status: string;
amount: number;
}
3 changes: 2 additions & 1 deletion src/payments/payments.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CreatePaymentDto } from './dto/create-payment.dto';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { expectNotFound, expectUnauthorized, expectValidationFailure } from '../../test/utils';
import { UserRole } from '../users/entities/user.entity';

describe('PaymentsController', () => {
let controller: PaymentsController;
Expand All @@ -13,7 +14,7 @@ describe('PaymentsController', () => {
getInvoice: jest.Mock;
};

const request = { user: { id: 'user-1' } };
const request = { user: { id: 'user-1', email: 'test@example.com', role: UserRole.STUDENT } };
const createPaymentDto: CreatePaymentDto = {
courseId: 'course-1',
amount: 120,
Expand Down
Loading
Loading