diff --git a/.env.example b/.env.example index 4905f4a..16ef135 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ DATABASE_URL= WHITELISTED_ORIGINS= TUNNEL_DOMAINS= JWT_SECRET= -VERIFICATION_LEVEL="device" # "orb" or "device", default is "device" \ No newline at end of file +ENABLE_VOTE_NORMALIZATION= +VERIFICATION_LEVEL="device" # "orb" or "device", default is "device" diff --git a/README.md b/README.md index c35976c..96e5655 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,339 @@ -

- Nest Logo -

- -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest - -

A progressive Node.js framework for building efficient and scalable server-side applications.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Coverage -Discord -Backers on Open Collective -Sponsors on Open Collective - Donate us - Support us - Follow us on Twitter -

- - -## Description - -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. - -## Project setup +# Worldview Backend + +## 1. Project Overview + +### Purpose +Worldview is a quadratic voting platform based on World mini-apps that allows users to create, manage, and participate in polls. The platform enables community-driven decision making through a weighted voting system, giving users the ability to express preference strength across multiple options. + +### Key Features +- Poll creation with draft capability +- Quadratic voting system for more representative outcomes +- User authentication using World ID +- Poll searching, filtering, and sorting +- Vote management for users +- Anonymous voting option + +### Live Links +- Production: https://backend.worldview.fyi +- Staging: https://backend.staging.worldview.fyi + +## 2. Architecture Overview + +### Tech Stack +- **Framework**: NestJS (Node.js) +- **Database**: PostgreSQL with Prisma ORM +- **Authentication**: JWT, World ID integration +- **Voting System**: Quadratic voting with normalization options +- **Docker**: Container-based deployment + +### System Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ │ │ │ │ │ +│ Frontend App │<────>│ NestJS API │<────>│ PostgreSQL DB │ +│ │ │ │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + │ + ┌───────▼───────┐ + │ │ + │ World ID │ + │ Integration │ + │ │ + └───────────────┘ +``` + +### Data Flow +1. Users authenticate via World ID +2. Authenticated users can create polls (draft or published) +3. Users can vote on active polls using quadratic voting +4. The system normalizes and calculates voting weights (based on feature flag) +5. Poll results are available for viewing based on permissions + +## 3. Getting Started + +### Prerequisites +- Node.js (v18+) +- PostgreSQL (v14+) +- Yarn package manager + +### Installation Steps ```bash +# Install dependencies $ yarn install + +# Generate Prisma client +$ npx prisma generate + +# Run database migrations +$ npx prisma migrate dev +``` + +### Configuration +Create a `.env` file in the root directory with the following variables: +``` +DATABASE_URL="postgresql://user:password@localhost:5432/worldview" +JWT_SECRET="your-secret-key" +WHITELISTED_ORIGINS="localhost:3000,yourdomain.com" +ENABLE_VOTE_NORMALIZATION=true +TUNNEL_DOMAINS="localtunnel.me,ngrok.io" ``` -## Compile and run the project +## 4. Usage Instructions +### Running the Application ```bash -# development -$ yarn run start - -# watch mode +# Development mode $ yarn run start:dev -# production mode +# Production mode +$ yarn run build $ yarn run start:prod ``` -## Run tests - +### Database Migrations ```bash -# unit tests -$ yarn run test +# Generate a new migration +$ yarn migration:generate + +# Apply migrations (production) +$ yarn migration:prod +``` + +## 5. API Endpoints -# e2e tests -$ yarn run test:e2e +### Authentication +- `GET /auth/nonce` - Get a nonce for World ID authentication +- `POST /auth/verifyWorldId` - Verify World ID and authenticate user -# test coverage -$ yarn run test:cov +### Polls +- `POST /poll` - Create a new published poll +- `PATCH /poll/draft` - Create or update a draft poll +- `GET /poll/draft` - Get user's draft poll +- `GET /poll` - Get polls with filtering and pagination + - Query params: page, limit, isActive, userVoted, userCreated, search, sortBy, sortOrder +- `GET /poll/:id` - Get detailed information about a specific poll +- `DELETE /poll/:id` - Delete a poll (if owner) + +### Users +- `GET /user/getUserData` - Get user profile data +- `GET /user/getUserActivities` - Get user activities +- `GET /user/getUserVotes` - Get user's voting history +- `POST /user/setVote` - Cast a vote on a poll +- `POST /user/editVote` - Edit an existing vote +- `POST /user/createUser` - Create a new user + +## 6. Database Schema + +### Main Entities + +#### User +```prisma +model User { + id Int @id @default(autoincrement()) + worldID String @unique + name String? + profilePicture String? + pollsCreatedCount Int @default(0) + pollsParticipatedCount Int @default(0) + createdPolls Poll[] @relation("PollAuthor") + actions UserAction[] + votes Vote[] +} +``` + +#### Poll +```prisma +model Poll { + pollId Int @id @default(autoincrement()) + authorUserId Int + title String? + description String? + options String[] + creationDate DateTime @default(now()) + startDate DateTime? + endDate DateTime? + tags String[] + isAnonymous Boolean @default(false) + participantCount Int @default(0) + voteResults Json + searchVector Unsupported("tsvector")? + status PollStatus @default(PUBLISHED) + author User @relation("PollAuthor", fields: [authorUserId], references: [id]) + userAction UserAction[] + votes Vote[] +} ``` -## Deployment +#### Vote +```prisma +model Vote { + voteID String @id @default(uuid()) + userId Int + pollId Int + votingPower Int + weightDistribution Json + proof String + quadraticWeights Json? + normalizedWeightDistribution Json? + normalizedQuadraticWeights Json? + poll Poll @relation(fields: [pollId], references: [pollId], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) +} +``` -When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. +#### UserAction +```prisma +model UserAction { + id Int @id @default(autoincrement()) + userId Int + pollId Int + type ActionType + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + poll Poll @relation(fields: [pollId], references: [pollId], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) +} +``` -If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: +## 7. File Structure -```bash -$ yarn install -g mau -$ mau deploy +``` +worldview-be +│ +├── prisma/ # Database schema and migrations +│ ├── schema.prisma # Database model definitions +│ └── migrations/ # Database migration files +│ +├── src/ +│ ├── auth/ # Authentication module +│ │ ├── auth.controller.ts +│ │ ├── auth.service.ts +│ │ ├── jwt.service.ts +│ │ └── jwt-auth.guard.ts +│ │ +│ ├── poll/ # Poll management module +│ │ ├── poll.controller.ts +│ │ ├── poll.service.ts +│ │ ├── Poll.dto.ts +│ │ └── poll.module.ts +│ │ +│ ├── user/ # User management module +│ │ ├── user.controller.ts +│ │ ├── user.service.ts +│ │ ├── user.dto.ts +│ │ └── user.module.ts +│ │ +│ ├── common/ # Shared utilities and exceptions +│ │ ├── exceptions.ts +│ │ ├── http-exception.filter.ts +│ │ └── validators.ts +│ │ +│ ├── database/ # Database connection module +│ │ └── database.service.ts +│ │ +│ ├── app.module.ts # Main application module +│ ├── app.controller.ts # Main application controller +│ ├── app.service.ts # Main application service +│ └── main.ts # Application entry point +│ +├── test/ # Test files +│ +├── .env # Environment variables (not in repo) +├── docker-compose.yml # Docker Compose configuration +└── package.json # Project dependencies and scripts ``` -With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. +## 8. Key Features Implementation -## Resources +### Draft Poll Management +The system allows users to save incomplete polls as drafts before publishing: +- Only one draft poll per user +- Draft polls are not visible to other users +- Fields are optional in draft mode +- Simple conversion from draft to published -Check out a few resources that may come in handy when working with NestJS: +Implementation details: +- The Poll entity includes a `status` field with DRAFT/PUBLISHED values +- `patchDraftPoll` endpoint handles creating and updating drafts +- Transaction-based approach ensures only one draft exists per user +- Poll queries include status filters to separate draft and published polls -- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. -- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). -- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). -- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. -- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). -- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). -- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). -- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). +### Quadratic Voting System +The platform uses quadratic voting for more democratic decision-making: +``` +1. Users distribute voting power across options +2. Final weight = square root of allocated points +3. Optional normalization for fair comparison +``` -## Support +Implementation details: +- Vote entity stores both raw weight distribution and quadratic weights +- Optional normalization can be enabled via environment variable +- PostgreSQL functions calculate quadratic weights and normalization +- Results are aggregated with proper weight calculations + +### Authentication Flow +1. User requests a nonce from the server +2. User authenticates with World ID +3. Server verifies World ID proof and issues JWT +4. JWT token is used for subsequent authenticated requests + +### Poll Filtering and Search +The API provides comprehensive filtering options: +- Active/inactive status filter based on poll date +- User participation filters (created/voted) +- Full-text search on title and description using PostgreSQL tsvector +- Custom sorting options (end date, participant count, creation date) + +### Vote Management +Users can: +- Cast votes with custom weight distribution +- Edit votes during active poll period +- View their voting history +- See vote calculations with quadratic weights + +## 9. Development Guidelines + +### Code Structure +- **Controllers**: Handle HTTP requests and responses +- **Services**: Implement business logic and database operations +- **DTOs**: Define data transfer objects for request/response validation +- **Guards**: Handle authentication and authorization +- **Exceptions**: Custom error handling and messages + +### Contribution Process +1. Fork the repository +2. Create a feature branch +3. Submit a pull request with detailed description +4. Pass automated tests +5. Complete code review + +## 10. Deployment + +### Docker Deployment +```bash +# Build the Docker image +docker-compose build + +# Run with Docker Compose +docker-compose up -d +``` -Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). +### Environment-Specific Configuration +- Development: Uses local database, allows tunnel domains +- Production: Uses secure connections, restricted CORS -## Stay in touch +## 11. Troubleshooting -- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) +### Common Issues +- **Authentication failures**: Check World ID configuration +- **Database connection issues**: Verify DATABASE_URL +- **CORS errors**: Update WHITELISTED_ORIGINS +- **Vote calculation issues**: Check ENABLE_VOTE_NORMALIZATION setting -## License +### Logging +- Application logs are available in both development and production environments via Grafana (For access please reach out to devOps) -Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). diff --git a/prisma/migrations/20250503203644_add_poll_status/migration.sql b/prisma/migrations/20250503203644_add_poll_status/migration.sql new file mode 100644 index 0000000..c596701 --- /dev/null +++ b/prisma/migrations/20250503203644_add_poll_status/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "PollStatus" AS ENUM ('DRAFT', 'PUBLISHED'); + +-- AlterTable +ALTER TABLE "Poll" ADD COLUMN "status" "PollStatus" NOT NULL DEFAULT 'PUBLISHED'; diff --git a/prisma/migrations/20250503214151_make_poll_fields_optional/migration.sql b/prisma/migrations/20250503214151_make_poll_fields_optional/migration.sql new file mode 100644 index 0000000..4e92a40 --- /dev/null +++ b/prisma/migrations/20250503214151_make_poll_fields_optional/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Poll" ALTER COLUMN "title" DROP NOT NULL, +ALTER COLUMN "startDate" DROP NOT NULL, +ALTER COLUMN "endDate" DROP NOT NULL; diff --git a/prisma/migrations/20250512113354_add_generated_normalized_votes/migration.sql b/prisma/migrations/20250512113354_add_generated_normalized_votes/migration.sql new file mode 100644 index 0000000..df56a59 --- /dev/null +++ b/prisma/migrations/20250512113354_add_generated_normalized_votes/migration.sql @@ -0,0 +1,109 @@ +-- First drop the existing trigger that might be using these functions +DROP TRIGGER IF EXISTS normalize_weights_trigger ON "Vote"; + +-- Drop existing functions for clean recreation +DROP FUNCTION IF EXISTS update_normalized_weights(); +DROP FUNCTION IF EXISTS json_normalize_weights(jsonb, integer); + +-- Recreate the normalize weights function with IMMUTABLE +CREATE OR REPLACE FUNCTION json_normalize_weights(weight_distribution JSONB, voting_power INTEGER) +RETURNS JSONB AS $$ +DECLARE + total NUMERIC := 0; + weight NUMERIC; + key TEXT; + normalized_weights JSONB := '{}'::JSONB; + normalized_total NUMERIC := 0; + diff INTEGER; + first_nonzero_key TEXT := NULL; +BEGIN + -- First check if input is a valid JSON object + IF weight_distribution IS NULL OR jsonb_typeof(weight_distribution) != 'object' THEN + -- Return empty object if input is not a valid object + RETURN '{}'::JSONB; + END IF; + + -- Calculate total weights + FOR key, weight IN SELECT * FROM jsonb_each_text(weight_distribution) + LOOP + total := total + weight::NUMERIC; + END LOOP; + + -- If total is 0 or already equals target, return original + IF total = 0 OR total = voting_power THEN + RETURN weight_distribution; + END IF; + + -- Normalize each weight + FOR key, weight IN SELECT * FROM jsonb_each_text(weight_distribution) + LOOP + -- Formula: normalizedValue = (weightValue / sumOfAllWeights) * voting_power + -- Round to integer to avoid fractional weights + normalized_weights := normalized_weights || + jsonb_build_object(key, ROUND((weight::NUMERIC / total) * voting_power)); + + -- Keep track of first non-zero value to adjust if necessary + IF first_nonzero_key IS NULL AND weight::NUMERIC > 0 THEN + first_nonzero_key := key; + END IF; + END LOOP; + + -- Check if rounding caused total to drift from target + SELECT SUM(value::NUMERIC) INTO normalized_total FROM jsonb_each_text(normalized_weights); + + IF normalized_total != voting_power AND first_nonzero_key IS NOT NULL THEN + diff := voting_power - normalized_total; + normalized_weights := jsonb_set( + normalized_weights, + ARRAY[first_nonzero_key], + to_jsonb((normalized_weights->>first_nonzero_key)::NUMERIC + diff) + ); + END IF; + + RETURN normalized_weights; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- Make sure calculate_normalized_quadratic_weights is immutable +DROP FUNCTION IF EXISTS calculate_normalized_quadratic_weights(jsonb); + +CREATE OR REPLACE FUNCTION calculate_normalized_quadratic_weights(weights JSONB) +RETURNS JSONB AS $$ +DECLARE + weight NUMERIC; + key TEXT; + quadratic_weights JSONB := '{}'::JSONB; +BEGIN + -- Calculate quadratic weights for each value + FOR key, weight IN SELECT * FROM jsonb_each_text(weights) + LOOP + -- Formula: quadraticWeight = sqrt(weight) + quadratic_weights := quadratic_weights || + jsonb_build_object(key, ROUND(SQRT(weight::NUMERIC), 2)); + END LOOP; + + RETURN quadratic_weights; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- Only drop and recreate the columns we're interested in +ALTER TABLE "Vote" DROP COLUMN IF EXISTS "normalizedWeightDistribution"; +ALTER TABLE "Vote" DROP COLUMN IF EXISTS "normalizedQuadraticWeights"; + +-- Add generated columns +ALTER TABLE "Vote" ADD COLUMN "normalizedWeightDistribution" JSONB + GENERATED ALWAYS AS (json_normalize_weights("weightDistribution", "votingPower")) STORED; + +ALTER TABLE "Vote" ADD COLUMN "normalizedQuadraticWeights" JSONB + GENERATED ALWAYS AS (calculate_normalized_quadratic_weights(json_normalize_weights("weightDistribution", "votingPower"))) STORED; + +-- Comment explaining usage +COMMENT ON FUNCTION json_normalize_weights IS + 'Normalizes a JSON weight distribution so values sum to the specified voting power. + Usage: SELECT json_normalize_weights(''{"option1": 10, "option2": 15}'', 100) + Returns: {"option1": 40, "option2": 60}'; + +COMMENT ON FUNCTION calculate_normalized_quadratic_weights IS + 'Calculates quadratic weights from linear weights. + Usage: SELECT calculate_normalized_quadratic_weights(''{"option1": 40, "option2": 60}'') + Returns: {"option1": 6.32, "option2": 7.75}'; \ No newline at end of file diff --git a/prisma/migrations/20250514122507_add_created_at_to_user_schema/migration.sql b/prisma/migrations/20250514122507_add_created_at_to_user_schema/migration.sql new file mode 100644 index 0000000..eb3480e --- /dev/null +++ b/prisma/migrations/20250514122507_add_created_at_to_user_schema/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7f62bd2..6e8df3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,7 @@ model User { profilePicture String? pollsCreatedCount Int @default(0) pollsParticipatedCount Int @default(0) + createdAt DateTime @default(now()) createdPolls Poll[] @relation("PollAuthor") actions UserAction[] votes Vote[] @@ -33,35 +34,43 @@ model UserAction { model Poll { pollId Int @id @default(autoincrement()) authorUserId Int - title String + title String? description String? options String[] creationDate DateTime @default(now()) - startDate DateTime - endDate DateTime + startDate DateTime? + endDate DateTime? tags String[] isAnonymous Boolean @default(false) participantCount Int @default(0) voteResults Json searchVector Unsupported("tsvector")? + status PollStatus @default(PUBLISHED) author User @relation("PollAuthor", fields: [authorUserId], references: [id]) userAction UserAction[] votes Vote[] } model Vote { - voteID String @id @default(uuid()) - userId Int - pollId Int - votingPower Int - weightDistribution Json - proof String - quadraticWeights Json? @default(dbgenerated("calculate_quadratic_weights(\"weightDistribution\")")) - poll Poll @relation(fields: [pollId], references: [pollId], onDelete: Cascade) - user User @relation(fields: [userId], references: [id]) + voteID String @id @default(uuid()) + userId Int + pollId Int + votingPower Int + weightDistribution Json + proof String + quadraticWeights Json? @default(dbgenerated("calculate_quadratic_weights(\"weightDistribution\")")) + normalizedWeightDistribution Json? @default(dbgenerated("json_normalize_weights(\"weightDistribution\", \"votingPower\")")) + normalizedQuadraticWeights Json? @default(dbgenerated("calculate_normalized_quadratic_weights(json_normalize_weights(\"weightDistribution\", \"votingPower\"))")) + poll Poll @relation(fields: [pollId], references: [pollId], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) } enum ActionType { CREATED VOTED } + +enum PollStatus { + DRAFT + PUBLISHED +} diff --git a/src/app.module.ts b/src/app.module.ts index 6fc528f..5765855 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,12 +4,13 @@ import { AppService } from './app.service'; import { DatabaseService } from './database/database.service'; import { DatabaseModule } from './database/database.module'; import { PollModule } from './poll/poll.module'; +import { VoteModule } from './vote/vote.module'; import { AuthModule } from './auth/auth.module'; import { AuthService } from './auth/auth.service'; import { UserModule } from './user/user.module'; @Module({ - imports: [DatabaseModule, PollModule, UserModule, AuthModule], + imports: [DatabaseModule, PollModule, VoteModule, UserModule, AuthModule], controllers: [AppController], providers: [AppService, DatabaseService, AuthService], }) diff --git a/src/common/common.dto.ts b/src/common/common.dto.ts new file mode 100644 index 0000000..502b32b --- /dev/null +++ b/src/common/common.dto.ts @@ -0,0 +1,15 @@ +import { IsDateString, IsOptional, IsString, Matches } from 'class-validator'; + +export class GetCountDto { + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsDateString({ strict: true }) + from?: string; + + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsDateString({ strict: true }) + to?: string; +} diff --git a/src/poll/Poll.dto.ts b/src/poll/Poll.dto.ts index 4e55d62..fc42e68 100644 --- a/src/poll/Poll.dto.ts +++ b/src/poll/Poll.dto.ts @@ -9,7 +9,9 @@ import { IsOptional, IsString, Min, + Validate, } from 'class-validator'; +import { IsPositiveInteger } from '../common/validators'; export class CreatePollDto { @IsString() @@ -43,6 +45,43 @@ export class CreatePollDto { isAnonymous?: boolean; } +export class DraftPollDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + options?: string[]; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsBoolean() + isAnonymous?: boolean; + + @IsOptional() + @Validate(IsPositiveInteger) + @Transform(({ value }) => (value ? parseInt(value, 10) : undefined)) + pollId?: number; +} + export class GetPollsDto { @IsString() @IsOptional() @@ -83,3 +122,10 @@ export class GetPollsDto { @IsEnum(['asc', 'desc']) sortOrder?: 'asc' | 'desc'; } + +export class GetPollVotesDto { + @IsInt() + @Type(() => Number) + @IsNotEmpty() + pollId: number; +} diff --git a/src/poll/poll.controller.ts b/src/poll/poll.controller.ts index e514297..a8572d4 100644 --- a/src/poll/poll.controller.ts +++ b/src/poll/poll.controller.ts @@ -4,12 +4,15 @@ import { Delete, Get, Param, + Patch, Post, Query, } from '@nestjs/common'; -import { CreatePollDto, GetPollsDto } from './Poll.dto'; -import { PollService } from './poll.service'; import { User } from 'src/auth/user.decorator'; +import { CreatePollDto, DraftPollDto, GetPollsDto } from './Poll.dto'; +import { PollService } from './poll.service'; +import { Public } from 'src/auth/jwt-auth.guard'; +import { GetCountDto } from '../common/common.dto'; @Controller('poll') export class PollController { @@ -23,6 +26,19 @@ export class PollController { return await this.pollService.createPoll(dto, worldID); } + @Patch('draft') + async patchDraftPoll( + @Body() dto: DraftPollDto, + @User('worldID') worldID: string, + ) { + return await this.pollService.patchDraftPoll(dto, worldID); + } + + @Get('draft') + async getDraftPoll(@User('worldID') worldID: string) { + return await this.pollService.getUserDraftPoll(worldID); + } + @Get() async getPolls( @Query() query: GetPollsDto, @@ -31,11 +47,22 @@ export class PollController { return await this.pollService.getPolls(query, worldID); } + @Get('count') + @Public() + async getPollsCount(@Query() query: GetCountDto): Promise { + return await this.pollService.getPollsCount(query); + } + @Get(':id') async getPollDetails(@Param('id') id: number) { return await this.pollService.getPollDetails(Number(id)); } + @Get(':id/votes') + async getPollVotes(@Param('id') id: number) { + return await this.pollService.getPollVotes(Number(id)); + } + @Delete(':id') async deletePoll(@Param('id') id: number, @User('worldID') worldID: string) { const poll = await this.pollService.deletePoll(Number(id), worldID); diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index 2e673fd..3c29bcb 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -4,15 +4,18 @@ import { Inject, Injectable, } from '@nestjs/common'; -import { ActionType, Prisma } from '@prisma/client'; +import { ActionType, PollStatus, Prisma } from '@prisma/client'; import { DatabaseService } from 'src/database/database.service'; import { UserService } from 'src/user/user.service'; +import { GetCountDto } from '../common/common.dto'; import { PollNotFoundException, UnauthorizedActionException, UserNotFoundException, } from '../common/exceptions'; -import { CreatePollDto, GetPollsDto } from './Poll.dto'; +import { CreatePollDto, DraftPollDto, GetPollsDto } from './Poll.dto'; + +const IS_VOTE_NORMALIZATION = process.env.ENABLE_VOTE_NORMALIZATION === 'true'; @Injectable() export class PollService { @@ -41,16 +44,19 @@ export class PollService { .split(' ') .map((word) => `${word}:*`) .join(' & '); + const includeStatus = PollStatus.PUBLISHED; const searchResults = await this.databaseService.$queryRaw< { pollId: number }[] >` SELECT "pollId" FROM "Poll" WHERE "searchVector" @@ to_tsquery('english', ${searchQuery}) + AND "status" = ${includeStatus}::text::"PollStatus" ORDER BY ts_rank("searchVector", to_tsquery('english', ${searchQuery})) DESC `; return searchResults.map((result) => result.pollId); } + // Should be used only for creating published polls async createPoll(createPollDto: CreatePollDto, worldID: string) { const user = await this.databaseService.user.findUnique({ where: { worldID }, @@ -71,6 +77,13 @@ export class PollService { throw new BadRequestException('End date must be after start date'); } return this.databaseService.$transaction(async (tx) => { + // Delete all existing drafts + await tx.poll.deleteMany({ + where: { + authorUserId: user.id, + status: PollStatus.DRAFT, + }, + }); const newPoll = await tx.poll.create({ data: { authorUserId: user.id, @@ -100,6 +113,126 @@ export class PollService { }); } + async patchDraftPoll(draftPollDto: DraftPollDto, worldID: string) { + const user = await this.databaseService.user.findUnique({ + where: { worldID }, + select: { id: true }, + }); + if (!user) { + throw new UserNotFoundException(); + } + + // Handle case with specified pollId + if (draftPollDto.pollId) { + // Update existing draft + const existingPoll = await this.databaseService.poll.findUnique({ + where: { pollId: draftPollDto.pollId }, + select: { authorUserId: true, status: true }, + }); + + if (!existingPoll) { + throw new PollNotFoundException(); + } + + if (existingPoll.authorUserId !== user.id) { + throw new UnauthorizedActionException(); + } + + if (existingPoll.status !== PollStatus.DRAFT) { + throw new BadRequestException('Cannot update a published poll!'); + } + + const updateData: Prisma.PollUpdateInput = {}; + if (draftPollDto.title !== undefined) + updateData.title = draftPollDto.title; + if (draftPollDto.description !== undefined) + updateData.description = draftPollDto.description; + if (draftPollDto.options !== undefined) + updateData.options = draftPollDto.options; + if (draftPollDto.startDate !== undefined) + updateData.startDate = new Date(draftPollDto.startDate); + if (draftPollDto.endDate !== undefined) + updateData.endDate = new Date(draftPollDto.endDate); + if (draftPollDto.tags !== undefined) updateData.tags = draftPollDto.tags; + if (draftPollDto.isAnonymous !== undefined) + updateData.isAnonymous = draftPollDto.isAnonymous; + + return await this.databaseService.poll.update({ + where: { pollId: draftPollDto.pollId }, + data: updateData, + }); + } else { + // For new drafts, use a transaction + return this.databaseService.$transaction(async (tx) => { + // Find existing drafts (if any) + const existingDrafts = await tx.poll.findMany({ + where: { authorUserId: user.id, status: PollStatus.DRAFT }, + select: { pollId: true }, + orderBy: { creationDate: 'desc' }, + }); + + // Prepare poll data + const pollData = { + authorUserId: user.id, + title: draftPollDto.title, + description: draftPollDto.description, + options: draftPollDto.options || [], + startDate: draftPollDto.startDate + ? new Date(draftPollDto.startDate) + : undefined, + endDate: draftPollDto.endDate + ? new Date(draftPollDto.endDate) + : undefined, + tags: draftPollDto.tags || [], + isAnonymous: draftPollDto.isAnonymous || false, + status: PollStatus.DRAFT, + voteResults: {}, + }; + + // Clean up multiple drafts if found + if (existingDrafts.length > 1) { + // Delete all but the most recent draft + for (let i = 1; i < existingDrafts.length; i++) { + await tx.poll.delete({ + where: { pollId: existingDrafts[i].pollId }, + }); + } + } + + // Update existing draft or create new one + if (existingDrafts.length > 0) { + return await tx.poll.update({ + where: { pollId: existingDrafts[0].pollId }, + data: pollData, + }); + } else { + return await tx.poll.create({ data: pollData }); + } + }); + } + } + + async getUserDraftPoll(worldID: string) { + const user = await this.databaseService.user.findUnique({ + where: { worldID }, + select: { id: true }, + }); + if (!user) { + throw new UserNotFoundException(); + } + // We should only have maximum one draft poll per user + const draft = await this.databaseService.poll.findFirst({ + where: { + authorUserId: user.id, + status: PollStatus.DRAFT, + }, + orderBy: { + creationDate: 'desc', + }, + }); + return draft; + } + async getPolls(query: GetPollsDto, worldID: string) { const { page = 1, @@ -113,7 +246,9 @@ export class PollService { } = query; const skip = (page - 1) * limit; const now = new Date(); - const filters: Prisma.PollWhereInput = {}; + const filters: Prisma.PollWhereInput = { + status: PollStatus.PUBLISHED, + }; if (isActive) { filters.startDate = { lte: now }; @@ -142,74 +277,191 @@ export class PollService { }); const votedPollIds = userVotes.map((v) => v.pollId); - filters.OR = [{ authorUserId: userId }, { pollId: { in: votedPollIds } }]; + // Create a copy of current filters to avoid overwriting status + const currentFilters = { ...filters }; + filters.AND = [ + currentFilters, + { OR: [{ authorUserId: userId }, { pollId: { in: votedPollIds } }] }, + ]; } else { if (userCreated) { - filters.authorUserId = userId; + // Create a copy of current filters to avoid overwriting status + const currentFilters = { ...filters }; + filters.AND = [currentFilters, { authorUserId: userId }]; } - let votedPollIds: number[] = []; + if (userVoted) { const userVotes = await this.databaseService.vote.findMany({ where: { userId }, select: { pollId: true }, }); - votedPollIds = userVotes.map((v) => v.pollId); - filters.pollId = { in: votedPollIds }; + const votedPollIds = userVotes.map((v) => v.pollId); + + // Create a copy of current filters to avoid overwriting status + const currentFilters = { ...filters }; + filters.AND = [currentFilters, { pollId: { in: votedPollIds } }]; } } if (search) { const pollIds = await this.searchPolls(search); - if (Object.keys(filters).length > 0) { + if (!filters.AND) { + // Create a copy of current filters to avoid overwriting status const currentFilters = { ...filters }; filters.AND = [currentFilters, { pollId: { in: pollIds } }]; } else { - filters.pollId = { in: pollIds }; + // Add to existing AND condition by creating a new array + filters.AND = [ + ...(filters.AND as Prisma.PollWhereInput[]), + { pollId: { in: pollIds } }, + ]; } } - const orderBy: Prisma.PollOrderByWithRelationInput = {}; - if (sortBy) { - orderBy[sortBy] = sortOrder; - } else { - orderBy.endDate = 'asc'; - } + let orderBy: Prisma.PollOrderByWithRelationInput[] = []; + + if (sortBy === 'endDate') { + const activeFilters = { + ...filters, + startDate: { lte: now }, + endDate: { gt: now }, + }; + + const inactiveFilters = { + ...filters, + OR: [{ startDate: { gt: now } }, { endDate: { lte: now } }], + }; + + const [activePolls, inactivePolls, total] = + await this.databaseService.$transaction([ + this.databaseService.poll.findMany({ + where: activeFilters, + include: { + author: true, + votes: { + where: { userId }, + select: { voteID: true }, + }, + }, + orderBy: { endDate: sortOrder }, + take: Number(limit) + skip, + }), + this.databaseService.poll.findMany({ + where: inactiveFilters, + include: { + author: true, + votes: { + where: { userId }, + select: { voteID: true }, + }, + }, + orderBy: { endDate: sortOrder }, + take: Number(limit) + skip, + }), + this.databaseService.poll.count({ where: filters }), + ]); + + const combinedPolls = [...activePolls, ...inactivePolls]; + + const paginatedPolls = combinedPolls.slice(skip, skip + Number(limit)); + + const pollsWithVoteStatus = paginatedPolls.map((poll) => { + const { votes, ...pollWithoutVotes } = poll; - const [polls, total] = await this.databaseService.$transaction([ - this.databaseService.poll.findMany({ - where: filters, - include: { - author: true, - votes: { - where: { userId }, - select: { voteID: true }, + return { + ...pollWithoutVotes, + hasVoted: votes.length > 0, + }; + }); + + return { + polls: pollsWithVoteStatus, + total, + }; + } else if (sortBy) { + orderBy.push({ + [sortBy]: sortOrder, + }); + + const [polls, total] = await this.databaseService.$transaction([ + this.databaseService.poll.findMany({ + where: filters, + include: { + author: true, + votes: { + where: { userId }, + select: { voteID: true }, + }, }, - }, - orderBy, - skip, - take: Number(limit), - }), - this.databaseService.poll.count({ where: filters }), - ]); + orderBy, + skip, + take: Number(limit), + }), + this.databaseService.poll.count({ where: filters }), + ]); - const pollsWithVoteStatus = polls.map((poll) => { - const { votes, ...pollWithoutVotes } = poll; + const pollsWithVoteStatus = polls.map((poll) => { + const { votes, ...pollWithoutVotes } = poll; + + return { + ...pollWithoutVotes, + hasVoted: votes.length > 0, + }; + }); return { - ...pollWithoutVotes, - hasVoted: votes.length > 0, + polls: pollsWithVoteStatus, + total, }; - }); + } else { + orderBy = [ + { + endDate: 'asc', + }, + { + startDate: 'asc', + }, + ]; - return { - polls: pollsWithVoteStatus, - total, - }; + const [polls, total] = await this.databaseService.$transaction([ + this.databaseService.poll.findMany({ + where: filters, + include: { + author: true, + votes: { + where: { userId }, + select: { voteID: true }, + }, + }, + orderBy, + skip, + take: Number(limit), + }), + this.databaseService.poll.count({ where: filters }), + ]); + + const pollsWithVoteStatus = polls.map((poll) => { + const { votes, ...pollWithoutVotes } = poll; + + return { + ...pollWithoutVotes, + hasVoted: votes.length > 0, + }; + }); + + return { + polls: pollsWithVoteStatus, + total, + }; + } } async getPollDetails(id: number) { const poll = await this.databaseService.poll.findUnique({ - where: { pollId: id }, + where: { + pollId: id, + status: PollStatus.PUBLISHED, + }, include: { author: true, }, @@ -217,8 +469,13 @@ export class PollService { if (!poll) { throw new PollNotFoundException(); } + const now = new Date(); - const isActive = now >= poll.startDate && now <= poll.endDate; + const isActive = + poll.startDate && + poll.endDate && + now >= poll.startDate && + now <= poll.endDate; const optionsTotalVotes = await this.getPollQuadraticResults(id); const totalVotes = Object.values(optionsTotalVotes).reduce( (acc, votes) => acc + votes, @@ -244,48 +501,123 @@ export class PollService { if (poll.authorUserId !== user.id) { throw new UnauthorizedActionException(); } + return this.databaseService.$transaction(async (tx) => { - const pollParticipants = await tx.userAction.findMany({ - where: { pollId, type: ActionType.VOTED }, - select: { userId: true }, - }); - const participantUserIds = [ - ...new Set(pollParticipants.map((v) => v.userId)), - ]; - const deleted = await tx.poll.delete({ - where: { - pollId, - }, - }); - await this.userService.updateUserPollsCount( - deleted.authorUserId, - ActionType.CREATED, - tx, - ); - for (const userId of participantUserIds) { + // If it's a published poll, update user action counts + if (poll.status === PollStatus.PUBLISHED) { + const pollParticipants = await tx.userAction.findMany({ + where: { pollId, type: ActionType.VOTED }, + select: { userId: true }, + }); + const participantUserIds = [ + ...new Set(pollParticipants.map((v) => v.userId)), + ]; + + const deleted = await tx.poll.delete({ + where: { + pollId, + }, + }); + await this.userService.updateUserPollsCount( - userId, - ActionType.VOTED, + deleted.authorUserId, + ActionType.CREATED, tx, ); + + for (const userId of participantUserIds) { + await this.userService.updateUserPollsCount( + userId, + ActionType.VOTED, + tx, + ); + } + + return deleted; + } else { + // If it's a draft poll, simply delete it without updating counts + return await tx.poll.delete({ + where: { + pollId, + }, + }); } - return deleted; }); } + async getPollVotes(pollId: number) { + const poll = await this.databaseService.poll.findUnique({ + where: { + pollId, + status: PollStatus.PUBLISHED, + isAnonymous: false, // Only return votes for non-anonymous polls + }, + select: { + pollId: true, + title: true, + }, + }); + + if (!poll) { + throw new PollNotFoundException(); + } + const select = IS_VOTE_NORMALIZATION + ? { + normalizedQuadraticWeights: true, + normalizedWeightDistribution: true, + user: { select: { worldID: true, name: true } }, + } + : { + quadraticWeights: true, + weightDistribution: true, + user: { select: { worldID: true, name: true } }, + }; + const votes = await this.databaseService.vote.findMany({ + where: { pollId }, + select, + }); + + const formattedVotes = votes.map((vote) => { + const quadraticWeights = IS_VOTE_NORMALIZATION + ? vote.normalizedQuadraticWeights + : vote.quadraticWeights; + + const totalQuadraticWeights = Object.values( + quadraticWeights as Record, + ).reduce((sum, value) => sum + value, 0); + return { + username: vote.user.name, + quadraticWeights, + totalQuadraticWeights, + }; + }); + + return { + votes: formattedVotes, + pollTitle: poll.title, + pollId: poll.pollId, + }; + } + async getPollQuadraticResults( pollId: number, ): Promise> { const poll = await this.databaseService.poll.findUnique({ - where: { pollId }, + where: { + pollId, + status: PollStatus.PUBLISHED, + }, select: { options: true }, }); if (!poll) { throw new PollNotFoundException(); } + const select = IS_VOTE_NORMALIZATION + ? { normalizedQuadraticWeights: true } + : { quadraticWeights: true }; const votes = await this.databaseService.vote.findMany({ where: { pollId }, - select: { quadraticWeights: true }, + select, }); const result: Record = poll.options.reduce( (acc, option) => { @@ -295,14 +627,33 @@ export class PollService { {} as Record, ); votes.forEach((vote) => { - if (vote.quadraticWeights) { - Object.entries(vote.quadraticWeights as Record).forEach( - ([option, weight]) => { + const weights = IS_VOTE_NORMALIZATION + ? vote.normalizedQuadraticWeights + : vote.quadraticWeights; + + if (weights && typeof weights === 'object') { + Object.entries(weights).forEach(([option, weight]) => { + if (typeof weight === 'number') { result[option] = (result[option] || 0) + weight; - }, - ); + } + }); } }); return result; } + + async getPollsCount(query: GetCountDto): Promise { + const { from, to } = query; + const where: Prisma.PollWhereInput = {}; + + if (from && to) { + where.creationDate = { gte: new Date(from), lte: new Date(to) }; + } else if (from) { + where.creationDate = { gte: new Date(from) }; + } else if (to) { + where.creationDate = { lte: new Date(to) }; + } + + return await this.databaseService.poll.count({ where }); + } } diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index dfd520f..923824b 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -15,6 +15,8 @@ import { } from './user.dto'; import { UserService } from './user.service'; import { User } from 'src/auth/user.decorator'; +import { Public } from 'src/auth/jwt-auth.guard'; +import { GetCountDto } from '../common/common.dto'; @Controller('user') export class UserController { @@ -62,4 +64,10 @@ export class UserController { async createUser(@Body() dto: CreateUserDto): Promise { return await this.userService.createUser(dto); } + + @Get('count') + @Public() + async getUserCount(@Query() query: GetCountDto): Promise { + return await this.userService.getUserCount(query); + } } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 35af03d..1e2e5af 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,5 +1,6 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { ActionType, Prisma } from '@prisma/client'; +import { ActionType, PollStatus, Prisma } from '@prisma/client'; +import { GetCountDto } from '../common/common.dto'; import { VOTING_POWER } from '../common/constants'; import { CreateUserException, @@ -124,16 +125,27 @@ export class UserService { throw new UserNotFoundException(); } - const filters: Prisma.UserActionWhereInput = { userId: user.id }; + const filters: Prisma.UserActionWhereInput = { + userId: user.id, + poll: { status: PollStatus.PUBLISHED }, + }; const now = new Date(); if (dto.isActive || dto.isInactive) { if (dto.isActive && !dto.isInactive) { - filters.poll = { endDate: { gte: now } }; + filters.poll = { + status: PollStatus.PUBLISHED, + endDate: { gte: now }, + }; } else if (!dto.isActive && dto.isInactive) { - filters.poll = { endDate: { lt: now } }; + filters.poll = { + status: PollStatus.PUBLISHED, + endDate: { lt: now }, + }; + } else { + // If both are true or both are false, only filter by status + filters.poll = { status: PollStatus.PUBLISHED }; } - // If both are true or both are false, don't filter by activity status } if (dto.isCreated || dto.isParticipated) { @@ -181,6 +193,7 @@ export class UserService { pollId: true, title: true, description: true, + isAnonymous: true, endDate: true, authorUserId: true, participantCount: true, @@ -200,10 +213,11 @@ export class UserService { id: action.id, type: action.type, pollId: action.poll.pollId, - pollTitle: action.poll.title, + pollTitle: action.poll.title || '', + isAnonymous: action.poll.isAnonymous, pollDescription: action.poll.description ?? '', - endDate: action.poll.endDate.toISOString(), - isActive: action.poll.endDate >= now, + endDate: action.poll.endDate ? action.poll.endDate.toISOString() : '', + isActive: action.poll.endDate ? action.poll.endDate >= now : false, votersParticipated: action.poll.participantCount, authorWorldId: author?.worldID || '', authorName: author?.name || '', @@ -227,10 +241,13 @@ export class UserService { throw new UserNotFoundException(); } const poll = await this.databaseService.poll.findUnique({ - where: { pollId: dto.pollId }, + where: { + pollId: dto.pollId, + status: PollStatus.PUBLISHED, + }, select: { endDate: true, options: true }, }); - if (!poll || poll.endDate < new Date()) { + if (!poll || (poll.endDate && poll.endDate < new Date())) { throw new PollNotFoundException(); } const vote = await this.databaseService.vote.findFirst({ @@ -264,10 +281,13 @@ export class UserService { throw new UserNotFoundException(); } const poll = await this.databaseService.poll.findUnique({ - where: { pollId: dto.pollId }, + where: { + pollId: dto.pollId, + status: PollStatus.PUBLISHED, + }, select: { endDate: true, options: true }, }); - if (!poll || poll.endDate < new Date()) { + if (!poll || (poll.endDate && poll.endDate < new Date())) { throw new PollNotFoundException(); } this.validateWeightDistribution(dto.weightDistribution, poll.options); @@ -314,15 +334,21 @@ export class UserService { where: { voteID: dto.voteID }, select: { userId: true, + pollId: true, poll: { - select: { endDate: true, options: true }, + select: { endDate: true, options: true, status: true }, }, }, }); if (!vote) { throw new VoteNotFoundException(); } - if (vote.poll.endDate < new Date()) { + + if (vote.poll.status !== PollStatus.PUBLISHED) { + throw new PollNotFoundException(); + } + + if (vote.poll.endDate && vote.poll.endDate < new Date()) { throw new PollNotFoundException(); } // TODO: should add worldID to Vote later @@ -384,4 +410,19 @@ export class UserService { }; }); } + + async getUserCount(query: GetCountDto): Promise { + const { from, to } = query; + const where: Prisma.UserWhereInput = {}; + + if (from && to) { + where.createdAt = { gte: new Date(from), lte: new Date(to) }; + } else if (from) { + where.createdAt = { gte: new Date(from) }; + } else if (to) { + where.createdAt = { lte: new Date(to) }; + } + + return await this.databaseService.user.count({ where }); + } } diff --git a/src/vote/vote.controller.ts b/src/vote/vote.controller.ts new file mode 100644 index 0000000..1e4a6d8 --- /dev/null +++ b/src/vote/vote.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { Public } from 'src/auth/jwt-auth.guard'; +import { GetCountDto } from '../common/common.dto'; +import { VoteService } from './vote.service'; + +@Controller('vote') +export class VoteController { + constructor(private readonly voteService: VoteService) {} + + @Get('count') + @Public() + async getVotesCount(@Query() query: GetCountDto) { + return await this.voteService.getVotesCount(query); + } +} diff --git a/src/vote/vote.module.ts b/src/vote/vote.module.ts new file mode 100644 index 0000000..ba0b697 --- /dev/null +++ b/src/vote/vote.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { VoteController } from './vote.controller'; +import { VoteService } from './vote.service'; + +@Module({ + imports: [], + controllers: [VoteController], + providers: [VoteService], +}) +export class VoteModule {} diff --git a/src/vote/vote.service.ts b/src/vote/vote.service.ts new file mode 100644 index 0000000..36207cd --- /dev/null +++ b/src/vote/vote.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { ActionType, Prisma } from '@prisma/client'; +import { DatabaseService } from 'src/database/database.service'; +import { GetCountDto } from '../common/common.dto'; + +@Injectable() +export class VoteService { + constructor(private readonly databaseService: DatabaseService) {} + + async getVotesCount(query: GetCountDto) { + const { from, to } = query; + const where: Prisma.UserActionWhereInput = { type: ActionType.VOTED }; + + if (from && to) { + where.createdAt = { gte: new Date(from), lte: new Date(to) }; + } else if (from) { + where.createdAt = { gte: new Date(from) }; + } else if (to) { + where.createdAt = { lte: new Date(to) }; + } + + return await this.databaseService.userAction.count({ + where, + }); + } +}