Bluewoo HRMS
Micro-Step Build PlanBuilding Blocks

Phase 06: Document Management

Document management with visibility levels, access control, and Google Cloud Storage integration

Phase 06: Document Management

Goal: Build a document management system with upload to Google Cloud Storage, visibility levels (private, team, department, company), fine-grained access control, and sharing capabilities.

AttributeValue
Steps93-108
Estimated Time10-14 hours
DependenciesPhase 05 complete (TanStack Query, API helper, CurrentUser decorator available)
Completion GateUsers can upload documents, set visibility, share with specific users/teams, access control enforced

Step Timing Estimates

StepTaskEst. Time
93Add DocumentCategory enum10 min
94Add DocumentVisibility enum10 min
95Add AccessType enum10 min
96Add DocumentPermission, DocumentStatus enums10 min
97Add Document model20 min
98Add DocumentAccess model15 min
99Run migration15 min
100Setup GCS configuration30 min
101Create upload service35 min
102Create DocumentRepository30 min
103Create DocumentAccessService35 min
104Create DocumentService40 min
105Create DocumentController35 min
106Create document upload page40 min
107Create document list page35 min
108Create share document modal30 min

Phase Context (READ FIRST)

What This Phase Accomplishes

  • Document upload to Google Cloud Storage
  • Signed URL generation for secure downloads
  • Visibility levels: PRIVATE, TEAM, DEPARTMENT, MANAGERS, COMPANY, CUSTOM
  • Fine-grained access control with VIEW, DOWNLOAD, EDIT permissions
  • Sharing with specific users, teams, or departments
  • Optional expiration dates on shared access
  • Document categories for organization

What This Phase Does NOT Include

  • Document versioning - single version only
  • Full-text search - future enhancement with AI
  • Document preview/thumbnails - download only
  • Folder/hierarchy structure - flat organization with categories
  • Document editing - view/download only
  • Collaborative editing - not in scope

Visibility Rules

VisibilityWho Can VIEWWho Can DOWNLOAD/EDIT
PRIVATEOwner onlyOwner only
TEAMOwner's teammatesRequires explicit DocumentAccess grant
DEPARTMENTOwner's departmentRequires explicit DocumentAccess grant
MANAGERSAll managers in tenantRequires explicit DocumentAccess grant
COMPANYEveryone in tenantRequires explicit DocumentAccess grant
CUSTOMExplicit grants onlyExplicit grants only

Note: Visibility grants VIEW access. DOWNLOAD/EDIT always require explicit DocumentAccess grants.

Bluewoo Anti-Pattern Reminder

This phase intentionally has NO:

  • Complex permission engines (Casbin, CASL) - simple access matrix
  • Separate file service microservice - integrated into main API
  • S3-compatible abstraction layers - direct GCS SDK usage
  • Document workflow/approval - simple CRUD
  • Real-time collaboration - static documents

If the AI suggests adding any of these, REJECT and continue with the spec.


Step 93: Add DocumentCategory Enum

Input

  • Phase 05 complete
  • Prisma schema at packages/database/prisma/schema.prisma

Constraints

  • DO NOT modify existing models
  • ONLY add the enum definition
  • Use exact values from database-schema.mdx

Task

Add to packages/database/prisma/schema.prisma:

// ==========================================
// DOCUMENT ENUMS
// ==========================================

enum DocumentCategory {
  POLICY
  HANDBOOK
  CONTRACT
  PERFORMANCE
  TRAINING
  CERTIFICATE
  PERSONAL
  OTHER
}

Gate

cd packages/database
npx prisma format
# Should complete without errors

cat prisma/schema.prisma | grep -A 12 "enum DocumentCategory"
# Should show all enum values

Common Errors

ErrorCauseFix
Enum already existsDuplicate definitionRemove duplicate
Invalid enum valueTypo in valueCheck spelling matches spec

Rollback

# Remove the enum block from schema.prisma

Lock

packages/database/prisma/schema.prisma (DocumentCategory enum)

Checkpoint

  • DocumentCategory enum added
  • prisma format succeeds

Step 94: Add DocumentVisibility Enum

Input

  • Step 93 complete
  • DocumentCategory enum exists

Constraints

  • DO NOT modify DocumentCategory enum
  • ONLY add DocumentVisibility enum

Task

Add to packages/database/prisma/schema.prisma (after DocumentCategory):

enum DocumentVisibility {
  PRIVATE      // Only document owner
  TEAM         // Owner's team(s)
  DEPARTMENT   // Owner's department(s)
  MANAGERS     // All employees with manager role
  COMPANY      // All employees in tenant
  CUSTOM       // Use DocumentAccess table for explicit grants
}

Gate

cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 8 "enum DocumentVisibility"

Rollback

# Remove DocumentVisibility enum block

Lock

packages/database/prisma/schema.prisma (DocumentVisibility enum)

Checkpoint

  • DocumentVisibility enum added
  • prisma format succeeds

Step 95: Add AccessType Enum

Input

  • Step 94 complete

Constraints

  • ONLY add AccessType enum

Task

Add to packages/database/prisma/schema.prisma:

enum AccessType {
  USER
  TEAM
  DEPARTMENT
  ROLE
}

Gate

cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 6 "enum AccessType"

Rollback

# Remove AccessType enum block

Lock

packages/database/prisma/schema.prisma (AccessType enum)

Checkpoint

  • AccessType enum added
  • prisma format succeeds

Step 96: Add DocumentPermission and DocumentStatus Enums

Input

  • Step 95 complete

Constraints

  • ONLY add the two enums
  • DocumentStatus is required for soft delete support

Task

Add to packages/database/prisma/schema.prisma:

enum DocumentPermission {
  VIEW
  DOWNLOAD
  EDIT
}

enum DocumentStatus {
  ACTIVE
  ARCHIVED
  DELETED
}

Gate

cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 5 "enum DocumentPermission"
cat prisma/schema.prisma | grep -A 5 "enum DocumentStatus"

Rollback

# Remove both enum blocks

Lock

packages/database/prisma/schema.prisma (DocumentPermission, DocumentStatus enums)

Checkpoint

  • DocumentPermission enum added
  • DocumentStatus enum added
  • prisma format succeeds

Step 97: Add Document Model

Input

  • Steps 93-96 complete (all document enums exist)

Constraints

  • Create Document model WITHOUT DocumentAccess relation first
  • Will add relation in Step 98 after DocumentAccess exists
  • Include tenantId for multi-tenant isolation

Task

Add to packages/database/prisma/schema.prisma:

// ==========================================
// DOCUMENT MODELS
// ==========================================

model Document {
  id           String             @id @default(cuid())
  tenantId     String
  employeeId   String?            // Optional link to employee record
  uploadedById String             // Who uploaded
  title        String
  description  String?
  category     DocumentCategory
  fileUrl      String             // GCS URL
  fileName     String             // Original filename
  mimeType     String
  fileSize     Int                // Bytes
  visibility   DocumentVisibility @default(PRIVATE)
  vectorId     String?            // For future AI search
  indexed      Boolean            @default(false)
  status       DocumentStatus     @default(ACTIVE)
  createdAt    DateTime           @default(now())
  updatedAt    DateTime           @updatedAt

  tenant       Tenant             @relation(fields: [tenantId], references: [id], onDelete: Cascade)

  @@index([tenantId, category])
  @@index([tenantId, visibility])
  @@index([tenantId, uploadedById])
  @@map("documents")
}

Also update the Tenant model to add the relation:

// Add to Tenant model
documents Document[]

Gate

cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 25 "model Document {"

Rollback

# Remove Document model and Tenant relation

Lock

packages/database/prisma/schema.prisma (Document model)

Checkpoint

  • Document model added with all fields
  • Tenant relation added
  • prisma format succeeds

Step 98: Add DocumentAccess Model

Input

  • Step 97 complete
  • Document model exists

Constraints

  • Add DocumentAccess model
  • Add relation arrays to Document model

Task

Add to packages/database/prisma/schema.prisma:

model DocumentAccess {
  id         String             @id @default(cuid())
  documentId String
  accessType AccessType
  targetId   String?            // userId, teamId, departmentId, or roleId
  permission DocumentPermission @default(VIEW)
  grantedBy  String
  grantedAt  DateTime           @default(now())
  expiresAt  DateTime?

  document   Document           @relation(fields: [documentId], references: [id], onDelete: Cascade)

  @@index([documentId])
  @@index([targetId])
  @@map("document_access")
}

Update the Document model to add the relation array:

// Add to Document model (after tenant relation)
accessGrants DocumentAccess[]

Gate

cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 15 "model DocumentAccess {"
cat prisma/schema.prisma | grep "accessGrants"

Rollback

# Remove DocumentAccess model and accessGrants relation

Lock

packages/database/prisma/schema.prisma (DocumentAccess model)

Checkpoint

  • DocumentAccess model added
  • accessGrants relation added to Document
  • prisma format succeeds

Step 99: Run Migration

Input

  • Steps 97-98 complete
  • All document models defined

Constraints

  • Migration name must be descriptive
  • Verify tables created correctly

Task

cd packages/database
npx prisma migrate dev --name add_document_management

Gate

cd packages/database
npx prisma studio
# Open browser, verify Document and DocumentAccess tables exist

