Bluewoo HRMS
AI Development GuideEntity References

Reference Implementation - Documents

Complete Document module with access control and visibility

Reference Implementation: Document Module

This reference provides a complete Document module implementation with fine-grained access control, visibility settings, and file management.

Module Structure

apps/api/src/modules/documents/
├── documents.module.ts
├── documents.controller.ts
├── documents.repository.ts
├── documents.service.ts
├── dto/
│   ├── create-document.dto.ts
│   ├── update-document.dto.ts
│   ├── document-access.dto.ts
│   └── document-response.dto.ts
├── guards/
│   └── document-access.guard.ts
└── documents.controller.spec.ts

Document Module

// apps/api/src/modules/documents/documents.module.ts
import { Module } from '@nestjs/common';
import { DocumentsController } from './documents.controller';
import { DocumentsRepository } from './documents.repository';
import { DocumentsService } from './documents.service';

@Module({
  controllers: [DocumentsController],
  providers: [DocumentsRepository, DocumentsService],
  exports: [DocumentsRepository, DocumentsService],
})
export class DocumentsModule {}

DTOs

Create Document DTO

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

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

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

  @IsEnum(DocumentType)
  type: DocumentType;

  @IsEnum(DocumentVisibility)
  @IsOptional()
  visibility?: DocumentVisibility = DocumentVisibility.PRIVATE;

  @IsString()
  @IsNotEmpty()
  fileUrl: string;

  @IsString()
  @IsNotEmpty()
  fileName: string;

  @IsString()
  @IsNotEmpty()
  mimeType: string;

  @IsNumber()
  fileSize: number;

  @IsUUID()
  @IsOptional()
  employeeId?: string;

  @IsUUID()
  @IsOptional()
  folderId?: string;

  @IsArray()
  @IsUUID('4', { each: true })
  @IsOptional()
  tagIds?: string[];
}

Update Document DTO

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

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

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

  @IsEnum(DocumentVisibility)
  @IsOptional()
  visibility?: DocumentVisibility;

  @IsUUID()
  @IsOptional()
  folderId?: string;

  @IsArray()
  @IsUUID('4', { each: true })
  @IsOptional()
  tagIds?: string[];
}

Document Access DTO

// apps/api/src/modules/documents/dto/document-access.dto.ts
import { IsUUID, IsEnum, IsOptional, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { DocumentVisibility } from '@prisma/client';

export class GrantAccessDto {
  @IsUUID()
  userId: string;

  @IsEnum(['VIEW', 'EDIT', 'ADMIN'])
  permission: 'VIEW' | 'EDIT' | 'ADMIN';
}

export class UpdateVisibilityDto {
  @IsEnum(DocumentVisibility)
  visibility: DocumentVisibility;

  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => GrantAccessDto)
  @IsOptional()
  customAccess?: GrantAccessDto[];

  @IsArray()
  @IsUUID('4', { each: true })
  @IsOptional()
  teamIds?: string[];

  @IsArray()
  @IsUUID('4', { each: true })
  @IsOptional()
  departmentIds?: string[];
}

Document Response DTO

// apps/api/src/modules/documents/dto/document-response.dto.ts
import { DocumentType, DocumentVisibility } from '@prisma/client';

export class DocumentResponseDto {
  id: string;
  title: string;
  description: string | null;
  type: DocumentType;
  visibility: DocumentVisibility;
  fileUrl: string;
  fileName: string;
  mimeType: string;
  fileSize: number;
  employeeId: string | null;
  folderId: string | null;
  uploadedById: string;
  createdAt: Date;
  updatedAt: Date;
  tags?: { id: string; name: string; color: string }[];
  accessList?: { userId: string; permission: string }[];
  canEdit: boolean;
  canDelete: boolean;
  canShare: boolean;
}

Document Repository

