Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions backend/cmmty/document-sharing/document-share.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Column } from 'typeorm';
import { Document } from '../../src/documents/entities/document.entity';

@Entity('cmmty_document_shares')
export class DocumentShare {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
documentId: string;

@Column()
sharedWithUserId: string;

@Column()
sharedByUserId: string;

@CreateDateColumn()
createdAt: Date;

@ManyToOne(() => Document, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'documentId' })
document: Document;
}
52 changes: 52 additions & 0 deletions backend/cmmty/document-sharing/document-sharing.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard';
import { DocumentSharingService } from './document-sharing.service';
import { ShareDocumentDto } from './dto/share-document.dto';
import { SharedDocumentsQueryDto } from './dto/shared-documents-query.dto';

@Controller('cmmty/documents')
@UseGuards(JwtAuthGuard)
export class DocumentSharingController {
constructor(private readonly documentSharingService: DocumentSharingService) {}

@Post(':id/share')
shareDocument(@Req() req: any, @Param('id') id: string, @Body() dto: ShareDocumentDto) {
const userId = req.user?.id;
if (!userId) {
throw new UnauthorizedException('User not found in request');
}

return this.documentSharingService.shareDocument(id, userId, dto);
}

@Delete(':id/share/:userId')
revokeShare(@Req() req: any, @Param('id') id: string, @Param('userId') userId: string) {
const currentUserId = req.user?.id;
if (!currentUserId) {
throw new UnauthorizedException('User not found in request');
}

return this.documentSharingService.revokeShare(id, currentUserId, userId);
}

@Get('shared-with-me')
listSharedWithMe(@Req() req: any, @Query() dto: SharedDocumentsQueryDto) {
const userId = req.user?.id;
if (!userId) {
throw new UnauthorizedException('User not found in request');
}

return this.documentSharingService.listSharedWithMe(userId, dto);
}
}
15 changes: 15 additions & 0 deletions backend/cmmty/document-sharing/document-sharing.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Document } from '../../src/documents/entities/document.entity';
import { User } from '../../src/users/entities/user.entity';
import { DocumentShare } from './document-share.entity';
import { DocumentSharingController } from './document-sharing.controller';
import { DocumentSharingService } from './document-sharing.service';

@Module({
imports: [TypeOrmModule.forFeature([DocumentShare, Document, User])],
controllers: [DocumentSharingController],
providers: [DocumentSharingService],
exports: [DocumentSharingService],
})
export class DocumentSharingModule {}
88 changes: 88 additions & 0 deletions backend/cmmty/document-sharing/document-sharing.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Document } from '../../src/documents/entities/document.entity';
import { User } from '../../src/users/entities/user.entity';
import { DocumentShare } from './document-share.entity';
import { DocumentSharingService } from './document-sharing.service';

describe('DocumentSharingService', () => {
let service: DocumentSharingService;

const mockDocumentShareRepository = {
findOne: jest.fn(),
create: jest.fn((value) => value),
save: jest.fn(),
delete: jest.fn(),
findAndCount: jest.fn(),
};

const mockDocumentRepository = {
findOne: jest.fn(),
};

const mockUserRepository = {
findOne: jest.fn(),
};

beforeEach(async () => {
jest.clearAllMocks();

const module: TestingModule = await Test.createTestingModule({
providers: [
DocumentSharingService,
{
provide: getRepositoryToken(DocumentShare),
useValue: mockDocumentShareRepository,
},
{
provide: getRepositoryToken(Document),
useValue: mockDocumentRepository,
},
{
provide: getRepositoryToken(User),
useValue: mockUserRepository,
},
],
}).compile();

service = module.get<DocumentSharingService>(DocumentSharingService);
});

it('should share a document with another user', async () => {
mockDocumentRepository.findOne.mockResolvedValue({
id: 'doc-1',
ownerId: 'owner-1',
});
mockUserRepository.findOne.mockResolvedValue({
id: 'user-2',
email: 'user2@example.com',
});
mockDocumentShareRepository.findOne.mockResolvedValue(null);
mockDocumentShareRepository.save.mockResolvedValue({
id: 'share-1',
documentId: 'doc-1',
sharedByUserId: 'owner-1',
sharedWithUserId: 'user-2',
});

const result = await service.shareDocument('doc-1', 'owner-1', {
email: 'user2@example.com',
});

expect(result.id).toBe('share-1');
expect(mockDocumentShareRepository.save).toHaveBeenCalled();
});

it('should revoke an existing share', async () => {
mockDocumentShareRepository.delete.mockResolvedValue({ affected: 1 });

const result = await service.revokeShare('doc-1', 'owner-1', 'user-2');

expect(result.message).toBe('Share revoked successfully');
expect(mockDocumentShareRepository.delete).toHaveBeenCalledWith({
documentId: 'doc-1',
sharedByUserId: 'owner-1',
sharedWithUserId: 'user-2',
});
});
});
120 changes: 120 additions & 0 deletions backend/cmmty/document-sharing/document-sharing.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Document } from '../../src/documents/entities/document.entity';
import { User } from '../../src/users/entities/user.entity';
import { DocumentShare } from './document-share.entity';
import { ShareDocumentDto } from './dto/share-document.dto';
import { SharedDocumentsQueryDto } from './dto/shared-documents-query.dto';

