diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index ea28de4e..40e8c904 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -112,6 +112,13 @@ export default { AND "maxBid" IS NOT NULL AND status <> 'STOPPED' AND "statusUpdatedAt" <= $2) + UNION ALL + (SELECT "Earn".id::text, "Earn".created_at AS "sortTime", FLOOR(msats / 1000) as "earnedSats", + 'Earn' AS type + FROM "Earn" + WHERE "Earn"."userId" = $1 AND + FLOOR(msats / 1000) > 0 + AND created_at <= $2) ORDER BY "sortTime" DESC OFFSET $3 LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 05d96229..9cc8fb61 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -160,6 +160,7 @@ export default { JOIN "Item" on "ItemAct"."itemId" = "Item".id WHERE "ItemAct"."userId" <> ${user.id} AND "ItemAct".act <> 'BOOST' AND "Item"."userId" = ${user.id}` + return sum || 0 }, sats: async (user, args, { models, me }) => { @@ -217,23 +218,33 @@ export default { return true } - const where = { - status: { - not: 'STOPPED' - }, - maxBid: { - not: null - }, - userId: user.id + const job = await models.item.findFirst({ + where: { + status: { + not: 'STOPPED' + }, + maxBid: { + not: null + }, + userId: user.id, + statusUpdatedAt: { + gt: user.checkedNotesAt || new Date(0) + } + } + }) + if (job) { + return true } - if (user.checkedNotesAt) { - where.statusUpdatedAt = { - gt: user.checkedNotesAt + const earn = await models.earn.findFirst({ + where: { + userId: user.id, + createdAt: { + gt: user.checkedNotesAt || new Date(0) + } } - } - const job = await models.item.findFirst({ where }) - if (job) { + }) + if (earn) { return true } diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 3f3ca834..bd2b700d 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -97,6 +97,12 @@ export default { WHERE "ItemAct"."userId" <> $1 AND "ItemAct".act <> 'BOOST' AND "Item"."userId" = $1 AND "ItemAct".created_at <= $2 GROUP BY "Item".id)`) + queries.push( + `(SELECT ('earn' || "Earn".id) as id, "Earn".id as "factId", NULL as bolt11, + created_at as "createdAt", msats, + 0 as "msatsFee", NULL as status, 'earn' as type + FROM "Earn" + WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2)`) } if (include.has('spent')) { diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 3899418f..e7dba19c 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -32,7 +32,13 @@ export default gql` sortTime: String! } - union Notification = Reply | Votification | Mention | Invitification | JobChanged + type Earn { + earnedSats: Int! + sortTime: String! + } + + union Notification = Reply | Votification | Mention + | Invitification | JobChanged | Earn type Notifications { lastChecked: String diff --git a/components/notifications.js b/components/notifications.js index da364624..7d85431b 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -6,6 +6,7 @@ import { useRouter } from 'next/router' import MoreFooter from './more-footer' import Invite from './invite' import { ignoreClick } from '../lib/clicks' +import Link from 'next/link' function Notification ({ n }) { const router = useRouter() @@ -13,6 +14,10 @@ function Notification ({ n }) {
{ + if (n.__typename === 'Earn') { + return + } + if (ignoreClick(e)) { return } @@ -48,33 +53,44 @@ function Notification ({ n }) {
) - : ( - <> - {n.__typename === 'Votification' && - - your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats - } - {n.__typename === 'Mention' && - - you were mentioned in - } - {n.__typename === 'JobChanged' && - - {n.item.status === 'NOSATS' - ? 'your job ran out of sats' - : 'your job is active again'} - } -
- {n.item.maxBid - ? - : n.item.title - ? - : ( -
- -
)} -
- )} + : n.__typename === 'Earn' + ? ( + <> +
+ you stacked {n.earnedSats} sats +
+
+ SN distributes the sats it earns back to its best users daily. These sats come from jobs, boost, and posting fees. +
+ + ) + : ( + <> + {n.__typename === 'Votification' && + + your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats + } + {n.__typename === 'Mention' && + + you were mentioned in + } + {n.__typename === 'JobChanged' && + + {n.item.status === 'NOSATS' + ? 'your job ran out of sats' + : 'your job is active again'} + } +
+ {n.item.maxBid + ? + : n.item.title + ? + : ( +
+ +
)} +
+ )} ) } diff --git a/fragments/notifications.js b/fragments/notifications.js index f2fbb755..3ef3871a 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -47,6 +47,10 @@ export const NOTIFICATIONS = gql` ...ItemFields } } + ... on Earn { + sortTime + earnedSats + } } } } ` diff --git a/pages/satistics.js b/pages/satistics.js index 1b79213f..fbe1c692 100644 --- a/pages/satistics.js +++ b/pages/satistics.js @@ -88,6 +88,15 @@ function Satus ({ status }) { } function Detail ({ fact }) { + if (fact.type === 'earn') { + return ( + <> +
+ SN gives the sats it earns back to its best users daily. These sats come from jobs, boost, and posting fees. +
+ + ) + } if (!fact.item) { return ( <> diff --git a/prisma/migrations/20220316212238_earn/migration.sql b/prisma/migrations/20220316212238_earn/migration.sql new file mode 100644 index 00000000..609ecfa2 --- /dev/null +++ b/prisma/migrations/20220316212238_earn/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "Earn" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "msats" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + + PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Earn.created_at_index" ON "Earn"("created_at"); + +-- CreateIndex +CREATE INDEX "Earn.userId_index" ON "Earn"("userId"); + +-- AddForeignKey +ALTER TABLE "Earn" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- charge the user for the auction item +CREATE OR REPLACE FUNCTION earn(user_id INTEGER, earn_msats INTEGER) RETURNS void AS $$ + DECLARE + BEGIN + PERFORM ASSERT_SERIALIZED(); + -- insert into earn + INSERT INTO "Earn" (msats, "userId") VALUES (earn_msats, user_id); + -- give the user the sats + UPDATE users SET msats = msats + earn_msats WHERE id = user_id; + END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 98d044ed..9417d6e4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,11 +41,25 @@ model User { upvotePopover Boolean @default(false) tipPopover Boolean @default(false) + Earn Earn[] @@index([createdAt]) @@index([inviteId]) @@map(name: "users") } +model Earn { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + + msats Int + user User @relation(fields: [userId], references: [id]) + userId Int + + @@index([createdAt]) + @@index([userId]) +} + model LnAuth { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map(name: "created_at") diff --git a/worker/earn.js b/worker/earn.js new file mode 100644 index 00000000..3ac5d01a --- /dev/null +++ b/worker/earn.js @@ -0,0 +1,44 @@ +const serialize = require('../api/resolvers/serial') + +// TODO: use a weekly trust measure or make trust decay +function earn ({ models }) { + return async function ({ name }) { + console.log('running', name) + + // compute how much sn earned today + const [{ sum }] = await models.$queryRaw` + SELECT sum("ItemAct".sats) + FROM "ItemAct" + JOIN "Item" on "ItemAct"."itemId" = "Item".id + WHERE ("ItemAct".act in ('BOOST', 'STREAM') + OR ("ItemAct".act = 'VOTE' AND "Item"."userId" = "ItemAct"."userId")) + AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'` + + // calculate the total trust + const { sum: { trust } } = await models.user.aggregate({ + sum: { + trust: true + } + }) + + // get earners { id, earnings } + const earners = await models.$queryRaw(` + SELECT id, FLOOR(${sum} * (trust/${trust}) * 1000) as earnings + FROM users + WHERE trust > 0`) + + // for each earner, serialize earnings + // we do this for each earner because we don't need to serialize + // all earner updates together + earners.forEach(async earner => { + if (earner.earnings > 0) { + await serialize(models, + models.$executeRaw`SELECT earn(${earner.id}, ${earner.earnings})`) + } + }) + + console.log('done', name) + } +} + +module.exports = { earn } diff --git a/worker/index.js b/worker/index.js index d163266d..3399acd5 100644 --- a/worker/index.js +++ b/worker/index.js @@ -6,6 +6,7 @@ const { checkInvoice, checkWithdrawal } = require('./wallet') const { repin } = require('./repin') const { trust } = require('./trust') const { auction } = require('./auction') +const { earn } = require('./earn') const { ApolloClient, HttpLink, InMemoryCache } = require('@apollo/client') const { indexItem, indexAllItems } = require('./search') const fetch = require('cross-fetch') @@ -43,6 +44,7 @@ async function work () { await boss.work('indexItem', indexItem(args)) await boss.work('indexAllItems', indexAllItems(args)) await boss.work('auction', auction(args)) + await boss.work('earn', earn(args)) console.log('working jobs') }