donations to rewards
This commit is contained in:
parent
439c83f975
commit
e1bdb9c769
|
@ -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`)
|
||||
},
|
||||
|
|
|
@ -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 }]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -93,12 +93,20 @@ export default {
|
|||
let users
|
||||
if (sort === 'spent') {
|
||||
users = await models.$queryRaw(`
|
||||
SELECT users.*, floor(sum("ItemAct".msats)/1000) as spent
|
||||
FROM "ItemAct"
|
||||
JOIN users on "ItemAct"."userId" = users.id
|
||||
WHERE "ItemAct".created_at <= $1
|
||||
SELECT users.*, sum(sats_spent) as spent
|
||||
FROM
|
||||
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
|
||||
FROM "ItemAct"
|
||||
WHERE "ItemAct".created_at <= $1
|
||||
${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"
|
||||
${within('ItemAct', when)}
|
||||
GROUP BY users.id, users.name
|
||||
ORDER BY spent DESC NULLS LAST, users.created_at DESC
|
||||
OFFSET $2
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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!]!
|
||||
}
|
||||
`
|
|
@ -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' }}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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}`
|
||||
|
|
|
@ -140,7 +140,8 @@ const COLORS = [
|
|||
'var(--info)',
|
||||
'var(--success)',
|
||||
'var(--boost)',
|
||||
'var(--theme-grey)'
|
||||
'var(--theme-grey)',
|
||||
'var(--danger)'
|
||||
]
|
||||
|
||||
function GrowthAreaChart ({ data }) {
|
||||
|
|
|
@ -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;
|
||||
$$;
|
|
@ -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;
|
||||
$$;
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue