referrals

This commit is contained in:
keyan 2022-12-19 16:27:52 -06:00
parent 4282f6724f
commit 41226245c5
35 changed files with 813 additions and 243 deletions

View File

@ -1,6 +1,6 @@
const PLACEHOLDERS_NUM = 616 const PLACEHOLDERS_NUM = 616
function interval (when) { export function interval (when) {
switch (when) { switch (when) {
case 'week': case 'week':
return '1 week' return '1 week'
@ -15,7 +15,7 @@ function interval (when) {
} }
} }
function timeUnit (when) { export function timeUnit (when) {
switch (when) { switch (when) {
case 'week': case 'week':
case 'month': case 'month':
@ -28,7 +28,7 @@ function timeUnit (when) {
} }
} }
function withClause (when) { export function withClause (when) {
const ival = interval(when) const ival = interval(when)
const unit = timeUnit(when) const unit = timeUnit(when)
@ -44,7 +44,7 @@ function withClause (when) {
} }
// HACKY AF this is a performance enhancement that allows us to use the created_at indices on tables // HACKY AF this is a performance enhancement that allows us to use the created_at indices on tables
function intervalClause (when, table, and) { export function intervalClause (when, table, and) {
if (when === 'forever') { if (when === 'forever') {
return and ? '' : 'TRUE' return and ? '' : 'TRUE'
} }
@ -58,7 +58,7 @@ export default {
return await models.$queryRaw( return await models.$queryRaw(
`${withClause(when)} `${withClause(when)}
SELECT time, json_build_array( SELECT time, json_build_array(
json_build_object('name', 'invited', 'value', count("inviteId")), json_build_object('name', 'referrals', 'value', count("referrerId")),
json_build_object('name', 'organic', 'value', count(users.id) FILTER(WHERE id > ${PLACEHOLDERS_NUM}) - count("inviteId")) json_build_object('name', 'organic', 'value', count(users.id) FILTER(WHERE id > ${PLACEHOLDERS_NUM}) - count("inviteId"))
) AS data ) AS data
FROM times FROM times
@ -131,7 +131,8 @@ export default {
json_build_object('name', 'any', 'value', count(distinct user_id)), json_build_object('name', 'any', 'value', count(distinct user_id)),
json_build_object('name', 'posts', 'value', count(distinct user_id) FILTER (WHERE type = 'POST')), json_build_object('name', 'posts', 'value', count(distinct user_id) FILTER (WHERE type = 'POST')),
json_build_object('name', 'comments', 'value', count(distinct user_id) FILTER (WHERE type = 'COMMENT')), json_build_object('name', 'comments', 'value', count(distinct user_id) FILTER (WHERE type = 'COMMENT')),
json_build_object('name', 'rewards', 'value', count(distinct user_id) FILTER (WHERE type = 'EARN')) json_build_object('name', 'rewards', 'value', count(distinct user_id) FILTER (WHERE type = 'EARN')),
json_build_object('name', 'referrals', 'value', count(distinct user_id) FILTER (WHERE type = 'REFERRAL'))
) AS data ) AS data
FROM times FROM times
LEFT JOIN LEFT JOIN
@ -142,7 +143,11 @@ export default {
UNION ALL UNION ALL
(SELECT created_at, "userId" as user_id, 'EARN' as type (SELECT created_at, "userId" as user_id, 'EARN' as type
FROM "Earn" FROM "Earn"
WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) WHERE ${intervalClause(when, 'Earn', false)})
UNION ALL
(SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type
FROM "ReferralAct"
WHERE ${intervalClause(when, 'ReferralAct', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time GROUP BY time
ORDER BY time ASC`) ORDER BY time ASC`)
}, },
@ -152,18 +157,24 @@ export default {
SELECT time, json_build_array( SELECT time, json_build_array(
json_build_object('name', 'rewards', 'value', coalesce(floor(sum(airdrop)/1000),0)), json_build_object('name', 'rewards', 'value', coalesce(floor(sum(airdrop)/1000),0)),
json_build_object('name', 'posts', 'value', coalesce(floor(sum(post)/1000),0)), json_build_object('name', 'posts', 'value', coalesce(floor(sum(post)/1000),0)),
json_build_object('name', 'comments', 'value', coalesce(floor(sum(comment)/1000),0)) json_build_object('name', 'comments', 'value', coalesce(floor(sum(comment)/1000),0)),
json_build_object('name', 'referrals', 'value', coalesce(floor(sum(referral)/1000),0))
) AS data ) AS data
FROM times FROM times
LEFT JOIN LEFT JOIN
((SELECT "ItemAct".created_at, 0 as airdrop, ((SELECT "ItemAct".created_at, 0 as airdrop,
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".msats END as comment, CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".msats END as comment,
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post,
0 as referral
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP') WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP')
UNION ALL UNION ALL
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment (SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral
FROM "ReferralAct"
WHERE ${intervalClause(when, 'ReferralAct', false)})
UNION ALL
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral
FROM "Earn" FROM "Earn"
WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time GROUP BY time

View File

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

View File

@ -160,6 +160,15 @@ export default {
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)` LIMIT ${LIMIT}+$3)`
) )
queries.push(
`(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats",
'Referral' AS type
FROM users
WHERE "users"."referrerId" = $1
AND "inviteId" IS NULL
AND users.created_at <= $2
LIMIT ${LIMIT}+$3)`
)
} }
if (meFull.noteEarning) { if (meFull.noteEarning) {

View File

@ -0,0 +1,55 @@
import { AuthenticationError } from 'apollo-server-micro'
import { withClause, intervalClause, timeUnit } from './growth'
export default {
Query: {
referrals: async (parent, { when }, { models, me }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
const [{ totalSats }] = await models.$queryRaw(`
SELECT COALESCE(FLOOR(sum(msats) / 1000), 0) as "totalSats"
FROM "ReferralAct"
WHERE ${intervalClause(when, 'ReferralAct', true)}
"ReferralAct"."referrerId" = $1
`, Number(me.id))
const [{ totalReferrals }] = await models.$queryRaw(`
SELECT count(*) as "totalReferrals"
FROM users
WHERE ${intervalClause(when, 'users', true)}
"referrerId" = $1
`, Number(me.id))
const stats = await models.$queryRaw(
`${withClause(when)}
SELECT time, json_build_array(
json_build_object('name', 'referrals', 'value', count(*) FILTER (WHERE act = 'REFERREE')),
json_build_object('name', 'sats', 'value', FLOOR(COALESCE(sum(msats) FILTER (WHERE act IN ('BOOST', 'STREAM', 'FEE')), 0)))
) AS data
FROM times
LEFT JOIN
((SELECT "ReferralAct".created_at, "ReferralAct".msats / 1000.0 as msats, "ItemAct".act::text as act
FROM "ReferralAct"
JOIN "ItemAct" ON "ItemAct".id = "ReferralAct"."itemActId"
WHERE ${intervalClause(when, 'ReferralAct', true)}
"ReferralAct"."referrerId" = $1)
UNION ALL
(SELECT created_at, 0.0 as sats, 'REFERREE' as act
FROM users
WHERE ${intervalClause(when, 'users', true)}
"referrerId" = $1)) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time
ORDER BY time ASC`, Number(me.id))
console.log(totalSats)
return {
totalSats,
totalReferrals,
stats
}
}
}
}

View File

@ -12,18 +12,19 @@ export default {
}) })
const [result] = await models.$queryRaw` const [result] = await models.$queryRaw`
SELECT coalesce(sum(sats), 0) as total, json_build_array( SELECT coalesce(FLOOR(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', 'donations', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'DONATION')), 0)),
json_build_object('name', 'fees', 'value', coalesce(sum(sats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION')), 0)), json_build_object('name', 'fees', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION'))), 0)),
json_build_object('name', 'boost', 'value', coalesce(sum(sats) FILTER(WHERE type = 'BOOST'), 0)), json_build_object('name', 'boost', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'BOOST')), 0)),
json_build_object('name', 'jobs', 'value', coalesce(sum(sats) FILTER(WHERE type = 'STREAM'), 0)) json_build_object('name', 'jobs', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'STREAM')), 0))
) AS sources ) AS sources
FROM ( FROM (
(SELECT msats / 1000 as sats, act::text as type (SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) / 1000.0 as sats, act::text as type
FROM "ItemAct" FROM "ItemAct"
WHERE created_at > ${lastReward.createdAt} AND "ItemAct".act <> 'TIP') LEFT JOIN "ReferralAct" ON "ItemAct".id = "ReferralAct"."itemActId"
WHERE "ItemAct".created_at > ${lastReward.createdAt} AND "ItemAct".act <> 'TIP')
UNION ALL UNION ALL
(SELECT sats, 'DONATION' as type (SELECT sats::FLOAT, 'DONATION' as type
FROM "Donation" FROM "Donation"
WHERE created_at > ${lastReward.createdAt}) WHERE created_at > ${lastReward.createdAt})
) subquery` ) subquery`

