donations to rewards

This commit is contained in:
keyan 2022-12-07 18:04:02 -06:00
parent 439c83f975
commit e1bdb9c769
16 changed files with 360 additions and 20 deletions

View File

@ -74,10 +74,18 @@ export default {
json_build_object('name', 'jobs', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'STREAM')),
json_build_object('name', 'boost', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'BOOST')),
json_build_object('name', 'fees', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'FEE')),
json_build_object('name', 'tips', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP'))
json_build_object('name', 'tips', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP')),
json_build_object('name', 'donation', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'DONATION'))
) AS data
FROM times
LEFT JOIN "ItemAct" ON ${intervalClause(when, 'ItemAct', true)} time = date_trunc('${timeUnit(when)}', created_at)
LEFT JOIN
((SELECT "ItemAct".created_at, "userId", act::text as act
FROM "ItemAct"
WHERE ${intervalClause(when, 'ItemAct', false)})
UNION ALL
(SELECT created_at, "userId", 'DONATION' as act
FROM "Donation"
WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time
ORDER BY time ASC`)
},
@ -98,14 +106,21 @@ export default {
return await models.$queryRaw(
`${withClause(when)}
SELECT time, json_build_array(
json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN "ItemAct".msats ELSE 0 END)/1000),0)),
json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN "ItemAct".msats ELSE 0 END)/1000),0)),
json_build_object('name', 'fees', 'value', coalesce(floor(sum(CASE WHEN act NOT IN ('BOOST', 'TIP', 'STREAM') THEN "ItemAct".msats ELSE 0 END)/1000),0)),
json_build_object('name', 'tips', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN "ItemAct".msats ELSE 0 END)/1000),0))
json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN msats ELSE 0 END)/1000),0)),
json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN msats ELSE 0 END)/1000),0)),
json_build_object('name', 'fees', 'value', coalesce(floor(sum(CASE WHEN act NOT IN ('BOOST', 'TIP', 'STREAM', 'DONATION') THEN msats ELSE 0 END)/1000),0)),
json_build_object('name', 'tips', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN msats ELSE 0 END)/1000),0)),
json_build_object('name', 'donations', 'value', coalesce(floor(sum(CASE WHEN act = 'DONATION' THEN msats ELSE 0 END)/1000),0))
) AS data
FROM times
LEFT JOIN "ItemAct" ON ${intervalClause(when, 'ItemAct', true)} time = date_trunc('${timeUnit(when)}', created_at)
JOIN "Item" ON "ItemAct"."itemId" = "Item".id
LEFT JOIN
((SELECT "ItemAct".created_at, msats, act::text as act
FROM "ItemAct"
WHERE ${intervalClause(when, 'ItemAct', false)})
UNION ALL
(SELECT created_at, sats * 1000 as msats, 'DONATION' as act
FROM "Donation"
WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time
ORDER BY time ASC`)
},

View File

@ -9,7 +9,8 @@ import sub from './sub'
import upload from './upload'
import growth from './growth'
import search from './search'
import rewards from './rewards'
import { GraphQLJSONObject } from 'graphql-type-json'
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
upload, growth, search, { JSONObject: GraphQLJSONObject }]
upload, growth, search, rewards, { JSONObject: GraphQLJSONObject }]

48
api/resolvers/rewards.js Normal file
View File

@ -0,0 +1,48 @@
import { AuthenticationError } from 'apollo-server-micro'
import serialize from './serial'
export default {
Query: {
expectedRewards: async (parent, args, { models }) => {
// get the last reward time, then get all contributions to rewards since then
const lastReward = await models.earn.findFirst({
orderBy: {
createdAt: 'desc'
}
})
const [result] = await models.$queryRaw`
SELECT coalesce(sum(sats), 0) as total, json_build_array(
json_build_object('name', 'donations', 'value', coalesce(sum(sats) FILTER(WHERE type = 'DONATION'), 0)),
json_build_object('name', 'fees', 'value', coalesce(sum(sats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM')), 0)),
json_build_object('name', 'boost', 'value', coalesce(sum(sats) FILTER(WHERE type = 'BOOST'), 0)),
json_build_object('name', 'jobs', 'value', coalesce(sum(sats) FILTER(WHERE type = 'STREAM'), 0))
) AS sources
FROM (
(SELECT msats / 1000 as sats, act::text as type
FROM "ItemAct"
WHERE created_at > ${lastReward.createdAt} AND "ItemAct".act <> 'TIP')
UNION ALL
(SELECT sats, 'DONATION' as type
FROM "Donation"
WHERE created_at > ${lastReward.createdAt})
) subquery`
return result
}
},
Mutation: {
donateToRewards: async (parent, { sats }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
await serialize(models,
models.$queryRaw(
'SELECT donate($1, $2)',
sats, Number(me.id)))
return sats
}
}
}

View File

@ -93,12 +93,20 @@ export default {
let users
if (sort === 'spent') {
users = await models.$queryRaw(`
SELECT users.*, floor(sum("ItemAct".msats)/1000) as spent
SELECT users.*, sum(sats_spent) as spent
FROM
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
FROM "ItemAct"
JOIN users on "ItemAct"."userId" = users.id
WHERE "ItemAct".created_at <= $1
AND NOT users."hideFromTopUsers"
${within('ItemAct', when)}
GROUP BY "userId")
UNION ALL
(SELECT "userId", sats as sats_spent
FROM "Donation"
WHERE created_at <= $1
${within('Donation', when)})) spending
JOIN users on spending."userId" = users.id
AND NOT users."hideFromTopUsers"
GROUP BY users.id, users.name
ORDER BY spent DESC NULLS LAST, users.created_at DESC
OFFSET $2

View File

@ -122,6 +122,13 @@ export default {
WHERE "ItemAct"."userId" = $1
AND "ItemAct".created_at <= $2
GROUP BY "Item".id)`)
queries.push(
`(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11,
created_at as "createdAt", sats * 1000 as msats,
0 as "msatsFee", NULL as status, 'donation' as type
FROM "Donation"
WHERE "userId" = $1
AND created_at <= $2)`)
}
if (queries.length === 0) {
@ -157,6 +164,9 @@ export default {
case 'spent':
f.msats *= -1
break
case 'donation':
f.msats *= -1
break
default:
break
}

View File

@ -10,6 +10,7 @@ import invite from './invite'
import sub from './sub'
import upload from './upload'
import growth from './growth'
import rewards from './rewards'
const link = gql`
type Query {
@ -26,4 +27,4 @@ const link = gql`
`
export default [link, user, item, message, wallet, lnurl, notifications, invite,
sub, upload, growth]
sub, upload, growth, rewards]

16
api/typeDefs/rewards.js Normal file
View File

@ -0,0 +1,16 @@
import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
expectedRewards: ExpectedRewards!
}
extend type Mutation {
donateToRewards(sats: Int!): Int!
}
type ExpectedRewards {
total: Int!
sources: [NameValue!]!
}
`

View File

@ -151,6 +151,13 @@ export default function Footer ({ noLinks }) {
<DarkModeIcon onClick={() => darkMode.toggle()} className='fill-grey theme' />
<LnIcon onClick={toggleLightning} width={24} height={24} className='ml-2 fill-grey theme' />
</div>}
<div className='mb-0' style={{ fontWeight: 500 }}>
<Link href='/rewards' passHref>
<a className='nav-link p-0 d-inline-flex'>
rewards
</a>
</Link>
</div>
<div className='mb-0' style={{ fontWeight: 500 }}>
<OverlayTrigger trigger='click' placement='top' overlay={AnalyticsPopover} rootClose>
<div className='nav-link p-0 d-inline-flex' style={{ cursor: 'pointer' }}>

View File

@ -1,10 +1,10 @@
import Layout from './layout'
import styles from './layout-center.module.css'
export default function LayoutCenter ({ children, ...props }) {
export default function LayoutCenter ({ children, footerLinks, ...props }) {
return (
<div className={styles.page}>
<Layout noContain noFooterLinks {...props}>
<Layout noContain noFooterLinks={!footerLinks} {...props}>
<div className={styles.content}>
{children}
</div>

142
pages/rewards.js Normal file
View File

@ -0,0 +1,142 @@
import { gql } from 'apollo-server-micro'
import { useEffect, useRef, useState } from 'react'
import { Button, InputGroup, Modal } from 'react-bootstrap'
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'
import { getGetServerSideProps } from '../api/ssrApollo'
import { Form, Input, SubmitButton } from '../components/form'
import LayoutCenter from '../components/layout-center'
import * as Yup from 'yup'
import { useMutation, useQuery } from '@apollo/client'
import Link from 'next/link'
const REWARDS = gql`
{
expectedRewards {
total
sources {
name
value
}
}
}
`
export const getServerSideProps = getGetServerSideProps(REWARDS)
export default function Rewards ({ data: { expectedRewards: { total, sources } } }) {
const { data } = useQuery(REWARDS, { pollInterval: 1000 })
if (data) {
({ expectedRewards: { total, sources } } = data)
}
return (
<LayoutCenter footerLinks>
<h4 className='font-weight-bold text-muted text-center'>
<div>{total} sats to be rewarded today</div>
<Link href='http://localhost:3000/faq#how-do-i-earn-sats-on-stacker-news' passHref>
<a className='text-reset'><small><small><small>learn about rewards</small></small></small></a>
</Link>
</h4>
<div className='my-3 w-100'>
<GrowthPieChart data={sources} />
</div>
<DonateButton />
</LayoutCenter>
)
}
const COLORS = [
'var(--secondary)',
'var(--info)',
'var(--success)',
'var(--boost)',
'var(--grey)'
]
function GrowthPieChart ({ data }) {
return (
<ResponsiveContainer width='100%' height={250} minWidth={200}>
<PieChart margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<Pie
dataKey='value'
isAnimationActive={false}
data={data}
cx='50%'
cy='50%'
outerRadius={80}
fill='var(--secondary)'
label
>
{
data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index]} />
))
}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)
}
export const DonateSchema = Yup.object({
amount: Yup.number().typeError('must be a number').required('required')
.positive('must be positive').integer('must be whole')
})
export function DonateButton () {
const [show, setShow] = useState(false)
const inputRef = useRef(null)
const [donateToRewards] = useMutation(
gql`
mutation donateToRewards($sats: Int!) {
donateToRewards(sats: $sats)
}`)
useEffect(() => {
inputRef.current?.focus()
}, [show])
return (
<>
<Button onClick={() => setShow(true)}>DONATE TO REWARDS</Button>
<Modal
show={show}
onHide={() => {
setShow(false)
}}
>
<div className='modal-close' onClick={() => setShow(false)}>X</div>
<Modal.Body>
<Form
initial={{
amount: 1000
}}
schema={DonateSchema}
onSubmit={async ({ amount }) => {
await donateToRewards({
variables: {
sats: Number(amount)
}
})
setShow(false)
}}
>
<Input
label='amount'
name='amount'
innerRef={inputRef}
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div className='d-flex'>
<SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>donate</SubmitButton>
</div>
</Form>
</Modal.Body>
</Modal>
</>
)
}

View File

@ -98,6 +98,16 @@ function Detail ({ fact }) {
</>
)
}
if (fact.type === 'donation') {
return (
<>
<div className={satusClass(fact.status)}>
You made a donation to daily rewards!
</div>
</>
)
}
if (!fact.item) {
return (
<>
@ -145,6 +155,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
case 'invoice':
return `/${fact.type}s/${fact.factId}`
case 'earn':
case 'donation':
return
default:
return `/items/${fact.factId}`

View File

@ -140,7 +140,8 @@ const COLORS = [
'var(--info)',
'var(--success)',
'var(--boost)',
'var(--theme-grey)'
'var(--theme-grey)',
'var(--danger)'
]
function GrowthAreaChart ({ data }) {

View File

@ -0,0 +1,39 @@
-- CreateTable
CREATE TABLE "Donation" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sats" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Donation" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE OR REPLACE FUNCTION donate(sats INTEGER, user_id INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_sats INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats / 1000
INTO user_sats
FROM users WHERE id = user_id;
IF sats > user_sats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
UPDATE users SET msats = msats - (sats * 1000) WHERE id = user_id;
INSERT INTO "Donate" (sats, "userId", created_at, updated_at)
VALUES (sats, user_id, now_utc(), now_utc());
RETURN sats;
END;
$$;

View File

@ -0,0 +1,25 @@
CREATE OR REPLACE FUNCTION donate(sats INTEGER, user_id INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_sats INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats / 1000
INTO user_sats
FROM users WHERE id = user_id;
IF sats > user_sats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
UPDATE users SET msats = msats - (sats * 1000) WHERE id = user_id;
INSERT INTO "Donate" (sats, "userId", created_at, updated_at)
VALUES (sats, user_id, now_utc(), now_utc());
RETURN sats;
END;
$$;

View File

@ -68,12 +68,22 @@ model User {
Earn Earn[]
Upload Upload[] @relation(name: "Uploads")
PollVote PollVote[]
Donation Donation[]
@@index([createdAt])
@@index([inviteId])
@@map(name: "users")
}
model Donation {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
sats Int
userId Int
user User @relation(fields: [userId], references: [id])
}
model Upload {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")

View File

@ -10,13 +10,19 @@ function earn ({ models }) {
console.log('running', name)
// compute how much sn earned today
const [{ sum }] = await models.$queryRaw`
SELECT sum("ItemAct".msats)
let [{ sum }] = await models.$queryRaw`
SELECT coalesce(sum("ItemAct".msats), 0) as sum
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct".act <> 'TIP'
AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'`
const [{ sum: donatedSum }] = await models.$queryRaw`
SELECT coalesce(sum(sats), 0) as sum
FROM "Donation"
WHERE created_at > now_utc() - INTERVAL '1 day'`
sum += donatedSum * 1000
/*
How earnings work:
1/3: top 21% posts over last 36 hours, scored on a relative basis