// apps/api/src/modules/documents/documents.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma, Document, DocumentVisibility } from '@prisma/client';

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

  async create(data: Prisma.DocumentCreateInput): Promise<Document> {
    return this.prisma.document.create({
      data,
      include: {
        tags: { include: { tag: true } },
        uploadedBy: { select: { id: true, email: true } },
      },
    });
  }

  async findById(id: string, tenantId: string): Promise<Document | null> {
    return this.prisma.document.findFirst({
      where: { id, tenantId },
      include: {
        tags: { include: { tag: true } },
        accessList: { include: { user: { select: { id: true, email: true } } } },
        uploadedBy: { select: { id: true, email: true } },
        folder: true,
        employee: { select: { id: true, firstName: true, lastName: true } },
      },
    });
  }

  async findAccessible(
    tenantId: string,
    userId: string,
    employeeId: string | null,
    teamIds: string[],
    departmentIds: string[],
    isManager: boolean,
    options?: {
      type?: string;
      folderId?: string;
      employeeId?: string;
      search?: string;
      skip?: number;
      take?: number;
    },
  ): Promise<{ documents: Document[]; total: number }> {
    // Build visibility conditions based on user's access
    const visibilityConditions: Prisma.DocumentWhereInput[] = [
      // User's own uploads
      { uploadedById: userId },
      // Public company documents
      { visibility: DocumentVisibility.COMPANY },
      // Custom access granted
      { accessList: { some: { userId } } },
    ];

    // Private - only owner
    // Already covered by uploadedById check

    // Team visibility - user must be in same team
    if (teamIds.length > 0) {
      visibilityConditions.push({
        AND: [
          { visibility: DocumentVisibility.TEAM },
          {
            OR: [
              // Document associated with user's teams
              { employee: { teamMemberships: { some: { teamId: { in: teamIds } } } } },
              // Or has team access
              { accessList: { some: { userId } } },
            ],
          },
        ],
      });
    }

    // Department visibility
    if (departmentIds.length > 0) {
      visibilityConditions.push({
        AND: [
          { visibility: DocumentVisibility.DEPARTMENT },
          { employee: { departmentId: { in: departmentIds } } },
        ],
      });
    }

    // Managers visibility
    if (isManager) {
      visibilityConditions.push({ visibility: DocumentVisibility.MANAGERS });
    }

    const where: Prisma.DocumentWhereInput = {
      tenantId,
      OR: visibilityConditions,
      ...(options?.type && { type: options.type as any }),
      ...(options?.folderId && { folderId: options.folderId }),
      ...(options?.employeeId && { employeeId: options.employeeId }),
      ...(options?.search && {
        OR: [
          { title: { contains: options.search, mode: 'insensitive' } },
          { description: { contains: options.search, mode: 'insensitive' } },
          { fileName: { contains: options.search, mode: 'insensitive' } },
        ],
      }),
    };

    const [documents, total] = await Promise.all([
      this.prisma.document.findMany({
        where,
        include: {
          tags: { include: { tag: true } },
          uploadedBy: { select: { id: true, email: true } },
          folder: true,
        },
        orderBy: { createdAt: 'desc' },
        skip: options?.skip ?? 0,
        take: options?.take ?? 20,
      }),
      this.prisma.document.count({ where }),
    ]);

    return { documents, total };
  }

  async update(id: string, tenantId: string, data: Prisma.DocumentUpdateInput): Promise<Document> {
    return this.prisma.document.update({
      where: { id, tenantId },
      data,
      include: {
        tags: { include: { tag: true } },
        accessList: { include: { user: { select: { id: true, email: true } } } },
      },
    });
  }

  async delete(id: string, tenantId: string): Promise<void> {
    await this.prisma.document.delete({
      where: { id, tenantId },
    });
  }

  async grantAccess(
    documentId: string,
    tenantId: string,
    userId: string,
    permission: string,
    grantedById: string,
  ): Promise<void> {
    await this.prisma.documentAccess.upsert({
      where: {
        documentId_userId: { documentId, userId },
      },
      create: {
        documentId,
        userId,
        permission,
        grantedById,
      },
      update: {
        permission,
        grantedById,
      },
    });
  }

  async revokeAccess(documentId: string, userId: string): Promise<void> {
    await this.prisma.documentAccess.delete({
      where: {
        documentId_userId: { documentId, userId },
      },
    });
  }

  async getAccessList(documentId: string): Promise<any[]> {
    return this.prisma.documentAccess.findMany({
      where: { documentId },
      include: {
        user: { select: { id: true, email: true } },
      },
    });
  }

  async checkAccess(
    documentId: string,
    userId: string,
    requiredPermission: 'VIEW' | 'EDIT' | 'ADMIN',
  ): Promise<boolean> {
    const access = await this.prisma.documentAccess.findUnique({
      where: {
        documentId_userId: { documentId, userId },
      },
    });

    if (!access) return false;

    const permissionHierarchy = { VIEW: 1, EDIT: 2, ADMIN: 3 };
    return permissionHierarchy[access.permission] >= permissionHierarchy[requiredPermission];
  }

  async syncTags(documentId: string, tagIds: string[], assignedById: string): Promise<void> {
    await this.prisma.$transaction(async (tx) => {
      // Remove existing tags
      await tx.documentTag.deleteMany({
        where: { documentId },
      });

      // Add new tags
      if (tagIds.length > 0) {
        await tx.documentTag.createMany({
          data: tagIds.map((tagId) => ({
            documentId,
            tagId,
            assignedById,
          })),
        });
      }
    });
  }
}