# Alternative: check migration file exists
ls -la prisma/migrations/*add_document_management*

Common Errors

ErrorCauseFix
Foreign key constraint failedMissing Tenant relationAdd documents to Tenant model
Enum not foundEnum not definedAdd missing enum from Steps 93-96
Column already existsDuplicate migrationReset and re-run migration

Rollback

cd packages/database
npx prisma migrate reset
# Then re-run migrations

Lock

packages/database/prisma/migrations/*add_document_management*

Checkpoint

  • Migration completed successfully
  • Document table exists
  • DocumentAccess table exists
  • Prisma Client regenerated

Step 100: Setup GCS Configuration

Input

  • Step 99 complete
  • Database tables exist

Constraints

  • Use Google Cloud Storage (not S3)
  • Configuration via environment variables
  • Support for development (local) and production (cloud)

Task

First, install the GCS SDK:

cd apps/api
npm install @google-cloud/storage

Create environment variables in .env.example and .env:

# apps/api/.env
GCS_PROJECT_ID=your-project-id
GCS_BUCKET_NAME=hrms-documents
GCS_KEY_FILE=./gcs-service-account.json
# For local development, you can use a service account key file
# For production, use workload identity or default credentials

Create the storage configuration:

// apps/api/src/documents/storage.config.ts
import { Storage } from '@google-cloud/storage';

export interface StorageConfig {
  projectId: string;
  bucketName: string;
  keyFilename?: string;
}

export function getStorageConfig(): StorageConfig {
  return {
    projectId: process.env.GCS_PROJECT_ID || '',
    bucketName: process.env.GCS_BUCKET_NAME || 'hrms-documents',
    keyFilename: process.env.GCS_KEY_FILE,
  };
}

export function createStorageClient(): Storage {
  const config = getStorageConfig();

  if (config.keyFilename) {
    return new Storage({
      projectId: config.projectId,
      keyFilename: config.keyFilename,
    });
  }

  // Use default credentials (for Cloud Run, GKE, etc.)
  return new Storage({
    projectId: config.projectId,
  });
}

Create the module directories:

mkdir -p apps/api/src/documents/repositories
mkdir -p apps/api/src/documents/services
mkdir -p apps/api/src/documents/controllers
mkdir -p apps/api/src/documents/dto

Gate

cd apps/api
cat src/documents/storage.config.ts
# Should show storage configuration

npx tsc --noEmit
# Should compile without errors

Common Errors

ErrorCauseFix
Cannot find module '@google-cloud/storage'Package not installedRun npm install @google-cloud/storage
Authentication failedInvalid service accountCheck GCS_KEY_FILE path and permissions

Rollback

rm -rf apps/api/src/documents
npm uninstall @google-cloud/storage

Lock

apps/api/src/documents/storage.config.ts

Checkpoint

  • @google-cloud/storage installed
  • storage.config.ts created
  • Environment variables documented
  • Directory structure created

Step 101: Create Upload Service

Input

  • Step 100 complete
  • GCS configuration exists

Constraints

  • Upload files to GCS with unique names
  • Generate signed URLs for downloads
  • Support file size limits
  • Validate mime types

Task

Create apps/api/src/documents/services/upload.service.ts:

import { Injectable, BadRequestException } from '@nestjs/common';
import { createStorageClient, getStorageConfig } from '../storage.config';
import { v4 as uuidv4 } from 'uuid';

const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const ALLOWED_MIME_TYPES = [
  'application/pdf',
  'application/msword',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  'application/vnd.ms-excel',
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  'image/png',
  'image/jpeg',
  'text/plain',
];

export interface UploadResult {
  fileUrl: string;
  fileName: string;
  mimeType: string;
  fileSize: number;
}

@Injectable()
export class UploadService {
  private storage = createStorageClient();
  private config = getStorageConfig();

  async uploadFile(
    file: Express.Multer.File,
    tenantId: string,
  ): Promise<UploadResult> {
    // Validate file size
    if (file.size > MAX_FILE_SIZE) {
      throw new BadRequestException(
        `File size exceeds maximum allowed (${MAX_FILE_SIZE / 1024 / 1024}MB)`,
      );
    }

    // Validate mime type
    if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
      throw new BadRequestException(
        `File type not allowed. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}`,
      );
    }

    // Generate unique filename
    const ext = file.originalname.split('.').pop();
    const uniqueName = `${tenantId}/${uuidv4()}.${ext}`;

    const bucket = this.storage.bucket(this.config.bucketName);
    const blob = bucket.file(uniqueName);

    // Upload to GCS
    await blob.save(file.buffer, {
      metadata: {
        contentType: file.mimetype,
        metadata: {
          originalName: file.originalname,
          tenantId,
        },
      },
    });

    // Get the public URL (or use signed URLs for private access)
    const fileUrl = `gs://${this.config.bucketName}/${uniqueName}`;

    return {
      fileUrl,
      fileName: file.originalname,
      mimeType: file.mimetype,
      fileSize: file.size,
    };
  }

  async getSignedUrl(fileUrl: string, expiresInMinutes = 60): Promise<string> {
    // Extract path from gs:// URL
    const path = fileUrl.replace(`gs://${this.config.bucketName}/`, '');

    const bucket = this.storage.bucket(this.config.bucketName);
    const blob = bucket.file(path);

    const [signedUrl] = await blob.getSignedUrl({
      version: 'v4',
      action: 'read',
      expires: Date.now() + expiresInMinutes * 60 * 1000,
    });

    return signedUrl;
  }

  async deleteFile(fileUrl: string): Promise<void> {
    const path = fileUrl.replace(`gs://${this.config.bucketName}/`, '');

    const bucket = this.storage.bucket(this.config.bucketName);
    const blob = bucket.file(path);

    await blob.delete();
  }
}

Install uuid if not already installed:

cd apps/api
npm install uuid
npm install -D @types/uuid

Gate

cd apps/api
cat src/documents/services/upload.service.ts
npx tsc --noEmit
# Should compile without errors

Common Errors

ErrorCauseFix
Cannot find module 'uuid'Package not installedRun npm install uuid
Property 'buffer' does not existMulter types missingInstall @types/multer

Rollback

rm apps/api/src/documents/services/upload.service.ts

Lock

apps/api/src/documents/services/upload.service.ts

Checkpoint

  • UploadService created
  • File validation implemented
  • Signed URL generation works
  • TypeScript compiles

Step 102: Create DocumentRepository

Input

  • Step 101 complete
  • Document model exists in database

Constraints

  • All queries must filter by tenantId
  • Support visibility-based filtering
  • Use Prisma for database operations

Task

Create apps/api/src/documents/repositories/document.repository.ts:

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import {
  Prisma,
  Document,
  DocumentCategory,
  DocumentVisibility,
  DocumentStatus,
} from '@prisma/client';

export interface DocumentWithAccess extends Document {
  accessGrants?: {
    id: string;
    accessType: string;
    targetId: string | null;
    permission: string;
    expiresAt: Date | null;
  }[];
}

export interface DocumentQueryOptions {
  category?: DocumentCategory;
  visibility?: DocumentVisibility;
  status?: DocumentStatus;
  uploadedById?: string;
  search?: string;
  page?: number;
  limit?: number;
}

@Injectable()
export class DocumentRepository {
  constructor(private prisma: PrismaService) {}

  async create(
    data: Prisma.DocumentCreateInput,
  ): Promise<Document> {
    return this.prisma.document.create({ data });
  }

  async findById(
    tenantId: string,
    id: string,
  ): Promise<DocumentWithAccess | null> {
    return this.prisma.document.findFirst({
      where: {
        id,
        tenantId,
        status: { not: 'DELETED' },
      },
      include: {
        accessGrants: {
          where: {
            OR: [
              { expiresAt: null },
              { expiresAt: { gt: new Date() } },
            ],
          },
        },
      },
    });
  }

  async findByTenant(
    tenantId: string,
    options: DocumentQueryOptions = {},
  ): Promise<{ items: Document[]; total: number }> {
    const {
      category,
      visibility,
      status = 'ACTIVE',
      uploadedById,
      search,
      page = 1,
      limit = 20,
    } = options;

    const where: Prisma.DocumentWhereInput = {
      tenantId,
      status,
      ...(category && { category }),
      ...(visibility && { visibility }),
      ...(uploadedById && { uploadedById }),
      ...(search && {
        OR: [
          { title: { contains: search, mode: 'insensitive' } },
          { description: { contains: search, mode: 'insensitive' } },
          { fileName: { contains: search, mode: 'insensitive' } },
        ],
      }),
    };

    const [items, total] = await Promise.all([
      this.prisma.document.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      this.prisma.document.count({ where }),
    ]);

    return { items, total };
  }

  async findAccessibleByUser(
    tenantId: string,
    userId: string,
    userTeamIds: string[],
    userDepartmentIds: string[],
    userRoleIds: string[],
    isManager: boolean,
    options: DocumentQueryOptions = {},
  ): Promise<{ items: Document[]; total: number }> {
    const {
      category,
      search,
      page = 1,
      limit = 20,
    } = options;

    // Pre-fetch dependent data (can't await inside array literal)
    const teamUploaderIds = userTeamIds.length > 0
      ? await this.getUploadersInTeams(tenantId, userTeamIds)
      : [];
    const deptUploaderIds = userDepartmentIds.length > 0
      ? await this.getUploadersInDepartments(tenantId, userDepartmentIds)
      : [];

    // Build visibility conditions
    const visibilityConditions: Prisma.DocumentWhereInput[] = [
      // Documents uploaded by the user
      { uploadedById: userId },
      // COMPANY visibility - all can see
      { visibility: 'COMPANY' },
      // MANAGERS visibility - if user is a manager
      ...(isManager ? [{ visibility: 'MANAGERS' as DocumentVisibility }] : []),
      // TEAM visibility - if user is in the same team as uploader
      ...(teamUploaderIds.length > 0 ? [{
        visibility: 'TEAM' as DocumentVisibility,
        uploadedById: { in: teamUploaderIds },
      }] : []),
      // DEPARTMENT visibility - if user is in the same department as uploader
      ...(deptUploaderIds.length > 0 ? [{
        visibility: 'DEPARTMENT' as DocumentVisibility,
        uploadedById: { in: deptUploaderIds },
      }] : []),
      // CUSTOM visibility - check DocumentAccess grants
      {
        visibility: 'CUSTOM',
        accessGrants: {
          some: {
            AND: [
              {
                OR: [
                  { accessType: 'USER', targetId: userId },
                  { accessType: 'TEAM', targetId: { in: userTeamIds } },
                  { accessType: 'DEPARTMENT', targetId: { in: userDepartmentIds } },
                  { accessType: 'ROLE', targetId: { in: userRoleIds } },
                ],
              },
              {
                OR: [
                  { expiresAt: null },
                  { expiresAt: { gt: new Date() } },
                ],
              },
            ],
          },
        },
      },
    ];

    // Build the main where clause
    // Note: When using OR for visibility AND search, we need to structure carefully
    const baseWhere: Prisma.DocumentWhereInput = {
      tenantId,
      status: 'ACTIVE',
      OR: visibilityConditions,
      ...(category && { category }),
    };

    // If search is provided, add it as an AND condition
    const where: Prisma.DocumentWhereInput = search
      ? {
          AND: [
            baseWhere,
            {
              OR: [
                { title: { contains: search, mode: 'insensitive' } },
                { description: { contains: search, mode: 'insensitive' } },
              ],
            },
          ],
        }
      : baseWhere;

    const [items, total] = await Promise.all([
      this.prisma.document.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      this.prisma.document.count({ where }),
    ]);

    return { items, total };
  }

  private async getUploadersInTeams(
    tenantId: string,
    teamIds: string[],
  ): Promise<string[]> {
    // Find all employees in the given teams, return their associated user IDs
    // Note: This assumes Employee has a relation to User via some identifier
    // The uploadedById in Document should match the authenticated user's ID
    const teamMembers = await this.prisma.employeeTeam.findMany({
      where: { teamId: { in: teamIds } },
      select: {
        employee: {
          select: { id: true, email: true }
        }
      },
    });
    // Return employee IDs as uploader identifiers
    // In production, this should map to the user ID from your auth system
    return teamMembers.map((tm) => tm.employee.id);
  }

  private async getUploadersInDepartments(
    tenantId: string,
    departmentIds: string[],
  ): Promise<string[]> {
    const deptMembers = await this.prisma.employeeDepartment.findMany({
      where: { departmentId: { in: departmentIds } },
      select: {
        employee: {
          select: { id: true, email: true }
        }
      },
    });
    // Return employee IDs as uploader identifiers
    return deptMembers.map((dm) => dm.employee.id);
  }

  async update(
    tenantId: string,
    id: string,
    data: Prisma.DocumentUpdateInput,
  ): Promise<Document> {
    // Use updateMany to enforce tenant isolation
    const result = await this.prisma.document.updateMany({
      where: { id, tenantId },
      data,
    });

    if (result.count === 0) {
      throw new NotFoundException(`Document ${id} not found`);
    }

    return this.findById(tenantId, id);
  }

  async softDelete(tenantId: string, id: string): Promise<void> {
    // Use updateMany to enforce tenant isolation
    const result = await this.prisma.document.updateMany({
      where: { id, tenantId },
      data: { status: 'DELETED' },
    });

    if (result.count === 0) {
      throw new NotFoundException(`Document ${id} not found`);
    }
  }
}

Gate

cd apps/api
cat src/documents/repositories/document.repository.ts
npx tsc --noEmit

Rollback

rm apps/api/src/documents/repositories/document.repository.ts

Lock

apps/api/src/documents/repositories/document.repository.ts

Checkpoint

  • DocumentRepository created
  • Tenant isolation implemented
  • Visibility-based queries work
  • TypeScript compiles

Step 103: Create DocumentAccessService

Input

  • Step 102 complete
  • DocumentRepository exists

Constraints

  • Check permissions based on visibility and access grants
  • Support VIEW, DOWNLOAD, EDIT permission levels
  • Handle expired access grants

Task

Create apps/api/src/documents/services/document-access.service.ts:

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import {
  Document,
  DocumentAccess,
  DocumentPermission,
  DocumentVisibility,
  AccessType,
} from '@prisma/client';

export interface UserContext {
  userId: string;
  employeeId: string;
  teamIds: string[];
  departmentIds: string[];
  roleIds: string[];
  isManager: boolean;
}

@Injectable()
export class DocumentAccessService {
  constructor(private prisma: PrismaService) {}

  async canView(
    document: Document,
    user: UserContext,
  ): Promise<boolean> {
    return this.checkPermission(document, user, 'VIEW');
  }

  async canDownload(
    document: Document,
    user: UserContext,
  ): Promise<boolean> {
    return this.checkPermission(document, user, 'DOWNLOAD');
  }

  async canEdit(
    document: Document,
    user: UserContext,
  ): Promise<boolean> {
    return this.checkPermission(document, user, 'EDIT');
  }

  async canDelete(
    document: Document,
    user: UserContext,
  ): Promise<boolean> {
    // Only the uploader can delete
    return document.uploadedById === user.userId;
  }

  async canShare(
    document: Document,
    user: UserContext,
  ): Promise<boolean> {
    // Only the uploader or users with EDIT permission can share
    if (document.uploadedById === user.userId) {
      return true;
    }
    return this.checkPermission(document, user, 'EDIT');
  }

  private async checkPermission(
    document: Document,
    user: UserContext,
    requiredPermission: DocumentPermission,
  ): Promise<boolean> {
    // Owner always has all permissions
    if (document.uploadedById === user.userId) {
      return true;
    }

    // Check visibility-based access
    const hasVisibilityAccess = await this.checkVisibilityAccess(
      document,
      user,
    );

    if (hasVisibilityAccess) {
      // Visibility-based access grants VIEW permission by default
      // DOWNLOAD and EDIT require explicit grants for non-owners
      if (requiredPermission === 'VIEW') {
        return true;
      }
    }

    // Check explicit access grants (for CUSTOM visibility or higher permissions)
    if (document.visibility === 'CUSTOM' || requiredPermission !== 'VIEW') {
      return this.checkExplicitAccess(document.id, user, requiredPermission);
    }

    return hasVisibilityAccess;
  }

  private async checkVisibilityAccess(
    document: Document,
    user: UserContext,
  ): Promise<boolean> {
    switch (document.visibility) {
      case 'PRIVATE':
        return false; // Only owner, handled above

      case 'COMPANY':
        return true; // All employees in tenant can view

      case 'MANAGERS':
        return user.isManager;

      case 'TEAM':
        // Check if user is in any of the uploader's teams
        return this.isInSameTeam(document.uploadedById, user.teamIds);

      case 'DEPARTMENT':
        // Check if user is in any of the uploader's departments
        return this.isInSameDepartment(document.uploadedById, user.departmentIds);

      case 'CUSTOM':
        return false; // Must check explicit grants

      default:
        return false;
    }
  }

  private async isInSameTeam(
    uploaderId: string,
    userTeamIds: string[],
  ): Promise<boolean> {
    if (userTeamIds.length === 0) return false;

    // uploaderId is the authenticated user's ID (from session)
    // We need to find if the uploader's employee record shares a team with the current user
    const uploaderTeams = await this.prisma.employeeTeam.findMany({
      where: {
        employee: { id: uploaderId }, // Using employee ID as uploader identifier
        teamId: { in: userTeamIds },
      },
    });

    return uploaderTeams.length > 0;
  }

  private async isInSameDepartment(
    uploaderId: string,
    userDepartmentIds: string[],
  ): Promise<boolean> {
    if (userDepartmentIds.length === 0) return false;

    const uploaderDepts = await this.prisma.employeeDepartment.findMany({
      where: {
        employee: { id: uploaderId }, // Using employee ID as uploader identifier
        departmentId: { in: userDepartmentIds },
      },
    });

    return uploaderDepts.length > 0;
  }

  private async checkExplicitAccess(
    documentId: string,
    user: UserContext,
    requiredPermission: DocumentPermission,
  ): Promise<boolean> {
    const permissionHierarchy: DocumentPermission[] = ['VIEW', 'DOWNLOAD', 'EDIT'];
    const requiredIndex = permissionHierarchy.indexOf(requiredPermission);

    const grants = await this.prisma.documentAccess.findMany({
      where: {
        documentId,
        AND: [
          {
            OR: [
              { accessType: 'USER', targetId: user.userId },
              { accessType: 'TEAM', targetId: { in: user.teamIds } },
              { accessType: 'DEPARTMENT', targetId: { in: user.departmentIds } },
              { accessType: 'ROLE', targetId: { in: user.roleIds } },
            ],
          },
          {
            OR: [
              { expiresAt: null },
              { expiresAt: { gt: new Date() } },
            ],
          },
        ],
      },
    });

    // Check if any grant meets the required permission level
    return grants.some((grant) => {
      const grantIndex = permissionHierarchy.indexOf(grant.permission);
      return grantIndex >= requiredIndex;
    });
  }

  async grantAccess(
    documentId: string,
    accessType: AccessType,
    targetId: string,
    permission: DocumentPermission,
    grantedBy: string,
    expiresAt?: Date,
  ): Promise<DocumentAccess> {
    // Check if grant already exists
    const existing = await this.prisma.documentAccess.findFirst({
      where: {
        documentId,
        accessType,
        targetId,
      },
    });

    if (existing) {
      // Update existing grant
      return this.prisma.documentAccess.update({
        where: { id: existing.id },
        data: { permission, expiresAt },
      });
    }

    // Create new grant
    return this.prisma.documentAccess.create({
      data: {
        documentId,
        accessType,
        targetId,
        permission,
        grantedBy,
        expiresAt,
      },
    });
  }

  async revokeAccess(
    documentId: string,
    accessType: AccessType,
    targetId: string,
  ): Promise<void> {
    await this.prisma.documentAccess.deleteMany({
      where: {
        documentId,
        accessType,
        targetId,
      },
    });
  }

  async getAccessGrants(documentId: string): Promise<DocumentAccess[]> {
    return this.prisma.documentAccess.findMany({
      where: {
        documentId,
        OR: [
          { expiresAt: null },
          { expiresAt: { gt: new Date() } },
        ],
      },
    });
  }
}

Gate

cd apps/api
cat src/documents/services/document-access.service.ts
npx tsc --noEmit

Rollback

rm apps/api/src/documents/services/document-access.service.ts

Lock

apps/api/src/documents/services/document-access.service.ts

Checkpoint

  • DocumentAccessService created
  • Permission checking implemented
  • Grant/revoke access works
  • TypeScript compiles

Step 104: Create DocumentService

Input

  • Steps 101-103 complete
  • UploadService, DocumentRepository, DocumentAccessService exist

Constraints

  • Combine all document operations
  • Enforce access control on all operations
  • Support CRUD operations

Task

Create apps/api/src/documents/services/document.service.ts:

import {
  Injectable,
  NotFoundException,
  ForbiddenException,
} from '@nestjs/common';
import { DocumentRepository } from '../repositories/document.repository';
import { DocumentAccessService, UserContext } from './document-access.service';
import { UploadService } from './upload.service';
import {
  Document,
  DocumentCategory,
  DocumentVisibility,
  DocumentPermission,
  AccessType,
} from '@prisma/client';

export interface CreateDocumentDto {
  title: string;
  description?: string;
  category: DocumentCategory;
  visibility: DocumentVisibility;
  employeeId?: string;
}

export interface UpdateDocumentDto {
  title?: string;
  description?: string;
  category?: DocumentCategory;
  visibility?: DocumentVisibility;
}

export interface ShareDocumentDto {
  accessType: AccessType;
  targetId: string;
  permission: DocumentPermission;
  expiresAt?: Date;
}

@Injectable()
export class DocumentService {
  constructor(
    private documentRepository: DocumentRepository,
    private accessService: DocumentAccessService,
    private uploadService: UploadService,
  ) {}

  async upload(
    tenantId: string,
    employeeId: string,  // Must be Employee.id, not User.id (for org lookups)
    file: Express.Multer.File,
    dto: CreateDocumentDto,
  ): Promise<Document> {
    // Upload file to GCS
    const uploadResult = await this.uploadService.uploadFile(file, tenantId);

    // Create document record
    // Note: uploadedById stores Employee.id for TEAM/DEPARTMENT visibility checks
    return this.documentRepository.create({
      tenant: { connect: { id: tenantId } },
      uploadedById: employeeId,
      title: dto.title,
      description: dto.description,
      category: dto.category,
      visibility: dto.visibility,
      employeeId: dto.employeeId,
      fileUrl: uploadResult.fileUrl,
      fileName: uploadResult.fileName,
      mimeType: uploadResult.mimeType,
      fileSize: uploadResult.fileSize,
    });
  }

  async findById(
    tenantId: string,
    id: string,
    user: UserContext,
  ): Promise<Document> {
    const document = await this.documentRepository.findById(tenantId, id);

    if (!document) {
      throw new NotFoundException('Document not found');
    }

    const canView = await this.accessService.canView(document, user);
    if (!canView) {
      throw new ForbiddenException('You do not have access to this document');
    }

    return document;
  }

  async findAccessible(
    tenantId: string,
    user: UserContext,
    options: {
      category?: DocumentCategory;
      search?: string;
      page?: number;
      limit?: number;
    } = {},
  ): Promise<{ items: Document[]; total: number; page: number; limit: number; totalPages: number }> {
    const { page = 1, limit = 20 } = options;

    const result = await this.documentRepository.findAccessibleByUser(
      tenantId,
      user.userId,
      user.teamIds,
      user.departmentIds,
      user.roleIds,
      user.isManager,
      options,
    );

    return {
      ...result,
      page,
      limit,
      totalPages: Math.ceil(result.total / limit),
    };
  }

  async findMyDocuments(
    tenantId: string,
    userId: string,
    options: {
      category?: DocumentCategory;
      search?: string;
      page?: number;
      limit?: number;
    } = {},
  ): Promise<{ items: Document[]; total: number; page: number; limit: number; totalPages: number }> {
    const { page = 1, limit = 20 } = options;

    const result = await this.documentRepository.findByTenant(tenantId, {
      ...options,
      uploadedById: userId,
    });

    return {
      ...result,
      page,
      limit,
      totalPages: Math.ceil(result.total / limit),
    };
  }

  async getDownloadUrl(
    tenantId: string,
    id: string,
    user: UserContext,
  ): Promise<string> {
    const document = await this.documentRepository.findById(tenantId, id);

    if (!document) {
      throw new NotFoundException('Document not found');
    }

    const canDownload = await this.accessService.canDownload(document, user);
    if (!canDownload) {
      throw new ForbiddenException('You do not have download access to this document');
    }

    return this.uploadService.getSignedUrl(document.fileUrl);
  }

  async update(
    tenantId: string,
    id: string,
    user: UserContext,
    dto: UpdateDocumentDto,
  ): Promise<Document> {
    const document = await this.documentRepository.findById(tenantId, id);

    if (!document) {
      throw new NotFoundException('Document not found');
    }

    const canEdit = await this.accessService.canEdit(document, user);
    if (!canEdit) {
      throw new ForbiddenException('You do not have edit access to this document');
    }

    return this.documentRepository.update(tenantId, id, dto);
  }

  async delete(
    tenantId: string,
    id: string,
    user: UserContext,
  ): Promise<void> {
    const document = await this.documentRepository.findById(tenantId, id);

    if (!document) {
      throw new NotFoundException('Document not found');
    }

    const canDelete = await this.accessService.canDelete(document, user);
    if (!canDelete) {
      throw new ForbiddenException('You do not have permission to delete this document');
    }

    // Soft delete
    await this.documentRepository.softDelete(tenantId, id);
  }

  async share(
    tenantId: string,
    id: string,
    user: UserContext,
    dto: ShareDocumentDto,
  ): Promise<void> {
    const document = await this.documentRepository.findById(tenantId, id);

    if (!document) {
      throw new NotFoundException('Document not found');
    }

    const canShare = await this.accessService.canShare(document, user);
    if (!canShare) {
      throw new ForbiddenException('You do not have permission to share this document');
    }

    // If sharing with CUSTOM visibility, update document visibility
    if (document.visibility !== 'CUSTOM') {
      await this.documentRepository.update(tenantId, id, {
        visibility: 'CUSTOM',
      });
    }

    await this.accessService.grantAccess(
      id,
      dto.accessType,
      dto.targetId,
      dto.permission,
      user.userId,
      dto.expiresAt,
    );
  }

  async unshare(
    tenantId: string,
    id: string,
    user: UserContext,
    accessType: AccessType,
    targetId: string,
  ): Promise<void> {
    const document = await this.documentRepository.findById(tenantId, id);

    if (!document) {
      throw new NotFoundException('Document not found');
    }

    const canShare = await this.accessService.canShare(document, user);
    if (!canShare) {
      throw new ForbiddenException('You do not have permission to modify sharing');
    }

    await this.accessService.revokeAccess(id, accessType, targetId);
  }

  async getSharing(
    tenantId: string,
    id: string,
    user: UserContext,
  ): Promise<{ accessGrants: any[] }> {
    const document = await this.documentRepository.findById(tenantId, id);

    if (!document) {
      throw new NotFoundException('Document not found');
    }

    const canShare = await this.accessService.canShare(document, user);
    if (!canShare) {
      throw new ForbiddenException('You do not have permission to view sharing settings');
    }

    const accessGrants = await this.accessService.getAccessGrants(id);
    return { accessGrants };
  }
}

Gate

cd apps/api
cat src/documents/services/document.service.ts
npx tsc --noEmit

Rollback

rm apps/api/src/documents/services/document.service.ts

Lock

apps/api/src/documents/services/document.service.ts

Checkpoint

  • DocumentService created
  • All CRUD operations implemented
  • Access control enforced
  • Sharing functionality works
  • TypeScript compiles

Step 105: Create DocumentController

Input

  • Step 104 complete
  • DocumentService exists

Constraints

  • Use TenantId decorator for tenant isolation
  • Use CurrentUser decorator for user context
  • Support file uploads with Multer

Task

First, create DTOs in apps/api/src/documents/dto/:

// apps/api/src/documents/dto/create-document.dto.ts
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { DocumentCategory, DocumentVisibility } from '@prisma/client';

export class CreateDocumentDto {
  @IsString()
  title: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsEnum(DocumentCategory)
  category: DocumentCategory;

  @IsEnum(DocumentVisibility)
  visibility: DocumentVisibility;

  @IsOptional()
  @IsString()
  employeeId?: string;
}
// apps/api/src/documents/dto/update-document.dto.ts
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { DocumentCategory, DocumentVisibility } from '@prisma/client';

export class UpdateDocumentDto {
  @IsOptional()
  @IsString()
  title?: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsEnum(DocumentCategory)
  category?: DocumentCategory;

  @IsOptional()
  @IsEnum(DocumentVisibility)
  visibility?: DocumentVisibility;
}
// apps/api/src/documents/dto/share-document.dto.ts
import { IsString, IsOptional, IsEnum, IsDateString } from 'class-validator';
import { AccessType, DocumentPermission } from '@prisma/client';

export class ShareDocumentDto {
  @IsEnum(AccessType)
  accessType: AccessType;

  @IsString()
  targetId: string;

  @IsEnum(DocumentPermission)
  permission: DocumentPermission;

  @IsOptional()
  @IsDateString()
  expiresAt?: string;
}

First, create the module in apps/api/src/documents/documents.module.ts:

// apps/api/src/documents/documents.module.ts
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { PrismaModule } from '../prisma/prisma.module';
import { DocumentRepository } from './repositories/document.repository';
import { UploadService } from './services/upload.service';
import { DocumentAccessService } from './services/document-access.service';
import { DocumentService } from './services/document.service';
import { DocumentController } from './controllers/document.controller';

@Module({
  imports: [
    PrismaModule,
    MulterModule.register({
      storage: memoryStorage(),
      limits: {
        fileSize: 50 * 1024 * 1024, // 50MB
      },
    }),
  ],
  controllers: [DocumentController],
  providers: [
    DocumentRepository,
    UploadService,
    DocumentAccessService,
    DocumentService,
  ],
  exports: [DocumentService],
})
export class DocumentsModule {}

Register the module in apps/api/src/app.module.ts:

// Add to imports in app.module.ts
import { DocumentsModule } from './documents/documents.module';

@Module({
  imports: [
    // ... existing modules
    DocumentsModule,
  ],
})
export class AppModule {}

Create the controller:

// apps/api/src/documents/controllers/document.controller.ts
import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  UseGuards,
  UseInterceptors,
  UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { TenantGuard } from '../../tenant/tenant.guard';
import { TenantId } from '../../tenant/tenant.decorator';
import { CurrentUser } from '../../auth/current-user.decorator';
import { DocumentService } from '../services/document.service';
import { CreateDocumentDto } from '../dto/create-document.dto';
import { UpdateDocumentDto } from '../dto/update-document.dto';
import { ShareDocumentDto } from '../dto/share-document.dto';
import { DocumentCategory, AccessType } from '@prisma/client';

@Controller('api/v1/documents')
@UseGuards(TenantGuard)
export class DocumentController {
  constructor(private documentService: DocumentService) {}

  @Post()
  @UseInterceptors(FileInterceptor('file'))
  async upload(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @UploadedFile() file: Express.Multer.File,
    @Body() dto: CreateDocumentDto,
  ) {
    // Pass employeeId (not userId) for TEAM/DEPARTMENT visibility checks
    const document = await this.documentService.upload(
      tenantId,
      user.employeeId,
      file,
      dto,
    );
    return { data: document, error: null };
  }

  @Get()
  async list(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @Query('category') category?: DocumentCategory,
    @Query('search') search?: string,
    @Query('page') page?: string,
    @Query('limit') limit?: string,
  ) {
    const userContext = await this.buildUserContext(user);
    const result = await this.documentService.findAccessible(
      tenantId,
      userContext,
      {
        category,
        search,
        page: page ? parseInt(page, 10) : 1,
        limit: limit ? parseInt(limit, 10) : 20,
      },
    );
    return { data: result, error: null };
  }

  @Get('my')
  async myDocuments(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @Query('category') category?: DocumentCategory,
    @Query('search') search?: string,
    @Query('page') page?: string,
    @Query('limit') limit?: string,
  ) {
    // Use employeeId to match uploadedById in documents
    const result = await this.documentService.findMyDocuments(
      tenantId,
      user.employeeId,
      {
        category,
        search,
        page: page ? parseInt(page, 10) : 1,
        limit: limit ? parseInt(limit, 10) : 20,
      },
    );
    return { data: result, error: null };
  }

  @Get(':id')
  async findOne(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @Param('id') id: string,
  ) {
    const userContext = await this.buildUserContext(user);
    const document = await this.documentService.findById(
      tenantId,
      id,
      userContext,
    );
    return { data: document, error: null };
  }

  @Get(':id/download')
  async download(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @Param('id') id: string,
  ) {
    const userContext = await this.buildUserContext(user);
    const url = await this.documentService.getDownloadUrl(
      tenantId,
      id,
      userContext,
    );
    return { data: { url }, error: null };
  }

  @Patch(':id')
  async update(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @Param('id') id: string,
    @Body() dto: UpdateDocumentDto,
  ) {
    const userContext = await this.buildUserContext(user);
    const document = await this.documentService.update(
      tenantId,
      id,
      userContext,
      dto,
    );
    return { data: document, error: null };
  }

  @Delete(':id')
  async delete(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @Param('id') id: string,
  ) {
    const userContext = await this.buildUserContext(user);
    await this.documentService.delete(tenantId, id, userContext);
    return { data: { success: true }, error: null };
  }

  @Post(':id/share')
  async share(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @Param('id') id: string,
    @Body() dto: ShareDocumentDto,
  ) {
    const userContext = await this.buildUserContext(user);
    await this.documentService.share(tenantId, id, userContext, {
      ...dto,
      expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined,
    });
    return { data: { success: true }, error: null };
  }

  @Delete(':id/share/:accessType/:targetId')
  async unshare(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @Param('id') id: string,
    @Param('accessType') accessType: AccessType,
    @Param('targetId') targetId: string,
  ) {
    const userContext = await this.buildUserContext(user);
    await this.documentService.unshare(
      tenantId,
      id,
      userContext,
      accessType,
      targetId,
    );
    return { data: { success: true }, error: null };
  }

  @Get(':id/sharing')
  async getSharing(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @Param('id') id: string,
  ) {
    const userContext = await this.buildUserContext(user);
    const sharing = await this.documentService.getSharing(
      tenantId,
      id,
      userContext,
    );
    return { data: sharing, error: null };
  }

  private async buildUserContext(user: any) {
    // MVP: Trust JWT claims for team/department membership
    // PRODUCTION NOTE: JWT claims are set at login and may become stale if user
    // is reassigned to different teams/departments. For production, consider:
    // - Fetching fresh membership from EmployeeTeam/EmployeeDepartment tables
    // - Using short-lived tokens with refresh
    // - Invalidating tokens on org changes
    return {
      userId: user.userId,
      employeeId: user.employeeId,
      teamIds: user.teamIds || [],
      departmentIds: user.departmentIds || [],
      roleIds: user.roleIds || [],
      isManager: user.isManager || false,
    };
  }
}

Note: The CurrentUser decorator was created in Phase 01 (Step 23). Import it from ../../auth/current-user.decorator. The TenantGuard and TenantId decorator are from the tenant module created in Phase 01.

Install required packages:

cd apps/api
npm install @nestjs/platform-express
npm install -D @types/multer

Gate

cd apps/api
cat src/documents/documents.module.ts
cat src/documents/controllers/document.controller.ts
npx tsc --noEmit

Common Errors

ErrorCauseFix
Cannot find CurrentUser decoratorWrong import pathImport from ../../auth/current-user.decorator
Cannot find TenantGuardWrong import pathImport from ../../tenant/tenant.guard
Cannot find TenantIdWrong import pathImport from ../../tenant/tenant.decorator
FileInterceptor not foundMissing platform-expressInstall @nestjs/platform-express
Module not registeredMissing from AppModuleAdd DocumentsModule to app.module.ts imports

Rollback

rm -rf apps/api/src/documents/controllers
rm -rf apps/api/src/documents/dto
rm apps/api/src/documents/documents.module.ts
# Also remove DocumentsModule from app.module.ts imports

Lock

apps/api/src/documents/documents.module.ts
apps/api/src/documents/controllers/document.controller.ts
apps/api/src/documents/dto/*

Checkpoint

  • DocumentsModule created
  • DocumentsModule registered in AppModule
  • DocumentController created with correct route prefix (api/v1/documents)
  • All DTOs created
  • Multer configured for memory storage
  • File upload endpoint works
  • All CRUD endpoints implemented
  • Sharing endpoints implemented
  • TypeScript compiles

Step 106: Create Document Upload Page

Input

  • Step 105 complete
  • API endpoints available

Constraints

  • Use Shadcn UI components
  • Use react-hook-form with zod validation
  • Support drag-and-drop file upload
  • Show upload progress

Task

First, update apps/web/lib/api.ts to support FormData uploads:

// Update the post method in apps/web/lib/api.ts to handle FormData
async post<T = unknown>(path: string, body: unknown): Promise<{ data: T; error: null }> {
  const tenantId = await getTenantId();

  const headers: Record<string, string> = {
    'x-tenant-id': tenantId,
  };

  let processedBody: string | FormData;

  if (body instanceof FormData) {
    // Don't set Content-Type for FormData - browser sets it with boundary
    processedBody = body;
  } else {
    headers['Content-Type'] = 'application/json';
    processedBody = JSON.stringify(body);
  }

  const res = await fetch(`${API_URL}/api/v1${path}`, {
    method: 'POST',
    headers,
    body: processedBody,
  });

  if (!res.ok) {
    throw new Error(`API request failed: ${res.status}`);
  }

  return res.json();
}

Create the API queries in apps/web/lib/queries/documents.ts:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api';

// Define types locally instead of importing from @prisma/client
// This ensures the frontend doesn't depend on Prisma directly

export type DocumentCategory =
  | 'POLICY'
  | 'HANDBOOK'
  | 'CONTRACT'
  | 'PERFORMANCE'
  | 'TRAINING'
  | 'CERTIFICATE'
  | 'PERSONAL'
  | 'OTHER';

export type DocumentVisibility =
  | 'PRIVATE'
  | 'TEAM'
  | 'DEPARTMENT'
  | 'MANAGERS'
  | 'COMPANY'
  | 'CUSTOM';

export type AccessType = 'USER' | 'TEAM' | 'DEPARTMENT' | 'ROLE';

export type DocumentPermission = 'VIEW' | 'DOWNLOAD' | 'EDIT';

export type DocumentStatus = 'ACTIVE' | 'ARCHIVED' | 'DELETED';

export interface Document {
  id: string;
  tenantId: string;
  employeeId: string | null;
  uploadedById: string;
  title: string;
  description: string | null;
  category: DocumentCategory;
  fileUrl: string;
  fileName: string;
  mimeType: string;
  fileSize: number;
  visibility: DocumentVisibility;
  vectorId: string | null;
  indexed: boolean;
  status: DocumentStatus;
  createdAt: string;
  updatedAt: string;
}

export interface DocumentAccess {
  id: string;
  documentId: string;
  accessType: AccessType;
  targetId: string | null;
  permission: DocumentPermission;
  grantedBy: string;
  grantedAt: string;
  expiresAt: string | null;
}

interface DocumentListResponse {
  items: Document[];
  total: number;
  page: number;
  limit: number;
  totalPages: number;
}

export function useDocuments(options?: {
  category?: DocumentCategory;
  search?: string;
  page?: number;
  limit?: number;
}) {
  return useQuery({
    queryKey: ['documents', options],
    queryFn: async (): Promise<DocumentListResponse> => {
      const params = new URLSearchParams();
      if (options?.category) params.set('category', options.category);
      if (options?.search) params.set('search', options.search);
      if (options?.page) params.set('page', options.page.toString());
      if (options?.limit) params.set('limit', options.limit.toString());
      const response = await api.get<DocumentListResponse>(
        `/documents?${params}`,
      );
      return response.data;
    },
  });
}

export function useMyDocuments(options?: {
  category?: DocumentCategory;
  search?: string;
  page?: number;
  limit?: number;
}) {
  return useQuery({
    queryKey: ['my-documents', options],
    queryFn: async (): Promise<DocumentListResponse> => {
      const params = new URLSearchParams();
      if (options?.category) params.set('category', options.category);
      if (options?.search) params.set('search', options.search);
      if (options?.page) params.set('page', options.page.toString());
      if (options?.limit) params.set('limit', options.limit.toString());
      const response = await api.get<DocumentListResponse>(
        `/documents/my?${params}`,
      );
      return response.data;
    },
  });
}

export function useDocument(id: string) {
  return useQuery({
    queryKey: ['document', id],
    queryFn: async (): Promise<Document> => {
      const response = await api.get<Document>(`/documents/${id}`);
      return response.data;
    },
    enabled: !!id,
  });
}

export function useUploadDocument() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: {
      file: File;
      title: string;
      description?: string;
      category: DocumentCategory;
      visibility: DocumentVisibility;
      employeeId?: string;
    }) => {
      const formData = new FormData();
      formData.append('file', data.file);
      formData.append('title', data.title);
      if (data.description) formData.append('description', data.description);
      formData.append('category', data.category);
      formData.append('visibility', data.visibility);
      if (data.employeeId) formData.append('employeeId', data.employeeId);

      const response = await api.post<Document>('/documents', formData);
      return response.data;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['documents'] });
      queryClient.invalidateQueries({ queryKey: ['my-documents'] });
    },
  });
}

export function useUpdateDocument() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({
      id,
      data,
    }: {
      id: string;
      data: {
        title?: string;
        description?: string;
        category?: DocumentCategory;
        visibility?: DocumentVisibility;
      };
    }) => {
      const response = await api.patch<Document>(`/documents/${id}`, data);
      return response.data;
    },
    onSuccess: (_, { id }) => {
      queryClient.invalidateQueries({ queryKey: ['documents'] });
      queryClient.invalidateQueries({ queryKey: ['my-documents'] });
      queryClient.invalidateQueries({ queryKey: ['document', id] });
    },
  });
}

export function useDeleteDocument() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (id: string) => {
      await api.delete(`/documents/${id}`);
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['documents'] });
      queryClient.invalidateQueries({ queryKey: ['my-documents'] });
    },
  });
}

export function useDownloadDocument() {
  return useMutation({
    mutationFn: async (id: string) => {
      const response = await api.get<{ url: string }>(
        `/documents/${id}/download`,
      );
      return response.data.url;
    },
  });
}

export function useShareDocument() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({
      id,
      data,
    }: {
      id: string;
      data: {
        accessType: AccessType;
        targetId: string;
        permission: DocumentPermission;
        expiresAt?: string;
      };
    }) => {
      await api.post(`/documents/${id}/share`, data);
    },
    onSuccess: (_, { id }) => {
      queryClient.invalidateQueries({ queryKey: ['document', id] });
    },
  });
}

export function useUnshareDocument() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({
      id,
      accessType,
      targetId,
    }: {
      id: string;
      accessType: AccessType;
      targetId: string;
    }) => {
      await api.delete(`/documents/${id}/share/${accessType}/${targetId}`);
    },
    onSuccess: (_, { id }) => {
      queryClient.invalidateQueries({ queryKey: ['document', id] });
    },
  });
}

Create the upload form component:

// apps/web/app/dashboard/documents/upload/page.tsx
'use client';

import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useDropzone } from 'react-dropzone';
import { Upload, X, File } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
import { useUploadDocument } from '@/lib/queries/documents';

const CATEGORIES = [
  { value: 'POLICY', label: 'Policy' },
  { value: 'HANDBOOK', label: 'Handbook' },
  { value: 'CONTRACT', label: 'Contract' },
  { value: 'PERFORMANCE', label: 'Performance' },
  { value: 'TRAINING', label: 'Training' },
  { value: 'CERTIFICATE', label: 'Certificate' },
  { value: 'PERSONAL', label: 'Personal' },
  { value: 'OTHER', label: 'Other' },
] as const;

const VISIBILITIES = [
  { value: 'PRIVATE', label: 'Private', description: 'Only you can see this document' },
  { value: 'TEAM', label: 'Team', description: 'Members of your team(s) can see this document' },
  { value: 'DEPARTMENT', label: 'Department', description: 'Members of your department(s) can see this document' },
  { value: 'MANAGERS', label: 'Managers', description: 'All managers can see this document' },
  { value: 'COMPANY', label: 'Company', description: 'Everyone in the company can see this document' },
] as const;

const uploadSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200),
  description: z.string().max(1000).optional(),
  category: z.enum(['POLICY', 'HANDBOOK', 'CONTRACT', 'PERFORMANCE', 'TRAINING', 'CERTIFICATE', 'PERSONAL', 'OTHER']),
  visibility: z.enum(['PRIVATE', 'TEAM', 'DEPARTMENT', 'MANAGERS', 'COMPANY']),
});

type UploadFormData = z.infer<typeof uploadSchema>;

export default function DocumentUploadPage() {
  const router = useRouter();
  const [file, setFile] = useState<File | null>(null);
  const uploadMutation = useUploadDocument();

  const form = useForm<UploadFormData>({
    resolver: zodResolver(uploadSchema),
    defaultValues: {
      title: '',
      description: '',
      category: 'OTHER',
      visibility: 'PRIVATE',
    },
  });

  const onDrop = useCallback((acceptedFiles: File[]) => {
    if (acceptedFiles.length > 0) {
      const selectedFile = acceptedFiles[0];
      setFile(selectedFile);
      // Auto-fill title from filename if empty
      if (!form.getValues('title')) {
        const nameWithoutExt = selectedFile.name.replace(/\.[^/.]+$/, '');
        form.setValue('title', nameWithoutExt);
      }
    }
  }, [form]);

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    multiple: false,
    accept: {
      'application/pdf': ['.pdf'],
      'application/msword': ['.doc'],
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
      'application/vnd.ms-excel': ['.xls'],
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
      'image/png': ['.png'],
      'image/jpeg': ['.jpg', '.jpeg'],
      'text/plain': ['.txt'],
    },
  });

  const onSubmit = async (data: UploadFormData) => {
    if (!file) {
      toast.error('Please select a file to upload');
      return;
    }

    try {
      await uploadMutation.mutateAsync({
        file,
        ...data,
      });
      toast.success('Document uploaded successfully');
      router.push('/dashboard/documents');
    } catch (error) {
      toast.error('Failed to upload document');
    }
  };

  const removeFile = () => {
    setFile(null);
  };

  const formatFileSize = (bytes: number) => {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  };

  return (
    <div className="container max-w-2xl py-8">
      <Card>
        <CardHeader>
          <CardTitle>Upload Document</CardTitle>
        </CardHeader>
        <CardContent>
          <Form {...form}>
            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
              {/* File Drop Zone */}
              <div>
                <label className="text-sm font-medium">File</label>
                {!file ? (
                  <div
                    {...getRootProps()}
                    className={`mt-2 border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-colors ${
                      isDragActive
                        ? 'border-primary bg-primary/5'
                        : 'border-muted-foreground/25 hover:border-primary'
                    }`}
                  >
                    <input {...getInputProps()} />
                    <Upload className="mx-auto h-12 w-12 text-muted-foreground" />
                    <p className="mt-2 text-sm text-muted-foreground">
                      {isDragActive
                        ? 'Drop the file here'
                        : 'Drag and drop a file here, or click to select'}
                    </p>
                    <p className="mt-1 text-xs text-muted-foreground">
                      PDF, Word, Excel, Images, or Text (max 50MB)
                    </p>
                  </div>
                ) : (
                  <div className="mt-2 flex items-center justify-between p-6 rounded-2xl bg-gray-50">
                    <div className="flex items-center gap-3">
                      <File className="h-8 w-8 text-muted-foreground" />
                      <div>
                        <p className="text-sm font-medium">{file.name}</p>
                        <p className="text-xs text-muted-foreground">
                          {formatFileSize(file.size)}
                        </p>
                      </div>
                    </div>
                    <Button
                      type="button"
                      variant="ghost"
                      size="icon"
                      onClick={removeFile}
                    >
                      <X className="h-4 w-4" />
                    </Button>
                  </div>
                )}
              </div>

              {/* Title */}
              <FormField
                control={form.control}
                name="title"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Title</FormLabel>
                    <FormControl>
                      <Input placeholder="Document title" {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />

              {/* Description */}
              <FormField
                control={form.control}
                name="description"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Description (optional)</FormLabel>
                    <FormControl>
                      <Textarea
                        placeholder="Brief description of the document"
                        rows={3}
                        {...field}
                      />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />

              {/* Category */}
              <FormField
                control={form.control}
                name="category"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Category</FormLabel>
                    <Select
                      onValueChange={field.onChange}
                      defaultValue={field.value}
                    >
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue placeholder="Select category" />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        {CATEGORIES.map((cat) => (
                          <SelectItem key={cat.value} value={cat.value}>
                            {cat.label}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                    <FormMessage />
                  </FormItem>
                )}
              />

              {/* Visibility */}
              <FormField
                control={form.control}
                name="visibility"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Visibility</FormLabel>
                    <Select
                      onValueChange={field.onChange}
                      defaultValue={field.value}
                    >
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue placeholder="Select visibility" />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        {VISIBILITIES.map((vis) => (
                          <SelectItem key={vis.value} value={vis.value}>
                            <div>
                              <div>{vis.label}</div>
                              <div className="text-xs text-muted-foreground">
                                {vis.description}
                              </div>
                            </div>
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                    <FormMessage />
                  </FormItem>
                )}
              />

              {/* Submit */}
              <div className="flex gap-4">
                <Button
                  type="button"
                  variant="outline"
                  onClick={() => router.back()}
                >
                  Cancel
                </Button>
                <Button
                  type="submit"
                  disabled={!file || uploadMutation.isPending}
                >
                  {uploadMutation.isPending ? 'Uploading...' : 'Upload Document'}
                </Button>
              </div>
            </form>
          </Form>
        </CardContent>
      </Card>
    </div>
  );
}

Install react-dropzone:

cd apps/web
npm install react-dropzone

Gate

cd apps/web
cat app/dashboard/documents/upload/page.tsx
npx tsc --noEmit
npm run build
# Should build without errors

Rollback

rm -rf apps/web/app/dashboard/documents
rm apps/web/lib/queries/documents.ts

Lock

apps/web/app/dashboard/documents/upload/page.tsx
apps/web/lib/queries/documents.ts

Checkpoint

  • Document queries created
  • Upload page created
  • Drag-and-drop works
  • Form validation works
  • TypeScript compiles

Step 107: Create Document List Page

Input

  • Step 106 complete
  • Document queries exist

Constraints

  • Show all accessible documents
  • Support filtering by category
  • Support search
  • Pagination

Task

Create apps/web/app/dashboard/documents/page.tsx:

'use client';

import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
  File,
  FileText,
  Image,
  Download,
  MoreHorizontal,
  Plus,
  Search,
  Share2,
  Trash2,
  Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import {
  useDocuments,
  useDeleteDocument,
  useDownloadDocument,
} from '@/lib/queries/documents';
import type { Document, DocumentCategory } from '@/lib/queries/documents';

const CATEGORIES = [
  { value: 'all', label: 'All Categories' },
  { value: 'POLICY', label: 'Policy' },
  { value: 'HANDBOOK', label: 'Handbook' },
  { value: 'CONTRACT', label: 'Contract' },
  { value: 'PERFORMANCE', label: 'Performance' },
  { value: 'TRAINING', label: 'Training' },
  { value: 'CERTIFICATE', label: 'Certificate' },
  { value: 'PERSONAL', label: 'Personal' },
  { value: 'OTHER', label: 'Other' },
] as const;

const VISIBILITY_LABELS: Record<string, string> = {
  PRIVATE: 'Private',
  TEAM: 'Team',
  DEPARTMENT: 'Department',
  MANAGERS: 'Managers',
  COMPANY: 'Company',
  CUSTOM: 'Custom',
};

const VISIBILITY_COLORS: Record<string, string> = {
  PRIVATE: 'bg-gray-100 text-gray-800',
  TEAM: 'bg-blue-100 text-blue-800',
  DEPARTMENT: 'bg-purple-100 text-purple-800',
  MANAGERS: 'bg-yellow-100 text-yellow-800',
  COMPANY: 'bg-green-100 text-green-800',
  CUSTOM: 'bg-orange-100 text-orange-800',
};

function getFileIcon(mimeType: string) {
  if (mimeType.startsWith('image/')) {
    return <Image className="h-5 w-5 text-purple-500" />;
  }
  if (mimeType === 'application/pdf') {
    return <FileText className="h-5 w-5 text-red-500" />;
  }
  return <File className="h-5 w-5 text-blue-500" />;
}

function formatFileSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function formatDate(date: string) {
  return new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
  });
}

