Wallet badges (#2040)
* Remove gun+horse streak * Add wallet badges * Fix empty recv wallet detected as enabled * Resolve badges via columns and triggers * Fix backwards compatibility by not dropping GQL fields * Gun+horse notifications as streaks via triggers * Fix error while computing streaks * Push notifications for horse+gun * Move logic to JS via pgboss job * Fix argument to notifyNewStreak * Update checkWallet comment * Refactor notification id hack * Formatting * Fix missing update of possibleTypes This didn't cause any bugs because the added types have no field resolvers. * Add user migration * Fix missing cast to date * Run checkWallet queries inside transaction
This commit is contained in:
		
							parent
							
								
									9df5a52bd3
								
							
						
					
					
						commit
						52365c32ed
					
				@ -316,13 +316,36 @@ export default {
 | 
			
		||||
 | 
			
		||||
      if (meFull.noteCowboyHat) {
 | 
			
		||||
        queries.push(
 | 
			
		||||
          `(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type
 | 
			
		||||
          `(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'CowboyHat' AS type
 | 
			
		||||
          FROM "Streak"
 | 
			
		||||
          WHERE "userId" = $1
 | 
			
		||||
          AND updated_at < $2
 | 
			
		||||
          AND type = 'COWBOY_HAT'
 | 
			
		||||
          ORDER BY "sortTime" DESC
 | 
			
		||||
          LIMIT ${LIMIT})`
 | 
			
		||||
        )
 | 
			
		||||
        for (const type of ['HORSE', 'GUN']) {
 | 
			
		||||
          const gqlType = type.charAt(0) + type.slice(1).toLowerCase()
 | 
			
		||||
          queries.push(
 | 
			
		||||
            `(SELECT id::text, "startedAt" AS "sortTime", 0 as "earnedSats", 'New${gqlType}' AS type
 | 
			
		||||
            FROM "Streak"
 | 
			
		||||
            WHERE "userId" = $1
 | 
			
		||||
            AND updated_at < $2
 | 
			
		||||
            AND type = '${type}'::"StreakType"
 | 
			
		||||
            ORDER BY "sortTime" DESC
 | 
			
		||||
            LIMIT ${LIMIT})`
 | 
			
		||||
          )
 | 
			
		||||
          queries.push(
 | 
			
		||||
            `(SELECT id::text AS id, "endedAt" AS "sortTime", 0 as "earnedSats", 'Lost${gqlType}' AS type
 | 
			
		||||
            FROM "Streak"
 | 
			
		||||
            WHERE "userId" = $1
 | 
			
		||||
            AND updated_at < $2
 | 
			
		||||
            AND "endedAt" IS NOT NULL
 | 
			
		||||
            AND type = '${type}'::"StreakType"
 | 
			
		||||
            ORDER BY "sortTime" DESC
 | 
			
		||||
            LIMIT ${LIMIT})`
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      queries.push(
 | 
			
		||||
@ -500,23 +523,14 @@ export default {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  Streak: {
 | 
			
		||||
  CowboyHat: {
 | 
			
		||||
    days: async (n, args, { models }) => {
 | 
			
		||||
      const res = await models.$queryRaw`
 | 
			
		||||
        SELECT "endedAt" - "startedAt" AS days
 | 
			
		||||
        SELECT "endedAt"::date - "startedAt"::date AS days
 | 
			
		||||
        FROM "Streak"
 | 
			
		||||
        WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
 | 
			
		||||
      `
 | 
			
		||||
 | 
			
		||||
      return res.length ? res[0].days : null
 | 
			
		||||
    },
 | 
			
		||||
    type: async (n, args, { models }) => {
 | 
			
		||||
      const res = await models.$queryRaw`
 | 
			
		||||
        SELECT "type"
 | 
			
		||||
        FROM "Streak"
 | 
			
		||||
        WHERE id = ${Number(n.id)}
 | 
			
		||||
      `
 | 
			
		||||
      return res.length ? res[0].type : null
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  Earn: {
 | 
			
		||||
 | 
			
		||||
@ -1082,19 +1082,17 @@ export default {
 | 
			
		||||
 | 
			
		||||
      return user.streak
 | 
			
		||||
    },
 | 
			
		||||
    gunStreak: async (user, args, { models }) => {
 | 
			
		||||
    hasSendWallet: async (user, args, { models }) => {
 | 
			
		||||
      if (user.hideCowboyHat) {
 | 
			
		||||
        return null
 | 
			
		||||
        return false
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return user.gunStreak
 | 
			
		||||
      return user.hasSendWallet
 | 
			
		||||
    },
 | 
			
		||||
    horseStreak: async (user, args, { models }) => {
 | 
			
		||||
    hasRecvWallet: async (user, args, { models }) => {
 | 
			
		||||
      if (user.hideCowboyHat) {
 | 
			
		||||
        return null
 | 
			
		||||
        return false
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return user.horseStreak
 | 
			
		||||
      return user.hasRecvWallet
 | 
			
		||||
    },
 | 
			
		||||
    maxStreak: async (user, args, { models }) => {
 | 
			
		||||
      if (user.hideCowboyHat) {
 | 
			
		||||
@ -1102,7 +1100,7 @@ export default {
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const [{ max }] = await models.$queryRaw`
 | 
			
		||||
        SELECT MAX(COALESCE("endedAt", (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt")
 | 
			
		||||
        SELECT MAX(COALESCE("endedAt"::date, (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt"::date)
 | 
			
		||||
        FROM "Streak" WHERE "userId" = ${user.id}`
 | 
			
		||||
      return max
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
@ -75,13 +75,6 @@ export default gql`
 | 
			
		||||
    tipComments: Int!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type Streak {
 | 
			
		||||
    id: ID!
 | 
			
		||||
    sortTime: Date!
 | 
			
		||||
    days: Int
 | 
			
		||||
    type: String!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type Earn {
 | 
			
		||||
    id: ID!
 | 
			
		||||
    earnedSats: Int!
 | 
			
		||||
@ -156,11 +149,37 @@ export default gql`
 | 
			
		||||
    sortTime: Date!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type CowboyHat {
 | 
			
		||||
    id: ID!
 | 
			
		||||
    sortTime: Date!
 | 
			
		||||
    days: Int
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type NewHorse {
 | 
			
		||||
    id: ID!
 | 
			
		||||
    sortTime: Date!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type LostHorse {
 | 
			
		||||
    id: ID!
 | 
			
		||||
    sortTime: Date!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type NewGun {
 | 
			
		||||
    id: ID!
 | 
			
		||||
    sortTime: Date!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type LostGun {
 | 
			
		||||
    id: ID!
 | 
			
		||||
    sortTime: Date!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  union Notification = Reply | Votification | Mention
 | 
			
		||||
    | Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
 | 
			
		||||
    | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
 | 
			
		||||
    | FollowActivity | ForwardedVotification | Revenue | SubStatus
 | 
			
		||||
    | TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
 | 
			
		||||
    | ReferralReward
 | 
			
		||||
    | ReferralReward | CowboyHat | NewHorse | LostHorse | NewGun | LostGun
 | 
			
		||||
 | 
			
		||||
  type Notifications {
 | 
			
		||||
    lastChecked: Date
 | 
			
		||||
 | 
			
		||||
@ -211,6 +211,8 @@ export default gql`
 | 
			
		||||
    streak: Int
 | 
			
		||||
    gunStreak: Int
 | 
			
		||||
    horseStreak: Int
 | 
			
		||||
    hasSendWallet: Boolean
 | 
			
		||||
    hasRecvWallet: Boolean
 | 
			
		||||
    maxStreak: Int
 | 
			
		||||
    isContributor: Boolean
 | 
			
		||||
    githubId: String
 | 
			
		||||
 | 
			
		||||
@ -1,29 +1,14 @@
 | 
			
		||||
import { Fragment } from 'react'
 | 
			
		||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
 | 
			
		||||
import Tooltip from 'react-bootstrap/Tooltip'
 | 
			
		||||
import CowboyHatIcon from '@/svgs/cowboy.svg'
 | 
			
		||||
import AnonIcon from '@/svgs/spy-fill.svg'
 | 
			
		||||
import { numWithUnits } from '@/lib/format'
 | 
			
		||||
import { USER_ID } from '@/lib/constants'
 | 
			
		||||
import GunIcon from '@/svgs/revolver.svg'
 | 
			
		||||
import HorseIcon from '@/svgs/horse.svg'
 | 
			
		||||
import { numWithUnits } from '@/lib/format'
 | 
			
		||||
import { USER_ID } from '@/lib/constants'
 | 
			
		||||
import classNames from 'classnames'
 | 
			
		||||
 | 
			
		||||
const BADGES = [
 | 
			
		||||
  {
 | 
			
		||||
    icon: CowboyHatIcon,
 | 
			
		||||
    streakName: 'streak'
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    icon: HorseIcon,
 | 
			
		||||
    streakName: 'horseStreak'
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    icon: GunIcon,
 | 
			
		||||
    streakName: 'gunStreak',
 | 
			
		||||
    sizeDelta: 2
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
 | 
			
		||||
  if (!user || Number(user.id) === USER_ID.ad) return null
 | 
			
		||||
  if (Number(user.id) === USER_ID.anon) {
 | 
			
		||||
@ -34,14 +19,41 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const badges = []
 | 
			
		||||
 | 
			
		||||
  const streak = user.optional.streak
 | 
			
		||||
  if (streak !== null) {
 | 
			
		||||
    badges.push({
 | 
			
		||||
      icon: CowboyHatIcon,
 | 
			
		||||
      overlayText: streak
 | 
			
		||||
        ? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
 | 
			
		||||
        : 'new'
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (user.optional.hasSendWallet) {
 | 
			
		||||
    badges.push({
 | 
			
		||||
      icon: GunIcon,
 | 
			
		||||
      sizeDelta: 2,
 | 
			
		||||
      overlayText: 'can send sats'
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (user.optional.hasRecvWallet) {
 | 
			
		||||
    badges.push({
 | 
			
		||||
      icon: HorseIcon,
 | 
			
		||||
      overlayText: 'can receive sats'
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <span className={className}>
 | 
			
		||||
      {BADGES.map(({ icon, streakName, sizeDelta }, i) => (
 | 
			
		||||
      {badges.map(({ icon, overlayText, sizeDelta }, i) => (
 | 
			
		||||
        <SNBadge
 | 
			
		||||
          key={streakName}
 | 
			
		||||
          key={i}
 | 
			
		||||
          user={user}
 | 
			
		||||
          badge={badge}
 | 
			
		||||
          streakName={streakName}
 | 
			
		||||
          overlayText={overlayText}
 | 
			
		||||
          badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
 | 
			
		||||
          IconForBadge={icon}
 | 
			
		||||
          height={height}
 | 
			
		||||
@ -53,20 +65,19 @@ export default function Badges ({ user, badge, className = 'ms-1', badgeClassNam
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SNBadge ({ user, badge, streakName, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
 | 
			
		||||
  const streak = user.optional[streakName]
 | 
			
		||||
  if (streak === null) {
 | 
			
		||||
    return null
 | 
			
		||||
function SNBadge ({ user, badge, overlayText, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
 | 
			
		||||
  let Wrapper = Fragment
 | 
			
		||||
 | 
			
		||||
  if (overlayText) {
 | 
			
		||||
    Wrapper = ({ children }) => (
 | 
			
		||||
      <BadgeTooltip overlayText={overlayText}>{children}</BadgeTooltip>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <BadgeTooltip
 | 
			
		||||
      overlayText={streak
 | 
			
		||||
        ? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
 | 
			
		||||
        : 'new'}
 | 
			
		||||
    >
 | 
			
		||||
    <Wrapper>
 | 
			
		||||
      <span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
 | 
			
		||||
    </BadgeTooltip>
 | 
			
		||||
    </Wrapper>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -58,7 +58,9 @@ function Notification ({ n, fresh }) {
 | 
			
		||||
        (type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
 | 
			
		||||
        (type === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) ||
 | 
			
		||||
        (type === 'Referral' && <Referral n={n} />) ||
 | 
			
		||||
        (type === 'Streak' && <Streak n={n} />) ||
 | 
			
		||||
        (type === 'CowboyHat' && <CowboyHat n={n} />) ||
 | 
			
		||||
        (['NewHorse', 'LostHorse'].includes(type) && <Horse n={n} />) ||
 | 
			
		||||
        (['NewGun', 'LostGun'].includes(type) && <Gun n={n} />) ||
 | 
			
		||||
        (type === 'Votification' && <Votification n={n} />) ||
 | 
			
		||||
        (type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
 | 
			
		||||
        (type === 'Mention' && <Mention n={n} />) ||
 | 
			
		||||
@ -165,7 +167,7 @@ const defaultOnClick = n => {
 | 
			
		||||
  if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
 | 
			
		||||
  if (type === 'Referral') return { href: '/referrals/month' }
 | 
			
		||||
  if (type === 'ReferralReward') return { href: '/referrals/month' }
 | 
			
		||||
  if (type === 'Streak') return {}
 | 
			
		||||
  if (['CowboyHat', 'NewHorse', 'LostHorse', 'NewGun', 'LostGun'].includes(type)) return {}
 | 
			
		||||
  if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
 | 
			
		||||
 | 
			
		||||
  if (!n.item) return {}
 | 
			
		||||
@ -174,30 +176,64 @@ const defaultOnClick = n => {
 | 
			
		||||
  return itemLink(n.item)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Streak ({ n }) {
 | 
			
		||||
  function blurb (n) {
 | 
			
		||||
    const type = n.type ?? 'COWBOY_HAT'
 | 
			
		||||
    const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
 | 
			
		||||
    if (n.days) {
 | 
			
		||||
      return `After ${numWithUnits(n.days, {
 | 
			
		||||
        abbreviate: false,
 | 
			
		||||
        unitSingular: 'day',
 | 
			
		||||
         unitPlural: 'days'
 | 
			
		||||
      })}, ` + LOST_BLURBS[type][index]
 | 
			
		||||
    }
 | 
			
		||||
function blurb (n) {
 | 
			
		||||
  const type = n.__typename === 'CowboyHat'
 | 
			
		||||
    ? 'COWBOY_HAT'
 | 
			
		||||
    : (n.__typename.includes('Horse') ? 'HORSE' : 'GUN')
 | 
			
		||||
  const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
 | 
			
		||||
  const lost = n.days || n.__typename.includes('Lost')
 | 
			
		||||
  return lost ? LOST_BLURBS[type][index] : FOUND_BLURBS[type][index]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
    return FOUND_BLURBS[type][index]
 | 
			
		||||
function CowboyHat ({ n }) {
 | 
			
		||||
  const Icon = n.days ? BaldIcon : CowboyHatIcon
 | 
			
		||||
 | 
			
		||||
  let body = ''
 | 
			
		||||
  if (n.days) {
 | 
			
		||||
    body = `After ${numWithUnits(n.days, {
 | 
			
		||||
      abbreviate: false,
 | 
			
		||||
      unitSingular: 'day',
 | 
			
		||||
      unitPlural: 'days'
 | 
			
		||||
    })}, `
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const Icon = n.days
 | 
			
		||||
    ? n.type === 'GUN' ? HolsterIcon : n.type === 'HORSE' ? SaddleIcon : BaldIcon
 | 
			
		||||
    : n.type === 'GUN' ? GunIcon : n.type === 'HORSE' ? HorseIcon : CowboyHatIcon
 | 
			
		||||
  body += `you ${n.days ? 'lost your' : 'found a'} cowboy hat`
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='d-flex'>
 | 
			
		||||
      <div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
 | 
			
		||||
      <div className='ms-1 p-1'>
 | 
			
		||||
        <span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} {n.type.toLowerCase().replace('_', ' ')}</span>
 | 
			
		||||
        <span className='fw-bold'>{body}</span>
 | 
			
		||||
        <div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Horse ({ n }) {
 | 
			
		||||
  const found = n.__typename.includes('New')
 | 
			
		||||
  const Icon = found ? HorseIcon : SaddleIcon
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='d-flex'>
 | 
			
		||||
      <div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
 | 
			
		||||
      <div className='ms-1 p-1'>
 | 
			
		||||
        <span className='fw-bold'>you {found ? 'found a' : 'lost your'} horse</span>
 | 
			
		||||
        <div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Gun ({ n }) {
 | 
			
		||||
  const found = n.__typename.includes('New')
 | 
			
		||||
  const Icon = found ? GunIcon : HolsterIcon
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='d-flex'>
 | 
			
		||||
      <div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
 | 
			
		||||
      <div className='ms-1 p-1'>
 | 
			
		||||
        <span className='fw-bold'>you {found ? 'found a' : 'lost your'} gun</span>
 | 
			
		||||
        <div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
@ -4,9 +4,9 @@ import { gql } from '@apollo/client'
 | 
			
		||||
const STREAK_FIELDS = gql`
 | 
			
		||||
  fragment StreakFields on User {
 | 
			
		||||
    optional {
 | 
			
		||||
    streak
 | 
			
		||||
    gunStreak
 | 
			
		||||
      horseStreak
 | 
			
		||||
      streak
 | 
			
		||||
      hasSendWallet
 | 
			
		||||
      hasRecvWallet
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
@ -5,9 +5,9 @@ import { COMMENTS } from './comments'
 | 
			
		||||
const STREAK_FIELDS = gql`
 | 
			
		||||
  fragment StreakFields on User {
 | 
			
		||||
    optional {
 | 
			
		||||
    streak
 | 
			
		||||
    gunStreak
 | 
			
		||||
      horseStreak
 | 
			
		||||
      streak
 | 
			
		||||
      hasSendWallet
 | 
			
		||||
      hasRecvWallet
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
@ -82,11 +82,26 @@ export const NOTIFICATIONS = gql`
 | 
			
		||||
            text
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        ... on Streak {
 | 
			
		||||
        ... on CowboyHat {
 | 
			
		||||
          id
 | 
			
		||||
          sortTime
 | 
			
		||||
          days
 | 
			
		||||
          type
 | 
			
		||||
        }
 | 
			
		||||
        ... on NewHorse {
 | 
			
		||||
          id
 | 
			
		||||
          sortTime
 | 
			
		||||
        }
 | 
			
		||||
        ... on LostHorse {
 | 
			
		||||
          id
 | 
			
		||||
          sortTime
 | 
			
		||||
        }
 | 
			
		||||
        ... on NewGun {
 | 
			
		||||
          id
 | 
			
		||||
          sortTime
 | 
			
		||||
        }
 | 
			
		||||
        ... on LostGun {
 | 
			
		||||
          id
 | 
			
		||||
          sortTime
 | 
			
		||||
        }
 | 
			
		||||
        ... on Earn {
 | 
			
		||||
          id
 | 
			
		||||
 | 
			
		||||
@ -6,9 +6,9 @@ import { COMMENTS_ITEM_EXT_FIELDS } from './comments'
 | 
			
		||||
const STREAK_FIELDS = gql`
 | 
			
		||||
  fragment StreakFields on User {
 | 
			
		||||
    optional {
 | 
			
		||||
    streak
 | 
			
		||||
    gunStreak
 | 
			
		||||
      horseStreak
 | 
			
		||||
      streak
 | 
			
		||||
      hasSendWallet
 | 
			
		||||
      hasRecvWallet
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
@ -6,9 +6,9 @@ import { SUB_FULL_FIELDS } from './subs'
 | 
			
		||||
export const STREAK_FIELDS = gql`
 | 
			
		||||
  fragment StreakFields on User {
 | 
			
		||||
    optional {
 | 
			
		||||
    streak
 | 
			
		||||
    gunStreak
 | 
			
		||||
      horseStreak
 | 
			
		||||
      streak
 | 
			
		||||
      hasSendWallet
 | 
			
		||||
      hasRecvWallet
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
@ -81,7 +81,11 @@ function getClient (uri) {
 | 
			
		||||
          'InvoicePaid',
 | 
			
		||||
          'WithdrawlPaid',
 | 
			
		||||
          'Referral',
 | 
			
		||||
          'Streak',
 | 
			
		||||
          'CowboyHat',
 | 
			
		||||
          'NewHorse',
 | 
			
		||||
          'LostHorse',
 | 
			
		||||
          'NewGun',
 | 
			
		||||
          'LostGun',
 | 
			
		||||
          'FollowActivity',
 | 
			
		||||
          'ForwardedVotification',
 | 
			
		||||
          'Revenue',
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										49
									
								
								prisma/migrations/20250328203730_wallet_badges/migration.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								prisma/migrations/20250328203730_wallet_badges/migration.sql
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,49 @@
 | 
			
		||||
ALTER TABLE "users"
 | 
			
		||||
    ADD COLUMN     "hasRecvWallet" BOOLEAN NOT NULL DEFAULT false,
 | 
			
		||||
    ADD COLUMN     "hasSendWallet" BOOLEAN NOT NULL DEFAULT false;
 | 
			
		||||
 | 
			
		||||
ALTER TABLE "Streak"
 | 
			
		||||
    ALTER COLUMN "startedAt" SET DATA TYPE TIMESTAMP(3),
 | 
			
		||||
    ALTER COLUMN "endedAt" SET DATA TYPE TIMESTAMP(3);
 | 
			
		||||
 | 
			
		||||
CREATE OR REPLACE FUNCTION check_wallet_trigger() RETURNS TRIGGER AS $$
 | 
			
		||||
DECLARE
 | 
			
		||||
    user_id INTEGER;
 | 
			
		||||
BEGIN
 | 
			
		||||
    -- if TG_OP is DELETE, then NEW.userId is NULL
 | 
			
		||||
    user_id := CASE WHEN TG_OP = 'DELETE' THEN OLD."userId" ELSE NEW."userId" END;
 | 
			
		||||
 | 
			
		||||
    INSERT INTO pgboss.job (name, data, retrylimit, startafter, keepuntil)
 | 
			
		||||
    VALUES ('checkWallet', jsonb_build_object('userId', user_id), 21, now(), now() + interval '5 minutes');
 | 
			
		||||
 | 
			
		||||
    RETURN NULL;
 | 
			
		||||
END;
 | 
			
		||||
$$ LANGUAGE plpgsql;
 | 
			
		||||
 | 
			
		||||
CREATE OR REPLACE TRIGGER check_wallet_trigger
 | 
			
		||||
AFTER INSERT OR UPDATE OR DELETE ON "Wallet"
 | 
			
		||||
FOR EACH ROW EXECUTE PROCEDURE check_wallet_trigger();
 | 
			
		||||
 | 
			
		||||
CREATE OR REPLACE FUNCTION wallet_badges_user_migration()
 | 
			
		||||
RETURNS INTEGER
 | 
			
		||||
LANGUAGE plpgsql
 | 
			
		||||
AS $$
 | 
			
		||||
DECLARE
 | 
			
		||||
BEGIN
 | 
			
		||||
    INSERT INTO pgboss.job (name, data, retrylimit, startafter, keepuntil)
 | 
			
		||||
    SELECT
 | 
			
		||||
        'checkWallet',
 | 
			
		||||
        jsonb_build_object('userId', "users"."id"),
 | 
			
		||||
        -- XXX we have around 1000 users with wallets
 | 
			
		||||
        -- to balance the db and network load (push notifications) a little bit,
 | 
			
		||||
        -- we select a random start time between now and 5 minutes.
 | 
			
		||||
        21, now() + (random() * interval '5 minutes'), now() + interval '10 minutes'
 | 
			
		||||
    FROM "users"
 | 
			
		||||
    WHERE EXISTS ( SELECT 1 FROM "Wallet" WHERE "users"."id" = "Wallet"."userId" );
 | 
			
		||||
    return 0;
 | 
			
		||||
EXCEPTION WHEN OTHERS THEN
 | 
			
		||||
    return 0;
 | 
			
		||||
END;
 | 
			
		||||
$$;
 | 
			
		||||
 | 
			
		||||
DROP FUNCTION IF EXISTS wallet_badges_user_migration;
 | 
			
		||||
@ -77,6 +77,8 @@ model User {
 | 
			
		||||
  streak                    Int?
 | 
			
		||||
  gunStreak                 Int?
 | 
			
		||||
  horseStreak               Int?
 | 
			
		||||
  hasSendWallet             Boolean              @default(false)
 | 
			
		||||
  hasRecvWallet             Boolean              @default(false)
 | 
			
		||||
  subs                      String[]
 | 
			
		||||
  hideCowboyHat             Boolean              @default(false)
 | 
			
		||||
  Bookmarks                 Bookmark[]
 | 
			
		||||
@ -383,8 +385,8 @@ model Streak {
 | 
			
		||||
  id        Int        @id @default(autoincrement())
 | 
			
		||||
  createdAt DateTime   @default(now()) @map("created_at")
 | 
			
		||||
  updatedAt DateTime   @default(now()) @updatedAt @map("updated_at")
 | 
			
		||||
  startedAt DateTime   @db.Date
 | 
			
		||||
  endedAt   DateTime?  @db.Date
 | 
			
		||||
  startedAt DateTime
 | 
			
		||||
  endedAt   DateTime?
 | 
			
		||||
  userId    Int
 | 
			
		||||
  type      StreakType @default(COWBOY_HAT)
 | 
			
		||||
  user      User       @relation(fields: [userId], references: [id], onDelete: Cascade)
 | 
			
		||||
 | 
			
		||||
@ -34,8 +34,8 @@ query TopCowboys($cursor: String) {
 | 
			
		||||
      name
 | 
			
		||||
      optional {
 | 
			
		||||
        streak
 | 
			
		||||
        gunStreak
 | 
			
		||||
        horseStreak
 | 
			
		||||
        hasSendWallet
 | 
			
		||||
        hasRecvWallet
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    cursor
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ import PgBoss from 'pg-boss'
 | 
			
		||||
import createPrisma from '@/lib/create-prisma'
 | 
			
		||||
import {
 | 
			
		||||
  checkInvoice, checkPendingDeposits, checkPendingWithdrawals,
 | 
			
		||||
  checkWithdrawal,
 | 
			
		||||
  checkWithdrawal, checkWallet,
 | 
			
		||||
  finalizeHodlInvoice, subscribeToWallet
 | 
			
		||||
} from './wallet'
 | 
			
		||||
import { repin } from './repin'
 | 
			
		||||
@ -144,6 +144,7 @@ async function work () {
 | 
			
		||||
  await boss.work('reminder', jobWrapper(remindUser))
 | 
			
		||||
  await boss.work('thisDay', jobWrapper(thisDay))
 | 
			
		||||
  await boss.work('socialPoster', jobWrapper(postToSocial))
 | 
			
		||||
  await boss.work('checkWallet', jobWrapper(checkWallet))
 | 
			
		||||
 | 
			
		||||
  console.log('working jobs')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -170,14 +170,6 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln
 | 
			
		||||
      await tx.$executeRaw`
 | 
			
		||||
        INSERT INTO pgboss.job (name, data)
 | 
			
		||||
        VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'COWBOY_HAT'))`
 | 
			
		||||
      if (dbInvoice.invoiceForward) {
 | 
			
		||||
        // only paid forwards are eligible for a gun streak
 | 
			
		||||
        await tx.$executeRaw`
 | 
			
		||||
          INSERT INTO pgboss.job (name, data)
 | 
			
		||||
          VALUES
 | 
			
		||||
            ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'GUN')),
 | 
			
		||||
            ('checkStreak', jsonb_build_object('id', ${dbInvoice.invoiceForward.withdrawl.userId}, 'type', 'HORSE'))`
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return updateFields
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										138
									
								
								worker/streak.js
									
									
									
									
									
								
							
							
						
						
									
										138
									
								
								worker/streak.js
									
									
									
									
									
								
							@ -2,59 +2,56 @@ import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush'
 | 
			
		||||
import { Prisma } from '@prisma/client'
 | 
			
		||||
 | 
			
		||||
const COWBOY_HAT_STREAK_THRESHOLD = 100
 | 
			
		||||
const GUN_STREAK_THRESHOLD = 1000
 | 
			
		||||
const HORSE_STREAK_THRESHOLD = 1000
 | 
			
		||||
 | 
			
		||||
export async function computeStreaks ({ models }) {
 | 
			
		||||
  // get all eligible users in the last day
 | 
			
		||||
  // if the user doesn't have an active streak, add one
 | 
			
		||||
  // if they have an active streak but didn't maintain it, end it
 | 
			
		||||
  for (const type of ['COWBOY_HAT', 'GUN', 'HORSE']) {
 | 
			
		||||
    const endingStreaks = await models.$queryRaw`
 | 
			
		||||
      WITH day_streaks (id) AS (
 | 
			
		||||
        ${getStreakQuery(type)}
 | 
			
		||||
      ), existing_streaks (id, started_at) AS (
 | 
			
		||||
        SELECT "userId", "startedAt"
 | 
			
		||||
        FROM "Streak"
 | 
			
		||||
        WHERE "Streak"."endedAt" IS NULL
 | 
			
		||||
        AND "type" = ${type}::"StreakType"
 | 
			
		||||
      ), new_streaks (id) AS (
 | 
			
		||||
        SELECT day_streaks.id
 | 
			
		||||
        FROM day_streaks
 | 
			
		||||
        LEFT JOIN existing_streaks ON existing_streaks.id = day_streaks.id
 | 
			
		||||
        WHERE existing_streaks.id IS NULL
 | 
			
		||||
      ), ending_streaks (id) AS (
 | 
			
		||||
        SELECT existing_streaks.id
 | 
			
		||||
        FROM existing_streaks
 | 
			
		||||
        LEFT JOIN day_streaks ON existing_streaks.id = day_streaks.id
 | 
			
		||||
        WHERE day_streaks.id IS NULL
 | 
			
		||||
      ), extending_streaks (id, started_at) AS (
 | 
			
		||||
        SELECT existing_streaks.id, existing_streaks.started_at
 | 
			
		||||
        FROM existing_streaks
 | 
			
		||||
        JOIN day_streaks ON existing_streaks.id = day_streaks.id
 | 
			
		||||
      ),
 | 
			
		||||
      -- a bunch of mutations
 | 
			
		||||
      streak_insert AS (
 | 
			
		||||
        INSERT INTO "Streak" ("userId", "startedAt", "type", created_at, updated_at)
 | 
			
		||||
        SELECT id, (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, ${type}::"StreakType", now_utc(), now_utc()
 | 
			
		||||
        FROM new_streaks
 | 
			
		||||
      ), user_update_new_streaks AS (
 | 
			
		||||
        UPDATE users SET ${getStreakColumn(type)} = 1 FROM new_streaks WHERE new_streaks.id = users.id
 | 
			
		||||
      ), user_update_end_streaks AS (
 | 
			
		||||
        UPDATE users SET ${getStreakColumn(type)} = NULL FROM ending_streaks WHERE ending_streaks.id = users.id
 | 
			
		||||
      ), user_update_extend_streaks AS (
 | 
			
		||||
        UPDATE users
 | 
			
		||||
        SET ${getStreakColumn(type)} = (now() AT TIME ZONE 'America/Chicago')::date - extending_streaks.started_at
 | 
			
		||||
        FROM extending_streaks WHERE extending_streaks.id = users.id
 | 
			
		||||
      )
 | 
			
		||||
      UPDATE "Streak"
 | 
			
		||||
      SET "endedAt" = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, updated_at = now_utc()
 | 
			
		||||
      FROM ending_streaks
 | 
			
		||||
      WHERE ending_streaks.id = "Streak"."userId" AND "endedAt" IS NULL AND "type" = ${type}::"StreakType"
 | 
			
		||||
      RETURNING "Streak".*`
 | 
			
		||||
  const type = 'COWBOY_HAT'
 | 
			
		||||
  const endingStreaks = await models.$queryRaw`
 | 
			
		||||
    WITH day_streaks (id) AS (
 | 
			
		||||
      ${getStreakQuery(type)}
 | 
			
		||||
    ), existing_streaks (id, started_at) AS (
 | 
			
		||||
      SELECT "userId", "startedAt"
 | 
			
		||||
      FROM "Streak"
 | 
			
		||||
      WHERE "Streak"."endedAt" IS NULL
 | 
			
		||||
      AND "type" = ${type}::"StreakType"
 | 
			
		||||
    ), new_streaks (id) AS (
 | 
			
		||||
      SELECT day_streaks.id
 | 
			
		||||
      FROM day_streaks
 | 
			
		||||
      LEFT JOIN existing_streaks ON existing_streaks.id = day_streaks.id
 | 
			
		||||
      WHERE existing_streaks.id IS NULL
 | 
			
		||||
    ), ending_streaks (id) AS (
 | 
			
		||||
      SELECT existing_streaks.id
 | 
			
		||||
      FROM existing_streaks
 | 
			
		||||
      LEFT JOIN day_streaks ON existing_streaks.id = day_streaks.id
 | 
			
		||||
      WHERE day_streaks.id IS NULL
 | 
			
		||||
    ), extending_streaks (id, started_at) AS (
 | 
			
		||||
      SELECT existing_streaks.id, existing_streaks.started_at
 | 
			
		||||
      FROM existing_streaks
 | 
			
		||||
      JOIN day_streaks ON existing_streaks.id = day_streaks.id
 | 
			
		||||
    ),
 | 
			
		||||
    -- a bunch of mutations
 | 
			
		||||
    streak_insert AS (
 | 
			
		||||
      INSERT INTO "Streak" ("userId", "startedAt", "type", created_at, updated_at)
 | 
			
		||||
      SELECT id, (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, ${type}::"StreakType", now_utc(), now_utc()
 | 
			
		||||
      FROM new_streaks
 | 
			
		||||
    ), user_update_new_streaks AS (
 | 
			
		||||
      UPDATE users SET "streak" = 1 FROM new_streaks WHERE new_streaks.id = users.id
 | 
			
		||||
    ), user_update_end_streaks AS (
 | 
			
		||||
      UPDATE users SET "streak" = NULL FROM ending_streaks WHERE ending_streaks.id = users.id
 | 
			
		||||
    ), user_update_extend_streaks AS (
 | 
			
		||||
      UPDATE users
 | 
			
		||||
      SET "streak" = (now() AT TIME ZONE 'America/Chicago')::date - extending_streaks.started_at::date
 | 
			
		||||
      FROM extending_streaks WHERE extending_streaks.id = users.id
 | 
			
		||||
    )
 | 
			
		||||
    UPDATE "Streak"
 | 
			
		||||
    SET "endedAt" = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, updated_at = now_utc()
 | 
			
		||||
    FROM ending_streaks
 | 
			
		||||
    WHERE ending_streaks.id = "Streak"."userId" AND "endedAt" IS NULL AND "type" = ${type}::"StreakType"
 | 
			
		||||
    RETURNING "Streak".*`
 | 
			
		||||
 | 
			
		||||
    Promise.allSettled(endingStreaks.map(streak => notifyStreakLost(streak.userId, streak)))
 | 
			
		||||
  }
 | 
			
		||||
  Promise.allSettled(endingStreaks.map(streak => notifyStreakLost(streak.userId, streak)))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function checkStreak ({ data: { id, type = 'COWBOY_HAT' }, models }) {
 | 
			
		||||
@ -75,7 +72,7 @@ export async function checkStreak ({ data: { id, type = 'COWBOY_HAT' }, models }
 | 
			
		||||
    WITH streak_started (id) AS (
 | 
			
		||||
        ${getStreakQuery(type, id)}
 | 
			
		||||
    ), user_start_streak AS (
 | 
			
		||||
      UPDATE users SET ${getStreakColumn(type)} = 0 FROM streak_started WHERE streak_started.id = users.id
 | 
			
		||||
      UPDATE users SET "streak" = 0 FROM streak_started WHERE streak_started.id = users.id
 | 
			
		||||
    )
 | 
			
		||||
    INSERT INTO "Streak" ("userId", "startedAt", "type", created_at, updated_at)
 | 
			
		||||
    SELECT id, (now() AT TIME ZONE 'America/Chicago')::date, ${type}::"StreakType", now_utc(), now_utc()
 | 
			
		||||
@ -93,31 +90,6 @@ function getStreakQuery (type, userId) {
 | 
			
		||||
    ? Prisma.sql`(now() AT TIME ZONE 'America/Chicago')::date`
 | 
			
		||||
    : Prisma.sql`(now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date`
 | 
			
		||||
 | 
			
		||||
  if (type === 'GUN') {
 | 
			
		||||
    return Prisma.sql`
 | 
			
		||||
      SELECT "Invoice"."userId"
 | 
			
		||||
        FROM "Invoice"
 | 
			
		||||
        JOIN "InvoiceForward" ON "Invoice".id = "InvoiceForward"."invoiceId"
 | 
			
		||||
        WHERE ("Invoice"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment}
 | 
			
		||||
        AND "Invoice"."actionState" = 'PAID' AND "Invoice"."actionType" = 'ZAP'
 | 
			
		||||
        ${userId ? Prisma.sql`AND "Invoice"."userId" = ${userId}` : Prisma.empty}
 | 
			
		||||
        GROUP BY "Invoice"."userId"
 | 
			
		||||
        HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${GUN_STREAK_THRESHOLD}`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (type === 'HORSE') {
 | 
			
		||||
    return Prisma.sql`
 | 
			
		||||
      SELECT "Withdrawl"."userId"
 | 
			
		||||
        FROM "Withdrawl"
 | 
			
		||||
        JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId"
 | 
			
		||||
        JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id
 | 
			
		||||
        WHERE ("Withdrawl"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment}
 | 
			
		||||
        AND "Invoice"."actionState" = 'PAID' AND "Invoice"."actionType" = 'ZAP'
 | 
			
		||||
        ${userId ? Prisma.sql`AND "Withdrawl"."userId" = ${userId}` : Prisma.empty}
 | 
			
		||||
        GROUP BY "Withdrawl"."userId"
 | 
			
		||||
        HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${HORSE_STREAK_THRESHOLD}`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return Prisma.sql`
 | 
			
		||||
      SELECT "userId"
 | 
			
		||||
        FROM
 | 
			
		||||
@ -145,25 +117,5 @@ function getStreakQuery (type, userId) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isStreakActive (type, user) {
 | 
			
		||||
  if (type === 'GUN') {
 | 
			
		||||
    return typeof user.gunStreak === 'number'
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (type === 'HORSE') {
 | 
			
		||||
    return typeof user.horseStreak === 'number'
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return typeof user.streak === 'number'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getStreakColumn (type) {
 | 
			
		||||
  if (type === 'GUN') {
 | 
			
		||||
    return Prisma.sql`"gunStreak"`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (type === 'HORSE') {
 | 
			
		||||
    return Prisma.sql`"horseStreak"`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return Prisma.sql`"streak"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -177,8 +177,8 @@ const THIS_DAY = gql`
 | 
			
		||||
          id
 | 
			
		||||
          optional {
 | 
			
		||||
            streak
 | 
			
		||||
            gunStreak
 | 
			
		||||
            horseStreak
 | 
			
		||||
            hasSendWallet
 | 
			
		||||
            hasRecvWallet
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        ncomments(when: "custom", from: $from, to: $to)
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,8 @@ import {
 | 
			
		||||
  paidActionCanceling
 | 
			
		||||
} from './paidAction'
 | 
			
		||||
import { payingActionConfirmed, payingActionFailed } from './payingAction'
 | 
			
		||||
import { canReceive, getWalletByType } from '@/wallets/common'
 | 
			
		||||
import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush'
 | 
			
		||||
 | 
			
		||||
export async function subscribeToWallet (args) {
 | 
			
		||||
  await subscribeToDeposits(args)
 | 
			
		||||
@ -284,3 +286,73 @@ export async function checkPendingWithdrawals (args) {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function checkWallet ({ data: { userId }, models }) {
 | 
			
		||||
  const pushNotifications = []
 | 
			
		||||
 | 
			
		||||
  await models.$transaction(async tx => {
 | 
			
		||||
    const wallets = await tx.wallet.findMany({
 | 
			
		||||
      where: {
 | 
			
		||||
        userId,
 | 
			
		||||
        enabled: true
 | 
			
		||||
      },
 | 
			
		||||
      include: {
 | 
			
		||||
        vaultEntries: true
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const { hasRecvWallet: oldHasRecvWallet, hasSendWallet: oldHasSendWallet } = await tx.user.findUnique({ where: { id: userId } })
 | 
			
		||||
 | 
			
		||||
    const newHasRecvWallet = wallets.some(({ type, wallet }) => canReceive({ def: getWalletByType(type), config: wallet }))
 | 
			
		||||
    const newHasSendWallet = wallets.some(({ vaultEntries }) => vaultEntries.length > 0)
 | 
			
		||||
 | 
			
		||||
    await tx.user.update({
 | 
			
		||||
      where: { id: userId },
 | 
			
		||||
      data: {
 | 
			
		||||
        hasRecvWallet: newHasRecvWallet,
 | 
			
		||||
        hasSendWallet: newHasSendWallet
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const startStreak = async (type) => {
 | 
			
		||||
      const streak = await tx.streak.create({
 | 
			
		||||
        data: { userId, type, startedAt: new Date() }
 | 
			
		||||
      })
 | 
			
		||||
      return streak.id
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const endStreak = async (type) => {
 | 
			
		||||
      const [streak] = await tx.$queryRaw`
 | 
			
		||||
        UPDATE "Streak"
 | 
			
		||||
        SET "endedAt" = now(), updated_at = now()
 | 
			
		||||
        WHERE "userId" = ${userId}
 | 
			
		||||
        AND "type" = ${type}::"StreakType"
 | 
			
		||||
        AND "endedAt" IS NULL
 | 
			
		||||
        RETURNING "id"
 | 
			
		||||
      `
 | 
			
		||||
      return streak?.id
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!oldHasRecvWallet && newHasRecvWallet) {
 | 
			
		||||
      const streakId = await startStreak('HORSE')
 | 
			
		||||
      if (streakId) pushNotifications.push(() => notifyNewStreak(userId, { type: 'HORSE', id: streakId }))
 | 
			
		||||
    }
 | 
			
		||||
    if (!oldHasSendWallet && newHasSendWallet) {
 | 
			
		||||
      const streakId = await startStreak('GUN')
 | 
			
		||||
      if (streakId) pushNotifications.push(() => notifyNewStreak(userId, { type: 'GUN', id: streakId }))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (oldHasRecvWallet && !newHasRecvWallet) {
 | 
			
		||||
      const streakId = await endStreak('HORSE')
 | 
			
		||||
      if (streakId) pushNotifications.push(() => notifyStreakLost(userId, { type: 'HORSE', id: streakId }))
 | 
			
		||||
    }
 | 
			
		||||
    if (oldHasSendWallet && !newHasSendWallet) {
 | 
			
		||||
      const streakId = await endStreak('GUN')
 | 
			
		||||
      if (streakId) pushNotifications.push(() => notifyStreakLost(userId, { type: 'GUN', id: streakId }))
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // run all push notifications at the end to make sure we don't
 | 
			
		||||
  // accidentally send duplicate push notifications because of a job retry
 | 
			
		||||
  await Promise.all(pushNotifications.map(notify => notify())).catch(console.error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user