|
1 | | -import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' |
| 1 | +import { HALLOWEEN_IMMUNITY_HOURS, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants' |
2 | 2 | import { msatsToSats, satsToMsats } from '@/lib/format' |
3 | | -import { notifyZapped } from '@/lib/webPush' |
| 3 | +import { datePivot } from '@/lib/time' |
| 4 | +import { notifyZapped, notifyInfected } from '@/lib/webPush' |
4 | 5 | import { getInvoiceableWallets } from '@/wallets/server' |
5 | 6 | import { Prisma } from '@prisma/client' |
6 | 7 |
|
@@ -78,9 +79,12 @@ export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, c |
78 | 79 | ] |
79 | 80 | }) |
80 | 81 |
|
81 | | - const [{ path }] = await tx.$queryRaw` |
82 | | - SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER` |
83 | | - return { id: itemId, sats, act: 'TIP', path, actIds: acts.map(act => act.id) } |
| 82 | + const [{ userId, path }] = await tx.$queryRaw` |
| 83 | + SELECT "userId", ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER` |
| 84 | + |
| 85 | + const immune = await isImmune(userId, { tx }) |
| 86 | + |
| 87 | + return { id: itemId, sats, act: 'TIP', path, immune, actIds: acts.map(act => act.id) } |
84 | 88 | } |
85 | 89 |
|
86 | 90 | export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) { |
@@ -216,6 +220,55 @@ export async function onPaid ({ invoice, actIds }, { tx }) { |
216 | 220 | SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL) |
217 | 221 | FROM bounty |
218 | 222 | WHERE "Item".id = bounty.id AND bounty.paid` |
| 223 | + |
| 224 | + await maybeInfectUser(itemAct, { tx }) |
| 225 | +} |
| 226 | + |
| 227 | +async function isImmune (userId, { tx }) { |
| 228 | + // immunity lasts one hour less every day until a minimum of 1 hour is reached |
| 229 | + const difficulty = Math.max(1, HALLOWEEN_IMMUNITY_HOURS - daysSinceHalloween()) |
| 230 | + const item = await tx.item.findFirst({ |
| 231 | + where: { |
| 232 | + userId, |
| 233 | + createdAt: { gt: datePivot(new Date(), { hours: -difficulty }) } |
| 234 | + } |
| 235 | + }) |
| 236 | + return !!item |
| 237 | +} |
| 238 | + |
| 239 | +function daysSinceHalloween () { |
| 240 | + // return 0 if Halloween has not happened yet else return the days since Halloween |
| 241 | + const diffTime = new Date().getTime() - new Date('2025-10-31').getTime() |
| 242 | + return Math.max(0, Math.floor(diffTime / (1000 * 60 * 60 * 24))) |
| 243 | +} |
| 244 | + |
| 245 | +async function maybeInfectUser (itemAct, { tx }) { |
| 246 | + // We added the 'infected' column to the users table so the query for users can continue |
| 247 | + // to only fetch columns from the users table. We only use it for display purposes. |
| 248 | + // The infection table is used to check if a user is infected and store additional information |
| 249 | + // (who infected who when why). |
| 250 | + |
| 251 | + const { id, userId: fromId, item: { userId: toId } } = itemAct |
| 252 | + const infection = await tx.infection.findFirst({ where: { infecteeId: fromId } }) |
| 253 | + if (!infection) { |
| 254 | + // zapper not infected, so can't infect other user |
| 255 | + return |
| 256 | + } |
| 257 | + |
| 258 | + if (await isImmune(toId, { tx })) { |
| 259 | + // user is immune because they created an item not too long ago |
| 260 | + return |
| 261 | + } |
| 262 | + |
| 263 | + const count = await tx.$executeRaw` |
| 264 | + INSERT INTO "Infection" ("itemActId", "infecteeId", "infectorId") |
| 265 | + VALUES (${id}::INTEGER, ${toId}::INTEGER, ${fromId}::INTEGER) |
| 266 | + ON CONFLICT ("infecteeId") DO NOTHING` |
| 267 | + await tx.user.update({ where: { id: toId }, data: { infected: true } }) |
| 268 | + |
| 269 | + if (count > 0) { |
| 270 | + notifyInfected(toId).catch(console.error) |
| 271 | + } |
219 | 272 | } |
220 | 273 |
|
221 | 274 | export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) { |
|
0 commit comments