View File

@ -271,6 +271,18 @@ export default {
if (newInvitees.length > 0) { if (newInvitees.length > 0) {
return true return true
} }
const referral = await models.user.findFirst({
where: {
referrerId: me.id,
createdAt: {
gt: lastChecked
}
}
})
if (referral) {
return true
}
} }
return false return false

View File

@ -5,7 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import lnpr from 'bolt11' import lnpr from 'bolt11'
import { SELECT } from './item' import { SELECT } from './item'
import { lnurlPayDescriptionHash } from '../../lib/lnurl' import { lnurlPayDescriptionHash } from '../../lib/lnurl'
import { msatsToSats } from '../../lib/format' import { msatsToSats, msatsToSatsDecimal } from '../../lib/format'
export async function getInvoice (parent, { id }, { me, models }) { export async function getInvoice (parent, { id }, { me, models }) {
if (!me) { if (!me) {
@ -110,6 +110,12 @@ export default {
FROM "Earn" FROM "Earn"
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2 WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
GROUP BY "userId", created_at)`) GROUP BY "userId", created_at)`)
queries.push(
`(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11,
created_at as "createdAt", msats,
0 as "msatsFee", NULL as status, 'referral' as type
FROM "ReferralAct"
WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`)
} }
if (include.has('spent')) { if (include.has('spent')) {
@ -287,8 +293,8 @@ export default {
return item return item
}, },
sats: fact => msatsToSats(fact.msats), sats: fact => msatsToSatsDecimal(fact.msats),
satsFee: fact => msatsToSats(fact.msatsFee) satsFee: fact => msatsToSatsDecimal(fact.msatsFee)
} }
} }

View File

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

View File

@ -50,8 +50,12 @@ export default gql`
sortTime: String! sortTime: String!
} }
type Referral {
sortTime: String!
}
union Notification = Reply | Votification | Mention union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | Invitification | Earn | JobChanged | InvoicePaid | Referral
type Notifications { type Notifications {
lastChecked: String lastChecked: String

13
api/typeDefs/referrals.js Normal file
View File

@ -0,0 +1,13 @@
import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
referrals(when: String): Referrals!
}
type Referrals {
totalSats: Int!
totalReferrals: Int!
stats: [TimeData!]!
}
`

