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
function interval (when) {
export function interval (when) {
switch (when) {
case 'week':
return '1 week'
@ -15,7 +15,7 @@ function interval (when) {
}
}
function timeUnit (when) {
export function timeUnit (when) {
switch (when) {
case 'week':
case 'month':
@ -28,7 +28,7 @@ function timeUnit (when) {
}
}
function withClause (when) {
export function withClause (when) {
const ival = interval(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
function intervalClause (when, table, and) {
export function intervalClause (when, table, and) {
if (when === 'forever') {
return and ? '' : 'TRUE'
}
@ -58,7 +58,7 @@ export default {
return await models.$queryRaw(
`${withClause(when)}
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"))
) AS data
FROM times
@ -131,7 +131,8 @@ export default {
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', '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
FROM times
LEFT JOIN
@ -142,7 +143,11 @@ export default {
UNION ALL
(SELECT created_at, "userId" as user_id, 'EARN' as type
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
ORDER BY time ASC`)
},
@ -152,18 +157,24 @@ export default {
SELECT time, json_build_array(
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', '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
FROM times
LEFT JOIN
((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 "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"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP')
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"
WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time

View File

@ -10,7 +10,8 @@ import upload from './upload'
import growth from './growth'
import search from './search'
import rewards from './rewards'
import referrals from './referrals'
import { GraphQLJSONObject } from 'graphql-type-json'
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
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) {

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`
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', 'DONATION')), 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))
SELECT coalesce(FLOOR(sum(sats)), 0) as total, json_build_array(
json_build_object('name', 'donations', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = '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(FLOOR(sum(sats) FILTER(WHERE type = 'BOOST')), 0)),
json_build_object('name', 'jobs', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'STREAM')), 0))
) AS sources
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"
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
(SELECT sats, 'DONATION' as type
(SELECT sats::FLOAT, 'DONATION' as type
FROM "Donation"
WHERE created_at > ${lastReward.createdAt})
) subquery`

View File

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

View File

@ -5,7 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import lnpr from 'bolt11'
import { SELECT } from './item'
import { lnurlPayDescriptionHash } from '../../lib/lnurl'
import { msatsToSats } from '../../lib/format'
import { msatsToSats, msatsToSatsDecimal } from '../../lib/format'
export async function getInvoice (parent, { id }, { me, models }) {
if (!me) {
@ -110,6 +110,12 @@ export default {
FROM "Earn"
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
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')) {
@ -287,8 +293,8 @@ export default {
return item
},
sats: fact => msatsToSats(fact.msats),
satsFee: fact => msatsToSats(fact.msatsFee)
sats: fact => msatsToSatsDecimal(fact.msats),
satsFee: fact => msatsToSatsDecimal(fact.msatsFee)
}
}

View File

@ -11,6 +11,7 @@ import sub from './sub'
import upload from './upload'
import growth from './growth'
import rewards from './rewards'
import referrals from './referrals'
const link = gql`
type Query {
@ -27,4 +28,4 @@ const link = gql`
`
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!
}
type Referral {
sortTime: String!
}
union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid
| Invitification | Earn | JobChanged | InvoicePaid | Referral
type Notifications {
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!
bolt11: String
createdAt: String!
sats: Int!
satsFee: Int
sats: Float!
satsFee: Float
status: String
type: String!
description: String

View File

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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ function Notification ({ n }) {
<div
className='clickToContext'
onClick={e => {
if (n.__typename === 'Earn') {
if (n.__typename === 'Earn' || n.__typename === 'Referral') {
return
}
@ -88,41 +88,50 @@ function Notification ({ n }) {
</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-success ml-2'>
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
</small>}
{n.__typename === 'Mention' &&
<small className='font-weight-bold text-info ml-2'>
you were mentioned in
</small>}
{n.__typename === 'JobChanged' &&
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
{n.item.status === 'ACTIVE'
? '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>
</>)}
<small className='font-weight-bold text-secondary ml-2'>
someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link>
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</small>
</>
)
: n.__typename === 'InvoicePaid'
? (
<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-success ml-2'>
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
</small>}
{n.__typename === 'Mention' &&
<small className='font-weight-bold text-info ml-2'>
you were mentioned in
</small>}
{n.__typename === 'JobChanged' &&
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
{n.item.status === 'ACTIVE'
? '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>
)
}

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
}
}
... on Referral {
sortTime
}
... on Reply {
sortTime
item {

View File

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

View File

@ -16,3 +16,10 @@ export const msatsToSats = msats => {
}
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 { getSession } from 'next-auth/client'
export default (req, res) => NextAuth(req, res, options)
const options = {
export default (req, res) => NextAuth(req, res, {
callbacks: {
/**
* @param {object} token Decrypted JSON Web Token
@ -26,22 +24,32 @@ const options = {
token.user = { id: Number(user.id) }
}
// sign them up for the newsletter
if (isNewUser && profile.email) {
fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
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)
if (isNewUser) {
// if referrer exists, set on user
if (req.cookies.sn_referrer && user?.id) {
const referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } })
if (referrer) {
await prisma.user.update({ where: { id: user.id }, data: { referrerId: referrer.id } })
}
}
// sign them up for the newsletter
if (profile.email) {
fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
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
@ -130,7 +138,7 @@ const options = {
pages: {
signIn: '/login'
}
}
})
function sendVerificationRequest ({
identifier: email,

View File

@ -120,7 +120,7 @@ export default function Invites () {
<h2 className='mt-3 mb-0'>
invite links
</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>
<InviteForm />
{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 Comment from '../components/comment'
import React from 'react'
import Info from '../components/info'
export const getServerSideProps = getGetServerSideProps(WALLET_HISTORY)
@ -102,7 +101,16 @@ function Detail ({ fact }) {
return (
<>
<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>
</>
)
@ -156,6 +164,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
return `/${fact.type}s/${fact.factId}`
case 'earn':
case 'donation':
case 'referral':
return
default:
return `/items/${fact.factId}`
@ -212,11 +221,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
<th className={styles.type}>type</th>
<th>detail</th>
<th className={styles.sats}>
<div>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>
sats
</th>
</tr>
</thead>
@ -231,7 +236,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
<td className={styles.description}>
<Detail fact={f} />
</td>
<td className={`${styles.sats} ${satusClass(f.status)}`}>{Math.floor(f.sats)}</td>
<td className={`${styles.sats} ${satusClass(f.status)}`}>{f.sats}</td>
</tr>
</Wrapper>
)

View File

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

View File

@ -1,11 +1,9 @@
import { gql } from '@apollo/client'
import { getGetServerSideProps } from '../../api/ssrApollo'
import Layout from '../../components/layout'
import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, AreaChart, Area } from 'recharts'
import { Col, Row } from 'react-bootstrap'
import { abbrNum } from '../../lib/format'
import { UsageHeader } from '../../components/usage-header'
import { useRouter } from 'next/router'
import { WhenLineChart, WhenAreaChart } from '../../components/when-charts'
export const getServerSideProps = getGetServerSideProps(
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 ({
data: { registrationGrowth, itemGrowth, spendingGrowth, spenderGrowth, stackingGrowth, stackerGrowth }
}) {
@ -104,112 +62,33 @@ export default function Growth ({
<Row>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>stackers</div>
<GrowthLineChart data={stackerGrowth} />
<WhenLineChart data={stackerGrowth} />
</Col>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>stacking</div>
<GrowthAreaChart data={stackingGrowth} />
<WhenAreaChart data={stackingGrowth} />
</Col>
</Row>
<Row>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>spenders</div>
<GrowthLineChart data={spenderGrowth} />
<WhenLineChart data={spenderGrowth} />
</Col>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>spending</div>
<GrowthAreaChart data={spendingGrowth} />
<WhenAreaChart data={spendingGrowth} />
</Col>
</Row>
<Row>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>registrations</div>
<GrowthAreaChart data={registrationGrowth} />
<WhenAreaChart data={registrationGrowth} />
</Col>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>items</div>
<GrowthAreaChart data={itemGrowth} />
<WhenAreaChart data={itemGrowth} />
</Col>
</Row>
</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)
tipPopover Boolean @default(false)
// referrals
referrer User? @relation("referrals", fields: [referrerId], references: [id])
referrerId Int?
referrees User[] @relation("referrals")
// tip settings
tipDefault Int @default(10)
turboTipping Boolean @default(false)
@ -68,10 +73,11 @@ model User {
wildWestMode Boolean @default(false)
greeterMode Boolean @default(false)
Earn Earn[]
Upload Upload[] @relation(name: "Uploads")
PollVote PollVote[]
Donation Donation[]
Earn Earn[]
Upload Upload[] @relation(name: "Uploads")
PollVote PollVote[]
Donation Donation[]
ReferralAct ReferralAct[]
@@index([createdAt])
@@index([inviteId])
@ -318,6 +324,17 @@ model Pin {
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 {
VOTE
BOOST
@ -329,15 +346,16 @@ enum ItemActType {
}
model ItemAct {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
msats BigInt
act ItemActType
item Item @relation(fields: [itemId], references: [id])
itemId Int
user User @relation(fields: [userId], references: [id])
userId Int
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
msats BigInt
act ItemActType
item Item @relation(fields: [itemId], references: [id])
itemId Int
user User @relation(fields: [userId], references: [id])
userId Int
ReferralAct ReferralAct[]
@@index([itemId])
@@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
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"
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'
AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'`