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
5 changes: 3 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,9 +463,10 @@ X-RateLimit-Reset: 1627583492

You can register webhooks to receive real-time events:

- `user.completed_module`
- `user.registered`
- `module.completed`
- `reward.issued`
- `credential.verified`
- `employer.contact_attempt`

See [Webhook Documentation](./WEBHOOKS.md) for details.

Expand Down
21 changes: 21 additions & 0 deletions integrations/auth.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import prisma from '../src/config/database'
import bcrypt from 'bcryptjs'
// import jwt from 'jsonwebtoken'

const { queueEventMock } = vi.hoisted(() => ({
queueEventMock: vi.fn().mockResolvedValue(undefined),
}))

// Mock dependencies
vi.mock('../src/config/database', () => ({
default: {
Expand All @@ -31,6 +35,14 @@ vi.mock('jsonwebtoken', () => ({
},
}))

vi.mock('../src/services/webhook.service', () => {
class WebhookService {
queueEvent = queueEventMock
}

return { WebhookService }
})

describe('AuthController', () => {
let authController: AuthController
let mockRequest: Partial<Request>
Expand Down Expand Up @@ -70,6 +82,15 @@ describe('AuthController', () => {
message: 'User registered successfully',
token: 'mock_token',
}))
expect(queueEventMock).toHaveBeenCalledWith(
'user.registered',
expect.objectContaining({
userId: '1',
email: 'test@example.com',
username: 'testuser',
role: 'LEARNER',
}),
)
})

it('should return 400 for invalid input', async () => {
Expand Down
91 changes: 91 additions & 0 deletions integrations/unit/module.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Request, Response } from 'express'
import { completeModule } from '../../src/controllers/module.controller'
import { prisma } from '../../src/config/database'

const { queueEventMock } = vi.hoisted(() => ({
queueEventMock: vi.fn().mockResolvedValue(undefined),
}))

const { queueNotificationMock } = vi.hoisted(() => ({
queueNotificationMock: vi.fn().mockResolvedValue(undefined),
}))

vi.mock('../../src/config/database', () => ({
prisma: {
module: {
findUnique: vi.fn(),
},
completion: {
findUnique: vi.fn(),
update: vi.fn(),
},
transaction: {
create: vi.fn(),
},
},
}))

vi.mock('../../src/services/notification.service', () => ({
NotificationService: class {
queueNotification = queueNotificationMock
},
}))

vi.mock('../../src/services/webhook.service', () => ({
WebhookService: class {
queueEvent = queueEventMock
},
}))

function createResponse() {
const response: Partial<Response> = {}
response.status = vi.fn().mockReturnValue(response)
response.json = vi.fn().mockReturnValue(response)

return response as Response
}

describe('ModuleController.completeModule', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('emits module.completed webhook event after a passing quiz', async () => {
;(prisma.module.findUnique as any).mockResolvedValue({
id: 'mod-1',
title: 'Stellar Fundamentals',
reward: 10,
})
;(prisma.completion.findUnique as any).mockResolvedValue({
userId: 'user-1',
moduleId: 'mod-1',
score: -1,
})
const completedAt = new Date('2026-01-01T00:00:00Z')
;(prisma.completion.update as any).mockResolvedValue({ completedAt })
;(prisma.transaction.create as any).mockResolvedValue({ id: 'txn-1' })

const req = {
user: { id: 'user-1' },
params: { id: 'mod-1' },
body: { quizAnswers: [{ questionId: 'q1', answer: 'a' }] },
} as unknown as Request
const res = createResponse()

await completeModule(req, res)

expect(queueEventMock).toHaveBeenCalledWith(
'module.completed',
expect.objectContaining({
userId: 'user-1',
moduleId: 'mod-1',
moduleTitle: 'Stellar Fundamentals',
score: 100,
isEligibleForReward: true,
reward: 10,
completedAt: completedAt.toISOString(),
}),
)
})
})
22 changes: 22 additions & 0 deletions integrations/unit/reward.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import { RewardController } from '../../src/controllers/reward.controller'
import { RewardService } from '../../src/services/reward.service'
import { Request, Response } from 'express'

const { queueEventMock } = vi.hoisted(() => ({
queueEventMock: vi.fn().mockResolvedValue(undefined),
}))

vi.mock('../../src/services/webhook.service', () => {
class WebhookService {
queueEvent = queueEventMock
}

return { WebhookService }
})

// Mock RewardService methods using vi.spyOn
describe('RewardController', () => {
let controller: RewardController
Expand Down Expand Up @@ -337,6 +349,15 @@ describe('RewardController', () => {
message: 'Withdrawal processed successfully',
}),
)
expect(queueEventMock).toHaveBeenCalledWith(
'reward.issued',
expect.objectContaining({
userId: 'user-123',
transactionId: 'txn-withdrawal-123',
amount: 50,
stellarTxHash: 'stellar-hash-xyz',
}),
)
})

it('should reject withdrawal if wallet address is missing', async () => {
Expand Down Expand Up @@ -459,6 +480,7 @@ describe('RewardController', () => {
expect(nextFn).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Stellar network error' }),
)
expect(queueEventMock).not.toHaveBeenCalled()
})
})

