Bluewoo HRMS
Micro-Step Build PlanBuilding BlocksFuture Enhancements

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:

  1. Email Notifications - Workflow alerts for approvals, status changes, sharing
  2. 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 resend

Create 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:3000

Gate

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..." messages

Checkpoint

  • 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 build

Checkpoint

  • 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-log

Checkpoint

  • 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

  1. Email rate limiting - No rate limiting on email sends. For high-volume, add BullMQ queue.
  2. Audit log size - JSON before/after can grow large. Consider retention policy.
  3. No email templates - Using inline HTML. For production, use React Email components.
  4. 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

FeatureRetrofit RiskReason
EmailLowPurely additive - just add hooks to existing methods
Audit LogLowSeparate table, middleware-based, no model changes

Neither requires restructuring existing code. Early data won't have audit history (acceptable for MVP).