diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 42cb8963..535d3998 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -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: { diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 78238075..1ae8d256 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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 }, diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index d416f01c..8152cb0c 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -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 diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index fd26838c..191e44a2 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -211,6 +211,8 @@ export default gql` streak: Int gunStreak: Int horseStreak: Int + hasSendWallet: Boolean + hasRecvWallet: Boolean maxStreak: Int isContributor: Boolean githubId: String diff --git a/components/badge.js b/components/badge.js index e1b6a162..5b5b264e 100644 --- a/components/badge.js +++ b/components/badge.js @@ -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 ( - {BADGES.map(({ icon, streakName, sizeDelta }, i) => ( + {badges.map(({ icon, overlayText, sizeDelta }, 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 }) => ( + {children} + ) } return ( - + - + ) } diff --git a/components/notifications.js b/components/notifications.js index de5807a1..7c12f489 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -58,7 +58,9 @@ function Notification ({ n, fresh }) { (type === 'InvoicePaid' && (n.invoice.nostr ? : )) || (type === 'WithdrawlPaid' && ) || (type === 'Referral' && ) || - (type === 'Streak' && ) || + (type === 'CowboyHat' && ) || + (['NewHorse', 'LostHorse'].includes(type) && ) || + (['NewGun', 'LostGun'].includes(type) && ) || (type === 'Votification' && ) || (type === 'ForwardedVotification' && ) || (type === 'Mention' && ) || @@ -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 (
- you {n.days ? 'lost your' : 'found a'} {n.type.toLowerCase().replace('_', ' ')} + {body} +
{blurb(n)}
+
+
+ ) +} + +function Horse ({ n }) { + const found = n.__typename.includes('New') + const Icon = found ? HorseIcon : SaddleIcon + + return ( +
+
+
+ you {found ? 'found a' : 'lost your'} horse +
{blurb(n)}
+
+
+ ) +} + +function Gun ({ n }) { + const found = n.__typename.includes('New') + const Icon = found ? GunIcon : HolsterIcon + + return ( +
+
+
+ you {found ? 'found a' : 'lost your'} gun
{blurb(n)}
diff --git a/fragments/comments.js b/fragments/comments.js index 04b2a71a..2fd28d0f 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -4,9 +4,9 @@ import { gql } from '@apollo/client' const STREAK_FIELDS = gql` fragment StreakFields on User { optional { - streak - gunStreak - horseStreak + streak + hasSendWallet + hasRecvWallet } } ` diff --git a/fragments/items.js b/fragments/items.js index c58de13a..7c8a49a1 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -5,9 +5,9 @@ import { COMMENTS } from './comments' const STREAK_FIELDS = gql` fragment StreakFields on User { optional { - streak - gunStreak - horseStreak + streak + hasSendWallet + hasRecvWallet } } ` diff --git a/fragments/notifications.js b/fragments/notifications.js index e9a91e35..48a5c046 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -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 diff --git a/fragments/subs.js b/fragments/subs.js index 1ff2c492..70fddd6b 100644 --- a/fragments/subs.js +++ b/fragments/subs.js @@ -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 } } ` diff --git a/fragments/users.js b/fragments/users.js index 94e1a7a6..e591cb2a 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -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 } } ` diff --git a/lib/apollo.js b/lib/apollo.js index cc4aad54..3739ba3f 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -81,7 +81,11 @@ function getClient (uri) { 'InvoicePaid', 'WithdrawlPaid', 'Referral', - 'Streak', + 'CowboyHat', + 'NewHorse', + 'LostHorse', + 'NewGun', + 'LostGun', 'FollowActivity', 'ForwardedVotification', 'Revenue', diff --git a/prisma/migrations/20250328203730_wallet_badges/migration.sql b/prisma/migrations/20250328203730_wallet_badges/migration.sql new file mode 100644 index 00000000..ef88c49d --- /dev/null +++ b/prisma/migrations/20250328203730_wallet_badges/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index caaf5b53..9daea9ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/scripts/newsletter.js b/scripts/newsletter.js index 3c2bf7ae..f7d7b9f7 100644 --- a/scripts/newsletter.js +++ b/scripts/newsletter.js @@ -34,8 +34,8 @@ query TopCowboys($cursor: String) { name optional { streak - gunStreak - horseStreak + hasSendWallet + hasRecvWallet } } cursor diff --git a/worker/index.js b/worker/index.js index 34c1ef91..03b1a3f4 100644 --- a/worker/index.js +++ b/worker/index.js @@ -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') } diff --git a/worker/paidAction.js b/worker/paidAction.js index e5ec2170..d628e3fd 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -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 }, diff --git a/worker/streak.js b/worker/streak.js index c6f1b5c1..6b72bd7c 100644 --- a/worker/streak.js +++ b/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"` -} diff --git a/worker/thisDay.js b/worker/thisDay.js index cbdabaf0..c5673f8a 100644 --- a/worker/thisDay.js +++ b/worker/thisDay.js @@ -177,8 +177,8 @@ const THIS_DAY = gql` id optional { streak - gunStreak - horseStreak + hasSendWallet + hasRecvWallet } } ncomments(when: "custom", from: $from, to: $to) diff --git a/worker/wallet.js b/worker/wallet.js index ac09c7ac..ab504d08 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -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) +}