Expand Down
12 changes: 12 additions & 0 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import jwt from 'jsonwebtoken'
import prisma from '../config/database'
import { loginSchema, registerSchema } from '../schemas/auth.schema'
import { UserRole } from '../types/user.types'
import { WebhookService } from '../services/webhook.service'

const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret'
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'

const webhookService = new WebhookService()

export class AuthController {
/**
* @openapi
Expand Down Expand Up @@ -83,6 +86,15 @@ export class AuthController {
// Generate token
const token = this.generateToken(user.id, user.role)

// Emit user.registered webhook event (non-blocking)
webhookService.queueEvent('user.registered', {
userId: user.id,
email: user.email,
username: user.username,
role: user.role,
registeredAt: new Date().toISOString(),
}).catch(err => console.error('[Webhook] user.registered error:', err))

res.status(201).json({
message: 'User registered successfully',
token,
Expand Down
3 changes: 3 additions & 0 deletions src/controllers/employer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@ export const contactCandidate = async (req: Request, res: Response) => {
return res.status(404).json({ message: 'Candidate not found' })
}

// Outreach is recorded as an audit trail directly on WebhookDelivery rather than
// routed through WebhookService.queueEvent: the system endpoint's URL is
// internal-only and must never trigger an outbound HMAC-signed HTTP delivery.
const outreachEndpoint = await prisma.webhookEndpoint.upsert({
where: { id: 'system-employer-outreach-log' },
update: {
Expand Down
13 changes: 13 additions & 0 deletions src/controllers/module.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ const COMPLETION_IN_PROGRESS_SCORE = -1
import { z } from 'zod'
import { prisma } from '../config/database'
import { NotificationService } from '../services/notification.service'
import { WebhookService } from '../services/webhook.service'

const notificationService = new NotificationService()
const webhookService = new WebhookService()

// Query parameter schemas for validation
const listModulesSchema = z.object({
Expand Down Expand Up @@ -450,6 +452,17 @@ export const completeModule = async (req: Request, res: Response) => {
: `You scored ${score}% on "${module.title}". Keep practicing to earn rewards!`
).catch(err => console.error('[Notifications] Quiz notification error:', err))

// Emit module.completed webhook event (non-blocking)
webhookService.queueEvent('module.completed', {
userId: req.user.id,
moduleId: id,
moduleTitle: module.title,
score,
isEligibleForReward,
reward: isEligibleForReward ? module.reward : 0,
completedAt: updatedCompletion.completedAt?.toISOString(),
}).catch(err => console.error('[Webhook] module.completed error:', err))

res.json({
message: 'Module completed successfully',
score,
Expand Down
15 changes: 15 additions & 0 deletions src/controllers/reward.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { Request, Response } from 'express'
import { RewardService } from '../services/reward.service'
import { asyncHandler } from '../middleware/error.middleware'
import { BadRequestError } from '../utils/errors'
import { WebhookService } from '../services/webhook.service'

export class RewardController {
private rewardService: RewardService
private webhookService: WebhookService

constructor() {
this.rewardService = new RewardService()
this.webhookService = new WebhookService()
}

/**
Expand Down Expand Up @@ -282,6 +285,18 @@ export class RewardController {
completedAt: result.completedAt?.toISOString(),
},
})

// Emit reward.issued for completed withdrawals (non-blocking, after response sent)
if (result.status === 'completed') {
this.webhookService.queueEvent('reward.issued', {
userId,
transactionId: result.transactionId,
amount: result.amount,
stellarTxHash: result.stellarTxHash,
walletAddress,
issuedAt: result.completedAt?.toISOString() ?? new Date().toISOString(),
}).catch(err => console.error('[Webhook] reward.issued error:', err))
}
},
)

Expand Down
3 changes: 2 additions & 1 deletion src/types/webhook.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ export type WebhookEventType =
| 'module.completed'
| 'reward.issued'
| 'user.registered'
| 'system.test';
| 'system.test'
| 'employer.contact_attempt';

export interface WebhookPayload {
eventId: string;
Expand Down
21 changes: 21 additions & 0 deletions tests/auth.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import prisma from '../src/config/database'
import bcrypt from 'bcryptjs'
// import jwt from 'jsonwebtoken'

const { queueEventMock } = vi.hoisted(() => ({
queueEventMock: vi.fn().mockResolvedValue(undefined),
}))

// Mock dependencies
vi.mock('../src/config/database', () => ({
default: {
Expand All @@ -31,6 +35,14 @@ vi.mock('jsonwebtoken', () => ({
},
}))

vi.mock('../src/services/webhook.service', () => {
class WebhookService {
queueEvent = queueEventMock
}

return { WebhookService }
})

describe('AuthController', () => {
let authController: AuthController
let mockRequest: Partial<Request>
Expand Down Expand Up @@ -70,6 +82,15 @@ describe('AuthController', () => {
message: 'User registered successfully',
token: 'mock_token',
}))
expect(queueEventMock).toHaveBeenCalledWith(
'user.registered',
expect.objectContaining({
userId: '1',
email: 'test@example.com',
username: 'testuser',
role: 'LEARNER',
}),
)
})

it('should return 400 for invalid input', async () => {
Expand Down
Loading
Loading