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