Document Service

// apps/api/src/modules/documents/documents.service.ts
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
import { DocumentsRepository } from './documents.repository';
import { DocumentVisibility } from '@prisma/client';

interface UserContext {
  userId: string;
  tenantId: string;
  employeeId: string | null;
  teamIds: string[];
  departmentIds: string[];
  isManager: boolean;
  permissions: string[];
}

@Injectable()
export class DocumentsService {
  constructor(private readonly repository: DocumentsRepository) {}

  async canAccess(documentId: string, user: UserContext, requiredPermission: 'VIEW' | 'EDIT' | 'ADMIN'): Promise<boolean> {
    const document = await this.repository.findById(documentId, user.tenantId);

    if (!document) return false;

    // Owner always has full access
    if (document.uploadedById === user.userId) return true;

    // Check explicit access grants
    const hasExplicitAccess = await this.repository.checkAccess(documentId, user.userId, requiredPermission);
    if (hasExplicitAccess) return true;

    // View-only access based on visibility
    if (requiredPermission === 'VIEW') {
      return this.checkVisibilityAccess(document, user);
    }

    return false;
  }

  private checkVisibilityAccess(document: any, user: UserContext): boolean {
    switch (document.visibility) {
      case DocumentVisibility.PRIVATE:
        return document.uploadedById === user.userId;

      case DocumentVisibility.TEAM:
        // User must be in the same team as document's associated employee
        if (document.employee?.teamMemberships) {
          const docTeamIds = document.employee.teamMemberships.map((m: any) => m.teamId);
          return user.teamIds.some((id) => docTeamIds.includes(id));
        }
        return false;

      case DocumentVisibility.DEPARTMENT:
        if (document.employee?.departmentId) {
          return user.departmentIds.includes(document.employee.departmentId);
        }
        return false;

      case DocumentVisibility.MANAGERS:
        return user.isManager;

      case DocumentVisibility.COMPANY:
        return true;

      case DocumentVisibility.CUSTOM:
        // Custom access is handled by explicit grants
        return false;

      default:
        return false;
    }
  }

  async getDocumentWithPermissions(documentId: string, user: UserContext): Promise<any> {
    const document = await this.repository.findById(documentId, user.tenantId);

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

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

    const canEdit = await this.canAccess(documentId, user, 'EDIT');
    const canAdmin = await this.canAccess(documentId, user, 'ADMIN');

    return {
      ...document,
      canEdit,
      canDelete: canAdmin || document.uploadedById === user.userId,
      canShare: canAdmin || document.uploadedById === user.userId,
    };
  }

  async updateVisibility(
    documentId: string,
    user: UserContext,
    visibility: DocumentVisibility,
    customAccess?: { userId: string; permission: string }[],
  ): Promise<void> {
    const canAdmin = await this.canAccess(documentId, user, 'ADMIN');
    const document = await this.repository.findById(documentId, user.tenantId);

    if (!canAdmin && document?.uploadedById !== user.userId) {
      throw new ForbiddenException('You cannot change visibility settings');
    }

    await this.repository.update(documentId, user.tenantId, { visibility });

    // Handle custom access
    if (visibility === DocumentVisibility.CUSTOM && customAccess) {
      // Clear existing custom access
      const existingAccess = await this.repository.getAccessList(documentId);
      for (const access of existingAccess) {
        if (access.userId !== user.userId) {
          await this.repository.revokeAccess(documentId, access.userId);
        }
      }

      // Grant new access
      for (const grant of customAccess) {
        await this.repository.grantAccess(
          documentId,
          user.tenantId,
          grant.userId,
          grant.permission,
          user.userId,
        );
      }
    }
  }
}

Document Controller

// apps/api/src/modules/documents/documents.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  UseGuards,
  ParseUUIDPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { DocumentsRepository } from './documents.repository';
