Skip to content

Commit 3aa1848

Browse files
committed
feat(poll): add poll vote module backing @Haklex poll node
- PollVoteModel collection (poll_votes) with unique (pollId, voterFingerprint) index for one-shot dedup - PollService: getState / batchGetStates / submit, with fingerprint = "r:<readerId>" for logged-in or sha256(ip+ua) for anonymous voters - Endpoints (under /api/v2/polls): - GET / — batch fetch (?ids=p_a,p_b,...) - GET /:pollId — single state - POST /:pollId/vote — submit; idempotent via unique index
1 parent 79eea7f commit 3aa1848

8 files changed

Lines changed: 231 additions & 0 deletions

File tree

apps/core/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { OptionModule } from './modules/option/option.module'
4747
import { OwnerModule } from './modules/owner/owner.module'
4848
import { PageModule } from './modules/page/page.module'
4949
import { PageProxyModule } from './modules/pageproxy/pageproxy.module'
50+
import { PollModule } from './modules/poll/poll.module'
5051
import { PostModule } from './modules/post/post.module'
5152
import { ProjectModule } from './modules/project/project.module'
5253
import { ReaderModule } from './modules/reader/reader.module'
@@ -101,6 +102,7 @@ import { TaskQueueModule } from './processors/task-queue/task-queue.module'
101102
NoteModule,
102103
OptionModule,
103104
PageModule,
105+
PollModule,
104106
PostModule,
105107
ProjectModule,
106108
RecentlyModule,

