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) {
|
if (meFull.noteCowboyHat) {
|
||||||
queries.push(
|
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"
|
FROM "Streak"
|
||||||
WHERE "userId" = $1
|
WHERE "userId" = $1
|
||||||
AND updated_at < $2
|
AND updated_at < $2
|
||||||
|
AND type = 'COWBOY_HAT'
|
||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
LIMIT ${LIMIT})`
|
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(
|
queries.push(
|
||||||
@ -500,23 +523,14 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Streak: {
|
CowboyHat: {
|
||||||
days: async (n, args, { models }) => {
|
days: async (n, args, { models }) => {
|
||||||
const res = await models.$queryRaw`
|
const res = await models.$queryRaw`
|
||||||
SELECT "endedAt" - "startedAt" AS days
|
SELECT "endedAt"::date - "startedAt"::date AS days
|
||||||
FROM "Streak"
|
FROM "Streak"
|
||||||
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
|
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
return res.length ? res[0].days : 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: {
|
Earn: {
|
||||||
|
@ -1082,19 +1082,17 @@ export default {
|
|||||||
|
|
||||||
return user.streak
|
return user.streak
|
||||||
},
|
},
|
||||||
gunStreak: async (user, args, { models }) => {
|
hasSendWallet: async (user, args, { models }) => {
|
||||||
if (user.hideCowboyHat) {
|
if (user.hideCowboyHat) {
|
||||||
return null
|
return false
|
||||||
}
|
}
|
||||||
|
return user.hasSendWallet
|
||||||
return user.gunStreak
|
|
||||||
},
|
},
|
||||||
horseStreak: async (user, args, { models }) => {
|
hasRecvWallet: async (user, args, { models }) => {
|
||||||
if (user.hideCowboyHat) {
|
if (user.hideCowboyHat) {
|
||||||
return null
|
return false
|
||||||
}
|
}
|
||||||
|
return user.hasRecvWallet
|
||||||
return user.horseStreak
|
|
||||||
},
|
},
|
||||||
maxStreak: async (user, args, { models }) => {
|
maxStreak: async (user, args, { models }) => {
|
||||||
if (user.hideCowboyHat) {
|
if (user.hideCowboyHat) {
|
||||||
@ -1102,7 +1100,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [{ max }] = await models.$queryRaw`
|
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}`
|
FROM "Streak" WHERE "userId" = ${user.id}`
|
||||||
return max
|
return max
|
||||||
},
|
},
|
||||||
|
@ -75,13 +75,6 @@ export default gql`
|
|||||||
tipComments: Int!
|
tipComments: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Streak {
|
|
||||||
id: ID!
|
|
||||||
sortTime: Date!
|
|
||||||
days: Int
|
|
||||||
type: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
type Earn {
|
type Earn {
|
||||||
id: ID!
|
id: ID!
|
||||||
earnedSats: Int!
|
earnedSats: Int!
|
||||||
@ -156,11 +149,37 @@ export default gql`
|
|||||||
sortTime: Date!
|
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
|
union Notification = Reply | Votification | Mention
|
||||||
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
|
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
|
||||||
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
|
| FollowActivity | ForwardedVotification | Revenue | SubStatus
|
||||||
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
|
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
|
||||||
| ReferralReward
|
| ReferralReward | CowboyHat | NewHorse | LostHorse | NewGun | LostGun
|
||||||
|
|
||||||
type Notifications {
|
type Notifications {
|
||||||
lastChecked: Date
|
lastChecked: Date
|
||||||
|
@ -211,6 +211,8 @@ export default gql`
|
|||||||
streak: Int
|
streak: Int
|
||||||
gunStreak: Int
|
gunStreak: Int
|
||||||
horseStreak: Int
|
horseStreak: Int
|
||||||
|
hasSendWallet: Boolean
|
||||||
|
hasRecvWallet: Boolean
|
||||||
maxStreak: Int
|
maxStreak: Int
|
||||||
isContributor: Boolean
|
isContributor: Boolean
|
||||||
githubId: String
|
githubId: String
|
||||||
|
@ -1,29 +1,14 @@
|
|||||||
|
import { Fragment } from 'react'
|
||||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
|
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
|
||||||
import Tooltip from 'react-bootstrap/Tooltip'
|
import Tooltip from 'react-bootstrap/Tooltip'
|
||||||
import CowboyHatIcon from '@/svgs/cowboy.svg'
|
import CowboyHatIcon from '@/svgs/cowboy.svg'
|
||||||
import AnonIcon from '@/svgs/spy-fill.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 GunIcon from '@/svgs/revolver.svg'
|
||||||
import HorseIcon from '@/svgs/horse.svg'
|
import HorseIcon from '@/svgs/horse.svg'
|
||||||
|
import { numWithUnits } from '@/lib/format'
|
||||||
|
import { USER_ID } from '@/lib/constants'
|
||||||
import classNames from 'classnames'
|
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 }) {
|
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 (!user || Number(user.id) === USER_ID.ad) return null
|
||||||
if (Number(user.id) === USER_ID.anon) {
|
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 (
|
return (
|
||||||
<span className={className}>
|
<span className={className}>
|
||||||
{BADGES.map(({ icon, streakName, sizeDelta }, i) => (
|
{badges.map(({ icon, overlayText, sizeDelta }, i) => (
|
||||||
<SNBadge
|
<SNBadge
|
||||||
key={streakName}
|
key={i}
|
||||||
user={user}
|
user={user}
|
||||||
badge={badge}
|
badge={badge}
|
||||||
streakName={streakName}
|
overlayText={overlayText}
|
||||||
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
|
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
|
||||||
IconForBadge={icon}
|
IconForBadge={icon}
|
||||||
height={height}
|
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 }) {
|
function SNBadge ({ user, badge, overlayText, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
|
||||||
const streak = user.optional[streakName]
|
let Wrapper = Fragment
|
||||||
if (streak === null) {
|
|
||||||
return null
|
if (overlayText) {
|
||||||
|
Wrapper = ({ children }) => (
|
||||||
|
<BadgeTooltip overlayText={overlayText}>{children}</BadgeTooltip>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BadgeTooltip
|
<Wrapper>
|
||||||
overlayText={streak
|
|
||||||
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
|
|
||||||
: 'new'}
|
|
||||||
>
|
|
||||||
<span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
|
<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 === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
|
||||||
(type === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) ||
|
(type === 'WithdrawlPaid' && <WithdrawlPaid n={n} />) ||
|
||||||
(type === 'Referral' && <Referral 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 === 'Votification' && <Votification n={n} />) ||
|
||||||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
|
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
|
||||||
(type === 'Mention' && <Mention n={n} />) ||
|
(type === 'Mention' && <Mention n={n} />) ||
|
||||||
@ -165,7 +167,7 @@ const defaultOnClick = n => {
|
|||||||
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
|
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
|
||||||
if (type === 'Referral') return { href: '/referrals/month' }
|
if (type === 'Referral') return { href: '/referrals/month' }
|
||||||
if (type === 'ReferralReward') 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 (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }
|
||||||
|
|
||||||
if (!n.item) return {}
|
if (!n.item) return {}
|
||||||
@ -174,30 +176,64 @@ const defaultOnClick = n => {
|
|||||||
return itemLink(n.item)
|
return itemLink(n.item)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Streak ({ n }) {
|
function blurb (n) {
|
||||||
function blurb (n) {
|
const type = n.__typename === 'CowboyHat'
|
||||||
const type = n.type ?? 'COWBOY_HAT'
|
? 'COWBOY_HAT'
|
||||||
|
: (n.__typename.includes('Horse') ? 'HORSE' : 'GUN')
|
||||||
const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
function CowboyHat ({ n }) {
|
||||||
|
const Icon = n.days ? BaldIcon : CowboyHatIcon
|
||||||
|
|
||||||
|
let body = ''
|
||||||
if (n.days) {
|
if (n.days) {
|
||||||
return `After ${numWithUnits(n.days, {
|
body = `After ${numWithUnits(n.days, {
|
||||||
abbreviate: false,
|
abbreviate: false,
|
||||||
unitSingular: 'day',
|
unitSingular: 'day',
|
||||||
unitPlural: 'days'
|
unitPlural: 'days'
|
||||||
})}, ` + LOST_BLURBS[type][index]
|
})}, `
|
||||||
}
|
}
|
||||||
|
|
||||||
return FOUND_BLURBS[type][index]
|
body += `you ${n.days ? 'lost your' : 'found a'} cowboy hat`
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = n.days
|
|
||||||
? n.type === 'GUN' ? HolsterIcon : n.type === 'HORSE' ? SaddleIcon : BaldIcon
|
|
||||||
: n.type === 'GUN' ? GunIcon : n.type === 'HORSE' ? HorseIcon : CowboyHatIcon
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='d-flex'>
|
<div className='d-flex'>
|
||||||
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
|
<div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
|
||||||
<div className='ms-1 p-1'>
|
<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><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,8 +5,8 @@ const STREAK_FIELDS = gql`
|
|||||||
fragment StreakFields on User {
|
fragment StreakFields on User {
|
||||||
optional {
|
optional {
|
||||||
streak
|
streak
|
||||||
gunStreak
|
hasSendWallet
|
||||||
horseStreak
|
hasRecvWallet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -6,8 +6,8 @@ const STREAK_FIELDS = gql`
|
|||||||
fragment StreakFields on User {
|
fragment StreakFields on User {
|
||||||
optional {
|
optional {
|
||||||
streak
|
streak
|
||||||
gunStreak
|
hasSendWallet
|
||||||
horseStreak
|
hasRecvWallet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -82,11 +82,26 @@ export const NOTIFICATIONS = gql`
|
|||||||
text
|
text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
... on Streak {
|
... on CowboyHat {
|
||||||
id
|
id
|
||||||
sortTime
|
sortTime
|
||||||
days
|
days
|
||||||
type
|
}
|
||||||
|
... on NewHorse {
|
||||||
|
id
|
||||||
|
sortTime
|
||||||
|
}
|
||||||
|
... on LostHorse {
|
||||||
|
id
|
||||||
|
sortTime
|
||||||
|
}
|
||||||
|
... on NewGun {
|
||||||
|
id
|
||||||
|
sortTime
|
||||||
|
}
|
||||||
|
... on LostGun {
|
||||||
|
id
|
||||||
|
sortTime
|
||||||
}
|
}
|
||||||
... on Earn {
|
... on Earn {
|
||||||
id
|
id
|
||||||
|
@ -7,8 +7,8 @@ const STREAK_FIELDS = gql`
|
|||||||
fragment StreakFields on User {
|
fragment StreakFields on User {
|
||||||
optional {
|
optional {
|
||||||
streak
|
streak
|
||||||
gunStreak
|
hasSendWallet
|
||||||
horseStreak
|
hasRecvWallet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -7,8 +7,8 @@ export const STREAK_FIELDS = gql`
|
|||||||
fragment StreakFields on User {
|
fragment StreakFields on User {
|
||||||
optional {
|
optional {
|
||||||
streak
|
streak
|
||||||
gunStreak
|
hasSendWallet
|
||||||
horseStreak
|
hasRecvWallet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -81,7 +81,11 @@ function getClient (uri) {
|
|||||||
'InvoicePaid',
|
'InvoicePaid',
|
||||||
'WithdrawlPaid',
|
'WithdrawlPaid',
|
||||||
'Referral',
|
'Referral',
|
||||||
'Streak',
|
'CowboyHat',
|
||||||
|
'NewHorse',
|
||||||
|
'LostHorse',
|
||||||
|
'NewGun',
|
||||||
|
'LostGun',
|
||||||
'FollowActivity',
|
'FollowActivity',
|
||||||
'ForwardedVotification',
|
'ForwardedVotification',
|
||||||
'Revenue',
|
'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?
|
streak Int?
|
||||||
gunStreak Int?
|
gunStreak Int?
|
||||||
horseStreak Int?
|
horseStreak Int?
|
||||||
|
hasSendWallet Boolean @default(false)
|
||||||
|
hasRecvWallet Boolean @default(false)
|
||||||
subs String[]
|
subs String[]
|
||||||
hideCowboyHat Boolean @default(false)
|
hideCowboyHat Boolean @default(false)
|
||||||
Bookmarks Bookmark[]
|
Bookmarks Bookmark[]
|
||||||
@ -383,8 +385,8 @@ model Streak {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
startedAt DateTime @db.Date
|
startedAt DateTime
|
||||||
endedAt DateTime? @db.Date
|
endedAt DateTime?
|
||||||
userId Int
|
userId Int
|
||||||
type StreakType @default(COWBOY_HAT)
|
type StreakType @default(COWBOY_HAT)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
@ -34,8 +34,8 @@ query TopCowboys($cursor: String) {
|
|||||||
name
|
name
|
||||||
optional {
|
optional {
|
||||||
streak
|
streak
|
||||||
gunStreak
|
hasSendWallet
|
||||||
horseStreak
|
hasRecvWallet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cursor
|
cursor
|
||||||
|
@ -4,7 +4,7 @@ import PgBoss from 'pg-boss'
|
|||||||
import createPrisma from '@/lib/create-prisma'
|
import createPrisma from '@/lib/create-prisma'
|
||||||
import {
|
import {
|
||||||
checkInvoice, checkPendingDeposits, checkPendingWithdrawals,
|
checkInvoice, checkPendingDeposits, checkPendingWithdrawals,
|
||||||
checkWithdrawal,
|
checkWithdrawal, checkWallet,
|
||||||
finalizeHodlInvoice, subscribeToWallet
|
finalizeHodlInvoice, subscribeToWallet
|
||||||
} from './wallet'
|
} from './wallet'
|
||||||
import { repin } from './repin'
|
import { repin } from './repin'
|
||||||
@ -144,6 +144,7 @@ async function work () {
|
|||||||
await boss.work('reminder', jobWrapper(remindUser))
|
await boss.work('reminder', jobWrapper(remindUser))
|
||||||
await boss.work('thisDay', jobWrapper(thisDay))
|
await boss.work('thisDay', jobWrapper(thisDay))
|
||||||
await boss.work('socialPoster', jobWrapper(postToSocial))
|
await boss.work('socialPoster', jobWrapper(postToSocial))
|
||||||
|
await boss.work('checkWallet', jobWrapper(checkWallet))
|
||||||
|
|
||||||
console.log('working jobs')
|
console.log('working jobs')
|
||||||
}
|
}
|
||||||
|
@ -170,14 +170,6 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln
|
|||||||
await tx.$executeRaw`
|
await tx.$executeRaw`
|
||||||
INSERT INTO pgboss.job (name, data)
|
INSERT INTO pgboss.job (name, data)
|
||||||
VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'COWBOY_HAT'))`
|
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
|
return updateFields
|
||||||
},
|
},
|
||||||
|
@ -2,14 +2,12 @@ import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush'
|
|||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
const COWBOY_HAT_STREAK_THRESHOLD = 100
|
const COWBOY_HAT_STREAK_THRESHOLD = 100
|
||||||
const GUN_STREAK_THRESHOLD = 1000
|
|
||||||
const HORSE_STREAK_THRESHOLD = 1000
|
|
||||||
|
|
||||||
export async function computeStreaks ({ models }) {
|
export async function computeStreaks ({ models }) {
|
||||||
// get all eligible users in the last day
|
// get all eligible users in the last day
|
||||||
// if the user doesn't have an active streak, add one
|
// if the user doesn't have an active streak, add one
|
||||||
// if they have an active streak but didn't maintain it, end it
|
// if they have an active streak but didn't maintain it, end it
|
||||||
for (const type of ['COWBOY_HAT', 'GUN', 'HORSE']) {
|
const type = 'COWBOY_HAT'
|
||||||
const endingStreaks = await models.$queryRaw`
|
const endingStreaks = await models.$queryRaw`
|
||||||
WITH day_streaks (id) AS (
|
WITH day_streaks (id) AS (
|
||||||
${getStreakQuery(type)}
|
${getStreakQuery(type)}
|
||||||
@ -39,12 +37,12 @@ export async function computeStreaks ({ models }) {
|
|||||||
SELECT id, (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, ${type}::"StreakType", now_utc(), now_utc()
|
SELECT id, (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, ${type}::"StreakType", now_utc(), now_utc()
|
||||||
FROM new_streaks
|
FROM new_streaks
|
||||||
), user_update_new_streaks AS (
|
), user_update_new_streaks AS (
|
||||||
UPDATE users SET ${getStreakColumn(type)} = 1 FROM new_streaks WHERE new_streaks.id = users.id
|
UPDATE users SET "streak" = 1 FROM new_streaks WHERE new_streaks.id = users.id
|
||||||
), user_update_end_streaks AS (
|
), user_update_end_streaks AS (
|
||||||
UPDATE users SET ${getStreakColumn(type)} = NULL FROM ending_streaks WHERE ending_streaks.id = users.id
|
UPDATE users SET "streak" = NULL FROM ending_streaks WHERE ending_streaks.id = users.id
|
||||||
), user_update_extend_streaks AS (
|
), user_update_extend_streaks AS (
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET ${getStreakColumn(type)} = (now() AT TIME ZONE 'America/Chicago')::date - extending_streaks.started_at
|
SET "streak" = (now() AT TIME ZONE 'America/Chicago')::date - extending_streaks.started_at::date
|
||||||
FROM extending_streaks WHERE extending_streaks.id = users.id
|
FROM extending_streaks WHERE extending_streaks.id = users.id
|
||||||
)
|
)
|
||||||
UPDATE "Streak"
|
UPDATE "Streak"
|
||||||
@ -54,7 +52,6 @@ export async function computeStreaks ({ models }) {
|
|||||||
RETURNING "Streak".*`
|
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 }) {
|
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 (
|
WITH streak_started (id) AS (
|
||||||
${getStreakQuery(type, id)}
|
${getStreakQuery(type, id)}
|
||||||
), user_start_streak AS (
|
), 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)
|
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()
|
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')::date`
|
||||||
: Prisma.sql`(now() AT TIME ZONE 'America/Chicago' - interval '1 day')::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`
|
return Prisma.sql`
|
||||||
SELECT "userId"
|
SELECT "userId"
|
||||||
FROM
|
FROM
|
||||||
@ -145,25 +117,5 @@ function getStreakQuery (type, userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isStreakActive (type, user) {
|
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'
|
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
|
id
|
||||||
optional {
|
optional {
|
||||||
streak
|
streak
|
||||||
gunStreak
|
hasSendWallet
|
||||||
horseStreak
|
hasRecvWallet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ncomments(when: "custom", from: $from, to: $to)
|
ncomments(when: "custom", from: $from, to: $to)
|
||||||
|
@ -12,6 +12,8 @@ import {
|
|||||||
paidActionCanceling
|
paidActionCanceling
|
||||||
} from './paidAction'
|
} from './paidAction'
|
||||||
import { payingActionConfirmed, payingActionFailed } from './payingAction'
|
import { payingActionConfirmed, payingActionFailed } from './payingAction'
|
||||||
|
import { canReceive, getWalletByType } from '@/wallets/common'
|
||||||
|
import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush'
|
||||||
|
|
||||||
export async function subscribeToWallet (args) {
|
export async function subscribeToWallet (args) {
|
||||||
await subscribeToDeposits(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