export default function DocumentsPage() {
  const router = useRouter();
  const [search, setSearch] = useState('');
  const [category, setCategory] = useState<string>('all');
  const [page, setPage] = useState(1);
  const [deleteId, setDeleteId] = useState<string | null>(null);

  const { data, isLoading, error } = useDocuments({
    category: category !== 'all' ? (category as DocumentCategory) : undefined,
    search: search || undefined,
    page,
    limit: 20,
  });

  const deleteMutation = useDeleteDocument();
  const downloadMutation = useDownloadDocument();

  const handleDownload = async (doc: Document) => {
    try {
      const url = await downloadMutation.mutateAsync(doc.id);
      window.open(url, '_blank');
    } catch (error) {
      toast.error('Failed to download document');
    }
  };

  const handleDelete = async () => {
    if (!deleteId) return;
    try {
      await deleteMutation.mutateAsync(deleteId);
      toast.success('Document deleted');
      setDeleteId(null);
    } catch (error) {
      toast.error('Failed to delete document');
    }
  };

  return (
    <div className="container py-8">
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold">Documents</h1>
        <Button asChild>
          <Link href="/dashboard/documents/upload">
            <Plus className="h-4 w-4 mr-2" />
            Upload Document
          </Link>
        </Button>
      </div>

      {/* Filters */}
      <div className="flex gap-4 mb-6">
        <div className="relative flex-1 max-w-sm">
          <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
          <Input
            placeholder="Search documents..."
            value={search}
            onChange={(e) => {
              setSearch(e.target.value);
              setPage(1);
            }}
            className="pl-9"
          />
        </div>
        <Select
          value={category}
          onValueChange={(value) => {
            setCategory(value);
            setPage(1);
          }}
        >
          <SelectTrigger className="w-48">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {CATEGORIES.map((cat) => (
              <SelectItem key={cat.value} value={cat.value}>
                {cat.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>

      {/* Table */}
      {isLoading ? (
        <div className="space-y-4">
          {[...Array(5)].map((_, i) => (
            <Skeleton key={i} className="h-16 w-full" />
          ))}
        </div>
      ) : error ? (
        <div className="text-center py-12 text-muted-foreground">
          Failed to load documents. Please try again.
        </div>
      ) : data?.items.length === 0 ? (
        <div className="text-center py-12 text-muted-foreground">
          No documents found.
          {search && ' Try adjusting your search.'}
        </div>
      ) : (
        <>
          <div className="rounded-2xl overflow-hidden shadow-lg shadow-gray-200/50">
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>Name</TableHead>
                  <TableHead>Category</TableHead>
                  <TableHead>Visibility</TableHead>
                  <TableHead>Size</TableHead>
                  <TableHead>Uploaded</TableHead>
                  <TableHead className="w-12"></TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {data?.items.map((doc) => (
                  <TableRow key={doc.id}>
                    <TableCell>
                      <div className="flex items-center gap-3">
                        {getFileIcon(doc.mimeType)}
                        <div>
                          <div className="font-medium">{doc.title}</div>
                          <div className="text-xs text-muted-foreground">
                            {doc.fileName}
                          </div>
                        </div>
                      </div>
                    </TableCell>
                    <TableCell>
                      <Badge variant="outline">{doc.category}</Badge>
                    </TableCell>
                    <TableCell>
                      <Badge
                        className={VISIBILITY_COLORS[doc.visibility]}
                        variant="secondary"
                      >
                        {VISIBILITY_LABELS[doc.visibility]}
                      </Badge>
                    </TableCell>
                    <TableCell className="text-muted-foreground">
                      {formatFileSize(doc.fileSize)}
                    </TableCell>
                    <TableCell className="text-muted-foreground">
                      {formatDate(doc.createdAt as unknown as string)}
                    </TableCell>
                    <TableCell>
                      <DropdownMenu>
                        <DropdownMenuTrigger asChild>
                          <Button variant="ghost" size="icon">
                            <MoreHorizontal className="h-4 w-4" />
                          </Button>
                        </DropdownMenuTrigger>
                        <DropdownMenuContent align="end">
                          <DropdownMenuItem
                            onClick={() =>
                              router.push(`/dashboard/documents/${doc.id}`)
                            }
                          >
                            <Eye className="h-4 w-4 mr-2" />
                            View Details
                          </DropdownMenuItem>
                          <DropdownMenuItem onClick={() => handleDownload(doc)}>
                            <Download className="h-4 w-4 mr-2" />
                            Download
                          </DropdownMenuItem>
                          <DropdownMenuItem
                            onClick={() =>
                              router.push(
                                `/dashboard/documents/${doc.id}/share`,
                              )
                            }
                          >
                            <Share2 className="h-4 w-4 mr-2" />
                            Share
                          </DropdownMenuItem>
                          <DropdownMenuSeparator />
                          <DropdownMenuItem
                            className="text-destructive"
                            onClick={() => setDeleteId(doc.id)}
                          >
                            <Trash2 className="h-4 w-4 mr-2" />
                            Delete
                          </DropdownMenuItem>
                        </DropdownMenuContent>
                      </DropdownMenu>
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          </div>

          {/* Pagination */}
          {data && data.totalPages > 1 && (
            <div className="flex justify-center gap-2 mt-6">
              <Button
                variant="outline"
                size="sm"
                onClick={() => setPage((p) => Math.max(1, p - 1))}
                disabled={page === 1}
              >
                Previous
              </Button>
              <span className="flex items-center px-4 text-sm text-muted-foreground">
                Page {page} of {data.totalPages}
              </span>
              <Button
                variant="outline"
                size="sm"
                onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
                disabled={page === data.totalPages}
              >
                Next
              </Button>
            </div>
          )}
        </>
      )}

      {/* Delete Confirmation */}
      <AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Delete Document</AlertDialogTitle>
            <AlertDialogDescription>
              Are you sure you want to delete this document? This action cannot
              be undone.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Cancel</AlertDialogCancel>
            <AlertDialogAction
              onClick={handleDelete}
              className="bg-destructive text-destructive-foreground"
            >
              Delete
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </div>
  );
}

Note: Add the /dashboard/documents route to the sidebar navigation in your layout file.

Gate

cd apps/web
cat app/dashboard/documents/page.tsx
npx tsc --noEmit
npm run build

Rollback

rm apps/web/app/dashboard/documents/page.tsx

Lock

apps/web/app/dashboard/documents/page.tsx

Checkpoint

  • Document list page created
  • Filtering by category works
  • Search works
  • Pagination works
  • Download action works
  • Delete confirmation works
  • TypeScript compiles

Step 108: Create Share Document Modal

Input

  • Step 107 complete
  • Document list page exists

Constraints

  • Allow sharing with users, teams, or departments
  • Support permission levels (VIEW, DOWNLOAD, EDIT)
  • Optional expiration date
  • Show existing shares

Task

First, create helper queries for teams, departments, and employees if they don't already exist from Phase 03:

// apps/web/lib/queries/org.ts
// Create this file if teams/departments/employees queries don't exist from Phase 03
import { useQuery } from '@tanstack/react-query';
import { api } from '../api';

export interface Team {
  id: string;
  name: string;
  description: string | null;
}

export interface Department {
  id: string;
  name: string;
  description: string | null;
}

export interface Employee {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  userId: string | null;
}

export function useTeams() {
  return useQuery({
    queryKey: ['teams'],
    queryFn: async (): Promise<Team[]> => {
      // api helper adds /api/v1 prefix - Phase 03 defines @Controller('api/v1/teams')
      const response = await api.get<{ items: Team[] }>('/teams');
      return response.data.items;
    },
  });
}

export function useDepartments() {
  return useQuery({
    queryKey: ['departments'],
    queryFn: async (): Promise<Department[]> => {
      // api helper adds /api/v1 prefix - Phase 03 defines @Controller('api/v1/departments')
      const response = await api.get<{ items: Department[] }>('/departments');
      return response.data.items;
    },
  });
}

export function useEmployees(options?: { limit?: number }) {
  return useQuery({
    queryKey: ['employees', options],
    queryFn: async (): Promise<{ items: Employee[] }> => {
      const params = new URLSearchParams();
      if (options?.limit) params.set('limit', options.limit.toString());
      const response = await api.get<{ items: Employee[] }>(`/employees?${params}`);
      return response.data;
    },
  });
}

Create apps/web/app/dashboard/documents/[id]/share/page.tsx:

'use client';

import { useState, use } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, Plus, Trash2, Users, Building2, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
import {
  useDocument,
  useShareDocument,
  useUnshareDocument,
  AccessType,
  DocumentPermission,
} from '@/lib/queries/documents';
import { useTeams, useDepartments, useEmployees } from '@/lib/queries/org';

const shareSchema = z.object({
  accessType: z.enum(['USER', 'TEAM', 'DEPARTMENT']),
  targetId: z.string().min(1, 'Please select a target'),
  permission: z.enum(['VIEW', 'DOWNLOAD', 'EDIT']),
  expiresAt: z.string().optional(),
});

type ShareFormData = z.infer<typeof shareSchema>;

const ACCESS_TYPE_ICONS = {
  USER: Users,
  TEAM: Users,
  DEPARTMENT: Building2,
  ROLE: Shield,
};

const PERMISSION_LABELS: Record<string, string> = {
  VIEW: 'View only',
  DOWNLOAD: 'Download',
  EDIT: 'Edit',
};

const PERMISSION_COLORS: Record<string, string> = {
  VIEW: 'bg-gray-100 text-gray-800',
  DOWNLOAD: 'bg-blue-100 text-blue-800',
  EDIT: 'bg-green-100 text-green-800',
};

export default function ShareDocumentPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  // Next.js 15: params is a Promise, use React.use() to unwrap
  const { id: documentId } = use(params);
  const router = useRouter();

  const { data: document, isLoading: docLoading } = useDocument(documentId);
  const { data: teams } = useTeams();
  const { data: departments } = useDepartments();
  const { data: employees } = useEmployees({ limit: 100 });

  const shareMutation = useShareDocument();
  const unshareMutation = useUnshareDocument();

  const [accessType, setAccessType] = useState<AccessType>('USER');

  const form = useForm<ShareFormData>({
    resolver: zodResolver(shareSchema),
    defaultValues: {
      accessType: 'USER',
      targetId: '',
      permission: 'VIEW',
      expiresAt: '',
    },
  });

  const onSubmit = async (data: ShareFormData) => {
    try {
      await shareMutation.mutateAsync({
        id: documentId,
        data: {
          accessType: data.accessType as AccessType,
          targetId: data.targetId,
          permission: data.permission as DocumentPermission,
          expiresAt: data.expiresAt || undefined,
        },
      });
      toast.success('Access granted');
      form.reset();
    } catch (error) {
      toast.error('Failed to grant access');
    }
  };

  const handleUnshare = async (
    accessType: AccessType,
    targetId: string,
  ) => {
    try {
      await unshareMutation.mutateAsync({
        id: documentId,
        accessType,
        targetId,
      });
      toast.success('Access revoked');
    } catch (error) {
      toast.error('Failed to revoke access');
    }
  };

  const getTargetName = (accessType: string, targetId: string) => {
    switch (accessType) {
      case 'USER':
        const employee = employees?.items.find((e) => e.userId === targetId);
        return employee
          ? `${employee.firstName} ${employee.lastName}`
          : targetId;
      case 'TEAM':
        const team = teams?.find((t) => t.id === targetId);
        return team?.name || targetId;
      case 'DEPARTMENT':
        const dept = departments?.find((d) => d.id === targetId);
        return dept?.name || targetId;
      default:
        return targetId;
    }
  };

  const getTargetOptions = () => {
    switch (accessType) {
      case 'USER':
        return (
          employees?.items.map((e) => ({
            value: e.userId || e.id,
            label: `${e.firstName} ${e.lastName}`,
          })) || []
        );
      case 'TEAM':
        return (
          teams?.map((t) => ({
            value: t.id,
            label: t.name,
          })) || []
        );
      case 'DEPARTMENT':
        return (
          departments?.map((d) => ({
            value: d.id,
            label: d.name,
          })) || []
        );
      default:
        return [];
    }
  };

  if (docLoading) {
    return (
      <div className="container max-w-2xl py-8">
        <Skeleton className="h-8 w-48 mb-4" />
        <Skeleton className="h-96 w-full" />
      </div>
    );
  }

  if (!document) {
    return (
      <div className="container max-w-2xl py-8">
        <p className="text-muted-foreground">Document not found</p>
      </div>
    );
  }

  const accessGrants = (document as any).accessGrants || [];

  return (
    <div className="container max-w-2xl py-8">
      <Button
        variant="ghost"
        className="mb-4"
        onClick={() => router.back()}
      >
        <ArrowLeft className="h-4 w-4 mr-2" />
        Back
      </Button>

      <Card className="mb-6">
        <CardHeader>
          <CardTitle>Share Document</CardTitle>
          <CardDescription>
            {document.title}
          </CardDescription>
        </CardHeader>
        <CardContent>
          <Form {...form}>
            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
              {/* Access Type */}
              <FormField
                control={form.control}
                name="accessType"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Share with</FormLabel>
                    <Select
                      onValueChange={(value) => {
                        field.onChange(value);
                        setAccessType(value as AccessType);
                        form.setValue('targetId', '');
                      }}
                      defaultValue={field.value}
                    >
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        <SelectItem value="USER">User</SelectItem>
                        <SelectItem value="TEAM">Team</SelectItem>
                        <SelectItem value="DEPARTMENT">Department</SelectItem>
                      </SelectContent>
                    </Select>
                    <FormMessage />
                  </FormItem>
                )}
              />

              {/* Target Selection */}
              <FormField
                control={form.control}
                name="targetId"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>
                      {accessType === 'USER' && 'Select User'}
                      {accessType === 'TEAM' && 'Select Team'}
                      {accessType === 'DEPARTMENT' && 'Select Department'}
                    </FormLabel>
                    <Select
                      onValueChange={field.onChange}
                      value={field.value}
                    >
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue placeholder="Select..." />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        {getTargetOptions().map((option) => (
                          <SelectItem key={option.value} value={option.value}>
                            {option.label}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                    <FormMessage />
                  </FormItem>
                )}
              />

              {/* Permission Level */}
              <FormField
                control={form.control}
                name="permission"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Permission</FormLabel>
                    <Select
                      onValueChange={field.onChange}
                      defaultValue={field.value}
                    >
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        <SelectItem value="VIEW">View only</SelectItem>
                        <SelectItem value="DOWNLOAD">Download</SelectItem>
                        <SelectItem value="EDIT">Edit</SelectItem>
                      </SelectContent>
                    </Select>
                    <FormMessage />
                  </FormItem>
                )}
              />

              {/* Expiration (optional) */}
              <FormField
                control={form.control}
                name="expiresAt"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Expires (optional)</FormLabel>
                    <FormControl>
                      <Input type="date" {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />

              <Button
                type="submit"
                disabled={shareMutation.isPending}
              >
                <Plus className="h-4 w-4 mr-2" />
                {shareMutation.isPending ? 'Sharing...' : 'Share'}
              </Button>
            </form>
          </Form>
        </CardContent>
      </Card>

      {/* Existing Shares */}
      <Card>
        <CardHeader>
          <CardTitle>Current Access</CardTitle>
          <CardDescription>
            People and groups with access to this document
          </CardDescription>
        </CardHeader>
        <CardContent>
          {accessGrants.length === 0 ? (
            <p className="text-sm text-muted-foreground">
              No custom access grants. Document uses visibility-based access.
            </p>
          ) : (
            <div className="space-y-3">
              {accessGrants.map((grant: any) => {
                const Icon = ACCESS_TYPE_ICONS[grant.accessType as keyof typeof ACCESS_TYPE_ICONS] || Users;
                return (
                  <div
                    key={grant.id}
                    className="flex items-center justify-between p-4 rounded-2xl bg-gray-50"
                  >
                    <div className="flex items-center gap-3">
                      <Icon className="h-5 w-5 text-muted-foreground" />
                      <div>
                        <div className="font-medium">
                          {getTargetName(grant.accessType, grant.targetId)}
                        </div>
                        <div className="text-xs text-muted-foreground">
                          {grant.accessType}
                          {grant.expiresAt && (
                            <> • Expires {new Date(grant.expiresAt).toLocaleDateString()}</>
                          )}
                        </div>
                      </div>
                    </div>
                    <div className="flex items-center gap-2">
                      <Badge
                        className={PERMISSION_COLORS[grant.permission]}
                        variant="secondary"
                      >
                        {PERMISSION_LABELS[grant.permission]}
                      </Badge>
                      <Button
                        variant="ghost"
                        size="icon"
                        onClick={() =>
                          handleUnshare(grant.accessType, grant.targetId)
                        }
                      >
                        <Trash2 className="h-4 w-4 text-destructive" />
                      </Button>
                    </div>
                  </div>
                );
              })}
            </div>
          )}
        </CardContent>
      </Card>
    </div>
  );
}

Gate

cd apps/web
cat app/dashboard/documents/[id]/share/page.tsx
npx tsc --noEmit
npm run build

Common Errors

ErrorCauseFix
useTeams not foundQuery not definedCreate lib/queries/org.ts with useTeams
useDepartments not foundQuery not definedCreate lib/queries/org.ts with useDepartments
useEmployees not foundQuery not definedCreate lib/queries/org.ts with useEmployees
AccessType not exportedMissing from documents.tsEnsure types are exported from documents.ts

Rollback

rm -rf apps/web/app/dashboard/documents/[id]
rm apps/web/lib/queries/org.ts

Lock

apps/web/app/dashboard/documents/[id]/share/page.tsx
apps/web/lib/queries/org.ts

Checkpoint

  • org.ts queries created (useTeams, useDepartments, useEmployees)
  • Share page created
  • User/Team/Department selection works
  • Permission levels work
  • Expiration date works
  • Existing shares displayed
  • Revoke access works
  • TypeScript compiles

Phase 06 Complete

Summary

You have implemented:

  1. Database Models: Document and DocumentAccess with all enums
  2. GCS Integration: File upload to Google Cloud Storage with signed URLs
  3. Access Control: Multi-level visibility and fine-grained permissions
  4. Backend Services: Repository, AccessService, DocumentService, Controller
  5. Frontend Pages: Upload form, document list, share modal

Files Created

Backend (apps/api/src/documents/):

  • documents.module.ts - Module registration with Multer config
  • storage.config.ts
  • repositories/document.repository.ts
  • services/upload.service.ts
  • services/document-access.service.ts
  • services/document.service.ts
  • controllers/document.controller.ts
  • dto/create-document.dto.ts
  • dto/update-document.dto.ts
  • dto/share-document.dto.ts

Frontend (apps/web/):

  • lib/api.ts - Updated with FormData support
  • lib/queries/documents.ts - Document queries with local types
  • lib/queries/org.ts - Teams, departments, employees queries
  • app/dashboard/documents/page.tsx
  • app/dashboard/documents/upload/page.tsx
  • app/dashboard/documents/[id]/share/page.tsx

Sidebar Navigation: Add to your dashboard layout:

{
  title: 'Documents',
  href: '/dashboard/documents',
  icon: FileText,
}

Database:

  • Migration: add_document_management
  • Enums: DocumentCategory, DocumentVisibility, AccessType, DocumentPermission, DocumentStatus
  • Models: Document, DocumentAccess

Locked Files After Phase 06

  • All Phase 05 locks, plus:
  • All document models in schema.prisma
  • apps/api/src/documents/*
  • apps/web/app/dashboard/documents/*

Known Limitations (MVP)

Archive vs Delete

The current implementation uses soft delete only (status = DELETED).

Not included in MVP:

  • Separate archive workflow (ARCHIVED vs DELETED distinction)
  • Restore/unarchive endpoint for recovering documents
  • UI for managing archived documents

Documents can be marked as DELETED via the DELETE endpoint, but cannot be recovered through the API. For MVP, manual database intervention is required to restore documents if needed.

Future Enhancement: Add PATCH /api/v1/documents/:id/status endpoint with status transitions (ACTIVE ↔ ARCHIVED, ACTIVE → DELETED) and a document recovery UI.

Next Phase

Phase 07: Tags & Custom Fields (Steps 109-122)


Phase Completion Checklist (MANDATORY)

BEFORE MOVING TO NEXT PHASE

Complete ALL items before proceeding. Do NOT skip any step.

1. Gate Verification

  • All step gates passed
  • Document upload working
  • Document categories working
  • Access control functional
  • Document preview working

2. Update PROJECT_STATE.md

- Mark Phase 06 as COMPLETED with timestamp
- Update "Current Phase" to Phase 07
- Add session log entry

3. Update WHAT_EXISTS.md

## Database Models
- Document, DocumentCategory

## API Endpoints
- /api/v1/documents/*

## Frontend Routes
- /dashboard/documents/*

4. Git Tag & Commit

git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 06 - Document Management"
git tag phase-06-document-management

Next Phase

After verification, proceed to Phase 07: Tags & Custom Fields


Last Updated: 2025-11-30

On this page

Phase 06: Document ManagementStep Timing EstimatesPhase Context (READ FIRST)What This Phase AccomplishesWhat This Phase Does NOT IncludeVisibility RulesBluewoo Anti-Pattern ReminderStep 93: Add DocumentCategory EnumInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 94: Add DocumentVisibility EnumInputConstraintsTaskGateRollbackLockCheckpointStep 95: Add AccessType EnumInputConstraintsTaskGateRollbackLockCheckpointStep 96: Add DocumentPermission and DocumentStatus EnumsInputConstraintsTaskGateRollbackLockCheckpointStep 97: Add Document ModelInputConstraintsTaskGateRollbackLockCheckpointStep 98: Add DocumentAccess ModelInputConstraintsTaskGateRollbackLockCheckpointStep 99: Run MigrationInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 100: Setup GCS ConfigurationInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 101: Create Upload ServiceInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 102: Create DocumentRepositoryInputConstraintsTaskGateRollbackLockCheckpointStep 103: Create DocumentAccessServiceInputConstraintsTaskGateRollbackLockCheckpointStep 104: Create DocumentServiceInputConstraintsTaskGateRollbackLockCheckpointStep 105: Create DocumentControllerInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 106: Create Document Upload PageInputConstraintsTaskGateRollbackLockCheckpointStep 107: Create Document List PageInputConstraintsTaskGateRollbackLockCheckpointStep 108: Create Share Document ModalInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointPhase 06 CompleteSummaryFiles CreatedLocked Files After Phase 06Known Limitations (MVP)Next PhasePhase Completion Checklist (MANDATORY)1. Gate Verification2. Update PROJECT_STATE.md3. Update WHAT_EXISTS.md4. Git Tag & CommitNext Phase