Notifications & Audit Logging
Email notifications for workflows and comprehensive audit trail
Building Block #11: Notifications & Audit Logging
Status: 📋 Planned Dependencies: All core phases complete (Phase 01-09) When: Post-MVP (can be added anytime) Context: Solo Developer + AI
Definition
Add two critical infrastructure features:
- Email Notifications - Workflow alerts for approvals, status changes, sharing
- Audit Logging - Track who changed what and when for compliance/accountability
Both are designed to retrofit easily without changing existing data models.
Dependencies
- ✅ Phase 05: Time-Off System (for approval notifications)
- ✅ Phase 06: Document Management (for sharing notifications)
- ✅ Phase 01: Auth (for user email access)
- Optional: Building Block #10 (BullMQ) for queued email sending
Part A: Email Notifications
Technology Stack
- Resend - Modern email API with React Email support
- React Email - Component-based email templates
- No queue needed for MVP (synchronous sending acceptable)
Estimated Time: 4-6 hours
Step 93: Add Resend + EmailService
Input
- Phase 01 complete (user emails available)
- Resend account created
Constraints
- Use Resend SDK (not raw SMTP)
- Environment-based configuration
- Graceful failure (don't break workflows if email fails)
Task
Install dependencies:
cd apps/api
npm install resendCreate apps/api/src/email/email.service.ts:
import { Injectable, Logger } from '@nestjs/common';
import { Resend } from 'resend';
export interface EmailPayload {
to: string;
subject: string;
html: string;
text?: string;
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private readonly resend: Resend;
private readonly from: string;
private readonly enabled: boolean;
constructor() {
const apiKey = process.env.RESEND_API_KEY;
this.from = process.env.EMAIL_FROM || 'noreply@example.com';
this.enabled = !!apiKey;
if (this.enabled) {
this.resend = new Resend(apiKey);
} else {
this.logger.warn('RESEND_API_KEY not set - emails will be logged only');
}
}
async send(payload: EmailPayload): Promise<boolean> {
if (!this.enabled) {
this.logger.log(`[DEV] Would send email to ${payload.to}: ${payload.subject}`);
return true;
}
try {
await this.resend.emails.send({
from: this.from,
to: payload.to,
subject: payload.subject,
html: payload.html,
text: payload.text,
});
this.logger.log(`Email sent to ${payload.to}: ${payload.subject}`);
return true;
} catch (error) {
this.logger.error(`Failed to send email to ${payload.to}`, error);
return false; // Don't throw - email failure shouldn't break workflow
}
}
// Convenience methods for common templates
async sendTimeOffRequestPending(managerEmail: string, data: {
employeeName: string;
dates: string;
policyName: string;
requestId: string;
}) {
return this.send({
to: managerEmail,
subject: `Time-off request from ${data.employeeName}`,
html: `
<h2>New Time-Off Request</h2>
<p><strong>${data.employeeName}</strong> has requested time off.</p>
<p><strong>Type:</strong> ${data.policyName}</p>
<p><strong>Dates:</strong> ${data.dates}</p>
<p><a href="${process.env.APP_URL}/dashboard/time-off/approvals">Review Request</a></p>
`,
});
}
async sendTimeOffApproved(employeeEmail: string, data: {
dates: string;
policyName: string;
approverName: string;
}) {
return this.send({
to: employeeEmail,
subject: `Your time-off request was approved`,
html: `
<h2>Request Approved ✓</h2>
<p>Your <strong>${data.policyName}</strong> request for <strong>${data.dates}</strong> has been approved by ${data.approverName}.</p>
`,
});
}
async sendTimeOffRejected(employeeEmail: string, data: {
dates: string;
policyName: string;
approverName: string;
reason?: string;
}) {
return this.send({
to: employeeEmail,
subject: `Your time-off request was declined`,
html: `
<h2>Request Declined</h2>
<p>Your <strong>${data.policyName}</strong> request for <strong>${data.dates}</strong> was declined by ${data.approverName}.</p>
${data.reason ? `<p><strong>Reason:</strong> ${data.reason}</p>` : ''}
`,
});
}
async sendDocumentShared(recipientEmail: string, data: {
documentName: string;
sharedByName: string;
}) {
return this.send({
to: recipientEmail,
subject: `${data.sharedByName} shared a document with you`,
html: `
<h2>Document Shared</h2>
<p><strong>${data.sharedByName}</strong> shared <strong>${data.documentName}</strong> with you.</p>
<p><a href="${process.env.APP_URL}/dashboard/documents">View Documents</a></p>
`,
});
}
async sendWelcome(userEmail: string, data: {
userName: string;
tenantName: string;
}) {
return this.send({
to: userEmail,
subject: `Welcome to ${data.tenantName}`,
html: `
<h2>Welcome, ${data.userName}!</h2>
<p>Your account has been set up for <strong>${data.tenantName}</strong>.</p>
<p><a href="${process.env.APP_URL}/dashboard">Go to Dashboard</a></p>
`,
});
}
}Create apps/api/src/email/email.module.ts:
import { Module, Global } from '@nestjs/common';
import { EmailService } from './email.service';
@Global() // Make available everywhere without importing
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}Add to apps/api/src/app.module.ts:
import { EmailModule } from './email/email.module';
@Module({
imports: [
// ... existing imports
EmailModule,
],
})
export class AppModule {}Add environment variables to .env.example:
# Email (Resend)
RESEND_API_KEY=re_xxxxx
EMAIL_FROM=noreply@yourapp.com
APP_URL=http://localhost:3000Gate
cd apps/api
npm run build
# Should compile without errors
# Test in development (no API key = logs only)
npm run dev
# Check logs for "[DEV] Would send email..." messagesCheckpoint
- EmailService created with send() method
- Convenience methods for all 5 templates
- Graceful failure (logs error, returns false)
- Works without API key in development
Step 94: Hook Email into TimeOffRequestService
Input
- Step 93 complete
- EmailService available globally
Task
Update apps/api/src/time-off/time-off-request.service.ts:
// Add import
import { EmailService } from '../email/email.service';
import { format } from 'date-fns';
// Add to constructor
constructor(
// ... existing deps
private emailService: EmailService,
) {}
// In create() method, after successful creation:
async create(tenantId: string, employeeId: string, dto: CreateTimeOffRequestDto) {
// ... existing validation and creation logic
const request = await this.requestRepository.create(tenantId, {
...dto,
employeeId,
status: 'PENDING',
});
// Send notification to manager (fire and forget)
if (request.employee.managerId) {
const manager = await this.prisma.employee.findUnique({
where: { id: request.employee.managerId },
select: { email: true },
});
if (manager?.email) {
this.emailService.sendTimeOffRequestPending(manager.email, {
employeeName: `${request.employee.firstName} ${request.employee.lastName}`,
dates: `${format(request.startDate, 'MMM d')} - ${format(request.endDate, 'MMM d, yyyy')}`,
policyName: request.policy.name,
requestId: request.id,
});
}
}
return request;
}
// In approve() method, after successful approval:
async approve(tenantId: string, requestId: string, approverId: string, dto: ApproveRequestDto) {
// ... existing approval logic
// Send notification to employee
const request = await this.findById(tenantId, requestId);
const approver = await this.prisma.employee.findUnique({
where: { id: approverId },
select: { firstName: true, lastName: true },
});
if (dto.status === 'APPROVED') {
this.emailService.sendTimeOffApproved(request.employee.email, {
dates: `${format(request.startDate, 'MMM d')} - ${format(request.endDate, 'MMM d, yyyy')}`,
policyName: request.policy.name,
approverName: `${approver?.firstName} ${approver?.lastName}`,
});
} else if (dto.status === 'REJECTED') {
this.emailService.sendTimeOffRejected(request.employee.email, {
dates: `${format(request.startDate, 'MMM d')} - ${format(request.endDate, 'MMM d, yyyy')}`,
policyName: request.policy.name,
approverName: `${approver?.firstName} ${approver?.lastName}`,
reason: dto.comment,
});
}
return request;
}Gate
# Create a time-off request and check logs for email
# Approve/reject and check logs
npm run buildCheckpoint
- Request creation sends email to manager
- Approval sends email to employee
- Rejection sends email to employee with reason
Step 95: Hook Email into DocumentService
Input
- Step 93 complete
Task
Update document sharing to send notifications:
// In DocumentService, after sharing a document:
async shareWithEmployee(tenantId: string, documentId: string, targetEmployeeId: string, sharedById: string) {
// ... existing sharing logic
const [document, targetEmployee, sharer] = await Promise.all([
this.findById(tenantId, documentId),
this.prisma.employee.findUnique({ where: { id: targetEmployeeId }, select: { email: true } }),
this.prisma.employee.findUnique({ where: { id: sharedById }, select: { firstName: true, lastName: true } }),
]);
if (targetEmployee?.email) {
this.emailService.sendDocumentShared(targetEmployee.email, {
documentName: document.name,
sharedByName: `${sharer?.firstName} ${sharer?.lastName}`,
});
}
return document;
}Checkpoint
- Document sharing sends email to recipient
Part B: Audit Logging
Technology Stack
- Prisma Middleware - Automatic capture of all mutations
- Separate AuditLog table - No changes to existing models
- JSON storage - Before/after state capture
Estimated Time: 6-8 hours
Step 96: Add AuditLog Model
Input
- All core phases complete
Task
Add to packages/database/prisma/schema.prisma:
model AuditLog {
id String @id @default(cuid())
tenantId String
actorId String // User who made the change
action String // CREATE, UPDATE, DELETE
entity String // Employee, TimeOffRequest, Department, etc.
entityId String
before Json? // Previous state (for UPDATE/DELETE)
after Json? // New state (for CREATE/UPDATE)
metadata Json? // Additional context (IP, user agent, etc.)
createdAt DateTime @default(now())
@@index([tenantId, entity, entityId])
@@index([tenantId, actorId])
@@index([tenantId, createdAt])
}Run migration:
cd packages/database
npx prisma migrate dev --name add-audit-logCheckpoint
- AuditLog model added
- Migration successful
- Indexes created
Step 97: Add Prisma Audit Middleware
Input
- Step 96 complete
Constraints
- Must capture current user from request context
- Must not break if context unavailable (e.g., seed scripts)
- Must handle transactions properly
Task
Create apps/api/src/common/audit/audit.middleware.ts:
import { Prisma } from '@prisma/client';
import { ClsService } from 'nestjs-cls';
// Models to audit (exclude AuditLog itself to prevent recursion)
const AUDITED_MODELS = [
'Employee',
'Department',
'Team',
'TimeOffRequest',
'TimeOffPolicy',
'TimeOffBalance',
'Document',
'CustomField',
'Tag',
];
export function createAuditMiddleware(cls: ClsService): Prisma.Middleware {
return async (params, next) => {
// Skip non-audited models
if (!params.model || !AUDITED_MODELS.includes(params.model)) {
return next(params);
}
// Skip read operations
if (!['create', 'update', 'delete', 'updateMany', 'deleteMany'].includes(params.action)) {
return next(params);
}
// Get context from CLS (set by request interceptor)
const tenantId = cls.get('tenantId');
const actorId = cls.get('userId');
// If no context, skip audit (e.g., seed scripts)
if (!tenantId || !actorId) {
return next(params);
}
// Capture "before" state for updates/deletes
let before: any = null;
if (params.action === 'update' || params.action === 'delete') {
try {
before = await (params as any).model.findUnique({
where: params.args.where,
});
} catch {
// Ignore - might not exist
}
}
// Execute the operation
const result = await next(params);
// Create audit log entry (fire and forget)
try {
const prisma = new (await import('@prisma/client')).PrismaClient();
await prisma.auditLog.create({
data: {
tenantId,
actorId,
action: params.action.toUpperCase(),
entity: params.model,
entityId: result?.id || params.args?.where?.id || 'unknown',
before: before ? JSON.parse(JSON.stringify(before)) : null,
after: result ? JSON.parse(JSON.stringify(result)) : null,
},
});
await prisma.$disconnect();
} catch (error) {
// Log but don't fail the original operation
console.error('Audit log failed:', error);
}
return result;
};
}Note: A more production-ready version would use the same Prisma instance and proper async handling. This simplified version demonstrates the pattern.
Alternative: Service-based Approach
For more control, inject audit logging directly into services:
// In any service method:
await this.auditService.log({
tenantId,
actorId: currentUserId,
action: 'UPDATE',
entity: 'Employee',
entityId: employee.id,
before: previousState,
after: newState,
});Checkpoint
- Middleware captures CREATE/UPDATE/DELETE
- Before/after states captured
- Graceful failure (doesn't break operations)
Step 98: Add AuditLogService + Controller
Input
- Step 97 complete
Task
Create apps/api/src/audit/audit.service.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AuditLogService {
constructor(private prisma: PrismaService) {}
async getByEntity(tenantId: string, entity: string, entityId: string) {
return this.prisma.auditLog.findMany({
where: { tenantId, entity, entityId },
orderBy: { createdAt: 'desc' },
});
}
async getByActor(tenantId: string, actorId: string, limit = 50) {
return this.prisma.auditLog.findMany({
where: { tenantId, actorId },
orderBy: { createdAt: 'desc' },
take: limit,
});
}
async getRecent(tenantId: string, options?: {
entity?: string;
limit?: number;
offset?: number;
}) {
return this.prisma.auditLog.findMany({
where: {
tenantId,
...(options?.entity && { entity: options.entity }),
},
orderBy: { createdAt: 'desc' },
take: options?.limit || 50,
skip: options?.offset || 0,
});
}
}Create apps/api/src/audit/audit.controller.ts:
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { TenantGuard } from '../common/guards/tenant.guard';
import { TenantId } from '../common/decorators';
import { AuditLogService } from './audit.service';
@Controller('api/v1/audit')
@UseGuards(TenantGuard)
export class AuditController {
constructor(private auditService: AuditLogService) {}
@Get()
async getRecent(
@TenantId() tenantId: string,
@Query('entity') entity?: string,
@Query('limit') limit?: string,
) {
const logs = await this.auditService.getRecent(tenantId, {
entity,
limit: limit ? parseInt(limit, 10) : 50,
});
return { data: logs, error: null };
}
@Get('entity/:entity/:entityId')
async getByEntity(
@TenantId() tenantId: string,
@Query('entity') entity: string,
@Query('entityId') entityId: string,
) {
const logs = await this.auditService.getByEntity(tenantId, entity, entityId);
return { data: logs, error: null };
}
}Checkpoint
- GET /api/v1/audit returns recent logs
- Filter by entity type works
- Pagination works
Step 99: Add Audit Log Viewer UI (Optional)
Input
- Step 98 complete
Task
Create /dashboard/admin/audit-log page with:
- Table of recent audit entries
- Filter by entity type
- Search by entity ID
- Expandable before/after JSON diff
This is optional for v1 - the API is sufficient for debugging and compliance queries.
Known Limitations
- Email rate limiting - No rate limiting on email sends. For high-volume, add BullMQ queue.
- Audit log size - JSON before/after can grow large. Consider retention policy.
- No email templates - Using inline HTML. For production, use React Email components.
- Sync email sending - Emails are sent synchronously. Add queue for better UX.
Completion Checklist
Part A: Email Notifications
- EmailService with Resend integration
- 5 email templates (request pending, approved, rejected, document shared, welcome)
- Hooked into TimeOffRequestService
- Hooked into DocumentService
- Graceful failure handling
Part B: Audit Logging
- AuditLog model with indexes
- Prisma middleware or service-based logging
- AuditLogService with query methods
- AuditController with API endpoints
- (Optional) Admin UI for viewing logs
Why This is Safe to Defer
| Feature | Retrofit Risk | Reason |
|---|---|---|
| Low | Purely additive - just add hooks to existing methods | |
| Audit Log | Low | Separate table, middleware-based, no model changes |
Neither requires restructuring existing code. Early data won't have audit history (acceptable for MVP).