You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
205 lines
6.2 KiB
205 lines
6.2 KiB
import { ExtendedKind } from '@/constants' |
|
import { getPollResponseFromEvent } from '@/lib/event-metadata' |
|
import DataLoader from 'dataloader' |
|
import dayjs from 'dayjs' |
|
import { Filter } from 'nostr-tools' |
|
import { queryService } from './client.service' |
|
|
|
export type TPollResults = { |
|
totalVotes: number |
|
results: Record<string, Set<string>> |
|
voters: Set<string> |
|
/** Wall-clock time of last successful merge (legacy / diagnostics). */ |
|
updatedAt: number |
|
/** Latest `created_at` among merged poll responses; used for open-poll incremental `since`. */ |
|
maxResponseCreatedAt?: number |
|
} |
|
|
|
type TFetchPollResultsParams = { |
|
pollEventId: string |
|
relays: string[] |
|
validPollOptionIds: string[] |
|
isMultipleChoice: boolean |
|
endsAt?: number |
|
} |
|
|
|
class PollResultsService { |
|
static instance: PollResultsService |
|
private pollResultsMap: Map<string, TPollResults> = new Map() |
|
private pollResultsSubscribers = new Map<string, Set<() => void>>() |
|
private loader = new DataLoader<TFetchPollResultsParams, TPollResults | undefined>( |
|
async (params) => { |
|
const pollMap = new Map<string, Omit<TFetchPollResultsParams, 'pollEventId'>>() |
|
|
|
params.forEach(({ pollEventId, relays, validPollOptionIds, isMultipleChoice, endsAt }) => { |
|
if (!pollMap.has(pollEventId)) { |
|
pollMap.set(pollEventId, { relays, validPollOptionIds, isMultipleChoice, endsAt }) |
|
} |
|
}) |
|
|
|
const pollResults = await Promise.allSettled( |
|
Array.from(pollMap).map(async ([pollEventId, pollParams]) => { |
|
const result = await this._fetchResults( |
|
pollEventId, |
|
pollParams.relays, |
|
pollParams.validPollOptionIds, |
|
pollParams.isMultipleChoice, |
|
pollParams.endsAt |
|
) |
|
return { pollEventId, result } |
|
}) |
|
) |
|
|
|
const resultMap = new Map<string, TPollResults>() |
|
pollResults.forEach((promiseResult) => { |
|
if (promiseResult.status === 'fulfilled' && promiseResult.value.result) { |
|
resultMap.set(promiseResult.value.pollEventId, promiseResult.value.result) |
|
} |
|
}) |
|
return params.map(({ pollEventId }) => resultMap.get(pollEventId)) |
|
}, |
|
{ cache: false } |
|
) |
|
|
|
constructor() { |
|
if (!PollResultsService.instance) { |
|
PollResultsService.instance = this |
|
} |
|
return PollResultsService.instance |
|
} |
|
|
|
async fetchResults( |
|
pollEventId: string, |
|
relays: string[], |
|
validPollOptionIds: string[], |
|
isMultipleChoice: boolean, |
|
endsAt?: number |
|
) { |
|
return this.loader.load({ |
|
pollEventId, |
|
relays, |
|
validPollOptionIds, |
|
isMultipleChoice, |
|
endsAt |
|
}) |
|
} |
|
|
|
private async _fetchResults( |
|
pollEventId: string, |
|
relays: string[], |
|
validPollOptionIds: string[], |
|
isMultipleChoice: boolean, |
|
endsAt?: number |
|
) { |
|
const nowSec = dayjs().unix() |
|
const pollIsClosed = endsAt != null && nowSec > endsAt |
|
|
|
const filter: Filter = { |
|
kinds: [ExtendedKind.POLL_RESPONSE], |
|
'#e': [pollEventId], |
|
limit: 100 |
|
} |
|
|
|
if (endsAt) { |
|
filter.until = endsAt |
|
} |
|
|
|
let results = this.pollResultsMap.get(pollEventId) |
|
if (!results) { |
|
results = { |
|
totalVotes: 0, |
|
results: validPollOptionIds.reduce( |
|
(acc, optionId) => { |
|
acc[optionId] = new Set<string>() |
|
return acc |
|
}, |
|
{} as Record<string, Set<string>> |
|
), |
|
voters: new Set<string>(), |
|
updatedAt: 0 |
|
} |
|
} else if (!pollIsClosed && (results.maxResponseCreatedAt ?? 0) > 0) { |
|
// Open poll: incremental fetch only by latest merged vote timestamp (not wall clock). |
|
filter.since = results.maxResponseCreatedAt |
|
} |
|
// Closed poll: never set `since` so we always re-query the full [0, endsAt] window. |
|
// (Using `updatedAt` as `since` or short-circuiting on `updatedAt >= endsAt` was wrong: |
|
// `updatedAt` was wall time, which hid all historical votes and froze empty caches.) |
|
|
|
const responseEvents = await queryService.fetchEvents(relays, filter) |
|
|
|
const responses = responseEvents |
|
.map((evt) => getPollResponseFromEvent(evt, validPollOptionIds, isMultipleChoice)) |
|
.filter((response): response is NonNullable<typeof response> => response !== null) |
|
|
|
let maxSeen = results.maxResponseCreatedAt ?? 0 |
|
|
|
responses |
|
.sort((a, b) => b.created_at - a.created_at) |
|
.forEach((response) => { |
|
if (results && results.voters.has(response.pubkey)) return |
|
results.voters.add(response.pubkey) |
|
|
|
results.totalVotes += response.selectedOptionIds.length |
|
response.selectedOptionIds.forEach((optionId) => { |
|
if (results.results[optionId]) { |
|
results.results[optionId].add(response.pubkey) |
|
} |
|
}) |
|
maxSeen = Math.max(maxSeen, response.created_at) |
|
}) |
|
|
|
results.updatedAt = nowSec |
|
results.maxResponseCreatedAt = maxSeen |
|
|
|
this.pollResultsMap.set(pollEventId, { ...results }) |
|
this.notifyPollResults(pollEventId) |
|
return results |
|
} |
|
|
|
subscribePollResults(pollEventId: string, callback: () => void) { |
|
let set = this.pollResultsSubscribers.get(pollEventId) |
|
if (!set) { |
|
set = new Set() |
|
this.pollResultsSubscribers.set(pollEventId, set) |
|
} |
|
set.add(callback) |
|
return () => { |
|
set?.delete(callback) |
|
if (set?.size === 0) this.pollResultsSubscribers.delete(pollEventId) |
|
} |
|
} |
|
|
|
private notifyPollResults(pollEventId: string) { |
|
const set = this.pollResultsSubscribers.get(pollEventId) |
|
if (set) { |
|
set.forEach((cb) => cb()) |
|
} |
|
} |
|
|
|
getPollResults(id: string): TPollResults | undefined { |
|
return this.pollResultsMap.get(id) |
|
} |
|
|
|
addPollResponse(pollEventId: string, pubkey: string, selectedOptionIds: string[]) { |
|
const results = this.pollResultsMap.get(pollEventId) |
|
if (!results) return |
|
|
|
if (results.voters.has(pubkey)) return |
|
|
|
results.voters.add(pubkey) |
|
results.totalVotes += selectedOptionIds.length |
|
selectedOptionIds.forEach((optionId) => { |
|
if (results.results[optionId]) { |
|
results.results[optionId].add(pubkey) |
|
} |
|
}) |
|
|
|
this.pollResultsMap.set(pollEventId, { ...results }) |
|
this.notifyPollResults(pollEventId) |
|
} |
|
} |
|
|
|
const instance = new PollResultsService() |
|
|
|
export default instance
|
|
|