View File

@ -41,8 +41,8 @@ export default gql`
factId: ID! factId: ID!
bolt11: String bolt11: String
createdAt: String! createdAt: String!
sats: Int! sats: Float!
satsFee: Int satsFee: Float
status: String status: String
type: String! type: String!
description: String description: String

View File

@ -18,6 +18,7 @@ import DontLikeThis from './dont-link-this'
import Flag from '../svgs/flag-fill.svg' import Flag from '../svgs/flag-fill.svg'
import { Badge } from 'react-bootstrap' import { Badge } from 'react-bootstrap'
import { abbrNum } from '../lib/format' import { abbrNum } from '../lib/format'
import Share from './share'
function Parent ({ item, rootText }) { function Parent ({ item, rootText }) {
const ParentFrag = () => ( const ParentFrag = () => (
@ -169,6 +170,7 @@ export default function Comment ({
localStorage.setItem(`commentCollapse:${item.id}`, 'yep') localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
}} }}
/>)} />)}
{topLevel && <Share item={item} />}
</div> </div>
{edit {edit
? ( ? (

View File

@ -92,13 +92,8 @@ export default function Header ({ sub }) {
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item> <NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
</Link> </Link>
<NavDropdown.Divider /> <NavDropdown.Divider />
<Link href='/invites' passHref> <Link href='/referrals/month' passHref>
<NavDropdown.Item eventKey='invites'>invites <NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
{me && !me.hasInvites &&
<div className='p-1 d-inline-block bg-success ml-1'>
<span className='invisible'>{' '}</span>
</div>}
</NavDropdown.Item>
</Link> </Link>
<NavDropdown.Divider /> <NavDropdown.Divider />
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>

View File

@ -84,7 +84,7 @@ export function ItemActModal () {
{[1, 10, 100, 1000, 10000].map(num => {[1, 10, 100, 1000, 10000].map(num =>
<Button <Button
size='sm' size='sm'
className={num > 1 ? 'ml-2' : ''} className={`${num > 1 ? 'ml-2' : ''} mb-2`}
key={num} key={num}
onClick={() => { setOValue(num) }} onClick={() => { setOValue(num) }}
> >

View File

@ -14,6 +14,7 @@ import { newComments } from '../lib/new-comments'
import { useMe } from './me' import { useMe } from './me'
import DontLikeThis from './dont-link-this' import DontLikeThis from './dont-link-this'
import Flag from '../svgs/flag-fill.svg' import Flag from '../svgs/flag-fill.svg'
import Share from './share'
import { abbrNum } from '../lib/format' import { abbrNum } from '../lib/format'
export function SearchTitle ({ title }) { export function SearchTitle ({ title }) {
@ -141,7 +142,11 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
</div> </div>
{showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />} {showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />}
</div> </div>
{toc && <Toc text={item.text} />} {toc &&
<>
<Share item={item} />
<Toc text={item.text} />
</>}
</div> </div>
{children && ( {children && (
<div className={styles.children}> <div className={styles.children}>

View File

@ -20,7 +20,7 @@ function Notification ({ n }) {
<div <div
className='clickToContext' className='clickToContext'
onClick={e => { onClick={e => {
if (n.__typename === 'Earn') { if (n.__typename === 'Earn' || n.__typename === 'Referral') {
return return
} }
@ -88,41 +88,50 @@ function Notification ({ n }) {
</div> </div>
</div> </div>
) )
: n.__typename === 'InvoicePaid' : n.__typename === 'Referral'
? ( ? (
<div className='font-weight-bold text-info ml-2 py-1'>
<Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</div>)
: (
<> <>
{n.__typename === 'Votification' && <small className='font-weight-bold text-secondary ml-2'>
<small className='font-weight-bold text-success ml-2'> someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link>
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`} <small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</small>} </small>
{n.__typename === 'Mention' && </>
<small className='font-weight-bold text-info ml-2'> )
you were mentioned in : n.__typename === 'InvoicePaid'
</small>} ? (
{n.__typename === 'JobChanged' && <div className='font-weight-bold text-info ml-2 py-1'>
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}> <Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account
{n.item.status === 'ACTIVE' <small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
? 'your job is active again' </div>)
: (n.item.status === 'NOSATS' : (
? 'your job promotion ran out of sats' <>
: 'your job has been stopped')} {n.__typename === 'Votification' &&
</small>} <small className='font-weight-bold text-success ml-2'>
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}> your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
{n.item.isJob </small>}
? <ItemJob item={n.item} /> {n.__typename === 'Mention' &&
: n.item.title <small className='font-weight-bold text-info ml-2'>
? <Item item={n.item} /> you were mentioned in
: ( </small>}
<div className='pb-2'> {n.__typename === 'JobChanged' &&
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext /> <small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
</div>)} {n.item.status === 'ACTIVE'
</div> ? 'your job is active again'
</>)} : (n.item.status === 'NOSATS'
? 'your job promotion ran out of sats'
: 'your job has been stopped')}
</small>}
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
{n.item.isJob
? <ItemJob item={n.item} />
: n.item.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext />
</div>)}
</div>
</>)}
</div> </div>
) )
} }

45
components/share.js Normal file
View File

