poll-results.service.ts raw
1 import { ExtendedKind } from '@/constants'
2 import { getPollResponseFromEvent } from '@/lib/event-metadata'
3 import DataLoader from 'dataloader'
4 import dayjs from 'dayjs'
5 import { Filter } from 'nostr-tools'
6 import client from './client.service'
7
8 export type TPollResults = {
9 totalVotes: number
10 results: Record<string, Set<string>>
11 voters: Set<string>
12 updatedAt: number
13 }
14
15 type TFetchPollResultsParams = {
16 pollEventId: string
17 relays: string[]
18 validPollOptionIds: string[]
19 isMultipleChoice: boolean
20 endsAt?: number
21 }
22
23 class PollResultsService {
24 static instance: PollResultsService
25 private pollResultsMap: Map<string, TPollResults> = new Map()
26 private pollResultsSubscribers = new Map<string, Set<() => void>>()
27 private loader = new DataLoader<TFetchPollResultsParams, TPollResults | undefined>(
28 async (params) => {
29 const pollMap = new Map<string, Omit<TFetchPollResultsParams, 'pollEventId'>>()
30
31 params.forEach(({ pollEventId, relays, validPollOptionIds, isMultipleChoice, endsAt }) => {
32 if (!pollMap.has(pollEventId)) {
33 pollMap.set(pollEventId, { relays, validPollOptionIds, isMultipleChoice, endsAt })
34 }
35 })
36
37 const pollResults = await Promise.allSettled(
38 Array.from(pollMap).map(async ([pollEventId, pollParams]) => {
39 const result = await this._fetchResults(
40 pollEventId,
41 pollParams.relays,
42 pollParams.validPollOptionIds,
43 pollParams.isMultipleChoice,
44 pollParams.endsAt
45 )
46 return { pollEventId, result }
47 })
48 )
49
50 const resultMap = new Map<string, TPollResults>()
51 pollResults.forEach((promiseResult) => {
52 if (promiseResult.status === 'fulfilled' && promiseResult.value.result) {
53 resultMap.set(promiseResult.value.pollEventId, promiseResult.value.result)
54 }
55 })
56 return params.map(({ pollEventId }) => resultMap.get(pollEventId))
57 },
58 { cache: false }
59 )
60
61 constructor() {
62 if (!PollResultsService.instance) {
63 PollResultsService.instance = this
64 }
65 return PollResultsService.instance
66 }
67
68 async fetchResults(
69 pollEventId: string,
70 relays: string[],
71 validPollOptionIds: string[],
72 isMultipleChoice: boolean,
73 endsAt?: number
74 ) {
75 return this.loader.load({
76 pollEventId,
77 relays,
78 validPollOptionIds,
79 isMultipleChoice,
80 endsAt
81 })
82 }
83
84 private async _fetchResults(
85 pollEventId: string,
86 relays: string[],
87 validPollOptionIds: string[],
88 isMultipleChoice: boolean,
89 endsAt?: number
90 ) {
91 const filter: Filter = {
92 kinds: [ExtendedKind.POLL_RESPONSE],
93 '#e': [pollEventId],
94 limit: 1000
95 }
96
97 if (endsAt) {
98 filter.until = endsAt
99 }
100
101 let results = this.pollResultsMap.get(pollEventId)
102 if (results) {
103 if (endsAt && results.updatedAt >= endsAt) {
104 return results
105 }
106 filter.since = results.updatedAt
107 } else {
108 results = {
109 totalVotes: 0,
110 results: validPollOptionIds.reduce(
111 (acc, optionId) => {
112 acc[optionId] = new Set<string>()
113 return acc
114 },
115 {} as Record<string, Set<string>>
116 ),
117 voters: new Set<string>(),
118 updatedAt: 0
119 }
120 }
121
122 const responseEvents = await client.fetchEvents(relays, filter)
123
124 results.updatedAt = dayjs().unix()
125
126 const responses = responseEvents
127 .map((evt) => getPollResponseFromEvent(evt, validPollOptionIds, isMultipleChoice))
128 .filter((response): response is NonNullable<typeof response> => response !== null)
129
130 responses
131 .sort((a, b) => b.created_at - a.created_at)
132 .forEach((response) => {
133 if (results && results.voters.has(response.pubkey)) return
134 results.voters.add(response.pubkey)
135
136 results.totalVotes += response.selectedOptionIds.length
137 response.selectedOptionIds.forEach((optionId) => {
138 if (results.results[optionId]) {
139 results.results[optionId].add(response.pubkey)
140 }
141 })
142 })
143
144 this.pollResultsMap.set(pollEventId, { ...results })
145 if (responseEvents.length) {
146 this.notifyPollResults(pollEventId)
147 }
148 return results
149 }
150
151 subscribePollResults(pollEventId: string, callback: () => void) {
152 let set = this.pollResultsSubscribers.get(pollEventId)
153 if (!set) {
154 set = new Set()
155 this.pollResultsSubscribers.set(pollEventId, set)
156 }
157 set.add(callback)
158 return () => {
159 set?.delete(callback)
160 if (set?.size === 0) this.pollResultsSubscribers.delete(pollEventId)
161 }
162 }
163
164 private notifyPollResults(pollEventId: string) {
165 const set = this.pollResultsSubscribers.get(pollEventId)
166 if (set) {
167 set.forEach((cb) => cb())
168 }
169 }
170
171 getPollResults(id: string): TPollResults | undefined {
172 return this.pollResultsMap.get(id)
173 }
174
175 addPollResponse(pollEventId: string, pubkey: string, selectedOptionIds: string[]) {
176 const results = this.pollResultsMap.get(pollEventId)
177 if (!results) return
178
179 if (results.voters.has(pubkey)) return
180
181 results.voters.add(pubkey)
182 results.totalVotes += selectedOptionIds.length
183 selectedOptionIds.forEach((optionId) => {
184 if (results.results[optionId]) {
185 results.results[optionId].add(pubkey)
186 }
187 })
188
189 this.pollResultsMap.set(pollEventId, { ...results })
190 this.notifyPollResults(pollEventId)
191 }
192 }
193
194 const instance = new PollResultsService()
195
196 export default instance
197