From 8e32029e79b7e7271305b60416f974129aea810d Mon Sep 17 00:00:00 2001 From: Fahmed2024 <100038212+Fahmedo@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:20:51 +0100 Subject: [PATCH] commit Implement DAO Proposal API for Content Review --- backend/src/dao/dao.controller.ts | 212 ++++++++++++++ backend/src/dao/dao.module.ts | 10 + backend/src/dao/dao.service.spec.ts | 289 ++++++++++++++++++++ backend/src/dao/dao.service.ts | 228 +++++++++++++++ backend/src/dao/dto/create-proposal.dto.ts | 102 +++++++ backend/src/dao/dto/update-proposal.dto.ts | 17 ++ backend/src/dao/dto/vote-proposal.dto.ts | 29 ++ backend/src/dao/entities/proposal.entity.ts | 32 +++ 8 files changed, 919 insertions(+) create mode 100644 backend/src/dao/dao.controller.ts create mode 100644 backend/src/dao/dao.module.ts create mode 100644 backend/src/dao/dao.service.spec.ts create mode 100644 backend/src/dao/dao.service.ts create mode 100644 backend/src/dao/dto/create-proposal.dto.ts create mode 100644 backend/src/dao/dto/update-proposal.dto.ts create mode 100644 backend/src/dao/dto/vote-proposal.dto.ts create mode 100644 backend/src/dao/entities/proposal.entity.ts diff --git a/backend/src/dao/dao.controller.ts b/backend/src/dao/dao.controller.ts new file mode 100644 index 0000000..ea8bf32 --- /dev/null +++ b/backend/src/dao/dao.controller.ts @@ -0,0 +1,212 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Delete, + Query, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiQuery, + ApiBearerAuth, +} from '@nestjs/swagger'; +import type { DAOService } from './dao.service'; +import { + type CreateProposalDto, + ProposalStatus, + ProposalType, +} from './dto/create-proposal.dto'; +import type { UpdateProposalDto } from './dto/update-proposal.dto'; +import type { VoteProposalDto } from './dto/vote-proposal.dto'; + +// Mock auth guard for demonstration +class AuthGuard { + canActivate() { + return true; + } +} + +@ApiTags('DAO Proposals') +@Controller('dao') +@UseGuards(AuthGuard) +@ApiBearerAuth() +export class DAOController { + constructor(private readonly daoService: DAOService) {} + + @Post('proposals') + @ApiOperation({ summary: 'Create a new proposal' }) + @ApiResponse({ + status: 201, + description: 'Proposal created successfully', + schema: { + example: { + id: 'uuid', + title: 'Introduction to Smart Contracts', + description: + 'A comprehensive course covering smart contract fundamentals', + type: 'course', + status: 'pending', + tags: ['blockchain', 'ethereum'], + authorId: 'user-uuid', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + votes: [], + voteCount: { approve: 0, reject: 0, abstain: 0 }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid input data' }) + createProposal( + createProposalDto: CreateProposalDto, + req: { user?: { id?: string } }, + ) { + const userId: string = req.user?.id ?? 'mock-user-id'; // Mock user ID + return this.daoService.createProposal(createProposalDto, userId); + } + + @Get('proposals') + @ApiOperation({ summary: 'Get all proposals with optional filtering' }) + @ApiQuery({ name: 'status', enum: ProposalStatus, required: false }) + @ApiQuery({ name: 'type', enum: ProposalType, required: false }) + @ApiResponse({ + status: 200, + description: 'List of proposals', + schema: { + type: 'array', + items: { + example: { + id: 'uuid', + title: 'Introduction to Smart Contracts', + status: 'pending', + type: 'course', + }, + }, + }, + }) + findAllProposals( + @Query('status') status?: ProposalStatus, + @Query('type') type?: ProposalType, + ) { + return this.daoService.findAllProposals(status, type); + } + + @Get('proposals/stats') + @ApiOperation({ summary: 'Get proposal statistics' }) + @ApiResponse({ + status: 200, + description: 'Proposal statistics', + schema: { + example: { + total: 10, + byStatus: { pending: 5, approved: 3, rejected: 2 }, + byType: { course: 6, lesson: 3, article: 1 }, + }, + }, + }) + getProposalStats() { + return this.daoService.getProposalStats(); + } + + @Get('proposals/:id') + @ApiOperation({ summary: 'Get a proposal by ID' }) + @ApiResponse({ status: 200, description: 'Proposal details' }) + @ApiResponse({ status: 404, description: 'Proposal not found' }) + findProposalById(@Param('id') id: string) { + return this.daoService.findProposalById(id); + } + + @Patch('proposals/:id') + @ApiOperation({ summary: 'Update a proposal' }) + @ApiResponse({ status: 200, description: 'Proposal updated successfully' }) + @ApiResponse({ + status: 403, + description: 'Forbidden - not the proposal author', + }) + @ApiResponse({ status: 404, description: 'Proposal not found' }) + updateProposal( + @Param('id') id: string, + updateProposalDto: UpdateProposalDto, + req: { user?: { id?: string } }, + ) { + const userId = req.user?.id || 'mock-user-id'; + return this.daoService.updateProposal(id, updateProposalDto, userId); + } + + @Patch('proposals/:id/review') + @ApiOperation({ summary: 'Review a proposal (admin only)' }) + @ApiResponse({ status: 200, description: 'Proposal reviewed successfully' }) + @ApiResponse({ + status: 400, + description: 'Invalid status or proposal already reviewed', + }) + @ApiResponse({ status: 404, description: 'Proposal not found' }) + reviewProposal( + @Param('id') id: string, + body: { status: ProposalStatus }, + req: { user?: { id?: string } }, + ) { + return this.daoService.reviewProposal(id, body.status); + } + + @Post('proposals/:id/vote') + @ApiOperation({ summary: 'Vote on a proposal' }) + @ApiResponse({ status: 201, description: 'Vote submitted successfully' }) + @ApiResponse({ + status: 400, + description: 'Invalid vote or user already voted', + }) + @ApiResponse({ status: 404, description: 'Proposal not found' }) + @HttpCode(HttpStatus.CREATED) + voteOnProposal( + @Param('id') id: string, + voteDto: VoteProposalDto, + req: { user?: { id?: string } }, + ) { + const userId = req.user?.id || 'mock-user-id'; + return this.daoService.voteOnProposal(id, voteDto, userId); + } + + @Get('proposals/:id/votes') + @ApiOperation({ summary: 'Get all votes for a proposal' }) + @ApiResponse({ + status: 200, + description: 'List of votes for the proposal', + schema: { + type: 'array', + items: { + example: { + id: 'vote-uuid', + proposalId: 'proposal-uuid', + userId: 'user-uuid', + vote: 'approve', + comment: 'Great proposal!', + createdAt: '2024-01-01T00:00:00.000Z', + }, + }, + }, + }) + getProposalVotes(@Param('id') id: string) { + return this.daoService.getProposalVotes(id); + } + + @Delete('proposals/:id') + @ApiOperation({ summary: 'Delete a proposal' }) + @ApiResponse({ status: 204, description: 'Proposal deleted successfully' }) + @ApiResponse({ + status: 403, + description: 'Forbidden - not the proposal author', + }) + @ApiResponse({ status: 404, description: 'Proposal not found' }) + @HttpCode(HttpStatus.NO_CONTENT) + deleteProposal(@Param('id') id: string, req: { user?: { id?: string } }) { + const userId: string = req.user?.id ?? 'mock-user-id'; + this.daoService.deleteProposal(id, userId); + } +} diff --git a/backend/src/dao/dao.module.ts b/backend/src/dao/dao.module.ts new file mode 100644 index 0000000..265310e --- /dev/null +++ b/backend/src/dao/dao.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { DAOService } from './dao.service'; +import { DAOController } from './dao.controller'; + +@Module({ + controllers: [DAOController], + providers: [DAOService], + exports: [DAOService], +}) +export class DAOModule {} diff --git a/backend/src/dao/dao.service.spec.ts b/backend/src/dao/dao.service.spec.ts new file mode 100644 index 0000000..1a4940a --- /dev/null +++ b/backend/src/dao/dao.service.spec.ts @@ -0,0 +1,289 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { DAOService } from './dao.service'; +import { + type CreateProposalDto, + ProposalType, + ProposalStatus, +} from './dto/create-proposal.dto'; +import { type VoteProposalDto, VoteType } from './dto/vote-proposal.dto'; +import { + NotFoundException, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; + +describe('DAOService', () => { + let service: DAOService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [DAOService], + }).compile(); + + service = module.get(DAOService); + }); + + describe('createProposal', () => { + it('should create a proposal successfully', async () => { + const createProposalDto: CreateProposalDto = { + title: 'Test Proposal', + description: 'This is a test proposal for unit testing purposes', + type: ProposalType.COURSE, + tags: ['test', 'unit-testing'], + estimatedDuration: 10, + }; + + const result = await service.createProposal(createProposalDto, 'user-1'); + + expect(result).toBeDefined(); + expect(result.title).toBe(createProposalDto.title); + expect(result.status).toBe(ProposalStatus.PENDING); + expect(result.authorId).toBe('user-1'); + expect(result.voteCount).toEqual({ approve: 0, reject: 0, abstain: 0 }); + }); + }); + + describe('findAllProposals', () => { + beforeEach(async () => { + await service.createProposal( + { + title: 'Course Proposal', + description: 'A course proposal for testing', + type: ProposalType.COURSE, + tags: ['course'], + }, + 'user-1', + ); + + await service.createProposal( + { + title: 'Article Proposal', + description: 'An article proposal for testing', + type: ProposalType.ARTICLE, + tags: ['article'], + }, + 'user-2', + ); + }); + + it('should return all proposals', async () => { + const result = await service.findAllProposals(); + expect(result).toHaveLength(2); + }); + + it('should filter proposals by status', async () => { + const result = await service.findAllProposals(ProposalStatus.PENDING); + expect(result).toHaveLength(2); + expect(result.every((p) => p.status === ProposalStatus.PENDING)).toBe( + true, + ); + }); + + it('should filter proposals by type', async () => { + const result = await service.findAllProposals( + undefined, + ProposalType.COURSE, + ); + expect(result).toHaveLength(1); + expect(result[0].type).toBe(ProposalType.COURSE); + }); + }); + + describe('findProposalById', () => { + it('should return a proposal by ID', async () => { + const proposal = await service.createProposal( + { + title: 'Test Proposal', + description: 'Test description for finding by ID', + type: ProposalType.LESSON, + tags: ['test'], + }, + 'user-1', + ); + + const result = await service.findProposalById(proposal.id); + expect(result).toEqual(proposal); + }); + + it('should throw NotFoundException for non-existent proposal', async () => { + await expect(service.findProposalById('non-existent-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('updateProposal', () => { + let proposalId: string; + + beforeEach(async () => { + const proposal = await service.createProposal( + { + title: 'Original Title', + description: 'Original description for update testing', + type: ProposalType.COURSE, + tags: ['original'], + }, + 'user-1', + ); + proposalId = proposal.id; + }); + + it('should update a proposal successfully', async () => { + const updateDto = { + title: 'Updated Title', + description: 'Updated description for testing', + }; + + const result = await service.updateProposal( + proposalId, + updateDto, + 'user-1', + ); + expect(result.title).toBe(updateDto.title); + expect(result.description).toBe(updateDto.description); + }); + + it('should throw ForbiddenException for non-author', async () => { + const updateDto = { title: 'Unauthorized Update' }; + + await expect( + service.updateProposal(proposalId, updateDto, 'user-2'), + ).rejects.toThrow(ForbiddenException); + }); + + it('should throw BadRequestException for status update', async () => { + const updateDto = { status: ProposalStatus.APPROVED }; + + await expect( + service.updateProposal(proposalId, updateDto, 'user-1'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('voteOnProposal', () => { + let proposalId: string; + + beforeEach(async () => { + const proposal = await service.createProposal( + { + title: 'Voting Test Proposal', + description: 'A proposal for testing voting functionality', + type: ProposalType.ARTICLE, + tags: ['voting', 'test'], + }, + 'user-1', + ); + proposalId = proposal.id; + }); + + it('should allow voting on a proposal', async () => { + const voteDto: VoteProposalDto = { + vote: VoteType.APPROVE, + comment: 'Great proposal!', + }; + + const result = await service.voteOnProposal( + proposalId, + voteDto, + 'user-2', + ); + expect(result.vote).toBe(VoteType.APPROVE); + expect(result.comment).toBe('Great proposal!'); + + const proposal = await service.findProposalById(proposalId); + expect(proposal.voteCount.approve).toBe(1); + }); + + it('should prevent duplicate voting', async () => { + const voteDto: VoteProposalDto = { vote: VoteType.APPROVE }; + + await service.voteOnProposal(proposalId, voteDto, 'user-2'); + + await expect( + service.voteOnProposal(proposalId, voteDto, 'user-2'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('reviewProposal', () => { + let proposalId: string; + + beforeEach(async () => { + const proposal = await service.createProposal( + { + title: 'Review Test Proposal', + description: 'A proposal for testing review functionality', + type: ProposalType.COURSE, + tags: ['review', 'test'], + }, + 'user-1', + ); + proposalId = proposal.id; + }); + + it('should review a proposal successfully', async () => { + const result = await service.reviewProposal( + proposalId, + ProposalStatus.APPROVED, + 'reviewer-1', + ); + expect(result.status).toBe(ProposalStatus.APPROVED); + }); + + it('should prevent re-reviewing approved proposals', async () => { + await service.reviewProposal( + proposalId, + ProposalStatus.APPROVED, + 'reviewer-1', + ); + + await expect( + service.reviewProposal( + proposalId, + ProposalStatus.REJECTED, + 'reviewer-1', + ), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getProposalStats', () => { + beforeEach(async () => { + await service.createProposal( + { + title: 'Course 1', + description: 'First course proposal', + type: ProposalType.COURSE, + tags: ['course'], + }, + 'user-1', + ); + + const proposal2 = await service.createProposal( + { + title: 'Article 1', + description: 'First article proposal', + type: ProposalType.ARTICLE, + tags: ['article'], + }, + 'user-2', + ); + + await service.reviewProposal( + proposal2.id, + ProposalStatus.APPROVED, + 'reviewer-1', + ); + }); + + it('should return correct statistics', async () => { + const stats = await service.getProposalStats(); + + expect(stats.total).toBe(2); + expect(stats.byStatus[ProposalStatus.PENDING]).toBe(1); + expect(stats.byStatus[ProposalStatus.APPROVED]).toBe(1); + expect(stats.byType[ProposalType.COURSE]).toBe(1); + expect(stats.byType[ProposalType.ARTICLE]).toBe(1); + }); + }); +}); diff --git a/backend/src/dao/dao.service.ts b/backend/src/dao/dao.service.ts new file mode 100644 index 0000000..9b4a646 --- /dev/null +++ b/backend/src/dao/dao.service.ts @@ -0,0 +1,228 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; +import { + type CreateProposalDto, + ProposalStatus, +} from './dto/create-proposal.dto'; +import type { UpdateProposalDto } from './dto/update-proposal.dto'; +import type { VoteProposalDto } from './dto/vote-proposal.dto'; +import type { Proposal, Vote } from './entities/proposal.entity'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class DAOService { + private proposals: Map = new Map(); + private votes: Map = new Map(); + + createProposal( + createProposalDto: CreateProposalDto, + authorId: string, + ): Proposal { + const proposalId = uuidv4(); + const now = new Date(); + + const proposal: Proposal = { + id: proposalId, + ...createProposalDto, + status: ProposalStatus.PENDING, + authorId, + createdAt: now, + updatedAt: now, + votes: [], + voteCount: { + approve: 0, + reject: 0, + abstain: 0, + }, + }; + + this.proposals.set(proposalId, proposal); + return proposal; + } + + findAllProposals(status?: ProposalStatus, type?: string): Proposal[] { + let proposals = Array.from(this.proposals.values()); + + if (status) { + proposals = proposals.filter((proposal) => proposal.status === status); + } + + if (type) { + proposals = proposals.filter( + (proposal) => String(proposal.type) === String(type), + ); + } + + return proposals.sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), + ); + } + + findProposalById(id: string): Proposal { + const proposal = this.proposals.get(id); + if (!proposal) { + throw new NotFoundException(`Proposal with ID ${id} not found`); + } + return proposal; + } + + updateProposal( + id: string, + updateProposalDto: UpdateProposalDto, + userId: string, + ): Proposal { + const proposal = this.findProposalById(id); + + // Only author or admin can update proposal + if (proposal.authorId !== userId) { + throw new ForbiddenException( + 'Only the proposal author can update this proposal', + ); + } + + // Don't allow status updates through this method + if (updateProposalDto.status) { + throw new BadRequestException( + 'Status updates should be done through the review process', + ); + } + + const updatedProposal = { + ...proposal, + ...updateProposalDto, + updatedAt: new Date(), + }; + + this.proposals.set(id, updatedProposal); + return updatedProposal; + } + + reviewProposal(id: string, status: ProposalStatus): Proposal { + const proposal = this.findProposalById(id); + + if ( + proposal.status === ProposalStatus.APPROVED || + proposal.status === ProposalStatus.REJECTED + ) { + throw new BadRequestException('Proposal has already been reviewed'); + } + + const updatedProposal = { + ...proposal, + status, + updatedAt: new Date(), + }; + + this.proposals.set(id, updatedProposal); + return updatedProposal; + } + + voteOnProposal( + proposalId: string, + voteDto: VoteProposalDto, + userId: string, + ): Vote { + const proposal = this.findProposalById(proposalId); + + if ( + proposal.status !== ProposalStatus.PENDING && + proposal.status !== ProposalStatus.UNDER_REVIEW + ) { + throw new BadRequestException( + 'Voting is only allowed on pending or under review proposals', + ); + } + + // Check if user has already voted + const existingVote = Array.from(this.votes.values()).find( + (vote) => vote.proposalId === proposalId && vote.userId === userId, + ); + + if (existingVote) { + throw new BadRequestException('User has already voted on this proposal'); + } + + const voteId = uuidv4(); + const vote: Vote = { + id: voteId, + proposalId, + userId, + vote: voteDto.vote, + comment: voteDto.comment, + createdAt: new Date(), + }; + + this.votes.set(voteId, vote); + + // Update proposal vote count + proposal.voteCount[voteDto.vote]++; + proposal.votes.push(vote); + this.proposals.set(proposalId, proposal); + + return vote; + } + + getProposalVotes(proposalId: string): Vote[] { + this.findProposalById(proposalId); // Ensure proposal exists + + return Array.from(this.votes.values()).filter( + (vote) => vote.proposalId === proposalId, + ); + } + + deleteProposal(id: string, userId: string): void { + const proposal = this.findProposalById(id); + + if (proposal.authorId !== userId) { + throw new ForbiddenException( + 'Only the proposal author can delete this proposal', + ); + } + + if (proposal.status !== ProposalStatus.PENDING) { + throw new BadRequestException('Only pending proposals can be deleted'); + } + + // Delete associated votes + const proposalVotes = Array.from(this.votes.entries()).filter( + ([, vote]) => vote.proposalId === id, + ); + proposalVotes.forEach(([voteId]) => this.votes.delete(voteId)); + + this.proposals.delete(id); + } + + getProposalStats(): { + total: number; + byStatus: Record; + byType: Record; + } { + const proposals = Array.from(this.proposals.values()); + + const byStatus = proposals.reduce( + (acc, proposal) => { + acc[proposal.status] = (acc[proposal.status] || 0) + 1; + return acc; + }, + {} as Record, + ); + + const byType = proposals.reduce( + (acc, proposal) => { + acc[proposal.type] = (acc[proposal.type] || 0) + 1; + return acc; + }, + {} as Record, + ); + + return { + total: proposals.length, + byStatus, + byType, + }; + } +} diff --git a/backend/src/dao/dto/create-proposal.dto.ts b/backend/src/dao/dto/create-proposal.dto.ts new file mode 100644 index 0000000..0cd3fcd --- /dev/null +++ b/backend/src/dao/dto/create-proposal.dto.ts @@ -0,0 +1,102 @@ +import { + IsString, + IsNotEmpty, + IsEnum, + IsOptional, + MinLength, + MaxLength, + IsArray, + ArrayMinSize, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export enum ProposalType { + COURSE = 'course', + LESSON = 'lesson', + ARTICLE = 'article', +} + +export enum ProposalStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + UNDER_REVIEW = 'under_review', +} + +export class CreateProposalDto { + @ApiProperty({ + description: 'Title of the proposal', + example: 'Introduction to Smart Contracts', + minLength: 5, + maxLength: 100, + }) + @IsString() + @IsNotEmpty() + @MinLength(5) + @MaxLength(100) + title: string; + + @ApiProperty({ + description: 'Detailed description of the proposal', + example: + 'A comprehensive course covering the fundamentals of smart contract development on Ethereum', + minLength: 20, + maxLength: 1000, + }) + @IsString() + @IsNotEmpty() + @MinLength(20) + @MaxLength(1000) + description: string; + + @ApiProperty({ + description: 'Type of content being proposed', + enum: ProposalType, + example: ProposalType.COURSE, + }) + @IsEnum(ProposalType) + type: ProposalType; + + @ApiProperty({ + description: 'Tags associated with the proposal', + example: ['blockchain', 'ethereum', 'smart-contracts'], + isArray: true, + type: String, + }) + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + tags: string[]; + + @ApiProperty({ + description: 'Estimated duration in hours (for courses/lessons)', + example: 40, + required: false, + }) + @IsOptional() + estimatedDuration?: number; + + @ApiProperty({ + description: 'Prerequisites for the content', + example: 'Basic understanding of JavaScript and blockchain concepts', + required: false, + }) + @IsOptional() + @IsString() + prerequisites?: string; + + @ApiProperty({ + description: 'Learning objectives', + example: [ + 'Understand smart contract basics', + 'Deploy contracts on testnet', + ], + isArray: true, + type: String, + required: false, + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + learningObjectives?: string[]; +} diff --git a/backend/src/dao/dto/update-proposal.dto.ts b/backend/src/dao/dto/update-proposal.dto.ts new file mode 100644 index 0000000..5c1dfef --- /dev/null +++ b/backend/src/dao/dto/update-proposal.dto.ts @@ -0,0 +1,17 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateProposalDto } from './create-proposal.dto'; +import { IsEnum, IsOptional } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { ProposalStatus } from './create-proposal.dto'; + +export class UpdateProposalDto extends PartialType(CreateProposalDto) { + @ApiProperty({ + description: 'Status of the proposal', + enum: ProposalStatus, + example: ProposalStatus.UNDER_REVIEW, + required: false, + }) + @IsOptional() + @IsEnum(ProposalStatus) + status?: ProposalStatus; +} diff --git a/backend/src/dao/dto/vote-proposal.dto.ts b/backend/src/dao/dto/vote-proposal.dto.ts new file mode 100644 index 0000000..820304d --- /dev/null +++ b/backend/src/dao/dto/vote-proposal.dto.ts @@ -0,0 +1,29 @@ +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export enum VoteType { + APPROVE = 'approve', + REJECT = 'reject', + ABSTAIN = 'abstain', +} + +export class VoteProposalDto { + @ApiProperty({ + description: 'Type of vote', + enum: VoteType, + example: VoteType.APPROVE, + }) + @IsEnum(VoteType) + vote: VoteType; + + @ApiProperty({ + description: 'Optional comment explaining the vote', + example: 'Great proposal, would be valuable for beginners', + required: false, + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + comment?: string; +} diff --git a/backend/src/dao/entities/proposal.entity.ts b/backend/src/dao/entities/proposal.entity.ts new file mode 100644 index 0000000..da489bc --- /dev/null +++ b/backend/src/dao/entities/proposal.entity.ts @@ -0,0 +1,32 @@ +import type { ProposalType, ProposalStatus } from '../dto/create-proposal.dto'; +import type { VoteType } from '../dto/vote-proposal.dto'; + +export class Proposal { + id: string; + title: string; + description: string; + type: ProposalType; + status: ProposalStatus; + tags: string[]; + estimatedDuration?: number; + prerequisites?: string; + learningObjectives?: string[]; + authorId: string; + createdAt: Date; + updatedAt: Date; + votes: Vote[]; + voteCount: { + approve: number; + reject: number; + abstain: number; + }; +} + +export class Vote { + id: string; + proposalId: string; + userId: string; + vote: VoteType; + comment?: string; + createdAt: Date; +}