Skip to content
Closed

#75 #118

Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ STELLAR_FUNDING_AMOUNT=10
STELLAR_FUNDING_MIN_BALANCE=1
STELLAR_FUNDING_MAX_RETRIES=5
JWT_SECRET=your_jwt_secret
DATABASE_URL=postgresql://user:password@localhost:5432/learnault
DATABASE_URL=postgresql://postgres@localhost:5432/learnault_test
19 changes: 18 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10

services:
postgres:
image: postgres:15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: learnault_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -40,13 +55,15 @@ jobs:
- name: Generate Prisma Client
run: npx prisma generate
env:
DATABASE_URL: "file:./dev.db"
DATABASE_URL: postgresql://postgres:password@127.0.0.1:5432/learnault_test

- name: Lint (ESLint)
run: pnpm run lint

- name: Run tests with coverage
run: pnpm run test:coverage
env:
DATABASE_URL: postgresql://postgres:password@127.0.0.1:5432/learnault_test

- name: Upload coverage (optional)
uses: actions/upload-artifact@v4
Expand Down
76 changes: 39 additions & 37 deletions src/config/logger.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,41 @@
import winston from 'winston'

const isProduction = process.env.NODE_ENV === 'production'

// JSON format for production (better for log aggregation)
const jsonFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
)

// Human-readable format for development
const devFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
let metaStr = ''
if (Object.keys(meta).length > 0) {
metaStr = JSON.stringify(meta, null, 2)
}
import winston from 'winston'

const isProduction = process.env.NODE_ENV === 'production'
const isTest = process.env.NODE_ENV === 'test'

// JSON format for production (better for log aggregation)
const jsonFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
)

// Human-readable format for development
const devFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
let metaStr = ''
if (Object.keys(meta).length > 0) {
metaStr = JSON.stringify(meta, null, 2)
}

return `${timestamp} [${level}]: ${message}${metaStr ? '\n' + metaStr : ''}`
})
)

const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: isProduction ? jsonFormat : devFormat,
transports: [
new winston.transports.Console({
stderrLevels: ['error'],
})
],
// Don't exit on uncaught exceptions - let the process handle it
exitOnError: false,
})

return `${timestamp} [${level}]: ${message}${metaStr ? '\n' + metaStr : ''}`
})
)

const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: isProduction ? jsonFormat : devFormat,
silent: isTest,
transports: [
new winston.transports.Console({
stderrLevels: ['error'],
})
],
// Don't exit on uncaught exceptions - let the process handle it
exitOnError: false,
})

export default logger
132 changes: 34 additions & 98 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import jwt from 'jsonwebtoken'
import prisma from '../config/database'
import { loginSchema, registerSchema, verifyEmailSchema, resendVerificationSchema, forgotPasswordSchema, resetPasswordSchema, otpRequestSchema, otpVerifySchema } from '../schemas/auth.schema'
import { UserRole } from '../types/user.types'

Check warning on line 7 in src/controllers/auth.controller.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, format, test

'UserRole' is defined but never used. Allowed unused vars must match /^I[A-Z]|^_/u
import { emailService } from '../services/email.service'
import { otpService, normalizePhone, OtpPurpose } from '../services/otp.service'
import logger from '../utils/logger'
import { AuthService, UserConflictError, AuthenticationError, AccountStatusError } from '../services/auth.service'

const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret'
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
Expand Down Expand Up @@ -133,70 +134,27 @@
error: 'Validation failed',
details: validation.error.format()
})

return
return
}

const { email, password, username, role } = validation.data

const existingUser = await prisma.user.findFirst({
where: {
OR: [
{ email },
{ username }
]
}
})

if (existingUser) {
res.status(409).json({ error: 'User with this email or username already exists' })

return
}

const salt = await bcrypt.genSalt(10)
const hashedPassword = await bcrypt.hash(password, salt)

const user = await prisma.user.create({
data: {
email,
username,
password: hashedPassword,
role: (role as any) || UserRole.LEARNER,
}
})

// Issue verification token
const { rawToken, tokenHash } = generateVerificationToken()
const expiresAt = new Date(Date.now() + VERIFICATION_TOKEN_EXPIRY_MS)

await prisma.verificationToken.create({
data: {
userId: user.id,
tokenHash,
expiresAt,
}
})

// Queue verification email via outbox
const { subject, body } = buildVerificationEmail(email, rawToken)
emailService.queueEmail(user.id, email, subject, body).catch(err =>
logger.error('[Auth] Failed to queue verification email:', err)
)

const token = this.generateToken(user.id, user.role)
const ipAddress = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim()
|| (req.headers['x-real-ip'] as string)
|| req.socket.remoteAddress
|| 'unknown'
const userAgent = req.headers['user-agent'] || 'unknown'

const result = await AuthService.register(validation.data, ipAddress, userAgent)
res.status(201).json({
message: 'User registered successfully',
token,
user: {
id: user.id,
email: user.email,
username: user.username,
role: user.role
}
...result
})
} catch (error) {
if (error instanceof UserConflictError) {
res.status(409).json({ error: error.message })

return
}
console.error('Registration error:', error)
res.status(500).json({ error: 'Internal server error during registration' })
}
Expand Down Expand Up @@ -474,54 +432,32 @@
error: 'Validation failed',
details: validation.error.format()
})

return
}

const { email, password } = validation.data

const user = await prisma.user.findUnique({
where: { email }
})

if (!user) {
res.status(401).json({ error: 'Invalid credentials' })

return
}

const isMatch = await bcrypt.compare(password, user.password)
if (!isMatch) {
res.status(401).json({ error: 'Invalid credentials' })

return

return
}

const statusError = await this.getAccountStatusError(user)
if (statusError) {
res.status(statusError.statusCode).json(statusError.body)

return
}

await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() }
})

const token = this.generateToken(user.id, user.role)
const ipAddress = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim()
|| (req.headers['x-real-ip'] as string)
|| req.socket.remoteAddress
|| 'unknown'
const userAgent = req.headers['user-agent'] || 'unknown'

const result = await AuthService.login(validation.data, ipAddress, userAgent)
res.status(200).json({
message: 'Login successful',
token,
user: {
id: user.id,
email: user.email,
username: user.username,
role: user.role
}
...result
})
} catch (error) {
if (error instanceof AuthenticationError) {
res.status(401).json({ error: error.message })

return
}
if (error instanceof AccountStatusError) {
res.status(error.statusCode).json(error.body)

return
}
console.error('Login error:', error)
res.status(500).json({ error: 'Internal server error during login' })
}
Expand Down
Loading
Loading