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