import { DocumentsService } from './documents.service';
import { TenantGuard } from '../common/guards';
// Note: PermissionsGuard not yet implemented in MVP - add in later phase
import { RequirePermissions } from '../auth/decorators/permissions.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CreateDocumentDto } from './dto/create-document.dto';
import { UpdateDocumentDto } from './dto/update-document.dto';
import { UpdateVisibilityDto, GrantAccessDto } from './dto/document-access.dto';

@Controller('documents')
@UseGuards(TenantGuard)
export class DocumentsController {
  constructor(
    private readonly repository: DocumentsRepository,
    private readonly service: DocumentsService,
  ) {}

  @Post()
  @RequirePermissions('documents:create')
  async create(@CurrentUser() user: any, @Body() dto: CreateDocumentDto) {
    const document = await this.repository.create({
      ...dto,
      tenant: { connect: { id: user.tenantId } },
      uploadedBy: { connect: { id: user.id } },
      ...(dto.employeeId && { employee: { connect: { id: dto.employeeId } } }),
      ...(dto.folderId && { folder: { connect: { id: dto.folderId } } }),
    });

    // Sync tags if provided
    if (dto.tagIds?.length) {
      await this.repository.syncTags(document.id, dto.tagIds, user.id);
    }

    return this.repository.findById(document.id, user.tenantId);
  }

  @Get()
  @RequirePermissions('documents:read')
  async findAll(
    @CurrentUser() user: any,
    @Query('type') type?: string,
    @Query('folderId') folderId?: string,
    @Query('employeeId') employeeId?: string,
    @Query('search') search?: string,
    @Query('page') page?: string,
    @Query('limit') limit?: string,
  ) {
    const pageNum = parseInt(page || '1', 10);
    const limitNum = Math.min(parseInt(limit || '20', 10), 100);
    const skip = (pageNum - 1) * limitNum;

    const { documents, total } = await this.repository.findAccessible(
      user.tenantId,
      user.id,
      user.employeeId,
      user.teamIds || [],
      user.departmentIds || [],
      user.isManager || false,
      { type, folderId, employeeId, search, skip, take: limitNum },
    );

    return {
      data: documents,
      meta: {
        total,
        page: pageNum,
        limit: limitNum,
        totalPages: Math.ceil(total / limitNum),
      },
    };
  }

  @Get(':id')
  @RequirePermissions('documents:read')
  async findOne(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
    return this.service.getDocumentWithPermissions(id, {
      userId: user.id,
      tenantId: user.tenantId,
      employeeId: user.employeeId,
      teamIds: user.teamIds || [],
      departmentIds: user.departmentIds || [],
      isManager: user.isManager || false,
      permissions: user.permissions || [],
    });
  }

  @Put(':id')
  @RequirePermissions('documents:update')
  async update(
    @CurrentUser() user: any,
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateDocumentDto,
  ) {
    const canEdit = await this.service.canAccess(id, {
      userId: user.id,
      tenantId: user.tenantId,
      employeeId: user.employeeId,
      teamIds: user.teamIds || [],
      departmentIds: user.departmentIds || [],
      isManager: user.isManager || false,
      permissions: user.permissions || [],
    }, 'EDIT');

    if (!canEdit) {
      throw new ForbiddenException('You cannot edit this document');
    }

    const { tagIds, ...updateData } = dto;

    const document = await this.repository.update(id, user.tenantId, updateData);

    if (tagIds !== undefined) {
      await this.repository.syncTags(id, tagIds, user.id);
    }

    return this.repository.findById(id, user.tenantId);
  }

  @Put(':id/visibility')
  @RequirePermissions('documents:update')
  async updateVisibility(
    @CurrentUser() user: any,
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateVisibilityDto,
  ) {
    await this.service.updateVisibility(
      id,
      {
        userId: user.id,
        tenantId: user.tenantId,
        employeeId: user.employeeId,
        teamIds: user.teamIds || [],
        departmentIds: user.departmentIds || [],
        isManager: user.isManager || false,
        permissions: user.permissions || [],
      },
      dto.visibility,
      dto.customAccess,
    );

    return this.repository.findById(id, user.tenantId);
  }

  @Post(':id/access')
  @RequirePermissions('documents:update')
  async grantAccess(
    @CurrentUser() user: any,
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: GrantAccessDto,
  ) {
    const canAdmin = await this.service.canAccess(id, {
      userId: user.id,
      tenantId: user.tenantId,
      employeeId: user.employeeId,
      teamIds: user.teamIds || [],
      departmentIds: user.departmentIds || [],
      isManager: user.isManager || false,
      permissions: user.permissions || [],
    }, 'ADMIN');

    const document = await this.repository.findById(id, user.tenantId);

    if (!canAdmin && document?.uploadedById !== user.id) {
      throw new ForbiddenException('You cannot grant access to this document');
    }

    await this.repository.grantAccess(id, user.tenantId, dto.userId, dto.permission, user.id);

    return { success: true };
  }

