From 7cbaec89f07b6f4b20d2c3fcb7c90d694492745c Mon Sep 17 00:00:00 2001 From: Ramin Date: Sun, 4 May 2025 14:09:39 +0330 Subject: [PATCH 01/25] add PollStatus --- prisma/schema.prisma | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bdb08bb..1c40c3c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,15 +33,16 @@ 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) + status PollStatus @default(PUBLISHED) voteResults Json searchVector Unsupported("tsvector")? author User @relation("PollAuthor", fields: [authorUserId], references: [id]) @@ -65,3 +66,8 @@ enum ActionType { CREATED VOTED } + +enum PollStatus { + DRAFT + PUBLISHED +} From 9426a193cdeb88397d7743f2f36883b50c91c8a3 Mon Sep 17 00:00:00 2001 From: Ramin Date: Sun, 4 May 2025 14:10:29 +0330 Subject: [PATCH 02/25] change all poll fields to optional --- .../migrations/20250503203644_add_poll_status/migration.sql | 5 +++++ .../20250503214151_make_poll_fields_optional/migration.sql | 4 ++++ 2 files changed, 9 insertions(+) create mode 100644 prisma/migrations/20250503203644_add_poll_status/migration.sql create mode 100644 prisma/migrations/20250503214151_make_poll_fields_optional/migration.sql 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; From d2ca4791384955088223d37b64f1a6ce5abf8d75 Mon Sep 17 00:00:00 2001 From: Ramin Date: Sun, 4 May 2025 14:25:35 +0330 Subject: [PATCH 03/25] add patchDraftPoll and getDraftPoll to poll controller --- src/poll/poll.controller.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/poll/poll.controller.ts b/src/poll/poll.controller.ts index e514297..3daadfa 100644 --- a/src/poll/poll.controller.ts +++ b/src/poll/poll.controller.ts @@ -4,12 +4,13 @@ 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'; @Controller('poll') export class PollController { @@ -23,6 +24,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, From f3eeb14262dbfe8cb8dd6a3b3138fe152506fe6b Mon Sep 17 00:00:00 2001 From: Ramin Date: Sun, 4 May 2025 14:27:42 +0330 Subject: [PATCH 04/25] add DraftPollDto --- src/poll/Poll.dto.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/poll/Poll.dto.ts b/src/poll/Poll.dto.ts index 4e55d62..1f148e5 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() From 7d82885c2471c9c7ffab1d424a2ca2c5606b5fab Mon Sep 17 00:00:00 2001 From: Ramin Date: Sun, 4 May 2025 15:15:28 +0330 Subject: [PATCH 05/25] add draft poll to poll service --- src/poll/poll.service.ts | 208 +++++++++++++++++++++++++++++++-------- 1 file changed, 168 insertions(+), 40 deletions(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index 3da56f6..4ca1241 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -4,7 +4,7 @@ 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 { @@ -12,7 +12,7 @@ import { UnauthorizedActionException, UserNotFoundException, } from '../common/exceptions'; -import { CreatePollDto, GetPollsDto } from './Poll.dto'; +import { CreatePollDto, DraftPollDto, GetPollsDto } from './Poll.dto'; @Injectable() export class PollService { @@ -41,11 +41,13 @@ 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); @@ -64,12 +66,6 @@ export class PollService { const endDate = new Date(createPollDto.endDate); const now = new Date(); - console.log('date', { - checkDate, - startDate, - now, - }); - if (checkDate < now) { throw new BadRequestException('Start date cannot be in the past'); } @@ -106,6 +102,98 @@ 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(); + } + + // Create new draft or update existing one + 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 { + // Create new draft poll without default values + return await this.databaseService.poll.create({ + data: { + 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: {}, + }, + }); + } + } + + 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, @@ -119,7 +207,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 }; @@ -148,29 +238,41 @@ 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 + filters.AND.push({ pollId: { in: pollIds } }); } } @@ -215,7 +317,10 @@ export class PollService { async getPollDetails(id: number) { const poll = await this.databaseService.poll.findUnique({ - where: { pollId: id }, + where: { + pollId: id, + status: PollStatus.PUBLISHED, + }, include: { author: true, }, @@ -223,8 +328,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, @@ -250,32 +360,47 @@ 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; }); } @@ -283,7 +408,10 @@ export class PollService { pollId: number, ): Promise> { const poll = await this.databaseService.poll.findUnique({ - where: { pollId }, + where: { + pollId, + status: PollStatus.PUBLISHED, + }, select: { options: true }, }); if (!poll) { From 95991f72b575683cfa28e1ecac351c77b9e0d04d Mon Sep 17 00:00:00 2001 From: Ramin Date: Sun, 4 May 2025 15:28:18 +0330 Subject: [PATCH 06/25] fix filters.AND error --- src/poll/poll.service.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index 4ca1241..ff26bb6 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -271,8 +271,11 @@ export class PollService { const currentFilters = { ...filters }; filters.AND = [currentFilters, { pollId: { in: pollIds } }]; } else { - // Add to existing AND condition - filters.AND.push({ pollId: { in: pollIds } }); + // Add to existing AND condition by creating a new array + filters.AND = [ + ...(filters.AND as Prisma.PollWhereInput[]), + { pollId: { in: pollIds } }, + ]; } } From 89a1e3064c602c43df23892d3469427586467003 Mon Sep 17 00:00:00 2001 From: Ramin Date: Sun, 4 May 2025 15:43:35 +0330 Subject: [PATCH 07/25] change userActivities to join only published polls --- src/poll/poll.service.ts | 1 + src/user/user.service.ts | 51 +++++++++++++++++++++++++++++----------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index ff26bb6..b154283 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -53,6 +53,7 @@ export class PollService { return searchResults.map((result) => result.pollId); } + // Should be used only for creatingpublished polls async createPoll(createPollDto: CreatePollDto, worldID: string) { const user = await this.databaseService.user.findUnique({ where: { worldID }, diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 35af03d..47351de 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,5 +1,5 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { ActionType, Prisma } from '@prisma/client'; +import { ActionType, PollStatus, Prisma } from '@prisma/client'; import { VOTING_POWER } from '../common/constants'; import { CreateUserException, @@ -124,16 +124,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) { @@ -200,10 +211,10 @@ export class UserService { id: action.id, type: action.type, pollId: action.poll.pollId, - pollTitle: action.poll.title, + pollTitle: action.poll.title || '', 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 +238,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 +278,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 +331,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 From e0a703b3b10e30d136903ee923d87ce447a01136 Mon Sep 17 00:00:00 2001 From: Ramin Date: Mon, 5 May 2025 15:15:31 +0330 Subject: [PATCH 08/25] check for existingDraft --- src/poll/poll.service.ts | 43 +++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index b154283..9101a11 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -152,24 +152,35 @@ export class PollService { data: updateData, }); } else { + 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: {}, + }; + const existingDraft = await this.databaseService.poll.findFirst({ + where: { authorUserId: user.id, status: PollStatus.DRAFT }, + select: { pollId: true }, + }); + if (existingDraft) { + return await this.databaseService.poll.update({ + where: { pollId: existingDraft.pollId }, + data: pollData, + }); + } // Create new draft poll without default values return await this.databaseService.poll.create({ - data: { - 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: {}, - }, + data: pollData, }); } } From 67f4612b2cc0681cb052887588444817a4d5c152 Mon Sep 17 00:00:00 2001 From: Ramin Date: Wed, 7 May 2025 02:45:53 +0330 Subject: [PATCH 09/25] prevent race conditions in creating draft poll --- src/poll/poll.service.ts | 75 ++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index 9101a11..c54275e 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -112,7 +112,7 @@ export class PollService { throw new UserNotFoundException(); } - // Create new draft or update existing one + // Handle case with specified pollId if (draftPollDto.pollId) { // Update existing draft const existingPoll = await this.databaseService.poll.findUnique({ @@ -152,35 +152,52 @@ export class PollService { data: updateData, }); } else { - 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: {}, - }; - const existingDraft = await this.databaseService.poll.findFirst({ - where: { authorUserId: user.id, status: PollStatus.DRAFT }, - select: { pollId: true }, - }); - if (existingDraft) { - return await this.databaseService.poll.update({ - where: { pollId: existingDraft.pollId }, - data: pollData, + // 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' }, }); - } - // Create new draft poll without default values - return await this.databaseService.poll.create({ - data: pollData, + + // 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 }); + } }); } } From 37e8d42e569a9af281e340aad051a19ceb8d5a8e Mon Sep 17 00:00:00 2001 From: Ramin Date: Mon, 12 May 2025 15:16:31 +0330 Subject: [PATCH 10/25] normalize voting distribution --- .../migration.sql | 109 ++++++++++++++++++ prisma/schema.prisma | 22 ++-- 2 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20250512113354_add_generated_normalized_votes/migration.sql 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/schema.prisma b/prisma/schema.prisma index 222a306..aefc69a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,24 +42,26 @@ model Poll { tags String[] isAnonymous Boolean @default(false) participantCount Int @default(0) - status PollStatus @default(PUBLISHED) 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 { From 1d54b871c2b3c2455b49a9f2804f4132f4e87fd4 Mon Sep 17 00:00:00 2001 From: Ramin Date: Mon, 12 May 2025 16:30:13 +0330 Subject: [PATCH 11/25] add env var to switch normalization on and off --- src/poll/poll.service.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index c54275e..f6ef354 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -14,6 +14,8 @@ import { } from '../common/exceptions'; import { CreatePollDto, DraftPollDto, GetPollsDto } from './Poll.dto'; +const IS_VOTE_NORMALIZATION = process.env.ENABLE_VOTE_NORMALIZATION === 'true'; + @Injectable() export class PollService { constructor( @@ -449,9 +451,12 @@ export class PollService { 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) => { @@ -461,12 +466,16 @@ 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; From feb2ffeee98d2236bcddb65e454a7b037094592e Mon Sep 17 00:00:00 2001 From: Ramin Date: Mon, 12 May 2025 16:38:47 +0330 Subject: [PATCH 12/25] update env example --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 2034b4b..71d1471 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ DATABASE_URL= WHITELISTED_ORIGINS= TUNNEL_DOMAINS= -JWT_SECRET= \ No newline at end of file +JWT_SECRET= +ENABLE_VOTE_NORMALIZATION= \ No newline at end of file From ec50b84c3b5f8be193ba604e30a07066aa1d74f8 Mon Sep 17 00:00:00 2001 From: Ramin Date: Mon, 12 May 2025 17:14:04 +0330 Subject: [PATCH 13/25] update comprehensive documentation for worldview BE --- README.md | 376 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 308 insertions(+), 68 deletions(-) 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). From ce8610abca45d2b07482a858c9a30db6f5d89a0f Mon Sep 17 00:00:00 2001 From: Meriem-BM Date: Wed, 14 May 2025 14:05:23 +0100 Subject: [PATCH 14/25] feat: add users count route --- .../migration.sql | 2 ++ prisma/schema.prisma | 1 + src/user/user.controller.ts | 8 ++++++++ src/user/user.dto.ts | 13 +++++++++++++ src/user/user.service.ts | 18 ++++++++++++++++++ 5 files changed, 42 insertions(+) create mode 100644 prisma/migrations/20250514122507_add_createed_at_to_user_schema/migration.sql diff --git a/prisma/migrations/20250514122507_add_createed_at_to_user_schema/migration.sql b/prisma/migrations/20250514122507_add_createed_at_to_user_schema/migration.sql new file mode 100644 index 0000000..eb3480e --- /dev/null +++ b/prisma/migrations/20250514122507_add_createed_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 aefc69a..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[] diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index dfd520f..4b08279 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -5,6 +5,7 @@ import { EditVoteDto, EditVoteResponseDto, GetUserActivitiesDto, + GetUserCountDto, GetUserDataDto, GetUserVotesDto, SetVoteDto, @@ -15,6 +16,7 @@ import { } from './user.dto'; import { UserService } from './user.service'; import { User } from 'src/auth/user.decorator'; +import { Public } from 'src/auth/jwt-auth.guard'; @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('userCount') + @Public() + async getUserCount(@Query() query: GetUserCountDto): Promise { + return await this.userService.getUserCount(query); + } } diff --git a/src/user/user.dto.ts b/src/user/user.dto.ts index 1f6863c..34d0880 100644 --- a/src/user/user.dto.ts +++ b/src/user/user.dto.ts @@ -10,6 +10,7 @@ import { IsNumber, IsOptional, IsString, + Matches, Validate, } from 'class-validator'; import { IsPositiveInteger, IsRecordStringNumber } from '../common/validators'; @@ -197,3 +198,15 @@ export class CreateUserResponseDto { @IsNumber() userId: number; } + +export class GetUserCountDto { + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + from?: string; + + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + to?: string; +} diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 47351de..84b515b 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -19,6 +19,7 @@ import { EditVoteDto, EditVoteResponseDto, GetUserActivitiesDto, + GetUserCountDto, GetUserDataDto, GetUserVotesDto, SetVoteDto, @@ -407,4 +408,21 @@ export class UserService { }; }); } + + async getUserCount(query: GetUserCountDto): Promise { + const { from, to } = query; + const where: Prisma.UserWhereInput = {}; + + console.log(from, to); + + if (from) { + where.createdAt = { gte: new Date(from) }; + } + + if (to) { + where.createdAt = { lte: new Date(to) }; + } + + return await this.databaseService.user.count({ where }); + } } From 07f11277ba62bca6e0eac53ecdf41ca697df633e Mon Sep 17 00:00:00 2001 From: Meriem-BM Date: Fri, 16 May 2025 12:29:33 +0100 Subject: [PATCH 15/25] feat: add vote & poll count --- src/app.module.ts | 3 ++- src/auth/jwt-auth.guard.ts | 2 ++ src/poll/Poll.dto.ts | 13 +++++++++++++ src/poll/poll.controller.ts | 8 ++++++++ src/poll/poll.service.ts | 23 ++++++++++++++++++++++- src/user/user.controller.ts | 6 +++--- src/user/user.dto.ts | 2 +- src/user/user.service.ts | 4 ++-- src/vote/vote.controller.ts | 15 +++++++++++++++ src/vote/vote.module.ts | 10 ++++++++++ src/vote/vote.service.ts | 26 ++++++++++++++++++++++++++ 11 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 src/vote/vote.controller.ts create mode 100644 src/vote/vote.module.ts create mode 100644 src/vote/vote.service.ts 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/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts index cc068c2..42d71cd 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/auth/jwt-auth.guard.ts @@ -30,6 +30,8 @@ export class JwtAuthGuard implements CanActivate { context.getHandler(), context.getClass(), ]); + + console.log(isPublic); if (isPublic) { return true; } diff --git a/src/poll/Poll.dto.ts b/src/poll/Poll.dto.ts index 1f148e5..f87918a 100644 --- a/src/poll/Poll.dto.ts +++ b/src/poll/Poll.dto.ts @@ -8,6 +8,7 @@ import { IsNotEmpty, IsOptional, IsString, + Matches, Min, Validate, } from 'class-validator'; @@ -122,3 +123,15 @@ export class GetPollsDto { @IsEnum(['asc', 'desc']) sortOrder?: 'asc' | 'desc'; } + +export class GetPollsCountDto { + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + from?: string; + + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + to?: string; +} diff --git a/src/poll/poll.controller.ts b/src/poll/poll.controller.ts index 3daadfa..82393fc 100644 --- a/src/poll/poll.controller.ts +++ b/src/poll/poll.controller.ts @@ -11,6 +11,8 @@ import { 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 'src/user/user.dto'; @Controller('poll') export class PollController { @@ -45,6 +47,12 @@ 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)); diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index f6ef354..d62b55c 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -12,7 +12,13 @@ import { UnauthorizedActionException, UserNotFoundException, } from '../common/exceptions'; -import { CreatePollDto, DraftPollDto, GetPollsDto } from './Poll.dto'; +import { + CreatePollDto, + DraftPollDto, + GetPollsDto, + GetPollsCountDto, +} from './Poll.dto'; +import { GetCountDto } from 'src/user/user.dto'; const IS_VOTE_NORMALIZATION = process.env.ENABLE_VOTE_NORMALIZATION === 'true'; @@ -480,4 +486,19 @@ export class PollService { }); return result; } + + async getPollsCount(query: GetPollsCountDto): Promise { + const { from, to } = query; + const where: Prisma.PollWhereInput = {}; + + if (from) { + where.creationDate = { gte: new Date(from) }; + } + + 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 4b08279..8ae15a8 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -5,7 +5,7 @@ import { EditVoteDto, EditVoteResponseDto, GetUserActivitiesDto, - GetUserCountDto, + GetCountDto, GetUserDataDto, GetUserVotesDto, SetVoteDto, @@ -65,9 +65,9 @@ export class UserController { return await this.userService.createUser(dto); } - @Get('userCount') + @Get('count') @Public() - async getUserCount(@Query() query: GetUserCountDto): Promise { + async getUserCount(@Query() query: GetCountDto): Promise { return await this.userService.getUserCount(query); } } diff --git a/src/user/user.dto.ts b/src/user/user.dto.ts index 34d0880..c651ccb 100644 --- a/src/user/user.dto.ts +++ b/src/user/user.dto.ts @@ -199,7 +199,7 @@ export class CreateUserResponseDto { userId: number; } -export class GetUserCountDto { +export class GetCountDto { @IsOptional() @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 84b515b..8f0ee39 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -19,7 +19,7 @@ import { EditVoteDto, EditVoteResponseDto, GetUserActivitiesDto, - GetUserCountDto, + GetCountDto, GetUserDataDto, GetUserVotesDto, SetVoteDto, @@ -409,7 +409,7 @@ export class UserService { }); } - async getUserCount(query: GetUserCountDto): Promise { + async getUserCount(query: GetCountDto): Promise { const { from, to } = query; const where: Prisma.UserWhereInput = {}; diff --git a/src/vote/vote.controller.ts b/src/vote/vote.controller.ts new file mode 100644 index 0000000..7ae9d80 --- /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 'src/user/user.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..26dff5f --- /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 'src/user/user.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) { + where.createdAt = { gte: from }; + } + + if (to) { + where.createdAt = { lte: to }; + } + + return await this.databaseService.userAction.count({ + where: { type: ActionType.VOTED }, + }); + } +} From 8e53b9110b27360cf479e2a90f74da3274475d3d Mon Sep 17 00:00:00 2001 From: Meriem-BM Date: Fri, 16 May 2025 12:31:58 +0100 Subject: [PATCH 16/25] chore: remove console log --- src/auth/jwt-auth.guard.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts index 42d71cd..4e7c79f 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/auth/jwt-auth.guard.ts @@ -31,7 +31,6 @@ export class JwtAuthGuard implements CanActivate { context.getClass(), ]); - console.log(isPublic); if (isPublic) { return true; } From 83b0f13f91939b03c314cad993f6be52d9f18600 Mon Sep 17 00:00:00 2001 From: Meriem-BM Date: Fri, 16 May 2025 13:45:43 +0100 Subject: [PATCH 17/25] chore: stats queries issue --- src/poll/poll.service.ts | 8 ++++---- src/user/user.service.ts | 10 ++++------ src/vote/vote.service.ts | 14 +++++++------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index d62b55c..afb55c2 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -491,11 +491,11 @@ export class PollService { const { from, to } = query; const where: Prisma.PollWhereInput = {}; - if (from) { + if (from && to) { + where.creationDate = { gte: new Date(from), lte: new Date(to) }; + } else if (from) { where.creationDate = { gte: new Date(from) }; - } - - if (to) { + } else if (to) { where.creationDate = { lte: new Date(to) }; } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 8f0ee39..262c0d0 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -413,13 +413,11 @@ export class UserService { const { from, to } = query; const where: Prisma.UserWhereInput = {}; - console.log(from, to); - - if (from) { + if (from && to) { + where.createdAt = { gte: new Date(from), lte: new Date(to) }; + } else if (from) { where.createdAt = { gte: new Date(from) }; - } - - if (to) { + } else if (to) { where.createdAt = { lte: new Date(to) }; } diff --git a/src/vote/vote.service.ts b/src/vote/vote.service.ts index 26dff5f..5090d2f 100644 --- a/src/vote/vote.service.ts +++ b/src/vote/vote.service.ts @@ -11,16 +11,16 @@ export class VoteService { const { from, to } = query; const where: Prisma.UserActionWhereInput = { type: ActionType.VOTED }; - if (from) { - where.createdAt = { gte: from }; - } - - if (to) { - where.createdAt = { lte: to }; + 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: { type: ActionType.VOTED }, + where, }); } } From 6c1f48dcec37ad3c79d7a369fb30cdd72b892f54 Mon Sep 17 00:00:00 2001 From: Meriem-BM Date: Fri, 16 May 2025 15:36:38 +0100 Subject: [PATCH 18/25] fix: add common dto & add date validation --- .../migration.sql | 0 src/common/common.dto.ts | 15 +++++++++++++++ src/poll/Poll.dto.ts | 13 ------------- src/poll/poll.controller.ts | 2 +- src/poll/poll.service.ts | 11 +++-------- src/user/user.controller.ts | 2 +- src/user/user.dto.ts | 13 ------------- src/user/user.service.ts | 2 +- src/vote/vote.controller.ts | 2 +- src/vote/vote.service.ts | 2 +- 10 files changed, 23 insertions(+), 39 deletions(-) rename prisma/migrations/{20250514122507_add_createed_at_to_user_schema => 20250514122507_add_created_at_to_user_schema}/migration.sql (100%) create mode 100644 src/common/common.dto.ts diff --git a/prisma/migrations/20250514122507_add_createed_at_to_user_schema/migration.sql b/prisma/migrations/20250514122507_add_created_at_to_user_schema/migration.sql similarity index 100% rename from prisma/migrations/20250514122507_add_createed_at_to_user_schema/migration.sql rename to prisma/migrations/20250514122507_add_created_at_to_user_schema/migration.sql 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 f87918a..1f148e5 100644 --- a/src/poll/Poll.dto.ts +++ b/src/poll/Poll.dto.ts @@ -8,7 +8,6 @@ import { IsNotEmpty, IsOptional, IsString, - Matches, Min, Validate, } from 'class-validator'; @@ -123,15 +122,3 @@ export class GetPollsDto { @IsEnum(['asc', 'desc']) sortOrder?: 'asc' | 'desc'; } - -export class GetPollsCountDto { - @IsOptional() - @IsString() - @Matches(/^\d{4}-\d{2}-\d{2}$/) - from?: string; - - @IsOptional() - @IsString() - @Matches(/^\d{4}-\d{2}-\d{2}$/) - to?: string; -} diff --git a/src/poll/poll.controller.ts b/src/poll/poll.controller.ts index 82393fc..1447c21 100644 --- a/src/poll/poll.controller.ts +++ b/src/poll/poll.controller.ts @@ -12,7 +12,7 @@ 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 'src/user/user.dto'; +import { GetCountDto } from '../common/common.dto'; @Controller('poll') export class PollController { diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index afb55c2..cbe607d 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -12,13 +12,8 @@ import { UnauthorizedActionException, UserNotFoundException, } from '../common/exceptions'; -import { - CreatePollDto, - DraftPollDto, - GetPollsDto, - GetPollsCountDto, -} from './Poll.dto'; -import { GetCountDto } from 'src/user/user.dto'; +import { CreatePollDto, DraftPollDto, GetPollsDto } from './Poll.dto'; +import { GetCountDto } from '../common/common.dto'; const IS_VOTE_NORMALIZATION = process.env.ENABLE_VOTE_NORMALIZATION === 'true'; @@ -487,7 +482,7 @@ export class PollService { return result; } - async getPollsCount(query: GetPollsCountDto): Promise { + async getPollsCount(query: GetCountDto): Promise { const { from, to } = query; const where: Prisma.PollWhereInput = {}; diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 8ae15a8..923824b 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -5,7 +5,6 @@ import { EditVoteDto, EditVoteResponseDto, GetUserActivitiesDto, - GetCountDto, GetUserDataDto, GetUserVotesDto, SetVoteDto, @@ -17,6 +16,7 @@ import { 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 { diff --git a/src/user/user.dto.ts b/src/user/user.dto.ts index c651ccb..1f6863c 100644 --- a/src/user/user.dto.ts +++ b/src/user/user.dto.ts @@ -10,7 +10,6 @@ import { IsNumber, IsOptional, IsString, - Matches, Validate, } from 'class-validator'; import { IsPositiveInteger, IsRecordStringNumber } from '../common/validators'; @@ -198,15 +197,3 @@ export class CreateUserResponseDto { @IsNumber() userId: number; } - -export class GetCountDto { - @IsOptional() - @IsString() - @Matches(/^\d{4}-\d{2}-\d{2}$/) - from?: string; - - @IsOptional() - @IsString() - @Matches(/^\d{4}-\d{2}-\d{2}$/) - to?: string; -} diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 262c0d0..ecf770b 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -19,7 +19,6 @@ import { EditVoteDto, EditVoteResponseDto, GetUserActivitiesDto, - GetCountDto, GetUserDataDto, GetUserVotesDto, SetVoteDto, @@ -29,6 +28,7 @@ import { UserDataResponseDto, UserVotesResponseDto, } from './user.dto'; +import { GetCountDto } from '../common/common.dto'; @Injectable() export class UserService { diff --git a/src/vote/vote.controller.ts b/src/vote/vote.controller.ts index 7ae9d80..1e4a6d8 100644 --- a/src/vote/vote.controller.ts +++ b/src/vote/vote.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get, Query } from '@nestjs/common'; import { Public } from 'src/auth/jwt-auth.guard'; -import { GetCountDto } from 'src/user/user.dto'; +import { GetCountDto } from '../common/common.dto'; import { VoteService } from './vote.service'; @Controller('vote') diff --git a/src/vote/vote.service.ts b/src/vote/vote.service.ts index 5090d2f..36207cd 100644 --- a/src/vote/vote.service.ts +++ b/src/vote/vote.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ActionType, Prisma } from '@prisma/client'; import { DatabaseService } from 'src/database/database.service'; -import { GetCountDto } from 'src/user/user.dto'; +import { GetCountDto } from '../common/common.dto'; @Injectable() export class VoteService { From 481e2e3153d3536d1e624bd25b47e3a9a97506c4 Mon Sep 17 00:00:00 2001 From: Ramin Date: Sat, 17 May 2025 14:43:06 +0330 Subject: [PATCH 19/25] Delete all existing drafts on creating new poll --- src/poll/poll.service.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index f6ef354..55a5bc4 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -55,7 +55,7 @@ export class PollService { return searchResults.map((result) => result.pollId); } - // Should be used only for creatingpublished polls + // Should be used only for creating published polls async createPoll(createPollDto: CreatePollDto, worldID: string) { const user = await this.databaseService.user.findUnique({ where: { worldID }, @@ -76,6 +76,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, From f668124ceb8f36d126ac23bd3704c6fc4a342447 Mon Sep 17 00:00:00 2001 From: Ramin Date: Sun, 18 May 2025 17:06:00 +0330 Subject: [PATCH 20/25] add getPollVotes endpoint --- src/poll/Poll.dto.ts | 7 ++++++ src/poll/poll.controller.ts | 5 +++++ src/poll/poll.service.ts | 45 +++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/src/poll/Poll.dto.ts b/src/poll/Poll.dto.ts index 1f148e5..fc42e68 100644 --- a/src/poll/Poll.dto.ts +++ b/src/poll/Poll.dto.ts @@ -122,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 3daadfa..13b012a 100644 --- a/src/poll/poll.controller.ts +++ b/src/poll/poll.controller.ts @@ -50,6 +50,11 @@ export class PollController { 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 f6ef354..767cd1d 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -438,6 +438,51 @@ export class PollService { }); } + 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, + }, + }); + + if (!poll) { + throw new PollNotFoundException(); + } + + const votes = await this.databaseService.vote.findMany({ + where: { pollId }, + select: { + quadraticWeights: true, + weightDistribution: true, + user: { + select: { + name: true, + }, + }, + }, + }); + + const formattedVotes = votes.map((vote) => { + const quadraticWeights = vote.quadraticWeights as Record; + const totalQuadraticWeights = Object.values(quadraticWeights).reduce( + (sum, value) => sum + value, + 0, + ); + return { + username: vote.user.name, + quadraticWeights, + totalQuadraticWeights, + }; + }); + + return { votes: formattedVotes }; + } + async getPollQuadraticResults( pollId: number, ): Promise> { From be8d82f8363144687eae81a0dcf96c0d155d66a3 Mon Sep 17 00:00:00 2001 From: Ramin Date: Tue, 20 May 2025 14:26:20 +0330 Subject: [PATCH 21/25] change getPollVotes to return normalized votes --- src/poll/poll.service.ts | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index 3f22c4a..d70ae7a 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -7,13 +7,13 @@ import { 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, DraftPollDto, GetPollsDto } from './Poll.dto'; -import { GetCountDto } from '../common/common.dto'; const IS_VOTE_NORMALIZATION = process.env.ENABLE_VOTE_NORMALIZATION === 'true'; @@ -461,26 +461,30 @@ export class PollService { 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: { - quadraticWeights: true, - weightDistribution: true, - user: { - select: { - name: true, - }, - }, - }, + select, }); const formattedVotes = votes.map((vote) => { - const quadraticWeights = vote.quadraticWeights as Record; - const totalQuadraticWeights = Object.values(quadraticWeights).reduce( - (sum, value) => sum + value, - 0, - ); + 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, From 6ab007f942f332c34039586f61fd2e7738d0803d Mon Sep 17 00:00:00 2001 From: Ramin Date: Tue, 20 May 2025 21:30:34 +0330 Subject: [PATCH 22/25] add pollId and title to getPollVotes --- src/poll/poll.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index d70ae7a..42dd462 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -455,6 +455,7 @@ export class PollService { }, select: { pollId: true, + title: true, }, }); @@ -492,7 +493,11 @@ export class PollService { }; }); - return { votes: formattedVotes }; + return { + votes: formattedVotes, + pollTitle: poll.title, + pollId: poll.pollId, + }; } async getPollQuadraticResults( From b208a50b838e7553266290c8ceded57210274655 Mon Sep 17 00:00:00 2001 From: Meriem-BM Date: Wed, 21 May 2025 12:13:33 +0100 Subject: [PATCH 23/25] fix: explore all polls order --- src/poll/poll.service.ts | 159 +++++++++++++++++++++++++++++++-------- 1 file changed, 129 insertions(+), 30 deletions(-) diff --git a/src/poll/poll.service.ts b/src/poll/poll.service.ts index 3f22c4a..730942e 100644 --- a/src/poll/poll.service.ts +++ b/src/poll/poll.service.ts @@ -318,43 +318,142 @@ export class PollService { } } - 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; + + return { + ...pollWithoutVotes, + hasVoted: votes.length > 0, + }; + }); - const [polls, total] = await this.databaseService.$transaction([ - this.databaseService.poll.findMany({ - where: filters, - include: { - author: true, - votes: { - where: { userId }, - select: { voteID: true }, + 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 }), + ]); + + const pollsWithVoteStatus = polls.map((poll) => { + const { votes, ...pollWithoutVotes } = poll; + + return { + ...pollWithoutVotes, + hasVoted: votes.length > 0, + }; + }); + + return { + polls: pollsWithVoteStatus, + total, + }; + } else { + orderBy = [ + { + endDate: 'asc', + }, + { + startDate: 'asc', }, - orderBy, - skip, - take: Number(limit), - }), - this.databaseService.poll.count({ where: filters }), - ]); + ]; - const pollsWithVoteStatus = polls.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 }, + }, + }, + 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 { - ...pollWithoutVotes, - hasVoted: votes.length > 0, + polls: pollsWithVoteStatus, + total, }; - }); - - return { - polls: pollsWithVoteStatus, - total, - }; + } } async getPollDetails(id: number) { From 61ad706ede5ff8717bc7abb497181751c1d6b301 Mon Sep 17 00:00:00 2001 From: Ramin Date: Wed, 21 May 2025 20:48:43 +0330 Subject: [PATCH 24/25] add isAnonymous to getUserActivities --- src/user/user.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/user/user.service.ts b/src/user/user.service.ts index ecf770b..dc03a67 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -193,6 +193,7 @@ export class UserService { pollId: true, title: true, description: true, + isAnonymous: true, endDate: true, authorUserId: true, participantCount: true, From 32bf1a7f0253de4586aec495e395a6008b4f3d73 Mon Sep 17 00:00:00 2001 From: Ramin Date: Wed, 21 May 2025 20:55:18 +0330 Subject: [PATCH 25/25] add isAnonymoys to action --- src/user/user.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/user/user.service.ts b/src/user/user.service.ts index dc03a67..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, PollStatus, Prisma } from '@prisma/client'; +import { GetCountDto } from '../common/common.dto'; import { VOTING_POWER } from '../common/constants'; import { CreateUserException, @@ -28,7 +29,6 @@ import { UserDataResponseDto, UserVotesResponseDto, } from './user.dto'; -import { GetCountDto } from '../common/common.dto'; @Injectable() export class UserService { @@ -214,6 +214,7 @@ export class UserService { type: action.type, pollId: action.poll.pollId, pollTitle: action.poll.title || '', + isAnonymous: action.poll.isAnonymous, pollDescription: action.poll.description ?? '', endDate: action.poll.endDate ? action.poll.endDate.toISOString() : '', isActive: action.poll.endDate ? action.poll.endDate >= now : false,