@ -0,0 +1,45 @@
import { Dropdown } from 'react-bootstrap'
import ShareIcon from '../svgs/share-fill.svg'
import copy from 'clipboard-copy'
import { useMe } from './me'
export default function Share ({ item }) {
const me = useMe()
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
return typeof window !== 'undefined' && navigator?.share
? (
<div className='d-flex align-items-center'>
<ShareIcon
onClick={() => {
if (navigator.share) {
navigator.share({
title: item.title || '',
text: '',
url
}).then(() => console.log('Successful share'))
.catch((error) => console.log('Error sharing', error))
} else {
console.log('no navigator.share')
}
}}
/>
</div>)
: (
<Dropdown alignRight className='pointer d-flex align-items-center' as='span'>
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
<ShareIcon className='mx-2 fill-grey theme' />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
className='text-center'
onClick={async () => {
copy(url)
}}
>
copy link
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>)
}

161
components/when-charts.js Normal file
View File

@ -0,0 +1,161 @@
import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, AreaChart, Area, ComposedChart, Bar } from 'recharts'
import { abbrNum } from '../lib/format'
import { useRouter } from 'next/router'
const dateFormatter = when => {
return timeStr => {
const date = new Date(timeStr)
switch (when) {
case 'week':
case 'month':
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${date.getUTCDate()}`
case 'year':
case 'forever':
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${String(date.getUTCFullYear()).slice(-2)}`
default:
return `${date.getHours() % 12 || 12}${date.getHours() >= 12 ? 'pm' : 'am'}`
}
}
}
function xAxisName (when) {
switch (when) {
case 'week':
case 'month':
return 'days'
case 'year':
case 'forever':
return 'months'
default:
return 'hours'
}
}
const transformData = data => {
return data.map(entry => {
const obj = { time: entry.time }
entry.data.forEach(entry1 => {
obj[entry1.name] = entry1.value
})
return obj
})
}
const COLORS = [
'var(--secondary)',
'var(--info)',
'var(--success)',
'var(--boost)',
'var(--theme-grey)',
'var(--danger)'
]
export function WhenAreaChart ({ data }) {
const router = useRouter()
if (!data || data.length === 0) {
return null
}
// transform data into expected shape
data = transformData(data)
// need to grab when
const when = router.query.when
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<AreaChart
data={data}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 0
}}
>
<XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
tick={{ fill: 'var(--theme-grey)' }}
/>
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
<Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
</AreaChart>
</ResponsiveContainer>
)
}
export function WhenLineChart ({ data }) {
const router = useRouter()
if (!data || data.length === 0) {
return null
}
// transform data into expected shape
data = transformData(data)
// need to grab when
const when = router.query.when
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<LineChart
data={data}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 0
}}
>
<XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
tick={{ fill: 'var(--theme-grey)' }}
/>
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
<Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)}
</LineChart>
</ResponsiveContainer>
)
}
export function WhenComposedChart ({ data, lineNames, areaNames, barNames }) {
const router = useRouter()
if (!data || data.length === 0) {
return null
}
// transform data into expected shape
data = transformData(data)
// need to grab when
const when = router.query.when
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<ComposedChart
data={data}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 0
}}
>
<XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
tick={{ fill: 'var(--theme-grey)' }}
/>
<YAxis yAxisId='left' orientation='left' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<YAxis yAxisId='right' orientation='right' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
<Legend />
{barNames?.map((v, i) =>
<Bar yAxisId='right' key={v} type='monotone' dataKey={v} name={v} stroke='var(--info)' fill='var(--info)' />)}
{areaNames?.map((v, i) =>
<Area yAxisId='left' key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
{lineNames?.map((v, i) =>
<Line yAxisId='left' key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
</ComposedChart>
</ResponsiveContainer>
)
}

View File

@ -37,6 +37,9 @@ export const NOTIFICATIONS = gql`
tips tips
} }
} }
... on Referral {
sortTime
}
... on Reply { ... on Reply {
sortTime sortTime
item { item {

View File

@ -1,4 +1,4 @@
export const NOFOLLOW_LIMIT = 1000 export const NOFOLLOW_LIMIT = 100
export const BOOST_MIN = 5000 export const BOOST_MIN = 5000
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024 export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024
export const IMAGE_PIXELS_MAX = 35000000 export const IMAGE_PIXELS_MAX = 35000000

View File

@ -16,3 +16,10 @@ export const msatsToSats = msats => {
} }
return Number(BigInt(msats) / 1000n) return Number(BigInt(msats) / 1000n)
} }
export const msatsToSatsDecimal = msats => {
if (msats === null || msats === undefined) {
return null
}
return fixedDecimal(msats / 1000.0, 3)
}

18
middleware.js Normal file
View File

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
export function middleware (request) {
const regex = /(\/.*)?\/r\/([\w_]+)/
const m = regex.exec(request.nextUrl.pathname)
const url = new URL(m[1] || '/', request.url)
url.search = request.nextUrl.search
url.hash = request.nextUrl.hash
const resp = NextResponse.redirect(url)
resp.cookies.set('sn_referrer', m[2])
return resp
}
export const config = {
matcher: ['/(.*/|)r/([\\w_]+)([?#]?.*)']
}

View File

@ -5,9 +5,7 @@ import prisma from '../../../api/models'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { getSession } from 'next-auth/client' import { getSession } from 'next-auth/client'
export default (req, res) => NextAuth(req, res, options) export default (req, res) => NextAuth(req, res, {
const options = {
callbacks: { callbacks: {
/** /**
* @param {object} token Decrypted JSON Web Token * @param {object} token Decrypted JSON Web Token
@ -26,22 +24,32 @@ const options = {
token.user = { id: Number(user.id) } token.user = { id: Number(user.id) }
} }
// sign them up for the newsletter if (isNewUser) {
if (isNewUser && profile.email) { // if referrer exists, set on user
fetch(process.env.LIST_MONK_URL + '/api/subscribers', { if (req.cookies.sn_referrer && user?.id) {
method: 'POST', const referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } })
headers: { if (referrer) {
'Content-Type': 'application/json', await prisma.user.update({ where: { id: user.id }, data: { referrerId: referrer.id } })
Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64') }
}, }
body: JSON.stringify({
email: profile.email, // sign them up for the newsletter
name: 'blank', if (profile.email) {
lists: [2], fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
status: 'enabled', method: 'POST',
preconfirm_subscriptions: true headers: {
}) 'Content-Type': 'application/json',
}).then(async r => console.log(await r.json())).catch(console.log) Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64')
},
body: JSON.stringify({
email: profile.email,
name: 'blank',
lists: [2],
status: 'enabled',
preconfirm_subscriptions: true
})
}).then(async r => console.log(await r.json())).catch(console.log)
}
} }
return token return token
@ -130,7 +138,7 @@ const options = {
pages: { pages: {
signIn: '/login' signIn: '/login'
} }
} })
function sendVerificationRequest ({ function sendVerificationRequest ({
identifier: email, identifier: email,

View File

@ -120,7 +120,7 @@ export default function Invites () {
<h2 className='mt-3 mb-0'> <h2 className='mt-3 mb-0'>
invite links invite links
</h2> </h2>
<small className='d-block text-muted font-weight-bold mx-5'>send these to people you trust somewhat, e.g. group chats or DMs</small> <small className='d-block text-muted font-weight-bold mx-5'>send these to people you trust, e.g. group chats or DMs</small>
</div> </div>
<InviteForm /> <InviteForm />
{active.length > 0 && <InviteList name='active' invites={active} />} {active.length > 0 && <InviteList name='active' invites={active} />}

75
pages/referrals/[when].js Normal file
View File

@ -0,0 +1,75 @@
import { gql } from 'apollo-server-micro'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { getGetServerSideProps } from '../../api/ssrApollo'
import { CopyInput, Form, Select } from '../../components/form'
import LayoutCenter from '../../components/layout-center'
import { useMe } from '../../components/me'
import { WhenComposedChart } from '../../components/when-charts'
export const getServerSideProps = getGetServerSideProps(
gql`
query Referrals($when: String!)
{
referrals(when: $when) {
totalSats
totalReferrals
stats {
time
data {
name
value
}
}
}
}`)
export default function Referrals ({ data: { referrals: { totalSats, totalReferrals, stats } } }) {
const router = useRouter()
const me = useMe()
return (
<LayoutCenter footerLinks>
<Form
initial={{
when: router.query.when
}}
>
<h4 className='font-weight-bold text-muted text-center pt-5 pb-3 d-flex align-items-center justify-content-center'>
{totalReferrals} referrals & {totalSats} sats in the last
<Select
groupClassName='mb-0 ml-2'
className='w-auto'
name='when'
size='sm'
items={['day', 'week', 'month', 'year', 'forever']}
onChange={(formik, e) => router.push(`/referrals/${e.target.value}`)}
/>
</h4>
</Form>
<WhenComposedChart data={stats} lineNames={['sats']} barNames={['referrals']} />
<div
className='text-small pt-5 px-3 d-flex w-100 align-items-center'
>
<div className='nav-item text-muted pr-2' style={{ 'white-space': 'nowrap' }}>referral link:</div>
<CopyInput
size='sm'
groupClassName='mb-0 w-100'
readOnly
noForm
placeholder={`https://stacker.news/r/${me.name}`}
/>
</div>
<ul className='py-3 text-muted'>
<li>{`appending /r/${me.name} to any SN link makes it a ref link`}
<ul>
<li>e.g. https://stacker.news/items/1/r/{me.name}</li>
</ul>
</li>
<li>earn 21% of boost and job fees spent by referred stackers</li>
<li>earn 2.1% of all tips received by referred stackers</li>
<li><Link href='/invites' passHref><a>invite links</a></Link> are also implicitly referral links</li>
</ul>
</LayoutCenter>
)
}