apps/core/src/constants/db.constant.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const NOTE_COLLECTION_NAME = 'notes'
1919
export const OPTION_COLLECTION_NAME = 'options'
2020
export const OWNER_PROFILE_COLLECTION_NAME = 'owner_profiles'
2121
export const PAGE_COLLECTION_NAME = 'pages'
22+
export const POLL_VOTE_COLLECTION_NAME = 'poll_votes'
2223
export const POST_COLLECTION_NAME = 'posts'
2324
export const PROJECT_COLLECTION_NAME = 'projects'
2425
export const READER_COLLECTION_NAME = 'readers'
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { index, modelOptions, prop } from '@typegoose/typegoose'
2+
3+
import { POLL_VOTE_COLLECTION_NAME } from '~/constants/db.constant'
4+
import { BaseModel } from '~/shared/model/base.model'
5+
6+
@modelOptions({
7+
options: { customName: POLL_VOTE_COLLECTION_NAME },
8+
})
9+
@index({ pollId: 1, voterFingerprint: 1 }, { unique: true })
10+
@index({ pollId: 1 })
11+
export class PollVoteModel extends BaseModel {
12+
@prop({ required: true })
13+
pollId: string
14+
15+
@prop({ required: true })
16+
voterFingerprint: string
17+
18+
@prop({ required: true, type: () => [String] })
19+
optionIds: string[]
20+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Body, Get, Param, Post, Query } from '@nestjs/common'
2+
3+
import { ApiController } from '~/common/decorators/api-controller.decorator'
4+
import { CurrentReaderId } from '~/common/decorators/current-user.decorator'
5+
import { IpLocation, type IpRecord } from '~/common/decorators/ip.decorator'
6+
7+
import { BatchPollQueryDto, PollIdDto, SubmitPollDto } from './poll.dto'
8+
import { PollService } from './poll.service'
9+
10+
@ApiController('polls')
11+
export class PollController {
12+
constructor(private readonly pollService: PollService) {}
13+
14+
@Get('/')
15+
async batch(
16+
@Query() query: BatchPollQueryDto,
17+
@IpLocation() ipLocation: IpRecord,
18+
@CurrentReaderId() readerId?: string,
19+
) {
20+
const fingerprint = this.pollService.computeFingerprint({
21+
readerId,
22+
ip: ipLocation.ip,
23+
agent: ipLocation.agent ?? '',
24+
})
25+
return this.pollService.batchGetStates(query.ids, fingerprint)
26+
}
27+
28+
@Get('/:pollId')
29+
async getOne(
30+
@Param() params: PollIdDto,
31+
@IpLocation() ipLocation: IpRecord,
32+
@CurrentReaderId() readerId?: string,
33+
) {
34+
const fingerprint = this.pollService.computeFingerprint({
35+
readerId,
36+
ip: ipLocation.ip,
37+
agent: ipLocation.agent ?? '',
38+
})
39+
return this.pollService.getState(params.pollId, fingerprint)
40+
}
41+
42+
@Post('/:pollId/vote')
43+
async vote(
44+
@Param() params: PollIdDto,
45+
@Body() body: SubmitPollDto,
46+
@IpLocation() ipLocation: IpRecord,
47+
@CurrentReaderId() readerId?: string,
48+
) {
49+
const fingerprint = this.pollService.computeFingerprint({
50+
readerId,
51+
ip: ipLocation.ip,
52+
agent: ipLocation.agent ?? '',
53+
})
54+
return this.pollService.submit(params.pollId, fingerprint, body.optionIds)
55+
}
56+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { createZodDto } from 'nestjs-zod'
2+
import { z } from 'zod'
3+
4+
const PollIdSchema = z
5+
.string()
6+
.trim()
7+
.min(3)
8+
.max(64)
9+
.regex(/^p_[\da-z]+$/i)
10+
11+
const OptionIdSchema = z
12+
.string()
13+
.trim()
14+
.min(3)
15+
.max(64)
16+
.regex(/^o_[\da-z]+$/i)
17+
18+
const PollIdParam = z.object({ pollId: PollIdSchema })
19+
export class PollIdDto extends createZodDto(PollIdParam) {}
20+
21+
const SubmitPollSchema = z.object({
22+
optionIds: z.array(OptionIdSchema).min(1).max(20),
23+
})
24+
export class SubmitPollDto extends createZodDto(SubmitPollSchema) {}
25+
26+
const BatchPollQuerySchema = z.object({
27+
ids: z
28+
.string()
29+
.min(3)
30+
.transform((s) =>
31+
s
32+
.split(',')
33+
.map((v) => v.trim())
34+
.filter(Boolean),
35+
)
36+
.pipe(z.array(PollIdSchema).min(1).max(50)),
37+
})
38+
export class BatchPollQueryDto extends createZodDto(BatchPollQuerySchema) {}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common'
2+
3+
import { PollController } from './poll.controller'
4+
import { PollService } from './poll.service'
5+
6+
@Module({
7+
controllers: [PollController],
8+
providers: [PollService],
9+
exports: [PollService],
10+
})
11+
export class PollModule {}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { createHash } from 'node:crypto'
2+
3+
import { Injectable, Logger } from '@nestjs/common'
4+
5+
import { InjectModel } from '~/transformers/model.transformer'
6+
7+
import { PollVoteModel } from './poll-vote.model'
8+
9+
export interface PollState {
10+
tallies: Record<string, number>
11+
totalVotes: number
12+
userVote?: string[]
13+
status: 'ready' | 'error'
14+
closed: boolean
15+
canVote: boolean
16+
errorMessage?: string
17+
}
18+
19+
interface FingerprintInput {
20+
readerId?: string | null
21+
ip: string
22+
agent: string
23+
}
24+
25+
@Injectable()
26+
export class PollService {
27+
private readonly logger = new Logger(PollService.name)
28+
29+
constructor(
30+
@InjectModel(PollVoteModel)
31+
private readonly model: MongooseModel<PollVoteModel>,
32+
) {}
33+
34+
/**
35+
* Stable identity for vote dedup. Logged-in readers map to `r:<id>`;
36+
* anonymous voters hash IP + UA into `a:<hex>`.
37+
*/
38+
computeFingerprint({ readerId, ip, agent }: FingerprintInput): string {
39+
if (readerId) return `r:${readerId}`
40+
const digest = createHash('sha256')
41+
.update(`${ip}|${agent}`)
42+
.digest('hex')
43+
.slice(0, 32)
44+
return `a:${digest}`
45+
}
46+
47+
async getState(pollId: string, voterFingerprint: string): Promise<PollState> {
48+
const [tallyDocs, vote, totalVotes] = await Promise.all([
49+
this.model
50+
.aggregate<{
51+
_id: string
52+
count: number
53+
}>([{ $match: { pollId } }, { $unwind: '$optionIds' }, { $group: { _id: '$optionIds', count: { $sum: 1 } } }])
54+
.exec(),
55+
this.model.findOne({ pollId, voterFingerprint }).lean().exec(),
56+
this.model.countDocuments({ pollId }).exec(),
57+
])
58+
59+
const tallies: Record<string, number> = {}
60+
for (const doc of tallyDocs) tallies[doc._id] = doc.count
61+
62+
return {
63+
tallies,
64+
totalVotes,
65+
userVote: vote?.optionIds,
66+
status: 'ready',
67+
closed: false,
68+
canVote: !vote,
69+
}
70+
}
71+
72+
async batchGetStates(
73+
pollIds: string[],
74+
voterFingerprint: string,
75+
): Promise<Record<string, PollState>> {
76+
const out: Record<string, PollState> = {}
77+
await Promise.all(
78+
pollIds.map(async (pollId) => {
79+
out[pollId] = await this.getState(pollId, voterFingerprint)
80+
}),
81+
)
82+
return out
83+
}
84+
85+
async submit(
86+
pollId: string,
87+
voterFingerprint: string,
88+
optionIds: string[],
89+
): Promise<PollState> {
90+
try {
91+
await this.model.create({ pollId, voterFingerprint, optionIds })
92+
} catch (err: any) {
93+
if (err?.code === 11_000) {
94+
const state = await this.getState(pollId, voterFingerprint)
95+
return { ...state, status: 'error', errorMessage: 'Already voted' }
96+
}
97+
throw err
98+
}
99+
return this.getState(pollId, voterFingerprint)
100+
}
101+
}

apps/core/src/processors/database/database.models.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { MetaPresetModel } from '~/modules/meta-preset/meta-preset.model'
1515
import { NoteModel } from '~/modules/note/note.model'
1616
import { OwnerProfileModel } from '~/modules/owner/owner-profile.model'
1717
import { PageModel } from '~/modules/page/page.model'
18+
import { PollVoteModel } from '~/modules/poll/poll-vote.model'
1819
import { PostModel } from '~/modules/post/post.model'
1920
import { ProjectModel } from '~/modules/project/project.model'
2021
import { ReaderModel } from '~/modules/reader/reader.model'
@@ -47,6 +48,7 @@ export const databaseModels = [
4748
NoteModel,
4849
OptionModel,
4950
PageModel,
51+
PollVoteModel,
5052
PostModel,
5153
ProjectModel,
5254
ReaderModel,

0 commit comments

Comments
 (0)