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:
ekzyis 2025-04-09 22:29:44 +02:00 committed by GitHub
parent 9df5a52bd3
commit 52365c32ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 369 additions and 202 deletions

View File

@ -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: {

View File

@ -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
},

View File

@ -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

View File

@ -211,6 +211,8 @@ export default gql`
streak: Int
gunStreak: Int
horseStreak: Int
hasSendWallet: Boolean
hasRecvWallet: Boolean
maxStreak: Int
isContributor: Boolean
githubId: String

View File

@ -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>
)
}

View File

@ -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>

View File

@ -4,9 +4,9 @@ import { gql } from '@apollo/client'
const STREAK_FIELDS = gql`
fragment StreakFields on User {
optional {
streak
gunStreak
horseStreak
streak
hasSendWallet
hasRecvWallet
}
}
`

View File

@ -5,9 +5,9 @@ import { COMMENTS } from './comments'
const STREAK_FIELDS = gql`
fragment StreakFields on User {
optional {
streak
gunStreak
horseStreak
streak
hasSendWallet
hasRecvWallet
}
}
`

View File

@ -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

View File

@ -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
}
}
`

View File

@ -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
}
}
`

View File

@ -81,7 +81,11 @@ function getClient (uri) {
'InvoicePaid',
'WithdrawlPaid',
'Referral',
'Streak',
'CowboyHat',
'NewHorse',
'LostHorse',
'NewGun',
'LostGun',
'FollowActivity',
'ForwardedVotification',
'Revenue',

View 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;

View File

@ -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)

View File

@ -34,8 +34,8 @@ query TopCowboys($cursor: String) {
name
optional {
streak
gunStreak
horseStreak
hasSendWallet
hasRecvWallet
}
}
cursor

View File

@ -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')
}

View File

@ -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
},

View File

@ -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"`
}

View File

@ -177,8 +177,8 @@ const THIS_DAY = gql`
id
optional {
streak
gunStreak
horseStreak
hasSendWallet
hasRecvWallet
}
}
ncomments(when: "custom", from: $from, to: $to)

View File

@ -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)
}