View File

@ -15,7 +15,6 @@ import { useRouter } from 'next/router'
import Item from '../components/item' import Item from '../components/item'
import Comment from '../components/comment' import Comment from '../components/comment'
import React from 'react' import React from 'react'
import Info from '../components/info'
export const getServerSideProps = getGetServerSideProps(WALLET_HISTORY) export const getServerSideProps = getGetServerSideProps(WALLET_HISTORY)
@ -102,7 +101,16 @@ function Detail ({ fact }) {
return ( return (
<> <>
<div className={satusClass(fact.status)}> <div className={satusClass(fact.status)}>
You made a donation to daily rewards! You made a donation to <Link href='/rewards' passHref><a>daily rewards</a></Link>!
</div>
</>
)
}
if (fact.type === 'referral') {
return (
<>
<div className={satusClass(fact.status)}>
You stacked sats from <Link href='/referrals/month' passHref><a>a referral</a></Link>!
</div> </div>
</> </>
) )
@ -156,6 +164,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
return `/${fact.type}s/${fact.factId}` return `/${fact.type}s/${fact.factId}`
case 'earn': case 'earn':
case 'donation': case 'donation':
case 'referral':
return return
default: default:
return `/items/${fact.factId}` return `/items/${fact.factId}`
@ -212,11 +221,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
<th className={styles.type}>type</th> <th className={styles.type}>type</th>
<th>detail</th> <th>detail</th>
<th className={styles.sats}> <th className={styles.sats}>
<div>sats sats
<Info>
<div className='font-weight-bold'>Sats are rounded down from millisats to the nearest sat, so the actual amount might be slightly larger.</div>
</Info>
</div>
</th> </th>
</tr> </tr>
</thead> </thead>
@ -231,7 +236,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
<td className={styles.description}> <td className={styles.description}>
<Detail fact={f} /> <Detail fact={f} />
</td> </td>
<td className={`${styles.sats} ${satusClass(f.status)}`}>{Math.floor(f.sats)}</td> <td className={`${styles.sats} ${satusClass(f.status)}`}>{f.sats}</td>
</tr> </tr>
</Wrapper> </Wrapper>
) )