  @Delete(':id/access/:userId')
  @RequirePermissions('documents:update')
  @HttpCode(HttpStatus.NO_CONTENT)
  async revokeAccess(
    @CurrentUser() user: any,
    @Param('id', ParseUUIDPipe) id: string,
    @Param('userId', ParseUUIDPipe) userId: string,
  ) {
    const canAdmin = await this.service.canAccess(id, {
      userId: user.id,
      tenantId: user.tenantId,
      employeeId: user.employeeId,
      teamIds: user.teamIds || [],
      departmentIds: user.departmentIds || [],
      isManager: user.isManager || false,
      permissions: user.permissions || [],
    }, 'ADMIN');

    const document = await this.repository.findById(id, user.tenantId);

    if (!canAdmin && document?.uploadedById !== user.id) {
      throw new ForbiddenException('You cannot revoke access to this document');
    }

    await this.repository.revokeAccess(id, userId);
  }

  @Delete(':id')
  @RequirePermissions('documents:delete')
  @HttpCode(HttpStatus.NO_CONTENT)
  async delete(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
    const canAdmin = await this.service.canAccess(id, {
      userId: user.id,
      tenantId: user.tenantId,
      employeeId: user.employeeId,
      teamIds: user.teamIds || [],
      departmentIds: user.departmentIds || [],
      isManager: user.isManager || false,
      permissions: user.permissions || [],
    }, 'ADMIN');

    const document = await this.repository.findById(id, user.tenantId);

    if (!canAdmin && document?.uploadedById !== user.id) {
      throw new ForbiddenException('You cannot delete this document');
    }

    await this.repository.delete(id, user.tenantId);
  }
}

Document Access Guard

// apps/api/src/modules/documents/guards/document-access.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { DocumentsService } from '../documents.service';

export const DOCUMENT_PERMISSION_KEY = 'documentPermission';
export const DocumentPermission = (permission: 'VIEW' | 'EDIT' | 'ADMIN') =>
  SetMetadata(DOCUMENT_PERMISSION_KEY, permission);

@Injectable()
export class DocumentAccessGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly documentsService: DocumentsService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const requiredPermission = this.reflector.getAllAndOverride<'VIEW' | 'EDIT' | 'ADMIN'>(
      DOCUMENT_PERMISSION_KEY,
      [context.getHandler(), context.getClass()],
    );

    if (!requiredPermission) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const documentId = request.params.id;

    if (!documentId) {
      return true;
    }

    const hasAccess = await this.documentsService.canAccess(documentId, {
      userId: user.id,
      tenantId: user.tenantId,
      employeeId: user.employeeId,
      teamIds: user.teamIds || [],
      departmentIds: user.departmentIds || [],
      isManager: user.isManager || false,
      permissions: user.permissions || [],
    }, requiredPermission);

    if (!hasAccess) {
      throw new ForbiddenException('You do not have access to this document');
    }

    return true;
  }
}

Unit Tests

// apps/api/src/modules/documents/documents.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { DocumentsController } from './documents.controller';
import { DocumentsRepository } from './documents.repository';
import { DocumentsService } from './documents.service';
import { DocumentVisibility, DocumentType } from '@prisma/client';

