Monthly rewards (#890)

* show placeholder for hidden stackers in top

* top rewardability views

* make territory revenue idependent job

* monthly rewards and leaderboard on rewards pages

* fix earn reschedule

* add query for rewards leaderboard

* reduce likelihood of rewards racing with views

* fix earn and refine values views
This commit is contained in:
Keyan 2024-03-01 10:28:55 -06:00 committed by GitHub
parent 508008f586
commit 0b0e36e3cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 688 additions and 324 deletions

View File

@ -42,7 +42,8 @@ export function viewGroup (range, view) {
${view}( ${view}(
date_trunc('hour', timezone('America/Chicago', now())), date_trunc('hour', timezone('America/Chicago', now())),
date_trunc('hour', timezone('America/Chicago', now())), '1 hour'::INTERVAL, 'hour') date_trunc('hour', timezone('America/Chicago', now())), '1 hour'::INTERVAL, 'hour')
WHERE "${view}".t >= date_trunc('${unit}', timezone('America/Chicago', $1))) WHERE "${view}".t >= date_trunc('${unit}', timezone('America/Chicago', $1))
AND "${view}".t <= date_trunc('${unit}', timezone('America/Chicago', $2)))
) u` ) u`
} }

View File

@ -3,23 +3,50 @@ import { amountSchema, ssValidate } from '../../lib/validate'
import { serializeInvoicable } from './serial' import { serializeInvoicable } from './serial'
import { ANON_USER_ID } from '../../lib/constants' import { ANON_USER_ID } from '../../lib/constants'
import { getItem } from './item' import { getItem } from './item'
import { topUsers } from './user'
const rewardCache = new Map() let rewardCache
async function updateCachedRewards (when, models) { async function updateCachedRewards (models) {
const rewards = await getRewards(when, models) const rewards = await getActiveRewards(models)
rewardCache.set(when, { rewards, createdAt: Date.now() }) rewardCache = { rewards, createdAt: Date.now() }
return rewards return rewards
} }
async function getCachedRewards (staleIn, when, models) { async function getCachedActiveRewards (staleIn, models) {
if (rewardCache.has(when)) { if (rewardCache) {
const { rewards, createdAt } = rewardCache.get(when) const { rewards, createdAt } = rewardCache
const expired = createdAt + staleIn < Date.now() const expired = createdAt + staleIn < Date.now()
if (expired) updateCachedRewards(when, models).catch(console.error) if (expired) updateCachedRewards(models).catch(console.error)
return rewards // serve stale rewards return rewards // serve stale rewards
} }
return await updateCachedRewards(when, models) return await updateCachedRewards(models)
}
async function getActiveRewards (models) {
return await models.$queryRaw`
SELECT
(sum(total) / 1000)::INT as total,
date_trunc('month', (now() AT TIME ZONE 'America/Chicago') + interval '1 month') AT TIME ZONE 'America/Chicago' as time,
json_build_array(
json_build_object('name', 'donations', 'value', (sum(donations) / 1000)::INT),
json_build_object('name', 'fees', 'value', (sum(fees) / 1000)::INT),
json_build_object('name', 'boost', 'value', (sum(boost) / 1000)::INT),
json_build_object('name', 'jobs', 'value', (sum(jobs) / 1000)::INT),
json_build_object('name', 'anon''s stack', 'value', (sum(anons_stack) / 1000)::INT)
) AS sources
FROM (
(SELECT *
FROM rewards_days
WHERE rewards_days.t >= date_trunc('month', now() AT TIME ZONE 'America/Chicago'))
UNION ALL
(SELECT * FROM rewards_today)
UNION ALL
(SELECT * FROM
rewards(
date_trunc('hour', timezone('America/Chicago', now())),
date_trunc('hour', timezone('America/Chicago', now())), '1 hour'::INTERVAL, 'hour'))
) u`
} }
async function getRewards (when, models) { async function getRewards (when, models) {
@ -45,37 +72,18 @@ async function getRewards (when, models) {
COALESCE(${when?.[when.length - 1]}::text::timestamp - interval '1 day', now() AT TIME ZONE 'America/Chicago'), COALESCE(${when?.[when.length - 1]}::text::timestamp - interval '1 day', now() AT TIME ZONE 'America/Chicago'),
interval '1 day') AS t interval '1 day') AS t
) )
SELECT coalesce(FLOOR(sum(sats)), 0) as total, SELECT (total / 1000)::INT as total,
days_cte.day + interval '1 day' as time, days_cte.day + interval '1 day' as time,
json_build_array( json_build_array(
json_build_object('name', 'donations', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'DONATION')), 0)), json_build_object('name', 'donations', 'value', donations / 1000),
json_build_object('name', 'fees', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION', 'ANON'))), 0)), json_build_object('name', 'fees', 'value', fees / 1000),
json_build_object('name', 'boost', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'BOOST')), 0)), json_build_object('name', 'boost', 'value', boost / 1000),
json_build_object('name', 'jobs', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'STREAM')), 0)), json_build_object('name', 'jobs', 'value', jobs / 1000),
json_build_object('name', 'anon''s stack', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'ANON')), 0)) json_build_object('name', 'anon''s stack', 'value', anons_stack / 1000)
) AS sources ) AS sources
FROM days_cte FROM days_cte
CROSS JOIN LATERAL ( JOIN rewards_days ON rewards_days.t = days_cte.day
(SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) / 1000.0 as sats, act::text as type GROUP BY days_cte.day, total, donations, fees, boost, jobs, anons_stack
FROM "ItemAct"
LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id
WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day AND "ItemAct".act <> 'TIP')
UNION ALL
(SELECT sats::FLOAT, 'DONATION' as type
FROM "Donation"
WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day)
UNION ALL
-- any earnings from anon's stack that are not forwarded to other users
(SELECT "ItemAct".msats / 1000.0 as sats, 'ANON' as type
FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP'
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day
GROUP BY "ItemAct".id, "ItemAct".msats
HAVING COUNT("ItemForward".id) = 0)
) subquery
GROUP BY days_cte.day
ORDER BY days_cte.day ASC` ORDER BY days_cte.day ASC`
return results.length ? results : [{ total: 0, time: '0', sources: [] }] return results.length ? results : [{ total: 0, time: '0', sources: [] }]
@ -84,7 +92,7 @@ async function getRewards (when, models) {
export default { export default {
Query: { Query: {
rewards: async (parent, { when }, { models }) => rewards: async (parent, { when }, { models }) =>
when ? await getRewards(when, models) : await getCachedRewards(5000, when, models), when ? await getRewards(when, models) : await getCachedActiveRewards(5000, models),
meRewards: async (parent, { when }, { me, models }) => { meRewards: async (parent, { when }, { me, models }) => {
if (!me) { if (!me) {
return null return null
@ -122,6 +130,15 @@ export default {
return results return results
} }
}, },
Rewards: {
leaderboard: async (parent, args, { models, ...context }) => {
// get to and from using postgres because it's easier to do there
const [{ to, from }] = await models.$queryRaw`
SELECT date_trunc('month', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from,
(date_trunc('month', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 month - 1 second' as to`
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 64 }, { models, ...context })
}
},
Mutation: { Mutation: {
donateToRewards: async (parent, { sats, hash, hmac }, { me, models, lnd }) => { donateToRewards: async (parent, { sats, hash, hmac }, { me, models, lnd }) => {
await ssValidate(amountSchema, { amount: sats }) await ssValidate(amountSchema, { amount: sats })

View File

@ -5,7 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { msatsToSats } from '../../lib/format' import { msatsToSats } from '../../lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate' import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item' import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item'
import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID } from '../../lib/constants' import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID, SN_USER_IDS } from '../../lib/constants'
import { viewGroup } from './growth' import { viewGroup } from './growth'
import { whenRange } from '../../lib/time' import { whenRange } from '../../lib/time'
@ -50,6 +50,65 @@ async function authMethods (user, args, { models, me }) {
} }
} }
export async function topUsers (parent, { cursor, when, by, from, to, limit = LIMIT }, { models, me }) {
const decodedCursor = decodeCursor(cursor)
const range = whenRange(when, from, to || decodeCursor.time)
let column
switch (by) {
case 'spent': column = 'spent'; break
case 'posts': column = 'nposts'; break
case 'comments': column = 'ncomments'; break
case 'referrals': column = 'referrals'; break
case 'stacking': column = 'stacked'; break
default: column = 'proportion'; break
}
const users = (await models.$queryRawUnsafe(`
SELECT *
FROM
(SELECT users.*,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments,
COALESCE(sum(referrals), 0) as referrals,
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked
FROM ${viewGroup(range, 'user_stats')}
JOIN users on users.id = u.id
GROUP BY users.id) uu
${column === 'proportion' ? `JOIN ${viewValueGroup()} ON uu.id = vv.id` : ''}
ORDER BY ${column} DESC NULLS LAST, uu.created_at ASC
OFFSET $3
LIMIT $4`, ...range, decodedCursor.offset, limit)
).map(
u => u.hideFromTopUsers ? null : u
)
return {
cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
users
}
}
export function viewValueGroup () {
return `(
SELECT v.id, sum(proportion) as proportion
FROM (
(SELECT *
FROM user_values_days
WHERE user_values_days.t >= date_trunc('day', timezone('America/Chicago', $1))
AND date_trunc('day', user_values_days.t) <= date_trunc('day', timezone('America/Chicago', $2)))
UNION ALL
(SELECT * FROM
user_values_today
WHERE user_values_today.t >= date_trunc('day', timezone('America/Chicago', $1))
AND date_trunc('day', user_values_today.t) <= date_trunc('day', timezone('America/Chicago', $2)))
) v
WHERE v.id NOT IN (${SN_USER_IDS.join(',')})
GROUP BY v.id
) vv`
}
export default { export default {
Query: { Query: {
me: async (parent, args, { models, me }) => { me: async (parent, args, { models, me }) => {
@ -82,7 +141,7 @@ export default {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
const range = whenRange('forever') const range = whenRange('forever')
const users = await models.$queryRawUnsafe(` const users = (await models.$queryRawUnsafe(`
SELECT users.*, SELECT users.*,
coalesce(floor(sum(msats_spent)/1000),0) as spent, coalesce(floor(sum(msats_spent)/1000),0) as spent,
coalesce(sum(posts),0) as nposts, coalesce(sum(posts),0) as nposts,
@ -91,11 +150,15 @@ export default {
coalesce(floor(sum(msats_stacked)/1000),0) as stacked coalesce(floor(sum(msats_stacked)/1000),0) as stacked
FROM ${viewGroup(range, 'user_stats')} FROM ${viewGroup(range, 'user_stats')}
JOIN users on users.id = u.id JOIN users on users.id = u.id
WHERE NOT "hideFromTopUsers" AND NOT "hideCowboyHat" AND streak IS NOT NULL WHERE streak IS NOT NULL
GROUP BY users.id GROUP BY users.id
ORDER BY streak DESC, created_at ASC ORDER BY streak DESC, created_at ASC
OFFSET $3 OFFSET $3
LIMIT ${LIMIT}`, ...range, decodedCursor.offset) LIMIT ${LIMIT}`, ...range, decodedCursor.offset)
).map(
u => u.hideFromTopUsers || u.hideCowboyHat ? null : u
)
return { return {
cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
users users
@ -126,39 +189,7 @@ export default {
return users return users
}, },
topUsers: async (parent, { cursor, when, by, from, to, limit = LIMIT }, { models, me }) => { topUsers,
const decodedCursor = decodeCursor(cursor)
const range = whenRange(when, from, to || decodeCursor.time)
let column
switch (by) {
case 'spent': column = 'spent'; break
case 'posts': column = 'nposts'; break
case 'comments': column = 'ncomments'; break
case 'referrals': column = 'referrals'; break
default: column = 'stacked'; break
}
const users = await models.$queryRawUnsafe(`
SELECT users.*,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments,
COALESCE(sum(referrals), 0) as referrals,
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked
FROM ${viewGroup(range, 'user_stats')}
JOIN users on users.id = u.id
WHERE NOT users."hideFromTopUsers"
GROUP BY users.id
ORDER BY ${column} DESC NULLS LAST, users.created_at ASC
OFFSET $3
LIMIT $4`, ...range, decodedCursor.offset, limit)
return {
cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
users
}
},
hasNewNotes: async (parent, args, { me, models }) => { hasNewNotes: async (parent, args, { me, models }) => {
if (!me) { if (!me) {
return false return false

View File

@ -14,6 +14,7 @@ export default gql`
total: Int! total: Int!
time: Date! time: Date!
sources: [NameValue!]! sources: [NameValue!]!
leaderboard: UsersNullable
} }
type Reward { type Reward {

View File

@ -7,13 +7,18 @@ export default gql`
user(name: String!): User user(name: String!): User
users: [User!] users: [User!]
nameAvailable(name: String!): Boolean! nameAvailable(name: String!): Boolean!
topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Users topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): UsersNullable!
topCowboys(cursor: String): Users topCowboys(cursor: String): UsersNullable!
searchUsers(q: String!, limit: Limit, similarity: Float): [User!]! searchUsers(q: String!, limit: Limit, similarity: Float): [User!]!
userSuggestions(q: String, limit: Limit): [User!]! userSuggestions(q: String, limit: Limit): [User!]!
hasNewNotes: Boolean! hasNewNotes: Boolean!
} }
type UsersNullable {
cursor: String
users: [User]!
}
type Users { type Users {
cursor: String cursor: String
users: [User!]! users: [User!]!

View File

@ -7,15 +7,17 @@ const REWARDS = gql`
{ {
rewards { rewards {
total total
time
} }
}` }`
export default function Rewards () { export default function Rewards () {
const { data } = useQuery(REWARDS, SSR ? { ssr: false } : { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' }) const { data } = useQuery(REWARDS, SSR ? { ssr: false } : { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' })
const total = data?.rewards?.[0]?.total const total = data?.rewards?.[0]?.total
const time = data?.rewards?.[0]?.time
return ( return (
<Link href='/rewards' className='nav-link p-0 p-0 d-inline-flex'> <Link href='/rewards' className='nav-link p-0 p-0 d-inline-flex'>
{total ? <span><RewardLine total={total} /></span> : 'rewards'} {total ? <span><RewardLine total={total} time={time} /></span> : 'rewards'}
</Link> </Link>
) )
} }

View File

@ -20,8 +20,8 @@ export default function TopHeader ({ sub, cat }) {
if (typeof query.by !== 'undefined') { if (typeof query.by !== 'undefined') {
if (query.by === '' || if (query.by === '' ||
(what === 'stackers' && (query.by === 'stacked' || !USER_SORTS.includes(query.by))) || (what === 'stackers' && (query.by === 'value' || !USER_SORTS.includes(query.by))) ||
(what === 'territories' && (query.by === 'stacked' || !SUB_SORTS.includes(query.by))) || (what === 'territories' && (query.by === 'stacking' || !SUB_SORTS.includes(query.by))) ||
(['posts', 'comments'].includes(what) && (query.by === 'zaprank' || !ITEM_SORTS.includes(query.by)))) { (['posts', 'comments'].includes(what) && (query.by === 'zaprank' || !ITEM_SORTS.includes(query.by)))) {
delete query.by delete query.by
} }
@ -36,7 +36,7 @@ export default function TopHeader ({ sub, cat }) {
} }
const what = cat const what = cat
const by = router.query.by || (what === 'stackers' ? 'stacked' : 'zaprank') const by = router.query.by || (what === 'stackers' ? 'value' : what === 'territories' ? 'stacking' : 'zaprank')
const when = router.query.when || '' const when = router.query.when || ''
return ( return (

View File

@ -36,7 +36,76 @@ function seperate (arr, seperator) {
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x]) return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x])
} }
export default function UserList ({ ssrData, query, variables, destructureData, rank }) { function User ({ user, rank, statComps, Embellish }) {
return (
<>
{rank
? (
<div className={styles.rank}>
{rank}
</div>)
: <div />}
<div className={`${styles.item} mb-2`}>
<Link href={`/${user.name}`}>
<Image
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
className={`${userStyles.userimg} me-2`}
/>
</Link>
<div className={styles.hunk}>
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
</Link>
<div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
</div>
{Embellish && <Embellish rank={rank} />}
</div>
</div>
</>
)
}
function UserHidden ({ rank, Embellish }) {
return (
<>
{rank
? (
<div className={styles.rank}>
{rank}
</div>)
: <div />}
<div className={`${styles.item} mb-2`}>
<span>
<Image
src='/dorian400.jpg' width='32' height='32'
className={`${userStyles.userimg} me-2 opacity-50`}
/>
</span>
<div className={`${styles.hunk} d-flex justify-content-center flex-column`}>
<div className={`${styles.title} text-muted d-inline-flex align-items-center`}>
stacker is in hiding
</div>
{Embellish && <Embellish rank={rank} />}
</div>
</div>
</>
)
}
export function ListUsers ({ users, rank, statComps = seperate(STAT_COMPONENTS, Seperator), Embellish }) {
return (
<div className={styles.grid}>
{users.map((user, i) => (
user
? <User key={user.id} user={user} rank={rank && i + 1} statComps={statComps} Embellish={Embellish} />
: <UserHidden key={i} rank={rank && i + 1} Embellish={Embellish} />
))}
</div>
)
}
export default function UserList ({ ssrData, query, variables, destructureData, rank, footer = true }) {
const { data, fetchMore } = useQuery(query, { variables }) const { data, fetchMore } = useQuery(query, { variables })
const dat = useData(data, ssrData) const dat = useData(data, ssrData)
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator)) const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
@ -62,35 +131,9 @@ export default function UserList ({ ssrData, query, variables, destructureData,
return ( return (
<> <>
<div className={styles.grid}> <ListUsers users={users} rank={rank} statComps={statComps} />
{users?.map((user, i) => ( {footer &&
<React.Fragment key={user.name}> <MoreFooter cursor={cursor} count={users?.length} fetchMore={fetchMore} Skeleton={UsersSkeleton} noMoreText='NO MORE' />}
{rank
? (
<div className={styles.rank}>
{i + 1}
</div>)
: <div />}
<div className={`${styles.item} mb-2`}>
<Link href={`/${user.name}`}>
<Image
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
className={`${userStyles.userimg} me-2`}
/>
</Link>
<div className={styles.hunk}>
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
</Link>
<div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
</div>
</div>
</div>
</React.Fragment>
))}
</div>
<MoreFooter cursor={cursor} count={users?.length} fetchMore={fetchMore} Skeleton={UsersSkeleton} noMoreText='NO MORE' />
</> </>
) )
} }

View File

@ -33,7 +33,7 @@ export const ITEM_SPAM_INTERVAL = '10m'
export const ANON_ITEM_SPAM_INTERVAL = '0' export const ANON_ITEM_SPAM_INTERVAL = '0'
export const INV_PENDING_LIMIT = 100 export const INV_PENDING_LIMIT = 100
export const BALANCE_LIMIT_MSATS = 250000000 // 250k sat export const BALANCE_LIMIT_MSATS = 250000000 // 250k sat
export const SN_USER_IDS = [616, 6030, 946, 4502] export const SN_USER_IDS = [616, 6030, 946, 4502, 27]
export const ANON_INV_PENDING_LIMIT = 1000 export const ANON_INV_PENDING_LIMIT = 1000
export const ANON_BALANCE_LIMIT_MSATS = 0 // disable export const ANON_BALANCE_LIMIT_MSATS = 0 // disable
export const MAX_POLL_NUM_CHOICES = 10 export const MAX_POLL_NUM_CHOICES = 10
@ -42,9 +42,9 @@ export const POLL_COST = 1
export const ITEM_FILTER_THRESHOLD = 1.2 export const ITEM_FILTER_THRESHOLD = 1.2
export const DONT_LIKE_THIS_COST = 1 export const DONT_LIKE_THIS_COST = 1
export const COMMENT_TYPE_QUERY = ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks'] export const COMMENT_TYPE_QUERY = ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks']
export const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals'] export const USER_SORTS = ['value', 'stacking', 'spending', 'comments', 'posts', 'referrals']
export const ITEM_SORTS = ['zaprank', 'comments', 'sats'] export const ITEM_SORTS = ['zaprank', 'comments', 'sats']
export const SUB_SORTS = ['stacked', 'revenue', 'spent', 'posts', 'comments'] export const SUB_SORTS = ['stacking', 'revenue', 'spending', 'posts', 'comments']
export const WHENS = ['day', 'week', 'month', 'year', 'forever', 'custom'] export const WHENS = ['day', 'week', 'month', 'year', 'forever', 'custom']
export const ITEM_TYPES_USER = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'jobs', 'bookmarks'] export const ITEM_TYPES_USER = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'jobs', 'bookmarks']
export const ITEM_TYPES = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'bios', 'jobs'] export const ITEM_TYPES = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'bios', 'jobs']

66
lib/madness.js Normal file
View File

@ -0,0 +1,66 @@
export const proportions = [
0.3312903760145768,
0.09938711280437303,
0.06625807520291535,
0.04969355640218651,
0.033129037601457675,
0.02981613384131191,
0.026503230081166142,
0.023190326321020374,
0.019877422560874606,
0.016564518800728838,
0.014908066920655955,
0.013251615040583071,
0.012920324664568494,
0.012589034288553918,
0.01225774391253934,
0.011926453536524764,
0.011595163160510187,
0.01126387278449561,
0.010932582408481033,
0.010601292032466457,
0.01027000165645188,
0.009938711280437303,
0.009607420904422726,
0.00927613052840815,
0.008944840152393572,
0.008613549776378996,
0.008282259400364419,
0.007950969024349842,
0.007619678648335266,
0.007288388272320689,
0.006957097896306112,
0.006625807520291536,
0.006294517144276959,
0.005963226768262382,
0.005631936392247805,
0.005300646016233228,
0.0049693556402186515,
0.004638065264204075,
0.004306774888189498,
0.003975484512174921,
0.0036441941361603446,
0.003312903760145768,
0.0031472585721384794,
0.002981613384131191,
0.0028159681961239026,
0.002650323008116614,
0.0024846778201093257,
0.0023190326321020373,
0.002153387444094749,
0.0019877422560874605,
0.0018220970680801723,
0.001656451880072884,
0.0014908066920655955,
0.001325161504058307,
0.0011595163160510187,
0.0009938711280437303,
0.000828225940036442,
0.0006625807520291535,
0.0004969355640218651,
0.00033129037601457677,
0.00033129037601457677,
0.00033129037601457677,
0.00033129037601457677,
0.00033129037601457677
]

View File

@ -115,3 +115,15 @@ export const whenToFrom = (when) => {
} }
export const sleep = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms)) export const sleep = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms))
export function dateToTimeZone (date, tz) {
return date.getTime() + tzOffset(tz) * 60 * 60 * 1000
}
function tzOffset (tz) {
const date = new Date()
date.setMilliseconds(0)
const targetDate = new Date(date.toLocaleString('en-US', { timeZone: tz }))
const targetOffsetHours = (date.getTime() - targetDate.getTime()) / 1000 / 60 / 60
return targetOffsetHours
}

View File

@ -1,10 +1,9 @@
import { gql } from 'graphql-tag' import { gql } from 'graphql-tag'
import { useMemo } from 'react'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import { getGetServerSideProps } from '../../api/ssrApollo' import { getGetServerSideProps } from '../../api/ssrApollo'
import { Form, Input, SubmitButton } from '../../components/form' import { Form, Input, SubmitButton } from '../../components/form'
import { CenterLayout } from '../../components/layout' import Layout from '../../components/layout'
import { useMutation, useQuery } from '@apollo/client' import { useMutation, useQuery } from '@apollo/client'
import Link from 'next/link' import Link from 'next/link'
import { amountSchema } from '../../lib/validate' import { amountSchema } from '../../lib/validate'
@ -16,15 +15,48 @@ import dynamic from 'next/dynamic'
import { SSR } from '../../lib/constants' import { SSR } from '../../lib/constants'
import { useToast } from '../../components/toast' import { useToast } from '../../components/toast'
import { useLightning } from '../../components/lightning' import { useLightning } from '../../components/lightning'
import { ListUsers } from '../../components/user-list'
import { Col, Row } from 'react-bootstrap'
import { proportions } from '../../lib/madness'
const GrowthPieChart = dynamic(() => import('../../components/charts').then(mod => mod.GrowthPieChart), { const GrowthPieChart = dynamic(() => import('../../components/charts').then(mod => mod.GrowthPieChart), {
loading: () => <div>Loading...</div> loading: () => <div>Loading...</div>
}) })
const REWARDS_FULL = gql`
{
rewards {
total
time
sources {
name
value
}
leaderboard {
users {
id
name
photoId
ncomments(when: $when, from: $from, to: $to)
nposts(when: $when, from: $from, to: $to)
optional {
streak
stacked(when: $when, from: $from, to: $to)
spent(when: $when, from: $from, to: $to)
referrals(when: $when, from: $from, to: $to)
}
}
}
}
}
`
const REWARDS = gql` const REWARDS = gql`
{ {
rewards { rewards {
total total
time
sources { sources {
name name
value value
@ -33,58 +65,82 @@ const REWARDS = gql`
} }
` `
function midnight (tz) { export const getServerSideProps = getGetServerSideProps({ query: REWARDS_FULL })
function tzOffset (tz) {
const date = new Date()
date.setMilliseconds(0)
const targetDate = new Date(date.toLocaleString('en-US', { timeZone: tz }))
const targetOffsetHours = (date.getTime() - targetDate.getTime()) / 1000 / 60 / 60
return targetOffsetHours
}
const date = new Date()
date.setHours(24, 0, 0, 0)
return date.getTime() + tzOffset(tz) * 60 * 60 * 1000
}
export const getServerSideProps = getGetServerSideProps({ query: REWARDS })
export function RewardLine ({ total }) {
const threshold = useMemo(() => midnight('America/Chicago'))
export function RewardLine ({ total, time }) {
return ( return (
<> <>
{numWithUnits(total)} in rewards <span tyle={{ whiteSpace: 'nowrap' }}>
{threshold && {numWithUnits(total)} in rewards
</span>
{time &&
<Countdown <Countdown
date={threshold} date={time}
renderer={props => <small className='text-monospace' suppressHydrationWarning> {props.formatted.hours}:{props.formatted.minutes}:{props.formatted.seconds}</small>} renderer={props =>
<small className='text-monospace' suppressHydrationWarning style={{ whiteSpace: 'nowrap' }}>
{props.formatted.days
? ` ${props.formatted.days}d ${props.formatted.hours}h ${props.formatted.minutes}m ${props.formatted.seconds}s`
: ` ${props.formatted.hours}:${props.formatted.minutes}:${props.formatted.seconds}`}
</small>}
/>} />}
</> </>
) )
} }
export default function Rewards ({ ssrData }) { export default function Rewards ({ ssrData }) {
const { data } = useQuery(REWARDS, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) // only poll for updates to rewards and not leaderboard
const { data } = useQuery(
REWARDS,
SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
if (!data && !ssrData) return <PageLoading /> if (!data && !ssrData) return <PageLoading />
const { rewards: [{ total, sources }] } = data || ssrData let { rewards: [{ total, sources, time, leaderboard }] } = ssrData
if (data?.rewards?.length > 0) {
total = data.rewards[0].total
sources = data.rewards[0].sources
time = data.rewards[0].time
}
function EstimatedReward ({ rank }) {
return (
<div className='text-muted fst-italic'>
<small>
<span>estimated reward: {numWithUnits(Math.floor(total * proportions[rank - 1]))}</span>
</small>
</div>
)
}
return ( return (
<CenterLayout footerLinks> <Layout footerLinks>
<h4 className='fw-bold text-muted text-center'> <Row className='py-3'>
<div> <Col>
<RewardLine total={total} /> <div
</div> className='d-flex flex-column sticky-lg-top py-5'
<Link href='/faq#how-do-i-earn-sats-on-stacker-news' className='text-info fw-normal'> >
<small><small><small>learn about rewards</small></small></small> <h3 className='text-center'>
</Link> <div>
</h4> <RewardLine total={total} time={time} />
<div className='my-3 w-100'> </div>
<GrowthPieChart data={sources} /> <Link href='/faq#how-do-i-earn-sats-on-stacker-news' className='text-info fw-normal'>
</div> <small><small><small>learn about rewards</small></small></small>
<DonateButton /> </Link>
</CenterLayout> </h3>
<div className='my-3 w-100'>
<GrowthPieChart data={sources} />
</div>
<DonateButton />
</div>
</Col>
{leaderboard?.users &&
<Col lg={7}>
<h2 className='pt-5 text-center'>leaderboard</h2>
<div className='d-flex justify-content-center pt-4'>
<ListUsers users={leaderboard.users} rank Embellish={EstimatedReward} />
</div>
</Col>}
</Row>
</Layout>
) )
} }
@ -100,46 +156,48 @@ export function DonateButton () {
return ( return (
<> <>
<Button onClick={() => showModal(onClose => ( <Button
<Form onClick={() => showModal(onClose => (
initial={{ <Form
amount: 10000 initial={{
}} amount: 10000
schema={amountSchema} }}
invoiceable schema={amountSchema}
onSubmit={async ({ amount, hash, hmac }) => { invoiceable
const { error } = await donateToRewards({ onSubmit={async ({ amount, hash, hmac }) => {
variables: { const { error } = await donateToRewards({
sats: Number(amount), variables: {
hash, sats: Number(amount),
hmac hash,
hmac
}
})
if (error) {
console.error(error)
toaster.danger('failed to donate')
} else {
const didStrike = strike()
if (!didStrike) {
toaster.success('donated')
}
} }
}) onClose()
if (error) { }}
console.error(error) >
toaster.danger('failed to donate') <Input
} else { label='amount'
const didStrike = strike() name='amount'
if (!didStrike) { type='number'
toaster.success('donated') required
} autoFocus
} append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
onClose() />
}} <div className='d-flex'>
> <SubmitButton variant='success' className='ms-auto mt-1 px-4' value='TIP'>donate</SubmitButton>
<Input </div>
label='amount' </Form>
name='amount' ))}
type='number' className='align-self-center'
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div className='d-flex'>
<SubmitButton variant='success' className='ms-auto mt-1 px-4' value='TIP'>donate</SubmitButton>
</div>
</Form>
))}
>DONATE TO REWARDS >DONATE TO REWARDS
</Button> </Button>
</> </>

View File

@ -0,0 +1,110 @@
CREATE INDEX IF NOT EXISTS "ItemAct.created_at_hour_index"
ON "ItemAct"(date_trunc('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago'));
CREATE INDEX IF NOT EXISTS "Donation.created_at_day_index"
ON "Donation"(date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago'));
CREATE INDEX IF NOT EXISTS "Item.created_at_day_index"
ON "Item"(date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago'));
CREATE INDEX IF NOT EXISTS "Donation.created_at_hour_index"
ON "Donation"(date_trunc('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago'));
CREATE INDEX IF NOT EXISTS "Item.created_at_hour_index"
ON "Item"(date_trunc('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago'));
CREATE OR REPLACE FUNCTION user_values(
min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT,
percentile_cutoff INTEGER DEFAULT 33,
each_upvote_portion FLOAT DEFAULT 4.0,
each_item_portion FLOAT DEFAULT 4.0,
handicap_ids INTEGER[] DEFAULT '{616, 6030, 946, 4502}',
handicap_zap_mult FLOAT DEFAULT 0.2)
RETURNS TABLE (
t TIMESTAMP(3), id INTEGER, proportion FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
min_utc TIMESTAMP(3) := timezone('utc', min AT TIME ZONE 'America/Chicago');
BEGIN
RETURN QUERY
SELECT period.t, u."userId", u.total_proportion
FROM generate_series(min, max, ival) period(t),
LATERAL
(WITH item_ratios AS (
SELECT *,
CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type,
CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio
FROM (
SELECT *,
NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS percentile,
ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS rank
FROM
"Item"
WHERE date_trunc(date_part, created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
AND "weightedVotes" > 0 AND "deletedAt" IS NULL AND NOT bio
) x
WHERE x.percentile <= percentile_cutoff
),
-- get top upvoters of top posts and comments
upvoter_islands AS (
SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId",
"ItemAct".msats as tipped, "ItemAct".created_at as acted_at,
ROW_NUMBER() OVER (partition by item_ratios.id order by "ItemAct".created_at asc)
- ROW_NUMBER() OVER (partition by item_ratios.id, "ItemAct"."userId" order by "ItemAct".created_at asc) AS island
FROM item_ratios
JOIN "ItemAct" on "ItemAct"."itemId" = item_ratios.id
WHERE act = 'TIP' AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
),
-- isolate contiguous upzaps from the same user on the same item so that when we take the log
-- of the upzaps it accounts for successive zaps and does not disproportionately reward them
upvoters AS (
SELECT "userId", upvoter_islands.id, ratio, "parentId", GREATEST(log(sum(tipped) / 1000), 0) as tipped, min(acted_at) as acted_at
FROM upvoter_islands
GROUP BY "userId", upvoter_islands.id, ratio, "parentId", island
),
-- the relative contribution of each upvoter to the post/comment
-- early multiplier: 10/ln(early_rank + e)
-- we also weight by trust in a step wise fashion
upvoter_ratios AS (
SELECT "userId", sum(early_multiplier*tipped_ratio*ratio*CASE WHEN users.id = ANY (handicap_ids) THEN handicap_zap_mult ELSE FLOOR(users.trust*3)+handicap_zap_mult END) as upvoter_ratio,
"parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type
FROM (
SELECT *,
10.0/LN(ROW_NUMBER() OVER (partition by upvoters.id order by acted_at asc) + EXP(1.0)) AS early_multiplier,
tipped::float/(sum(tipped) OVER (partition by upvoters.id)) tipped_ratio
FROM upvoters
WHERE tipped > 0
) u
JOIN users on "userId" = users.id
GROUP BY "userId", "parentId" IS NULL
),
proportions AS (
SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank,
upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/each_upvote_portion as proportion
FROM upvoter_ratios
WHERE upvoter_ratio > 0
UNION ALL
SELECT "userId", item_ratios.id, type, rank, ratio/each_item_portion as proportion
FROM item_ratios
)
SELECT "userId", sum(proportions.proportion) AS total_proportion
FROM proportions
GROUP BY "userId"
HAVING sum(proportions.proportion) > 0.000001) u;
END;
$$;
CREATE OR REPLACE VIEW today AS
SELECT date_trunc('day', timezone('America/Chicago', now())) as min,
date_trunc('day', timezone('America/Chicago', now())) as max;
DROP MATERIALIZED VIEW IF EXISTS user_values_today;
CREATE MATERIALIZED VIEW IF NOT EXISTS user_values_today AS
SELECT (user_values(min, max, '1 day'::INTERVAL, 'day')).* FROM today;
CREATE UNIQUE INDEX IF NOT EXISTS user_values_today_idx ON user_values_today(id);
CREATE INDEX IF NOT EXISTS user_values_today_proportion_idx ON user_values_today(proportion DESC);
DROP MATERIALIZED VIEW IF EXISTS user_values_days;
CREATE MATERIALIZED VIEW IF NOT EXISTS user_values_days AS
SELECT (user_values(min, max, '1 day'::INTERVAL, 'day')).* FROM all_days;
CREATE UNIQUE INDEX IF NOT EXISTS user_values_days_idx ON user_values_days(t, id);

View File

@ -0,0 +1,16 @@
CREATE OR REPLACE FUNCTION schedule_territory_revenue()
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
INSERT INTO pgboss.schedule (name, cron, timezone)
VALUES ('territoryRevenue', '0 0 * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
return 0;
EXCEPTION WHEN OTHERS THEN
return 0;
END;
$$;
SELECT schedule_territory_revenue();
DROP FUNCTION IF EXISTS create_territory_billing_job;

View File

@ -0,0 +1,73 @@
CREATE OR REPLACE FUNCTION rewards(min TIMESTAMP(3), max TIMESTAMP(3), ival INTERVAL, date_part TEXT)
RETURNS TABLE (
t TIMESTAMP(3), total BIGINT, donations BIGINT, fees BIGINT, boost BIGINT, jobs BIGINT, anons_stack BIGINT
)
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
RETURN QUERY
SELECT period.t,
coalesce(FLOOR(sum(msats)), 0)::BIGINT as total,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'DONATION')), 0)::BIGINT as donations,
coalesce(FLOOR(sum(msats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION', 'ANON'))), 0)::BIGINT as fees,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'BOOST')), 0)::BIGINT as boost,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'STREAM')), 0)::BIGINT as jobs,
coalesce(FLOOR(sum(msats) FILTER(WHERE type = 'ANON')), 0)::BIGINT as anons_stack
FROM generate_series(min, max, ival) period(t),
LATERAL
(
(SELECT
("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) * COALESCE("Sub"."rewardsPct", 100) * 0.01 as msats,
act::text as type
FROM "ItemAct"
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id
WHERE date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
AND "ItemAct".act <> 'TIP')
UNION ALL
(SELECT sats * 1000 as msats, 'DONATION' as type
FROM "Donation"
WHERE date_trunc(date_part, "Donation".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t)
UNION ALL
-- any earnings from anon's stack that are not forwarded to other users
(SELECT "ItemAct".msats, 'ANON' as type
FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
WHERE "Item"."userId" = 27 AND "ItemAct".act = 'TIP'
AND date_trunc(date_part, "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = period.t
GROUP BY "ItemAct".id, "ItemAct".msats
HAVING COUNT("ItemForward".id) = 0)
) x
GROUP BY period.t;
END;
$$;
DROP MATERIALIZED VIEW IF EXISTS rewards_today;
CREATE MATERIALIZED VIEW IF NOT EXISTS rewards_today AS
SELECT (rewards(min, max, '1 day'::INTERVAL, 'day')).* FROM today;
DROP MATERIALIZED VIEW IF EXISTS rewards_days;
CREATE MATERIALIZED VIEW IF NOT EXISTS rewards_days AS
SELECT (rewards(min, max, '1 day'::INTERVAL, 'day')).* FROM all_days;
CREATE UNIQUE INDEX IF NOT EXISTS rewards_today_idx ON rewards_today(t);
CREATE UNIQUE INDEX IF NOT EXISTS rewards_days_idx ON rewards_days(t);
CREATE OR REPLACE FUNCTION reschedule_earn_job()
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
UPDATE pgboss.schedule set cron = '10 0 1 * *' WHERE name = 'earn';
return 0;
EXCEPTION WHEN OTHERS THEN
return 0;
END;
$$;
SELECT reschedule_earn_job();
DROP FUNCTION IF EXISTS reschedule_earn_job;

View File

@ -1,49 +1,22 @@
import serialize from '../api/resolvers/serial.js' import serialize from '../api/resolvers/serial.js'
import { sendUserNotification } from '../api/webPush/index.js' import { sendUserNotification } from '../api/webPush/index.js'
import { ANON_USER_ID, SN_USER_IDS } from '../lib/constants.js'
import { msatsToSats, numWithUnits } from '../lib/format.js' import { msatsToSats, numWithUnits } from '../lib/format.js'
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { proportions } from '../lib/madness.js'
import { SN_USER_IDS } from '../lib/constants.js'
const ITEM_EACH_REWARD = 4.0 const TOTAL_UPPER_BOUND_MSATS = 10000000000
const UPVOTE_EACH_REWARD = 4.0
const TOP_PERCENTILE = 33
const TOTAL_UPPER_BOUND_MSATS = 1000000000
export async function earn ({ name }) { export async function earn ({ name }) {
// rewards are calculated sitewide still
// however for user gen subs currently only 50% of their fees go to rewards
// the other 50% goes to the founder of the sub
// grab a greedy connection // grab a greedy connection
const models = new PrismaClient() const models = new PrismaClient()
try { try {
// compute how much sn earned today // compute how much sn earned got the month
const [{ sum: sumDecimal }] = await models.$queryRaw` const [{ sum: sumDecimal }] = await models.$queryRaw`
SELECT coalesce(sum(msats), 0) as sum SELECT coalesce(sum(total), 0) as sum
FROM ( FROM rewards_days
(SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) * COALESCE("Sub"."rewardsPct", 100) * 0.01 as msats WHERE date_trunc('month', rewards_days.t) = date_trunc('month', (now() - interval '1 month') AT TIME ZONE 'America/Chicago')`
FROM "ItemAct"
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id
WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago')
AND "ItemAct".act <> 'TIP')
UNION ALL
(SELECT sats * 1000 as msats
FROM "Donation"
WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago'))
UNION ALL
-- any earnings from anon's stack that are not forwarded to other users
(SELECT "ItemAct".msats
FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP'
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago')
GROUP BY "ItemAct".id, "ItemAct".msats
HAVING COUNT("ItemForward".id) = 0)
) subquery`
// XXX primsa will return a Decimal (https://mikemcl.github.io/decimal.js) // XXX primsa will return a Decimal (https://mikemcl.github.io/decimal.js)
// because sum of a BIGINT returns a NUMERIC type (https://www.postgresql.org/docs/13/functions-aggregate.html) // because sum of a BIGINT returns a NUMERIC type (https://www.postgresql.org/docs/13/functions-aggregate.html)
@ -75,70 +48,18 @@ export async function earn ({ name }) {
- how early they upvoted it - how early they upvoted it
- how the post/comment scored - how the post/comment scored
Now: 100% of earnings go to top 33% of comments/posts and their upvoters Now: 100% of earnings go to top 33% of comments/posts and their upvoters for month
*/ */
// get earners { userId, id, type, rank, proportion } // get earners { userId, id, type, rank, proportion }
const earners = await models.$queryRaw` const earners = await models.$queryRaw`
-- get top 33% of posts and comments SELECT id AS "userId", sum(proportion) as proportion
WITH item_ratios AS ( FROM user_values_days
SELECT *, WHERE date_trunc('month', user_values_days.t) = date_trunc('month', (now() - interval '1 month') AT TIME ZONE 'America/Chicago')
CASE WHEN "parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type, AND NOT (id = ANY (${SN_USER_IDS}))
CASE WHEN "weightedVotes" > 0 THEN "weightedVotes"/(sum("weightedVotes") OVER (PARTITION BY "parentId" IS NULL)) ELSE 0 END AS ratio GROUP BY id
FROM ( ORDER BY proportion DESC
SELECT *, LIMIT 64`
NTILE(100) OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS percentile,
ROW_NUMBER() OVER (PARTITION BY "parentId" IS NULL ORDER BY ("weightedVotes"-"weightedDownVotes") desc) AS rank
FROM
"Item"
WHERE created_at >= now_utc() - interval '36 hours'
AND "weightedVotes" > 0 AND "deletedAt" IS NULL AND NOT bio
) x
WHERE x.percentile <= ${TOP_PERCENTILE}
),
-- get top upvoters of top posts and comments
upvoter_islands AS (
SELECT "ItemAct"."userId", item_ratios.id, item_ratios.ratio, item_ratios."parentId",
"ItemAct".msats as tipped, "ItemAct".created_at as acted_at,
ROW_NUMBER() OVER (partition by item_ratios.id order by "ItemAct".created_at asc)
- ROW_NUMBER() OVER (partition by item_ratios.id, "ItemAct"."userId" order by "ItemAct".created_at asc) AS island
FROM item_ratios
JOIN "ItemAct" on "ItemAct"."itemId" = item_ratios.id
WHERE act = 'TIP'
),
-- isolate contiguous upzaps from the same user on the same item so that when we take the log
-- of the upzaps it accounts for successive zaps and does not disproporionately reward them
upvoters AS (
SELECT "userId", id, ratio, "parentId", GREATEST(log(sum(tipped) / 1000), 0) as tipped, min(acted_at) as acted_at
FROM upvoter_islands
GROUP BY "userId", id, ratio, "parentId", island
),
-- the relative contribution of each upvoter to the post/comment
-- early multiplier: 10/ln(early_rank + e)
-- we also weight by trust in a step wise fashion
upvoter_ratios AS (
SELECT "userId", sum(early_multiplier*tipped_ratio*ratio*CASE WHEN users.id = ANY (${SN_USER_IDS}) THEN 0.2 ELSE CEIL(users.trust*2)+1 END) as upvoter_ratio,
"parentId" IS NULL as "isPost", CASE WHEN "parentId" IS NULL THEN 'TIP_POST' ELSE 'TIP_COMMENT' END as type
FROM (
SELECT *,
10.0/LN(ROW_NUMBER() OVER (partition by id order by acted_at asc) + EXP(1.0)) AS early_multiplier,
tipped::float/(sum(tipped) OVER (partition by id)) tipped_ratio
FROM upvoters
) u
JOIN users on "userId" = users.id
GROUP BY "userId", "parentId" IS NULL
),
proportions AS (
SELECT "userId", NULL as id, type, ROW_NUMBER() OVER (PARTITION BY "isPost" ORDER BY upvoter_ratio DESC) as rank,
upvoter_ratio/(sum(upvoter_ratio) OVER (PARTITION BY "isPost"))/${UPVOTE_EACH_REWARD} as proportion
FROM upvoter_ratios
WHERE upvoter_ratio > 0
UNION ALL
SELECT "userId", id, type, rank, ratio/${ITEM_EACH_REWARD} as proportion
FROM item_ratios)
SELECT "userId", id, type, rank, proportion
FROM proportions
WHERE proportion > 0.000001`
// in order to group earnings for users we use the same createdAt time for // in order to group earnings for users we use the same createdAt time for
// all earnings // all earnings
@ -148,8 +69,8 @@ export async function earn ({ name }) {
let total = 0 let total = 0
const notifications = {} const notifications = {}
for (const earner of earners) { for (const [i, earner] of earners.entries()) {
const earnings = Math.floor(parseFloat(earner.proportion) * sum) const earnings = Math.floor(parseFloat(proportions[i] * sum))
total += earnings total += earnings
if (total > sum) { if (total > sum) {
console.log(name, 'total exceeds sum', total, '>', sum) console.log(name, 'total exceeds sum', total, '>', sum)
@ -185,8 +106,6 @@ export async function earn ({ name }) {
} }
} }
await territoryRevenue({ models })
Promise.allSettled(Object.entries(notifications).map(([userId, earnings]) => Promise.allSettled(Object.entries(notifications).map(([userId, earnings]) =>
sendUserNotification(parseInt(userId, 10), buildUserNotification(earnings)) sendUserNotification(parseInt(userId, 10), buildUserNotification(earnings))
)).catch(console.error) )).catch(console.error)
@ -195,38 +114,6 @@ export async function earn ({ name }) {
} }
} }
async function territoryRevenue ({ models }) {
await serialize(models,
models.$executeRaw`
WITH revenue AS (
SELECT coalesce(sum(msats), 0) as revenue, "subName", "userId"
FROM (
SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) * (1 - (COALESCE("Sub"."rewardsPct", 100) * 0.01)) as msats,
"Sub"."name" as "subName", "Sub"."userId" as "userId"
FROM "ItemAct"
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId"
LEFT JOIN "Item" root ON "Item"."rootId" = root.id
JOIN "Sub" ON "Sub"."name" = COALESCE(root."subName", "Item"."subName")
LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id
WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago')
AND "ItemAct".act <> 'TIP'
AND "Sub".status <> 'STOPPED'
) subquery
GROUP BY "subName", "userId"
),
"SubActResult" AS (
INSERT INTO "SubAct" (msats, "subName", "userId", type)
SELECT revenue, "subName", "userId", 'REVENUE'
FROM revenue
WHERE revenue > 1000
RETURNING *
)
UPDATE users SET msats = users.msats + "SubActResult".msats
FROM "SubActResult"
WHERE users.id = "SubActResult"."userId"`
)
}
function buildUserNotification (earnings) { function buildUserNotification (earnings) {
const fmt = msats => numWithUnits(msatsToSats(msats, { abbreviate: false })) const fmt = msats => numWithUnits(msatsToSats(msats, { abbreviate: false }))

View File

@ -20,7 +20,7 @@ import { views, rankViews } from './views.js'
import { imgproxy } from './imgproxy.js' import { imgproxy } from './imgproxy.js'
import { deleteItem } from './ephemeralItems.js' import { deleteItem } from './ephemeralItems.js'
import { deleteUnusedImages } from './deleteUnusedImages.js' import { deleteUnusedImages } from './deleteUnusedImages.js'
import { territoryBilling } from './territory.js' import { territoryBilling, territoryRevenue } from './territory.js'
import { ofac } from './ofac.js' import { ofac } from './ofac.js'
import { autoWithdraw } from './autowithdraw.js' import { autoWithdraw } from './autowithdraw.js'
@ -98,6 +98,7 @@ async function work () {
await boss.work('deleteItem', jobWrapper(deleteItem)) await boss.work('deleteItem', jobWrapper(deleteItem))
await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages)) await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages))
await boss.work('territoryBilling', jobWrapper(territoryBilling)) await boss.work('territoryBilling', jobWrapper(territoryBilling))
await boss.work('territoryRevenue', jobWrapper(territoryRevenue))
await boss.work('ofac', jobWrapper(ofac)) await boss.work('ofac', jobWrapper(ofac))
console.log('working jobs') console.log('working jobs')

View File

@ -40,3 +40,35 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
await territoryStatusUpdate() await territoryStatusUpdate()
} }
} }
export async function territoryRevenue ({ models }) {
await serialize(models,
models.$executeRaw`
WITH revenue AS (
SELECT coalesce(sum(msats), 0) as revenue, "subName", "userId"
FROM (
SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) * (1 - (COALESCE("Sub"."rewardsPct", 100) * 0.01)) as msats,
"Sub"."name" as "subName", "Sub"."userId" as "userId"
FROM "ItemAct"
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId"
LEFT JOIN "Item" root ON "Item"."rootId" = root.id
JOIN "Sub" ON "Sub"."name" = COALESCE(root."subName", "Item"."subName")
LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id
WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago')
AND "ItemAct".act <> 'TIP'
AND "Sub".status <> 'STOPPED'
) subquery
GROUP BY "subName", "userId"
),
"SubActResult" AS (
INSERT INTO "SubAct" (msats, "subName", "userId", type)
SELECT revenue, "subName", "userId", 'REVENUE'
FROM revenue
WHERE revenue > 1000
RETURNING *
)
UPDATE users SET msats = users.msats + "SubActResult".msats
FROM "SubActResult"
WHERE users.id = "SubActResult"."userId"`
)
}

View File

@ -150,7 +150,7 @@ async function getGraph (models) {
confidence(before - disagree, b_total - after, ${Z_CONFIDENCE}) confidence(before - disagree, b_total - after, ${Z_CONFIDENCE})
ELSE 0 END AS trust ELSE 0 END AS trust
FROM user_pair FROM user_pair
WHERE b_id <> ANY (${SN_USER_IDS}) WHERE NOT (b_id = ANY (${SN_USER_IDS}))
UNION ALL UNION ALL
SELECT a_id AS id, seed_id AS oid, ${MAX_TRUST}::numeric as trust SELECT a_id AS id, seed_id AS oid, ${MAX_TRUST}::numeric as trust
FROM user_pair, unnest(${SN_USER_IDS}::int[]) seed_id FROM user_pair, unnest(${SN_USER_IDS}::int[]) seed_id

View File

@ -9,6 +9,15 @@ export async function views ({ data: { period } = { period: 'days' } }) {
const models = new PrismaClient() const models = new PrismaClient()
try { try {
// these views are bespoke so we can't use the loop
if (period === 'days') {
await models.$queryRawUnsafe('REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_days')
await models.$queryRawUnsafe('REFRESH MATERIALIZED VIEW CONCURRENTLY rewards_days')
}
if (period === 'hours') {
await models.$queryRawUnsafe('REFRESH MATERIALIZED VIEW CONCURRENTLY user_values_today')
await models.$queryRawUnsafe('REFRESH MATERIALIZED VIEW CONCURRENTLY rewards_today')
}
for (const view of viewPrefixes) { for (const view of viewPrefixes) {
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}_${period}`) await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}_${period}`)
} }