View File

@ -146,7 +146,7 @@ export default function Settings ({ data: { settings } }) {
groupClassName='mb-0' groupClassName='mb-0'
/> />
<Checkbox <Checkbox
label='my invite links are redeemed' label='someone joins using my invite or referral links'
name='noteInvites' name='noteInvites'
groupClassName='mb-0' groupClassName='mb-0'
/> />

View File

@ -1,11 +1,9 @@
import { gql } from '@apollo/client' import { gql } from '@apollo/client'
import { getGetServerSideProps } from '../../api/ssrApollo' import { getGetServerSideProps } from '../../api/ssrApollo'
import Layout from '../../components/layout' import Layout from '../../components/layout'
import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, AreaChart, Area } from 'recharts'
import { Col, Row } from 'react-bootstrap' import { Col, Row } from 'react-bootstrap'
import { abbrNum } from '../../lib/format'
import { UsageHeader } from '../../components/usage-header' import { UsageHeader } from '../../components/usage-header'
import { useRouter } from 'next/router' import { WhenLineChart, WhenAreaChart } from '../../components/when-charts'
export const getServerSideProps = getGetServerSideProps( export const getServerSideProps = getGetServerSideProps(
gql` gql`
@ -55,46 +53,6 @@ export const getServerSideProps = getGetServerSideProps(
} }
}`) }`)
// todo: this needs to accomodate hours, days, months now
const dateFormatter = when => {
return timeStr => {
const date = new Date(timeStr)
switch (when) {
case 'week':
case 'month':
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${date.getUTCDate()}`
case 'year':
case 'forever':
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${String(date.getUTCFullYear()).slice(-2)}`
default:
return `${date.getHours() % 12 || 12}${date.getHours() >= 12 ? 'pm' : 'am'}`
}
}
}
function xAxisName (when) {
switch (when) {
case 'week':
case 'month':
return 'days'
case 'year':
case 'forever':
return 'months'
default:
return 'hours'
}
}
const transformData = data => {
return data.map(entry => {
const obj = { time: entry.time }
entry.data.forEach(entry1 => {
obj[entry1.name] = entry1.value
})
return obj
})
}
export default function Growth ({ export default function Growth ({
data: { registrationGrowth, itemGrowth, spendingGrowth, spenderGrowth, stackingGrowth, stackerGrowth } data: { registrationGrowth, itemGrowth, spendingGrowth, spenderGrowth, stackingGrowth, stackerGrowth }
}) { }) {
@ -104,112 +62,33 @@ export default function Growth ({
<Row> <Row>
<Col className='mt-3'> <Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>stackers</div> <div className='text-center text-muted font-weight-bold'>stackers</div>
<GrowthLineChart data={stackerGrowth} /> <WhenLineChart data={stackerGrowth} />
</Col> </Col>
<Col className='mt-3'> <Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>stacking</div> <div className='text-center text-muted font-weight-bold'>stacking</div>
<GrowthAreaChart data={stackingGrowth} /> <WhenAreaChart data={stackingGrowth} />
</Col> </Col>
</Row> </Row>
<Row> <Row>
<Col className='mt-3'> <Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>spenders</div> <div className='text-center text-muted font-weight-bold'>spenders</div>
<GrowthLineChart data={spenderGrowth} /> <WhenLineChart data={spenderGrowth} />
</Col> </Col>
<Col className='mt-3'> <Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>spending</div> <div className='text-center text-muted font-weight-bold'>spending</div>
<GrowthAreaChart data={spendingGrowth} /> <WhenAreaChart data={spendingGrowth} />
</Col> </Col>
</Row> </Row>
<Row> <Row>
<Col className='mt-3'> <Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>registrations</div> <div className='text-center text-muted font-weight-bold'>registrations</div>
<GrowthAreaChart data={registrationGrowth} /> <WhenAreaChart data={registrationGrowth} />
</Col> </Col>
<Col className='mt-3'> <Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>items</div> <div className='text-center text-muted font-weight-bold'>items</div>
<GrowthAreaChart data={itemGrowth} /> <WhenAreaChart data={itemGrowth} />
</Col> </Col>
</Row> </Row>
</Layout> </Layout>
) )
} }
const COLORS = [
'var(--secondary)',
'var(--info)',
'var(--success)',
'var(--boost)',
'var(--theme-grey)',
'var(--danger)'
]
function GrowthAreaChart ({ data }) {
const router = useRouter()
if (!data || data.length === 0) {
return null
}
// transform data into expected shape
data = transformData(data)
// need to grab when
const when = router.query.when
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<AreaChart
data={data}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 0
}}
>
<XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
tick={{ fill: 'var(--theme-grey)' }}
/>
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
<Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
</AreaChart>
</ResponsiveContainer>
)
}
function GrowthLineChart ({ data }) {
const router = useRouter()
if (!data || data.length === 0) {
return null
}
// transform data into expected shape
data = transformData(data)
// need to grab when
const when = router.query.when
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<LineChart
data={data}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 0
}}
>
<XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
tick={{ fill: 'var(--theme-grey)' }}
/>
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
<Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)}
</LineChart>
</ResponsiveContainer>
)
}

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "referrerId" INTEGER;
-- AddForeignKey
ALTER TABLE "users" ADD FOREIGN KEY ("referrerId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "ReferralAct" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"referrerId" INTEGER NOT NULL,
"itemActId" INTEGER NOT NULL,
"msats" BIGINT NOT NULL,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ReferralAct" ADD FOREIGN KEY ("referrerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReferralAct" ADD FOREIGN KEY ("itemActId") REFERENCES "ItemAct"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,202 @@
CREATE OR REPLACE FUNCTION referral_act(referrer_id INTEGER, item_act_id INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
act_msats BIGINT;
referral_act "ItemActType";
referral_msats BIGINT;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats, act INTO act_msats, referral_act FROM "ItemAct" WHERE id = item_act_id;
IF referral_act IN ('FEE', 'BOOST', 'STREAM') THEN
referral_msats := CEIL(act_msats * .21);
INSERT INTO "ReferralAct" ("referrerId", "itemActId", msats, created_at, updated_at)
VALUES(referrer_id, item_act_id, referral_msats, now_utc(), now_utc());
UPDATE users
SET msats = msats + referral_msats, "stackedMsats" = "stackedMsats" + referral_msats
WHERE id = referrer_id;
END IF;
RETURN 0;
END;
$$;
-- add referral act on item_act
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
act_msats BIGINT;
fee_msats BIGINT;
item_act_id INTEGER;
referrer_id INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
act_msats := act_sats * 1000;
SELECT msats, "referrerId" INTO user_msats, referrer_id FROM users WHERE id = user_id;
IF act_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- deduct msats from actor
UPDATE users SET msats = msats - act_msats WHERE id = user_id;
IF act = 'VOTE' THEN
RAISE EXCEPTION 'SN_UNSUPPORTED';
END IF;
IF act = 'TIP' THEN
-- call to influence weightedVotes ... we need to do this before we record the acts because
-- the priors acts are taken into account
PERFORM weighted_votes_after_tip(item_id, user_id, act_sats);
-- take 10% and insert as FEE
fee_msats := CEIL(act_msats * 0.1);
act_msats := act_msats - fee_msats;
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (fee_msats, item_id, user_id, 'FEE', now_utc(), now_utc())
RETURNING id INTO item_act_id;
-- add sats to actee's balance and stacked count
UPDATE users
SET msats = msats + act_msats, "stackedMsats" = "stackedMsats" + act_msats
WHERE id = (SELECT COALESCE("fwdUserId", "userId") FROM "Item" WHERE id = item_id)
RETURNING "referrerId" INTO referrer_id;
-- leave the rest as a tip
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_msats, item_id, user_id, 'TIP', now_utc(), now_utc());
-- call to denormalize sats and commentSats
PERFORM sats_after_tip(item_id, user_id, act_msats + fee_msats);
ELSE -- BOOST, POLL, DONT_LIKE_THIS, STREAM
-- call to influence if DONT_LIKE_THIS weightedDownVotes
IF act = 'DONT_LIKE_THIS' THEN
-- make sure they haven't done this before
IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'DONT_LIKE_THIS') THEN
RAISE EXCEPTION 'SN_DUPLICATE';
END IF;
PERFORM weighted_downvotes_after_act(item_id, user_id, act_sats);
END IF;
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_msats, item_id, user_id, act, now_utc(), now_utc())
RETURNING id INTO item_act_id;
END IF;
-- they have a referrer and the referrer isn't the one tipping them
IF referrer_id IS NOT NULL AND user_id <> referrer_id THEN
PERFORM referral_act(referrer_id, item_act_id);
END IF;
RETURN 0;
END;
$$;
CREATE OR REPLACE FUNCTION run_auction(item_id INTEGER) RETURNS void AS $$
DECLARE
bid_sats INTEGER;
user_msats BIGINT;
user_id INTEGER;
item_status "Status";
status_updated_at timestamp(3);
BEGIN
PERFORM ASSERT_SERIALIZED();
-- extract data we need
SELECT "maxBid", "userId", status, "statusUpdatedAt" INTO bid_sats, user_id, item_status, status_updated_at FROM "Item" WHERE id = item_id;
SELECT msats INTO user_msats FROM users WHERE id = user_id;
-- 0 bid items expire after 30 days unless updated
IF bid_sats = 0 THEN
IF item_status <> 'STOPPED' THEN
IF status_updated_at < now_utc() - INTERVAL '30 days' THEN
UPDATE "Item" SET status = 'STOPPED', "statusUpdatedAt" = now_utc() WHERE id = item_id;
ELSEIF item_status = 'NOSATS' THEN
UPDATE "Item" SET status = 'ACTIVE' WHERE id = item_id;
END IF;
END IF;
RETURN;
END IF;
-- check if user wallet has enough sats
IF bid_sats * 1000 > user_msats THEN
-- if not, set status = NOSATS and statusUpdatedAt to now_utc if not already set
IF item_status <> 'NOSATS' THEN
UPDATE "Item" SET status = 'NOSATS', "statusUpdatedAt" = now_utc() WHERE id = item_id;
END IF;
ELSE
PERFORM item_act(item_id, user_id, 'STREAM', bid_sats);
-- update item status = ACTIVE and statusUpdatedAt = now_utc if NOSATS
IF item_status = 'NOSATS' THEN
UPDATE "Item" SET status = 'ACTIVE', "statusUpdatedAt" = now_utc() WHERE id = item_id;
END IF;
END IF;
END;
$$ LANGUAGE plpgsql;
-- retro actively, turn all invites into referrals
UPDATE users
SET "referrerId" = subquery.inviter
FROM (
SELECT invitees.id AS invitee, inviters.id AS inviter
FROM users invitees
JOIN "Invite" ON invitees."inviteId" = "Invite".id
JOIN users inviters ON inviters.id = "Invite"."userId") subquery
WHERE id = subquery.invitee;
-- make inviters referrers too
CREATE OR REPLACE FUNCTION invite_drain(user_id INTEGER, invite_id TEXT)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
inviter_id INTEGER;
inviter_sats INTEGER;
gift INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
-- check user was created in last hour
-- check user did not already redeem an invite
PERFORM FROM users
WHERE id = user_id AND users.created_at >= NOW() AT TIME ZONE 'UTC' - INTERVAL '1 HOUR'
AND users."inviteId" IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'SN_INELIGIBLE';
END IF;
-- check that invite has not reached limit
-- check that invite is not revoked
SELECT "Invite"."userId", "Invite".gift INTO inviter_id, gift FROM "Invite"
LEFT JOIN users ON users."inviteId" = invite_id
WHERE "Invite".id = invite_id AND NOT "Invite".revoked
GROUP BY "Invite".id
HAVING COUNT(DISTINCT users.id) < "Invite".limit OR "Invite".limit IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'SN_REVOKED_OR_EXHAUSTED';
END IF;
-- check that inviter has sufficient balance
SELECT (msats / 1000) INTO inviter_sats
FROM users WHERE id = inviter_id;
IF inviter_sats < gift THEN
RAISE EXCEPTION 'SN_REVOKED_OR_EXHAUSTED';
END IF;
-- subtract amount from inviter
UPDATE users SET msats = msats - (1000 * gift) WHERE id = inviter_id;
-- add amount to invitee
UPDATE users SET msats = msats + (1000 * gift), "inviteId" = invite_id, "referrerId" = inviter_id WHERE id = user_id;
RETURN 0;
END;
$$;