@Injectable()
export class DocumentSharingService {
constructor(
@InjectRepository(DocumentShare)
private readonly documentShareRepository: Repository<DocumentShare>,
@InjectRepository(Document)
private readonly documentRepository: Repository<Document>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}

async shareDocument(
documentId: string,
sharedByUserId: string,
dto: ShareDocumentDto,
): Promise<DocumentShare> {
const document = await this.documentRepository.findOne({ where: { id: documentId } });
if (!document) {
throw new NotFoundException('Document not found');
}

if (document.ownerId !== sharedByUserId) {
throw new ForbiddenException('You can only share documents you own');
}

const targetUser = await this.userRepository.findOne({ where: { email: dto.email } });
if (!targetUser) {
throw new NotFoundException('Target user not found');
}

if (targetUser.id === sharedByUserId) {
throw new BadRequestException('You cannot share a document with yourself');
}

const existingShare = await this.documentShareRepository.findOne({
where: {
documentId,
sharedByUserId,
sharedWithUserId: targetUser.id,
},
});

if (existingShare) {
return existingShare;
}

const share = this.documentShareRepository.create({
documentId,
sharedByUserId,
sharedWithUserId: targetUser.id,
});

return this.documentShareRepository.save(share);
}

async revokeShare(
documentId: string,
sharedByUserId: string,
sharedWithUserId: string,
): Promise<{ message: string }> {
const result = await this.documentShareRepository.delete({
documentId,
sharedByUserId,
sharedWithUserId,
});

if (!result.affected) {
throw new NotFoundException('Share not found');
}

return { message: 'Share revoked successfully' };
}

async listSharedWithMe(userId: string, dto: SharedDocumentsQueryDto) {
const { page = 1, limit = 10 } = dto;
const skip = (page - 1) * limit;

const [shares, total] = await this.documentShareRepository.findAndCount({
where: { sharedWithUserId: userId },
relations: ['document'],
order: { createdAt: 'DESC' },
skip,
take: limit,
});

return {
data: shares.map((share) => ({
id: share.id,
documentId: share.documentId,
sharedByUserId: share.sharedByUserId,
createdAt: share.createdAt,
document: share.document
? {
id: share.document.id,
ownerId: share.document.ownerId,
title: share.document.title,
status: share.document.status,
}
: null,
})),
total,
page,
limit,
};
}
}
6 changes: 6 additions & 0 deletions backend/cmmty/document-sharing/dto/share-document.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';

export class ShareDocumentDto {
@IsEmail()
email: string;
}
16 changes: 16 additions & 0 deletions backend/cmmty/document-sharing/dto/shared-documents-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';

export class SharedDocumentsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;

@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit = 10;
}
16 changes: 16 additions & 0 deletions backend/cmmty/expiry-notifier/expiry-notification-log.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity('cmmty_expiry_notification_logs')
export class ExpiryNotificationLog {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
documentId: string;

@Column()
userId: string;

@CreateDateColumn()
sentAt: Date;
}
13 changes: 13 additions & 0 deletions backend/cmmty/expiry-notifier/expiry-notifier.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Document } from '../../src/documents/entities/document.entity';
import { MailModule } from '../../src/mail/mail.module';
import { ExpiryNotificationLog } from './expiry-notification-log.entity';
import { ExpiryNotifierService } from './expiry-notifier.service';

@Module({
imports: [TypeOrmModule.forFeature([Document, ExpiryNotificationLog]), MailModule],
providers: [ExpiryNotifierService],
exports: [ExpiryNotifierService],
})
export class ExpiryNotifierModule {}
Loading
Loading