describe('DocumentsController', () => {
  let controller: DocumentsController;
  let repository: jest.Mocked<DocumentsRepository>;
  let service: jest.Mocked<DocumentsService>;

  const mockUser = {
    id: 'user-1',
    tenantId: 'tenant-1',
    employeeId: 'emp-1',
    teamIds: ['team-1'],
    departmentIds: ['dept-1'],
    isManager: false,
    permissions: ['documents:create', 'documents:read'],
  };

  const mockDocument = {
    id: 'doc-1',
    title: 'Test Document',
    description: 'Test description',
    type: DocumentType.CONTRACT,
    visibility: DocumentVisibility.PRIVATE,
    fileUrl: 'https://storage.example.com/doc.pdf',
    fileName: 'doc.pdf',
    mimeType: 'application/pdf',
    fileSize: 1024,
    tenantId: 'tenant-1',
    uploadedById: 'user-1',
    employeeId: null,
    folderId: null,
    createdAt: new Date(),
    updatedAt: new Date(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [DocumentsController],
      providers: [
        {
          provide: DocumentsRepository,
          useValue: {
            create: jest.fn(),
            findById: jest.fn(),
            findAccessible: jest.fn(),
            update: jest.fn(),
            delete: jest.fn(),
            grantAccess: jest.fn(),
            revokeAccess: jest.fn(),
            syncTags: jest.fn(),
          },
        },
        {
          provide: DocumentsService,
          useValue: {
            canAccess: jest.fn(),
            getDocumentWithPermissions: jest.fn(),
            updateVisibility: jest.fn(),
          },
        },
      ],
    }).compile();

    controller = module.get<DocumentsController>(DocumentsController);
    repository = module.get(DocumentsRepository);
    service = module.get(DocumentsService);
  });

  describe('create', () => {
    it('should create a document', async () => {
      const dto = {
        title: 'New Document',
        type: DocumentType.CONTRACT,
        fileUrl: 'https://storage.example.com/new.pdf',
        fileName: 'new.pdf',
        mimeType: 'application/pdf',
        fileSize: 2048,
      };

      repository.create.mockResolvedValue(mockDocument);
      repository.findById.mockResolvedValue(mockDocument);

      const result = await controller.create(mockUser, dto as any);

      expect(repository.create).toHaveBeenCalled();
      expect(result).toEqual(mockDocument);
    });
  });

  describe('findAll', () => {
    it('should return accessible documents with pagination', async () => {
      repository.findAccessible.mockResolvedValue({
        documents: [mockDocument],
        total: 1,
      });

      const result = await controller.findAll(mockUser, undefined, undefined, undefined, undefined, '1', '20');

      expect(result.data).toHaveLength(1);
      expect(result.meta.total).toBe(1);
      expect(result.meta.page).toBe(1);
    });
  });

  describe('findOne', () => {
    it('should return document with permissions', async () => {
      const documentWithPermissions = {
        ...mockDocument,
        canEdit: true,
        canDelete: true,
        canShare: true,
      };

      service.getDocumentWithPermissions.mockResolvedValue(documentWithPermissions);

      const result = await controller.findOne(mockUser, 'doc-1');

      expect(result.canEdit).toBe(true);
      expect(result.canDelete).toBe(true);
    });
  });

  describe('update', () => {
    it('should update document when user has edit access', async () => {
      service.canAccess.mockResolvedValue(true);
      repository.update.mockResolvedValue(mockDocument);
      repository.findById.mockResolvedValue(mockDocument);

      const result = await controller.update(mockUser, 'doc-1', { title: 'Updated Title' });

      expect(repository.update).toHaveBeenCalledWith('doc-1', 'tenant-1', { title: 'Updated Title' });
    });

    it('should throw when user lacks edit access', async () => {
      service.canAccess.mockResolvedValue(false);

      await expect(controller.update(mockUser, 'doc-1', { title: 'Updated' }))
        .rejects.toThrow('You cannot edit this document');
    });
  });

  describe('delete', () => {
    it('should delete document when user is owner', async () => {
      service.canAccess.mockResolvedValue(false);
      repository.findById.mockResolvedValue(mockDocument); // uploadedById matches user.id

      await controller.delete(mockUser, 'doc-1');

      expect(repository.delete).toHaveBeenCalledWith('doc-1', 'tenant-1');
    });

    it('should throw when user cannot delete', async () => {
      service.canAccess.mockResolvedValue(false);
      repository.findById.mockResolvedValue({ ...mockDocument, uploadedById: 'other-user' });

      await expect(controller.delete(mockUser, 'doc-1'))
        .rejects.toThrow('You cannot delete this document');
    });
  });
});

Visibility Rules Summary

VisibilityWho Can View
PRIVATEOnly the document owner
TEAMOwner + users in same team as document's employee
DEPARTMENTOwner + users in same department
MANAGERSOwner + all users with manager role
COMPANYAll users in tenant
CUSTOMOwner + users with explicit access grants

Key Implementation Notes

  1. Visibility Check Order: Owner check → Explicit access → Visibility-based access
  2. Permission Hierarchy: VIEW < EDIT < ADMIN - higher permissions include lower ones
  3. Tenant Isolation: All queries filter by tenantId first
  4. Access Grants: Custom visibility uses DocumentAccess table for fine-grained control
  5. Tag Integration: Documents can be tagged using the Tag system (see Reference Implementation - Tags)