View File

@ -47,6 +47,11 @@ model User {
upvotePopover Boolean @default(false) upvotePopover Boolean @default(false)
tipPopover Boolean @default(false) tipPopover Boolean @default(false)
// referrals
referrer User? @relation("referrals", fields: [referrerId], references: [id])
referrerId Int?
referrees User[] @relation("referrals")
// tip settings // tip settings
tipDefault Int @default(10) tipDefault Int @default(10)
turboTipping Boolean @default(false) turboTipping Boolean @default(false)
@ -68,10 +73,11 @@ model User {
wildWestMode Boolean @default(false) wildWestMode Boolean @default(false)
greeterMode Boolean @default(false) greeterMode Boolean @default(false)
Earn Earn[] Earn Earn[]
Upload Upload[] @relation(name: "Uploads") Upload Upload[] @relation(name: "Uploads")
PollVote PollVote[] PollVote PollVote[]
Donation Donation[] Donation Donation[]
ReferralAct ReferralAct[]
@@index([createdAt]) @@index([createdAt])
@@index([inviteId]) @@index([inviteId])
@ -318,6 +324,17 @@ model Pin {
Item Item[] Item Item[]
} }
model ReferralAct {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
referrerId Int
referrer User @relation(fields: [referrerId], references: [id])
itemActId Int
itemAct ItemAct @relation(fields: [itemActId], references: [id])
msats BigInt
}
enum ItemActType { enum ItemActType {
VOTE VOTE
BOOST BOOST
@ -329,15 +346,16 @@ enum ItemActType {
} }
model ItemAct { model ItemAct {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at") createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at") updatedAt DateTime @updatedAt @map(name: "updated_at")
msats BigInt msats BigInt
act ItemActType act ItemActType
item Item @relation(fields: [itemId], references: [id]) item Item @relation(fields: [itemId], references: [id])
itemId Int itemId Int
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId Int userId Int
ReferralAct ReferralAct[]
@@index([itemId]) @@index([itemId])
@@index([userId]) @@index([userId])

1
svgs/share-fill.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M13.576 17.271l-5.11-2.787a3.5 3.5 0 1 1 0-4.968l5.11-2.787a3.5 3.5 0 1 1 .958 1.755l-5.11 2.787a3.514 3.514 0 0 1 0 1.458l5.11 2.787a3.5 3.5 0 1 1-.958 1.755z"/></svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M13 14h-2a8.999 8.999 0 0 0-7.968 4.81A10.136 10.136 0 0 1 3 18C3 12.477 7.477 8 13 8V3l10 8-10 8v-5z"/></svg>

After

Width:  |  Height:  |  Size: 239 B

View File

@ -11,9 +11,10 @@ function earn ({ models }) {
// compute how much sn earned today // compute how much sn earned today
let [{ sum }] = await models.$queryRaw` let [{ sum }] = await models.$queryRaw`
SELECT coalesce(sum("ItemAct".msats), 0) as sum SELECT coalesce(sum("ItemAct".msats - coalesce("ReferralAct".msats, 0)), 0) as sum
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" ON "ItemAct"."itemId" = "Item".id
LEFT JOIN "ReferralAct" ON "ItemAct".id = "ReferralAct"."itemActId"
WHERE "ItemAct".act <> 'TIP' WHERE "ItemAct".act <> 'TIP'
AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'` AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'`