territories
This commit is contained in:
parent
82a1b450f5
commit
10203f565c
@ -81,7 +81,8 @@ export default {
|
|||||||
json_build_object('name', 'boost', 'value', floor(avg(boost))),
|
json_build_object('name', 'boost', 'value', floor(avg(boost))),
|
||||||
json_build_object('name', 'fees', 'value', floor(avg(fees))),
|
json_build_object('name', 'fees', 'value', floor(avg(fees))),
|
||||||
json_build_object('name', 'zaps', 'value', floor(avg(tips))),
|
json_build_object('name', 'zaps', 'value', floor(avg(tips))),
|
||||||
json_build_object('name', 'donation', 'value', floor(avg(donations)))
|
json_build_object('name', 'donation', 'value', floor(avg(donations))),
|
||||||
|
json_build_object('name', 'territories', 'value', floor(avg(territories)))
|
||||||
) AS data
|
) AS data
|
||||||
FROM spender_growth_days
|
FROM spender_growth_days
|
||||||
WHERE ${viewIntervalClause(range, 'spender_growth_days')}
|
WHERE ${viewIntervalClause(range, 'spender_growth_days')}
|
||||||
@ -97,7 +98,8 @@ export default {
|
|||||||
json_build_object('name', 'boost', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'BOOST')),
|
json_build_object('name', 'boost', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'BOOST')),
|
||||||
json_build_object('name', 'fees', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'FEE')),
|
json_build_object('name', 'fees', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'FEE')),
|
||||||
json_build_object('name', 'zaps', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP')),
|
json_build_object('name', 'zaps', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP')),
|
||||||
json_build_object('name', 'donation', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'DONATION'))
|
json_build_object('name', 'donation', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'DONATION')),
|
||||||
|
json_build_object('name', 'territories', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TERRITORY'))
|
||||||
) AS data
|
) AS data
|
||||||
FROM times
|
FROM times
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
@ -107,7 +109,12 @@ export default {
|
|||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT created_at, "userId", 'DONATION' as act
|
(SELECT created_at, "userId", 'DONATION' as act
|
||||||
FROM "Donation"
|
FROM "Donation"
|
||||||
WHERE ${intervalClause(range, 'Donation')})) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
|
WHERE ${intervalClause(range, 'Donation')})
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, "userId", 'TERRITORY' as act
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE type = 'BILLING' AND ${intervalClause(range, 'SubAct')})
|
||||||
|
) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
|
||||||
GROUP BY time
|
GROUP BY time
|
||||||
ORDER BY time ASC`, ...range)
|
ORDER BY time ASC`, ...range)
|
||||||
},
|
},
|
||||||
@ -151,7 +158,8 @@ export default {
|
|||||||
json_build_object('name', 'boost', 'value', sum(boost)),
|
json_build_object('name', 'boost', 'value', sum(boost)),
|
||||||
json_build_object('name', 'fees', 'value', sum(fees)),
|
json_build_object('name', 'fees', 'value', sum(fees)),
|
||||||
json_build_object('name', 'zaps', 'value', sum(tips)),
|
json_build_object('name', 'zaps', 'value', sum(tips)),
|
||||||
json_build_object('name', 'donations', 'value', sum(donations))
|
json_build_object('name', 'donations', 'value', sum(donations)),
|
||||||
|
json_build_object('name', 'territories', 'value', sum(territories))
|
||||||
) AS data
|
) AS data
|
||||||
FROM spending_growth_days
|
FROM spending_growth_days
|
||||||
WHERE ${viewIntervalClause(range, 'spending_growth_days')}
|
WHERE ${viewIntervalClause(range, 'spending_growth_days')}
|
||||||
@ -164,9 +172,10 @@ export default {
|
|||||||
SELECT time, json_build_array(
|
SELECT time, json_build_array(
|
||||||
json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN msats ELSE 0 END)/1000),0)),
|
json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN msats ELSE 0 END)/1000),0)),
|
||||||
json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN msats ELSE 0 END)/1000),0)),
|
json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN msats ELSE 0 END)/1000),0)),
|
||||||
json_build_object('name', 'fees', 'value', coalesce(floor(sum(CASE WHEN act NOT IN ('BOOST', 'TIP', 'STREAM', 'DONATION') THEN msats ELSE 0 END)/1000),0)),
|
json_build_object('name', 'fees', 'value', coalesce(floor(sum(CASE WHEN act NOT IN ('BOOST', 'TIP', 'STREAM', 'DONATION', 'REVENUE') THEN msats ELSE 0 END)/1000),0)),
|
||||||
json_build_object('name', 'zaps', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN msats ELSE 0 END)/1000),0)),
|
json_build_object('name', 'zaps', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN msats ELSE 0 END)/1000),0)),
|
||||||
json_build_object('name', 'donations', 'value', coalesce(floor(sum(CASE WHEN act = 'DONATION' THEN msats ELSE 0 END)/1000),0))
|
json_build_object('name', 'donations', 'value', coalesce(floor(sum(CASE WHEN act = 'DONATION' THEN msats ELSE 0 END)/1000),0)),
|
||||||
|
json_build_object('name', 'territories', 'value', coalesce(floor(sum(CASE WHEN act = 'REVENUE' THEN msats ELSE 0 END)/1000),0))
|
||||||
) AS data
|
) AS data
|
||||||
FROM times
|
FROM times
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
@ -176,7 +185,12 @@ export default {
|
|||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT created_at, sats * 1000 as msats, 'DONATION' as act
|
(SELECT created_at, sats * 1000 as msats, 'DONATION' as act
|
||||||
FROM "Donation"
|
FROM "Donation"
|
||||||
WHERE ${intervalClause(range, 'Donation')})) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
|
WHERE ${intervalClause(range, 'Donation')})
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, msats, 'REVENUE' as act
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE type = 'BILLING' AND ${intervalClause(range, 'SubAct')})
|
||||||
|
) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
|
||||||
GROUP BY time
|
GROUP BY time
|
||||||
ORDER BY time ASC`, ...range)
|
ORDER BY time ASC`, ...range)
|
||||||
},
|
},
|
||||||
@ -190,7 +204,8 @@ export default {
|
|||||||
json_build_object('name', 'posts', 'value', floor(avg(posts))),
|
json_build_object('name', 'posts', 'value', floor(avg(posts))),
|
||||||
json_build_object('name', 'comments', 'value', floor(floor(avg(comments)))),
|
json_build_object('name', 'comments', 'value', floor(floor(avg(comments)))),
|
||||||
json_build_object('name', 'rewards', 'value', floor(avg(rewards))),
|
json_build_object('name', 'rewards', 'value', floor(avg(rewards))),
|
||||||
json_build_object('name', 'referrals', 'value', floor(avg(referrals)))
|
json_build_object('name', 'referrals', 'value', floor(avg(referrals))),
|
||||||
|
json_build_object('name', 'territories', 'value', floor(avg(territories)))
|
||||||
) AS data
|
) AS data
|
||||||
FROM stackers_growth_days
|
FROM stackers_growth_days
|
||||||
WHERE ${viewIntervalClause(range, 'stackers_growth_days')}
|
WHERE ${viewIntervalClause(range, 'stackers_growth_days')}
|
||||||
@ -205,7 +220,8 @@ export default {
|
|||||||
json_build_object('name', 'posts', 'value', count(distinct user_id) FILTER (WHERE type = 'POST')),
|
json_build_object('name', 'posts', 'value', count(distinct user_id) FILTER (WHERE type = 'POST')),
|
||||||
json_build_object('name', 'comments', 'value', count(distinct user_id) FILTER (WHERE type = 'COMMENT')),
|
json_build_object('name', 'comments', 'value', count(distinct user_id) FILTER (WHERE type = 'COMMENT')),
|
||||||
json_build_object('name', 'rewards', 'value', count(distinct user_id) FILTER (WHERE type = 'EARN')),
|
json_build_object('name', 'rewards', 'value', count(distinct user_id) FILTER (WHERE type = 'EARN')),
|
||||||
json_build_object('name', 'referrals', 'value', count(distinct user_id) FILTER (WHERE type = 'REFERRAL'))
|
json_build_object('name', 'referrals', 'value', count(distinct user_id) FILTER (WHERE type = 'REFERRAL')),
|
||||||
|
json_build_object('name', 'territories', 'value', count(distinct user_id) FILTER (WHERE type = 'REVENUE'))
|
||||||
) AS data
|
) AS data
|
||||||
FROM times
|
FROM times
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
@ -217,6 +233,10 @@ export default {
|
|||||||
(SELECT created_at, "userId" as user_id, 'EARN' as type
|
(SELECT created_at, "userId" as user_id, 'EARN' as type
|
||||||
FROM "Earn"
|
FROM "Earn"
|
||||||
WHERE ${intervalClause(range, 'Earn')})
|
WHERE ${intervalClause(range, 'Earn')})
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, "userId" as user_id, 'REVENUE' as type
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE type = 'REVENUE' AND ${intervalClause(range, 'SubAct')})
|
||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type
|
(SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type
|
||||||
FROM "ReferralAct"
|
FROM "ReferralAct"
|
||||||
@ -233,7 +253,8 @@ export default {
|
|||||||
json_build_object('name', 'rewards', 'value', sum(rewards)),
|
json_build_object('name', 'rewards', 'value', sum(rewards)),
|
||||||
json_build_object('name', 'posts', 'value', sum(posts)),
|
json_build_object('name', 'posts', 'value', sum(posts)),
|
||||||
json_build_object('name', 'comments', 'value', sum(comments)),
|
json_build_object('name', 'comments', 'value', sum(comments)),
|
||||||
json_build_object('name', 'referrals', 'value', sum(referrals))
|
json_build_object('name', 'referrals', 'value', sum(referrals)),
|
||||||
|
json_build_object('name', 'territories', 'value', sum(territories))
|
||||||
) AS data
|
) AS data
|
||||||
FROM stacking_growth_days
|
FROM stacking_growth_days
|
||||||
WHERE ${viewIntervalClause(range, 'stacking_growth_days')}
|
WHERE ${viewIntervalClause(range, 'stacking_growth_days')}
|
||||||
@ -247,23 +268,28 @@ export default {
|
|||||||
json_build_object('name', 'rewards', 'value', coalesce(floor(sum(airdrop)/1000),0)),
|
json_build_object('name', 'rewards', 'value', coalesce(floor(sum(airdrop)/1000),0)),
|
||||||
json_build_object('name', 'posts', 'value', coalesce(floor(sum(post)/1000),0)),
|
json_build_object('name', 'posts', 'value', coalesce(floor(sum(post)/1000),0)),
|
||||||
json_build_object('name', 'comments', 'value', coalesce(floor(sum(comment)/1000),0)),
|
json_build_object('name', 'comments', 'value', coalesce(floor(sum(comment)/1000),0)),
|
||||||
json_build_object('name', 'referrals', 'value', coalesce(floor(sum(referral)/1000),0))
|
json_build_object('name', 'referrals', 'value', coalesce(floor(sum(referral)/1000),0)),
|
||||||
|
json_build_object('name', 'territories', 'value', coalesce(floor(sum(revenue)/1000),0))
|
||||||
) AS data
|
) AS data
|
||||||
FROM times
|
FROM times
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
((SELECT "ItemAct".created_at, 0 as airdrop,
|
((SELECT "ItemAct".created_at, 0 as airdrop,
|
||||||
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".msats END as comment,
|
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".msats END as comment,
|
||||||
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post,
|
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post,
|
||||||
0 as referral
|
0 as referral, 0 as revenue
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
WHERE ${intervalClause(range, 'ItemAct')} AND "ItemAct".act = 'TIP')
|
WHERE ${intervalClause(range, 'ItemAct')} AND "ItemAct".act = 'TIP')
|
||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral
|
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral, 0 as revenue
|
||||||
FROM "ReferralAct"
|
FROM "ReferralAct"
|
||||||
WHERE ${intervalClause(range, 'ReferralAct')})
|
WHERE ${intervalClause(range, 'ReferralAct')})
|
||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral
|
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, 0 as referral, msats as revenue
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE type = 'REVENUE' AND ${intervalClause(range, 'SubAct')})
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral, 0 as revenue
|
||||||
FROM "Earn"
|
FROM "Earn"
|
||||||
WHERE ${intervalClause(range, 'Earn')})) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
|
WHERE ${intervalClause(range, 'Earn')})) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
|
||||||
GROUP BY time
|
GROUP BY time
|
||||||
|
@ -466,23 +466,21 @@ export default {
|
|||||||
orderBy: 'ORDER BY rank DESC'
|
orderBy: 'ORDER BY rank DESC'
|
||||||
}, decodedCursor.offset, limit, ...subArr)
|
}, decodedCursor.offset, limit, ...subArr)
|
||||||
|
|
||||||
// XXX this is just for migration purposes ... can remove after initial deployment
|
// XXX this is just for subs that are really empty
|
||||||
// and views have been populated
|
if (decodedCursor.offset === 0 && items.length < limit) {
|
||||||
if (items.length === 0) {
|
|
||||||
items = await itemQueryWithMeta({
|
items = await itemQueryWithMeta({
|
||||||
me,
|
me,
|
||||||
models,
|
models,
|
||||||
query: `
|
query: `
|
||||||
${SELECT}, rank
|
${SELECT}
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
JOIN zap_rank_tender_view ON "Item".id = zap_rank_tender_view.id
|
|
||||||
${whereClause(
|
${whereClause(
|
||||||
subClause(sub, 3, 'Item', true),
|
subClause(sub, 3, 'Item', true),
|
||||||
muteClause(me))}
|
muteClause(me))}
|
||||||
ORDER BY rank ASC
|
ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC
|
||||||
OFFSET $1
|
OFFSET $1
|
||||||
LIMIT $2`,
|
LIMIT $2`,
|
||||||
orderBy: 'ORDER BY rank ASC'
|
orderBy: `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
|
||||||
}, decodedCursor.offset, limit, ...subArr)
|
}, decodedCursor.offset, limit, ...subArr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -229,6 +229,15 @@ export default {
|
|||||||
AND created_at <= $2
|
AND created_at <= $2
|
||||||
GROUP BY "userId", created_at`
|
GROUP BY "userId", created_at`
|
||||||
)
|
)
|
||||||
|
queries.push(
|
||||||
|
`SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats",
|
||||||
|
'Revenue' AS type
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE "userId" = $1
|
||||||
|
AND type = 'REVENUE'
|
||||||
|
AND created_at <= $2
|
||||||
|
GROUP BY "userId", "subName", created_at`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meFull.noteCowboyHat) {
|
if (meFull.noteCowboyHat) {
|
||||||
@ -330,6 +339,17 @@ export default {
|
|||||||
JobChanged: {
|
JobChanged: {
|
||||||
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
||||||
},
|
},
|
||||||
|
Revenue: {
|
||||||
|
subName: async (n, args, { models }) => {
|
||||||
|
const subAct = await models.subAct.findUnique({
|
||||||
|
where: {
|
||||||
|
id: Number(n.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return subAct.subName
|
||||||
|
}
|
||||||
|
},
|
||||||
Streak: {
|
Streak: {
|
||||||
days: async (n, args, { models }) => {
|
days: async (n, args, { models }) => {
|
||||||
const res = await models.$queryRaw`
|
const res = await models.$queryRaw`
|
||||||
|
@ -15,7 +15,13 @@ export default async function serialize (models, ...calls) {
|
|||||||
return calls.length > 1 ? result : result[0]
|
return calls.length > 1 ? result : result[0]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
if (error.message.includes('SN_INSUFFICIENT_FUNDS')) {
|
// two cases where we get insufficient funds:
|
||||||
|
// 1. plpgsql function raises
|
||||||
|
// 2. constraint violation via a prisma call
|
||||||
|
// XXX prisma does not provide a way to distinguish these cases so we
|
||||||
|
// have to check the error message
|
||||||
|
if (error.message.includes('SN_INSUFFICIENT_FUNDS') ||
|
||||||
|
error.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
|
||||||
bail(new GraphQLError('insufficient funds', { extensions: { code: 'BAD_INPUT' } }))
|
bail(new GraphQLError('insufficient funds', { extensions: { code: 'BAD_INPUT' } }))
|
||||||
}
|
}
|
||||||
if (error.message.includes('SN_NOT_SERIALIZABLE')) {
|
if (error.message.includes('SN_NOT_SERIALIZABLE')) {
|
||||||
@ -65,24 +71,28 @@ export async function serializeInvoicable (query, { models, lnd, hash, hmac, me,
|
|||||||
throw new Error('you must be logged in or pay')
|
throw new Error('you must be logged in or pay')
|
||||||
}
|
}
|
||||||
|
|
||||||
let trx = [query]
|
let trx = Array.isArray(query) ? query : [query]
|
||||||
|
|
||||||
let invoice
|
let invoice
|
||||||
if (hash) {
|
if (hash) {
|
||||||
invoice = await checkInvoice(models, hash, hmac, enforceFee)
|
invoice = await checkInvoice(models, hash, hmac, enforceFee)
|
||||||
trx = [
|
trx = [
|
||||||
models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`,
|
models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`,
|
||||||
query,
|
...trx,
|
||||||
models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })
|
models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await serialize(models, ...trx)
|
let results = await serialize(models, ...trx)
|
||||||
const result = trx.length > 1 ? results[1][0] : results[0]
|
|
||||||
|
|
||||||
if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd })
|
if (hash) {
|
||||||
|
if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd })
|
||||||
|
results = results.slice(1, -1)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
// if there is only one result, return it directly, else the array
|
||||||
|
results = results.flat(2)
|
||||||
|
return results.length > 1 ? results : results[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkInvoice (models, hash, hmac, fee) {
|
export async function checkInvoice (models, hash, hmac, fee) {
|
||||||
|
@ -1,3 +1,65 @@
|
|||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
import serialize, { serializeInvoicable } from './serial'
|
||||||
|
import { TERRITORY_COST_MONTHLY, TERRITORY_COST_ONCE, TERRITORY_COST_YEARLY } from '../../lib/constants'
|
||||||
|
import { datePivot } from '../../lib/time'
|
||||||
|
import { ssValidate, territorySchema } from '../../lib/validate'
|
||||||
|
|
||||||
|
export function paySubQueries (sub, models) {
|
||||||
|
let billingAt = datePivot(sub.billedLastAt, { months: 1 })
|
||||||
|
let billAt = datePivot(sub.billedLastAt, { months: 2 })
|
||||||
|
if (sub.billingType === 'ONCE') {
|
||||||
|
return []
|
||||||
|
} else if (sub.billingType === 'YEARLY') {
|
||||||
|
billingAt = datePivot(sub.billedLastAt, { years: 1 })
|
||||||
|
billAt = datePivot(sub.billedLastAt, { years: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cost = BigInt(sub.billingCost) * BigInt(1000)
|
||||||
|
|
||||||
|
return [
|
||||||
|
models.user.update({
|
||||||
|
where: {
|
||||||
|
id: sub.userId
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
msats: {
|
||||||
|
decrement: cost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// update 'em
|
||||||
|
models.sub.update({
|
||||||
|
where: {
|
||||||
|
name: sub.name
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
billedLastAt: billingAt,
|
||||||
|
status: 'ACTIVE'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// record 'em
|
||||||
|
models.subAct.create({
|
||||||
|
data: {
|
||||||
|
userId: sub.userId,
|
||||||
|
subName: sub.name,
|
||||||
|
msats: cost,
|
||||||
|
type: 'BILLING'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
models.$executeRaw`
|
||||||
|
DELETE FROM pgboss.job
|
||||||
|
WHERE name = 'territoryBilling'
|
||||||
|
AND data->>'subName' = ${sub.name}
|
||||||
|
AND completedon IS NULL`,
|
||||||
|
// schedule 'em
|
||||||
|
models.$queryRaw`
|
||||||
|
INSERT INTO pgboss.job (name, data, startafter) VALUES ('territoryBilling',
|
||||||
|
${JSON.stringify({
|
||||||
|
subName: sub.name
|
||||||
|
})}::JSONB, ${billAt})`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
sub: async (parent, { name }, { models, me }) => {
|
sub: async (parent, { name }, { models, me }) => {
|
||||||
@ -20,6 +82,18 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
subs: async (parent, args, { models }) => {
|
||||||
|
return await models.sub.findMany({
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
not: 'STOPPED'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
subLatestPost: async (parent, { name }, { models, me }) => {
|
subLatestPost: async (parent, { name }, { models, me }) => {
|
||||||
const latest = await models.item.findFirst({
|
const latest = await models.item.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@ -32,5 +106,164 @@ export default {
|
|||||||
|
|
||||||
return latest?.createdAt
|
return latest?.createdAt
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
Mutation: {
|
||||||
|
upsertSub: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX this is because we did the wrong thing and used the subName as a primary key
|
||||||
|
const existing = await models.sub.findUnique({
|
||||||
|
where: {
|
||||||
|
name: data.name,
|
||||||
|
userId: me.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await ssValidate(territorySchema, data, { models, me })
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return await updateSub(parent, data, { me, models, lnd, hash, hmac })
|
||||||
|
} else {
|
||||||
|
return await createSub(parent, data, { me, models, lnd, hash, hmac })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
paySub: async (parent, { name, hash, hmac }, { me, models, lnd }) => {
|
||||||
|
// check that they own the sub
|
||||||
|
const sub = await models.sub.findUnique({
|
||||||
|
where: {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sub) {
|
||||||
|
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub.userId !== me.id) {
|
||||||
|
throw new GraphQLError('you do not own this sub', { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub.status === 'ACTIVE') {
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
const queries = paySubQueries(sub, models)
|
||||||
|
if (queries.length === 0) {
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await serializeInvoicable(
|
||||||
|
queries,
|
||||||
|
{ models, lnd, hash, hmac, me, enforceFee: sub.billingCost })
|
||||||
|
return results[1]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Sub: {
|
||||||
|
user: async (sub, args, { models }) => {
|
||||||
|
if (sub.user) {
|
||||||
|
return sub.user
|
||||||
|
}
|
||||||
|
return await models.user.findUnique({ where: { id: sub.userId } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
|
||||||
|
const { billingType } = data
|
||||||
|
let billingCost = TERRITORY_COST_MONTHLY
|
||||||
|
let billAt = datePivot(new Date(), { months: 1 })
|
||||||
|
|
||||||
|
if (billingType === 'ONCE') {
|
||||||
|
billingCost = TERRITORY_COST_ONCE
|
||||||
|
billAt = null
|
||||||
|
} else if (billingType === 'YEARLY') {
|
||||||
|
billingCost = TERRITORY_COST_YEARLY
|
||||||
|
billAt = datePivot(new Date(), { years: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cost = BigInt(1000) * BigInt(billingCost)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await serializeInvoicable([
|
||||||
|
// bill 'em
|
||||||
|
models.user.update({
|
||||||
|
where: {
|
||||||
|
id: me.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
msats: {
|
||||||
|
decrement: cost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// create 'em
|
||||||
|
models.sub.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
billingCost,
|
||||||
|
rankingType: 'WOT',
|
||||||
|
userId: me.id
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// record 'em
|
||||||
|
models.subAct.create({
|
||||||
|
data: {
|
||||||
|
userId: me.id,
|
||||||
|
subName: data.name,
|
||||||
|
msats: cost,
|
||||||
|
type: 'BILLING'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// schedule 'em
|
||||||
|
...(billAt
|
||||||
|
? [models.$queryRaw`
|
||||||
|
INSERT INTO pgboss.job (name, data, startafter) VALUES ('territoryBilling',
|
||||||
|
${JSON.stringify({
|
||||||
|
subName: data.name
|
||||||
|
})}::JSONB, ${billAt})`]
|
||||||
|
: [])
|
||||||
|
], { models, lnd, hash, hmac, me, enforceFee: billingCost })
|
||||||
|
|
||||||
|
return results[1]
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSub (parent, { name, ...data }, { me, models, lnd, hash, hmac }) {
|
||||||
|
const { billingType } = data
|
||||||
|
|
||||||
|
let billingCost = TERRITORY_COST_MONTHLY
|
||||||
|
if (billingType === 'ONCE') {
|
||||||
|
billingCost = TERRITORY_COST_ONCE
|
||||||
|
} else if (billingType === 'YEARLY') {
|
||||||
|
billingCost = TERRITORY_COST_YEARLY
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await serialize(models,
|
||||||
|
// update 'em
|
||||||
|
models.sub.update({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
billingCost,
|
||||||
|
billingType
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return results[0]
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -106,101 +106,108 @@ export default {
|
|||||||
|
|
||||||
if (include.has('invoice')) {
|
if (include.has('invoice')) {
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT ('invoice' || id) as id, id as "factId", bolt11, created_at as "createdAt",
|
`(SELECT
|
||||||
COALESCE("msatsReceived", "msatsRequested") as msats, NULL as "msatsFee",
|
id, created_at as "createdAt", COALESCE("msatsReceived", "msatsRequested") as msats,
|
||||||
CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED'
|
'invoice' as type,
|
||||||
WHEN "expiresAt" <= $2 THEN 'EXPIRED'
|
jsonb_build_object(
|
||||||
WHEN cancelled THEN 'CANCELLED'
|
'bolt11', bolt11,
|
||||||
ELSE 'PENDING' END as status,
|
'status', CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED'
|
||||||
"desc" as description,
|
WHEN "expiresAt" <= $2 THEN 'EXPIRED'
|
||||||
comment as "invoiceComment",
|
WHEN cancelled THEN 'CANCELLED'
|
||||||
"lud18Data" as "invoicePayerData",
|
ELSE 'PENDING' END,
|
||||||
'invoice' as type
|
'description', "desc",
|
||||||
FROM "Invoice"
|
'invoiceComment', comment,
|
||||||
WHERE "userId" = $1
|
'invoicePayerData', "lud18Data") as other
|
||||||
AND created_at <= $2)`)
|
FROM "Invoice"
|
||||||
|
WHERE "userId" = $1
|
||||||
|
AND created_at <= $2)`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (include.has('withdrawal')) {
|
if (include.has('withdrawal')) {
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT ('withdrawal' || id) as id, id as "factId", bolt11, created_at as "createdAt",
|
`(SELECT
|
||||||
CASE WHEN status = 'CONFIRMED' THEN "msatsPaid"
|
id, created_at as "createdAt",
|
||||||
ELSE "msatsPaying" END as msats,
|
COALESCE("msatsPaid", "msatsPaying") as msats,
|
||||||
CASE WHEN status = 'CONFIRMED' THEN "msatsFeePaid"
|
'withdrawal' as type,
|
||||||
ELSE "msatsFeePaying" END as "msatsFee",
|
jsonb_build_object(
|
||||||
COALESCE(status::text, 'PENDING') as status,
|
'status', COALESCE(status::text, 'PENDING'),
|
||||||
NULL as description,
|
'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other
|
||||||
NULL as "invoiceComment",
|
FROM "Withdrawl"
|
||||||
NULL as "invoicePayerData",
|
WHERE "userId" = $1
|
||||||
'withdrawal' as type
|
AND created_at <= $2)`
|
||||||
FROM "Withdrawl"
|
)
|
||||||
WHERE "userId" = $1
|
|
||||||
AND created_at <= $2)`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (include.has('stacked')) {
|
if (include.has('stacked')) {
|
||||||
// query1 - get all sats stacked as OP or as a forward
|
// query1 - get all sats stacked as OP or as a forward
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT
|
`(SELECT
|
||||||
('stacked' || "Item".id) AS id,
|
"Item".id,
|
||||||
"Item".id AS "factId",
|
MAX("ItemAct".created_at) AS "createdAt",
|
||||||
NULL AS bolt11,
|
FLOOR(
|
||||||
MAX("ItemAct".created_at) AS "createdAt",
|
SUM("ItemAct".msats)
|
||||||
FLOOR(
|
* (CASE WHEN "Item"."userId" = $1 THEN
|
||||||
SUM("ItemAct".msats)
|
COALESCE(1 - ((SELECT SUM(pct) FROM "ItemForward" WHERE "itemId" = "Item".id) / 100.0), 1)
|
||||||
* (CASE WHEN "Item"."userId" = $1 THEN
|
ELSE
|
||||||
COALESCE(1 - ((SELECT SUM(pct) FROM "ItemForward" WHERE "itemId" = "Item".id) / 100.0), 1)
|
(SELECT pct FROM "ItemForward" WHERE "itemId" = "Item".id AND "userId" = $1) / 100.0
|
||||||
ELSE
|
END)
|
||||||
(SELECT pct FROM "ItemForward" WHERE "itemId" = "Item".id AND "userId" = $1) / 100.0
|
) AS msats,
|
||||||
END)
|
'stacked' AS type, NULL::JSONB AS other
|
||||||
) AS "msats",
|
FROM "ItemAct"
|
||||||
0 AS "msatsFee",
|
JOIN "Item" ON "ItemAct"."itemId" = "Item".id
|
||||||
NULL AS status,
|
-- only join to with item forward for items where we aren't the OP
|
||||||
NULL as description,
|
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "Item"."userId" <> $1
|
||||||
NULL as "invoiceComment",
|
WHERE "ItemAct".act = 'TIP'
|
||||||
NULL as "invoicePayerData",
|
AND ("Item"."userId" = $1 OR "ItemForward"."userId" = $1)
|
||||||
'stacked' AS type
|
AND "ItemAct".created_at <= $2
|
||||||
FROM "ItemAct"
|
GROUP BY "Item".id)`
|
||||||
JOIN "Item" ON "ItemAct"."itemId" = "Item".id
|
|
||||||
-- only join to with item forward for items where we aren't the OP
|
|
||||||
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "Item"."userId" <> $1
|
|
||||||
WHERE "ItemAct".act = 'TIP'
|
|
||||||
AND ("Item"."userId" = $1 OR "ItemForward"."userId" = $1)
|
|
||||||
AND "ItemAct".created_at <= $2
|
|
||||||
GROUP BY "Item".id)`
|
|
||||||
)
|
)
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11,
|
`(SELECT
|
||||||
created_at as "createdAt", sum(msats),
|
min("Earn".id) as id, created_at as "createdAt",
|
||||||
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'earn' as type
|
sum(msats) as msats, 'earn' as type, NULL::JSONB AS other
|
||||||
FROM "Earn"
|
FROM "Earn"
|
||||||
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
|
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
|
||||||
GROUP BY "userId", created_at)`)
|
GROUP BY "userId", created_at)`
|
||||||
|
)
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11,
|
`(SELECT id, created_at as "createdAt", msats, 'referral' as type, NULL::JSONB AS other
|
||||||
created_at as "createdAt", msats,
|
|
||||||
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'referral' as type
|
|
||||||
FROM "ReferralAct"
|
FROM "ReferralAct"
|
||||||
WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`)
|
WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`
|
||||||
|
)
|
||||||
|
queries.push(
|
||||||
|
`(SELECT id, created_at as "createdAt", msats, 'revenue' as type,
|
||||||
|
jsonb_build_object('subName', "SubAct"."subName") as other
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE "userId" = $1 AND type = 'REVENUE'
|
||||||
|
AND created_at <= $2)`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (include.has('spent')) {
|
if (include.has('spent')) {
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT ('spent' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
|
`(SELECT "Item".id, MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats,
|
||||||
MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats,
|
'spent' as type, NULL::JSONB AS other
|
||||||
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'spent' as type
|
FROM "ItemAct"
|
||||||
FROM "ItemAct"
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
WHERE "ItemAct"."userId" = $1
|
||||||
WHERE "ItemAct"."userId" = $1
|
AND "ItemAct".created_at <= $2
|
||||||
AND "ItemAct".created_at <= $2
|
GROUP BY "Item".id)`
|
||||||
GROUP BY "Item".id)`)
|
)
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11,
|
`(SELECT id, created_at as "createdAt", sats * 1000 as msats,'donation' as type, NULL::JSONB AS other
|
||||||
created_at as "createdAt", sats * 1000 as msats,
|
|
||||||
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'donation' as type
|
|
||||||
FROM "Donation"
|
FROM "Donation"
|
||||||
WHERE "userId" = $1
|
WHERE "userId" = $1
|
||||||
AND created_at <= $2)`)
|
AND created_at <= $2)`
|
||||||
|
)
|
||||||
|
queries.push(
|
||||||
|
`(SELECT id, created_at as "createdAt", msats, 'billing' as type,
|
||||||
|
jsonb_build_object('subName', "SubAct"."subName") as other
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE "userId" = $1 AND type = 'BILLING'
|
||||||
|
AND created_at <= $2)`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queries.length === 0) {
|
if (queries.length === 0) {
|
||||||
@ -211,12 +218,15 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let history = await models.$queryRawUnsafe(`
|
let history = await models.$queryRawUnsafe(`
|
||||||
${queries.join(' UNION ALL ')}
|
${queries.join(' UNION ALL ')}
|
||||||
ORDER BY "createdAt" DESC
|
ORDER BY "createdAt" DESC
|
||||||
OFFSET $3
|
OFFSET $3
|
||||||
LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset)
|
LIMIT ${LIMIT}`,
|
||||||
|
me.id, decodedCursor.time, decodedCursor.offset)
|
||||||
|
|
||||||
history = history.map(f => {
|
history = history.map(f => {
|
||||||
|
f = { ...f, ...f.other }
|
||||||
|
|
||||||
if (f.bolt11) {
|
if (f.bolt11) {
|
||||||
const inv = lnpr.decode(f.bolt11)
|
const inv = lnpr.decode(f.bolt11)
|
||||||
if (inv) {
|
if (inv) {
|
||||||
@ -230,14 +240,14 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (f.type) {
|
switch (f.type) {
|
||||||
case 'withdrawal':
|
case 'withdrawal':
|
||||||
f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee)
|
f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee)
|
||||||
break
|
break
|
||||||
case 'spent':
|
case 'spent':
|
||||||
f.msats *= -1
|
|
||||||
break
|
|
||||||
case 'donation':
|
case 'donation':
|
||||||
|
case 'billing':
|
||||||
f.msats *= -1
|
f.msats *= -1
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@ -281,6 +291,8 @@ export default {
|
|||||||
expires_at: expiresAt
|
expires_at: expiresAt
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('invoice', balanceLimit)
|
||||||
|
|
||||||
const [inv] = await serialize(models,
|
const [inv] = await serialize(models,
|
||||||
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request},
|
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request},
|
||||||
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL,
|
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL,
|
||||||
@ -411,12 +423,11 @@ export default {
|
|||||||
const [item] = await models.$queryRawUnsafe(`
|
const [item] = await models.$queryRawUnsafe(`
|
||||||
${SELECT}
|
${SELECT}
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
WHERE id = $1`, Number(fact.factId))
|
WHERE id = $1`, Number(fact.id))
|
||||||
|
|
||||||
return item
|
return item
|
||||||
},
|
},
|
||||||
sats: fact => msatsToSatsDecimal(fact.msats),
|
sats: fact => msatsToSatsDecimal(fact.msats)
|
||||||
satsFee: fact => msatsToSatsDecimal(fact.msatsFee)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,7 +105,7 @@ export function getGetServerSideProps (
|
|||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data || (notFound && notFound(data, vars))) {
|
if (error || !data || (notFound && notFound(data, vars, me))) {
|
||||||
return {
|
return {
|
||||||
notFound: true
|
notFound: true
|
||||||
}
|
}
|
||||||
|
@ -77,6 +77,13 @@ export default gql`
|
|||||||
sources: EarnSources
|
sources: EarnSources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Revenue {
|
||||||
|
id: ID!
|
||||||
|
earnedSats: Int!
|
||||||
|
sortTime: Date!
|
||||||
|
subName: String!
|
||||||
|
}
|
||||||
|
|
||||||
type InvoicePaid {
|
type InvoicePaid {
|
||||||
id: ID!
|
id: ID!
|
||||||
earnedSats: Int!
|
earnedSats: Int!
|
||||||
@ -91,7 +98,7 @@ export default gql`
|
|||||||
|
|
||||||
union Notification = Reply | Votification | Mention
|
union Notification = Reply | Votification | Mention
|
||||||
| Invitification | Earn | JobChanged | InvoicePaid | Referral
|
| Invitification | Earn | JobChanged | InvoicePaid | Referral
|
||||||
| Streak | FollowActivity | ForwardedVotification
|
| Streak | FollowActivity | ForwardedVotification | Revenue
|
||||||
|
|
||||||
type Notifications {
|
type Notifications {
|
||||||
lastChecked: Date
|
lastChecked: Date
|
||||||
|
@ -4,14 +4,28 @@ export default gql`
|
|||||||
extend type Query {
|
extend type Query {
|
||||||
sub(name: String): Sub
|
sub(name: String): Sub
|
||||||
subLatestPost(name: String!): String
|
subLatestPost(name: String!): String
|
||||||
|
subs: [Sub!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
upsertSub(name: String!, desc: String, baseCost: Int!,
|
||||||
|
postTypes: [String!]!, billingType: String!, hash: String, hmac: String): Sub
|
||||||
|
paySub(name: String!, hash: String, hmac: String): Sub
|
||||||
}
|
}
|
||||||
|
|
||||||
type Sub {
|
type Sub {
|
||||||
name: String!
|
name: ID!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
|
userId: Int!
|
||||||
|
user: User!
|
||||||
|
desc: String
|
||||||
updatedAt: Date!
|
updatedAt: Date!
|
||||||
postTypes: [String!]!
|
postTypes: [String!]!
|
||||||
|
billingCost: Int!
|
||||||
|
billingType: String!
|
||||||
rankingType: String!
|
rankingType: String!
|
||||||
|
billedLastAt: Date!
|
||||||
baseCost: Int!
|
baseCost: Int!
|
||||||
|
status: String!
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -49,17 +49,16 @@ export default gql`
|
|||||||
|
|
||||||
type Fact {
|
type Fact {
|
||||||
id: ID!
|
id: ID!
|
||||||
factId: ID!
|
|
||||||
bolt11: String
|
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
sats: Float!
|
sats: Float!
|
||||||
satsFee: Float
|
|
||||||
status: String
|
|
||||||
type: String!
|
type: String!
|
||||||
|
bolt11: String
|
||||||
|
status: String
|
||||||
description: String
|
description: String
|
||||||
item: Item
|
item: Item
|
||||||
invoiceComment: String
|
invoiceComment: String
|
||||||
invoicePayerData: JSONObject
|
invoicePayerData: JSONObject
|
||||||
|
subName: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type History {
|
type History {
|
||||||
|
@ -31,3 +31,16 @@ export default function AccordianItem ({ header, body, headerColor = 'var(--them
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AccordianCard ({ header, children, show }) {
|
||||||
|
return (
|
||||||
|
<Accordion defaultActiveKey={show ? '0' : undefined}>
|
||||||
|
<Accordion.Item eventKey='0'>
|
||||||
|
<Accordion.Header>{header}</Accordion.Header>
|
||||||
|
<Accordion.Body>
|
||||||
|
{children}
|
||||||
|
</Accordion.Body>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -5,7 +5,7 @@ import Countdown from './countdown'
|
|||||||
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
||||||
import InputGroup from 'react-bootstrap/InputGroup'
|
import InputGroup from 'react-bootstrap/InputGroup'
|
||||||
import { bountySchema } from '../lib/validate'
|
import { bountySchema } from '../lib/validate'
|
||||||
import { SubSelectInitial } from './sub-select-form'
|
import { SubSelectInitial } from './sub-select'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
|
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
|
||||||
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
||||||
|
@ -11,3 +11,23 @@ export default function SimpleCountdown ({ className, onComplete, date }) {
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LongCountdown ({ className, onComplete, date }) {
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
<Countdown
|
||||||
|
date={date}
|
||||||
|
renderer={props => {
|
||||||
|
return (
|
||||||
|
<span suppressHydrationWarning>
|
||||||
|
{props.formatted.days && `${props.formatted.days} days `}
|
||||||
|
{props.formatted.minutes && `${props.formatted.minutes} minutes `}
|
||||||
|
{props.formatted.seconds && `${props.formatted.seconds} seconds `}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onComplete={onComplete}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -7,7 +7,7 @@ import { ITEM_FIELDS } from '../fragments/items'
|
|||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import Item from './item'
|
import Item from './item'
|
||||||
import { discussionSchema } from '../lib/validate'
|
import { discussionSchema } from '../lib/validate'
|
||||||
import { SubSelectInitial } from './sub-select-form'
|
import { SubSelectInitial } from './sub-select'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
|
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
|
||||||
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
||||||
|
@ -4,7 +4,7 @@ import ActionTooltip from './action-tooltip'
|
|||||||
import Info from './info'
|
import Info from './info'
|
||||||
import styles from './fee-button.module.css'
|
import styles from './fee-button.module.css'
|
||||||
import { gql, useQuery } from '@apollo/client'
|
import { gql, useQuery } from '@apollo/client'
|
||||||
import { FREEBIE_BASE_COST_THRESHOLD, SSR } from '../lib/constants'
|
import { SSR } from '../lib/constants'
|
||||||
import { numWithUnits } from '../lib/format'
|
import { numWithUnits } from '../lib/format'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import AnonIcon from '../svgs/spy-fill.svg'
|
import AnonIcon from '../svgs/spy-fill.svg'
|
||||||
@ -95,7 +95,8 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useFeeButton () {
|
export function useFeeButton () {
|
||||||
return useContext(FeeButtonContext)
|
const context = useContext(FeeButtonContext)
|
||||||
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
function FreebieDialog () {
|
function FreebieDialog () {
|
||||||
@ -114,9 +115,7 @@ export default function FeeButton ({ ChildButton = SubmitButton, variant, text,
|
|||||||
const me = useMe()
|
const me = useMe()
|
||||||
const { lines, total, disabled: ctxDisabled } = useFeeButton()
|
const { lines, total, disabled: ctxDisabled } = useFeeButton()
|
||||||
// freebies: there's only a base cost, it's less than 10, and we have less than 10 sats
|
// freebies: there's only a base cost, it's less than 10, and we have less than 10 sats
|
||||||
const free = total === lines.baseCost?.modifier(0) &&
|
const free = total === lines.baseCost?.modifier(0) && me?.privates?.sats < total
|
||||||
total <= FREEBIE_BASE_COST_THRESHOLD &&
|
|
||||||
me?.privates?.sats < FREEBIE_BASE_COST_THRESHOLD
|
|
||||||
const feeText = free
|
const feeText = free
|
||||||
? 'free'
|
? 'free'
|
||||||
: total > 1
|
: total > 1
|
||||||
|
@ -696,11 +696,14 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, disabled, ...props }) {
|
export function Checkbox ({
|
||||||
|
children, label, groupClassName, type = 'checkbox',
|
||||||
|
hiddenLabel, extra, handleChange, inline, disabled, ...props
|
||||||
|
}) {
|
||||||
// React treats radios and checkbox inputs differently other input types, select, and textarea.
|
// React treats radios and checkbox inputs differently other input types, select, and textarea.
|
||||||
// Formik does this too! When you specify `type` to useField(), it will
|
// Formik does this too! When you specify `type` to useField(), it will
|
||||||
// return the correct bag of props for you
|
// return the correct bag of props for you
|
||||||
const [field,, helpers] = useField({ ...props, type: 'checkbox' })
|
const [field, meta, helpers] = useField({ ...props, type })
|
||||||
return (
|
return (
|
||||||
<FormGroup className={groupClassName}>
|
<FormGroup className={groupClassName}>
|
||||||
{hiddenLabel && <BootstrapForm.Label className='invisible'>{label}</BootstrapForm.Label>}
|
{hiddenLabel && <BootstrapForm.Label className='invisible'>{label}</BootstrapForm.Label>}
|
||||||
@ -709,7 +712,8 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
|
|||||||
inline={inline}
|
inline={inline}
|
||||||
>
|
>
|
||||||
<BootstrapForm.Check.Input
|
<BootstrapForm.Check.Input
|
||||||
{...field} {...props} disabled={disabled} type='checkbox' onChange={(e) => {
|
isInvalid={meta.touched && meta.error}
|
||||||
|
{...field} {...props} disabled={disabled} type={type} onChange={(e) => {
|
||||||
field.onChange(e)
|
field.onChange(e)
|
||||||
handleChange && handleChange(e.target.checked, helpers.setValue)
|
handleChange && handleChange(e.target.checked, helpers.setValue)
|
||||||
}}
|
}}
|
||||||
@ -726,6 +730,19 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CheckboxGroup ({ label, groupClassName, children, ...props }) {
|
||||||
|
const [, meta] = useField(props)
|
||||||
|
return (
|
||||||
|
<FormGroup label={label} className={groupClassName}>
|
||||||
|
{children}
|
||||||
|
{/* force the feedback to display with d-block */}
|
||||||
|
<BootstrapForm.Control.Feedback className='d-block' type='invalid'>
|
||||||
|
{meta.touched && meta.error}
|
||||||
|
</BootstrapForm.Control.Feedback>
|
||||||
|
</FormGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const StorageKeyPrefixContext = createContext()
|
const StorageKeyPrefixContext = createContext()
|
||||||
|
|
||||||
export function Form ({
|
export function Form ({
|
||||||
@ -832,7 +849,17 @@ export function Select ({ label, items, groupClassName, onChange, noForm, overri
|
|||||||
}}
|
}}
|
||||||
isInvalid={invalid}
|
isInvalid={invalid}
|
||||||
>
|
>
|
||||||
{items?.map(item => <option key={item}>{item}</option>)}
|
{items.map(item => {
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
return (
|
||||||
|
<optgroup key={item.label} label={item.label}>
|
||||||
|
{item.items.map(item => <option key={item}>{item}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return <option key={item}>{item}</option>
|
||||||
|
}
|
||||||
|
})}
|
||||||
</BootstrapForm.Select>
|
</BootstrapForm.Select>
|
||||||
<BootstrapForm.Control.Feedback type='invalid'>
|
<BootstrapForm.Control.Feedback type='invalid'>
|
||||||
{meta.touched && meta.error}
|
{meta.touched && meta.error}
|
||||||
|
@ -16,10 +16,9 @@ import { abbrNum } from '../lib/format'
|
|||||||
import NoteIcon from '../svgs/notification-4-fill.svg'
|
import NoteIcon from '../svgs/notification-4-fill.svg'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import LightningIcon from '../svgs/bolt.svg'
|
import LightningIcon from '../svgs/bolt.svg'
|
||||||
import { Select } from './form'
|
|
||||||
import SearchIcon from '../svgs/search-line.svg'
|
import SearchIcon from '../svgs/search-line.svg'
|
||||||
import BackArrow from '../svgs/arrow-left-line.svg'
|
import BackArrow from '../svgs/arrow-left-line.svg'
|
||||||
import { SSR, SUBS } from '../lib/constants'
|
import { SSR } from '../lib/constants'
|
||||||
import { useLightning } from './lightning'
|
import { useLightning } from './lightning'
|
||||||
import { HAS_NOTIFICATIONS } from '../fragments/notifications'
|
import { HAS_NOTIFICATIONS } from '../fragments/notifications'
|
||||||
import AnonIcon from '../svgs/spy-fill.svg'
|
import AnonIcon from '../svgs/spy-fill.svg'
|
||||||
@ -27,6 +26,7 @@ import Hat from './hat'
|
|||||||
import HiddenWalletSummary from './hidden-wallet-summary'
|
import HiddenWalletSummary from './hidden-wallet-summary'
|
||||||
import { clearNotifications } from '../lib/badge'
|
import { clearNotifications } from '../lib/badge'
|
||||||
import { useServiceWorker } from './serviceworker'
|
import { useServiceWorker } from './serviceworker'
|
||||||
|
import SubSelect from './sub-select'
|
||||||
|
|
||||||
function WalletSummary ({ me }) {
|
function WalletSummary ({ me }) {
|
||||||
if (!me) return null
|
if (!me) return null
|
||||||
@ -213,57 +213,17 @@ function LurkerCorner ({ path }) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PREPEND_SUBS = ['home']
|
||||||
|
const APPEND_SUBS = [{ label: '--------', items: ['create'] }]
|
||||||
function NavItems ({ className, sub, prefix }) {
|
function NavItems ({ className, sub, prefix }) {
|
||||||
const router = useRouter()
|
|
||||||
sub ||= 'home'
|
sub ||= 'home'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav.Item className={className}>
|
<Nav.Item className={className}>
|
||||||
<Select
|
<SubSelect
|
||||||
|
sub={sub} prependSubs={PREPEND_SUBS} appendSubs={APPEND_SUBS} noForm
|
||||||
groupClassName='mb-0'
|
groupClassName='mb-0'
|
||||||
onChange={(_, e) => {
|
|
||||||
const sub = e.target.value === 'home' ? undefined : e.target.value
|
|
||||||
let asPath
|
|
||||||
// are we currently in a sub (ie not home)
|
|
||||||
if (router.query.sub) {
|
|
||||||
// are we going to a sub or home?
|
|
||||||
const subReplace = sub ? `/~${sub}` : ''
|
|
||||||
|
|
||||||
// if we are going to a sub, replace the current sub with the new one
|
|
||||||
asPath = router.asPath.replace(`/~${router.query.sub}`, subReplace)
|
|
||||||
// if we're going to home, just go there directly
|
|
||||||
if (asPath === '') {
|
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// we're currently on the home sub
|
|
||||||
// are we in a sub aware route?
|
|
||||||
if (router.pathname.startsWith('/~')) {
|
|
||||||
// if we are, go to the same path but in the sub
|
|
||||||
asPath = `/~${sub}` + router.asPath
|
|
||||||
} else {
|
|
||||||
// otherwise, just go to the sub
|
|
||||||
router.push(sub ? `/~${sub}` : '/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const query = {
|
|
||||||
...router.query,
|
|
||||||
sub
|
|
||||||
}
|
|
||||||
delete query.nodata
|
|
||||||
router.push({
|
|
||||||
pathname: router.pathname,
|
|
||||||
query
|
|
||||||
}, asPath)
|
|
||||||
}}
|
|
||||||
name='sub'
|
|
||||||
size='sm'
|
|
||||||
value={sub}
|
|
||||||
noForm
|
|
||||||
items={['home', ...SUBS]}
|
|
||||||
/>
|
/>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item className={className}>
|
<Nav.Item className={className}>
|
||||||
|
@ -9,9 +9,9 @@ import Item from './item'
|
|||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import { linkSchema } from '../lib/validate'
|
import { linkSchema } from '../lib/validate'
|
||||||
import Moon from '../svgs/moon-fill.svg'
|
import Moon from '../svgs/moon-fill.svg'
|
||||||
import { SubSelectInitial } from './sub-select-form'
|
|
||||||
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
|
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
|
import { SubSelectInitial } from './sub-select'
|
||||||
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { ItemButtonBar } from './post'
|
import { ItemButtonBar } from './post'
|
||||||
|
@ -25,6 +25,7 @@ import { nostrZapDetails } from '../lib/nostr'
|
|||||||
import Text from './text'
|
import Text from './text'
|
||||||
import NostrIcon from '../svgs/nostr.svg'
|
import NostrIcon from '../svgs/nostr.svg'
|
||||||
import { numWithUnits } from '../lib/format'
|
import { numWithUnits } from '../lib/format'
|
||||||
|
import BountyIcon from '../svgs/bounty-bag.svg'
|
||||||
|
|
||||||
function Notification ({ n, fresh }) {
|
function Notification ({ n, fresh }) {
|
||||||
const type = n.__typename
|
const type = n.__typename
|
||||||
@ -33,6 +34,7 @@ function Notification ({ n, fresh }) {
|
|||||||
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
|
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
|
||||||
{
|
{
|
||||||
(type === 'Earn' && <EarnNotification n={n} />) ||
|
(type === 'Earn' && <EarnNotification n={n} />) ||
|
||||||
|
(type === 'Revenue' && <RevenueNotification n={n} />) ||
|
||||||
(type === 'Invitification' && <Invitification n={n} />) ||
|
(type === 'Invitification' && <Invitification n={n} />) ||
|
||||||
(type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
|
(type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
|
||||||
(type === 'Referral' && <Referral n={n} />) ||
|
(type === 'Referral' && <Referral n={n} />) ||
|
||||||
@ -83,6 +85,7 @@ const defaultOnClick = n => {
|
|||||||
href += dayMonthYear(new Date(n.sortTime))
|
href += dayMonthYear(new Date(n.sortTime))
|
||||||
return { href }
|
return { href }
|
||||||
}
|
}
|
||||||
|
if (type === 'Revenue') return { href: `/~${n.subName}` }
|
||||||
if (type === 'Invitification') return { href: '/invites' }
|
if (type === 'Invitification') return { href: '/invites' }
|
||||||
if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
|
if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
|
||||||
if (type === 'Referral') return { href: '/referrals/month' }
|
if (type === 'Referral') return { href: '/referrals/month' }
|
||||||
@ -135,10 +138,10 @@ function Streak ({ n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='d-flex fw-bold ms-2 py-1'>
|
<div className='d-flex ms-2 py-1'>
|
||||||
<div style={{ fontSize: '2rem' }}>{n.days ? <BaldIcon className='fill-grey' height={40} width={40} /> : <CowboyHatIcon className='fill-grey' height={40} width={40} />}</div>
|
<div style={{ fontSize: '2rem' }}>{n.days ? <BaldIcon className='fill-grey' height={40} width={40} /> : <CowboyHatIcon className='fill-grey' height={40} width={40} />}</div>
|
||||||
<div className='ms-1 p-1'>
|
<div className='ms-1 p-1'>
|
||||||
you {n.days ? 'lost your' : 'found a'} cowboy hat
|
<span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} cowboy hat</span>
|
||||||
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
|
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -171,6 +174,22 @@ function EarnNotification ({ n }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RevenueNotification ({ n }) {
|
||||||
|
return (
|
||||||
|
<div className='d-flex ms-2 py-1'>
|
||||||
|
<BountyIcon className='align-self-center fill-success mx-1' width={24} height={24} style={{ flex: '0 0 24px' }} />
|
||||||
|
<div className='ms-2 pb-1'>
|
||||||
|
<div className='fw-bold text-success'>
|
||||||
|
you stacked {numWithUnits(n.earnedSats, { abbreviate: false })} in territory revenue<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
|
</div>
|
||||||
|
<div style={{ lineHeight: '140%' }}>
|
||||||
|
As the founder of territory <Link href={`/~${n.subName}`}>~{n.subName}</Link>, you receive 50% of the revenue it generates and the other 50% go to <Link href='/rewards'>rewards</Link>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Invitification ({ n }) {
|
function Invitification ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -5,7 +5,7 @@ import Countdown from './countdown'
|
|||||||
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
||||||
import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../lib/constants'
|
import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../lib/constants'
|
||||||
import { pollSchema } from '../lib/validate'
|
import { pollSchema } from '../lib/validate'
|
||||||
import { SubSelectInitial } from './sub-select-form'
|
import { SubSelectInitial } from './sub-select'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
|
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
|
@ -9,7 +9,7 @@ import { DiscussionForm } from './discussion-form'
|
|||||||
import { LinkForm } from './link-form'
|
import { LinkForm } from './link-form'
|
||||||
import { PollForm } from './poll-form'
|
import { PollForm } from './poll-form'
|
||||||
import { BountyForm } from './bounty-form'
|
import { BountyForm } from './bounty-form'
|
||||||
import SubSelect from './sub-select-form'
|
import SubSelect, { SubInfo } from './sub-select'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import FeeButton, { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
|
import FeeButton, { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
|
||||||
import Delete from './delete'
|
import Delete from './delete'
|
||||||
@ -29,33 +29,89 @@ export function PostForm ({ type, sub, children }) {
|
|||||||
}, [me, setErrorMessage])
|
}, [me, setErrorMessage])
|
||||||
|
|
||||||
if (!type) {
|
if (!type) {
|
||||||
|
let postButtons = []
|
||||||
|
let morePostButtons = []
|
||||||
|
|
||||||
|
if (sub) {
|
||||||
|
if (sub?.postTypes?.includes('LINK')) {
|
||||||
|
postButtons.push(
|
||||||
|
<Link key='LINK' href={prefix + '/post?type=link'}>
|
||||||
|
<Button variant='secondary'>link</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub?.postTypes?.includes('DISCUSSION')) {
|
||||||
|
postButtons.push(
|
||||||
|
<Link key='DISCUSSION' href={prefix + '/post?type=discussion'}>
|
||||||
|
<Button variant='secondary'>discussion</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub?.postTypes?.includes('POLL')) {
|
||||||
|
const array = postButtons.length < 2 ? postButtons : morePostButtons
|
||||||
|
array.push(
|
||||||
|
<Link key='POLL' href={prefix + '/post?type=poll'}>
|
||||||
|
<Button variant={postButtons.length < 2 ? 'secondary' : 'info'}>poll</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub?.postTypes?.includes('BOUNTY')) {
|
||||||
|
const array = postButtons.length < 2 ? postButtons : morePostButtons
|
||||||
|
array.push(
|
||||||
|
<Link key='BOUNTY' href={prefix + '/post?type=bounty'}>
|
||||||
|
<Button onClick={checkSession} variant={postButtons.length < 2 ? 'secondary' : 'info'}>bounty</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
postButtons = [
|
||||||
|
<Link key='LINK' href={prefix + '/post?type=link'}>
|
||||||
|
<Button variant='secondary'>link</Button>
|
||||||
|
</Link>,
|
||||||
|
<Link key='DISCUSSION' href={prefix + '/post?type=discussion'}>
|
||||||
|
<Button variant='secondary'>discussion</Button>
|
||||||
|
</Link>
|
||||||
|
]
|
||||||
|
morePostButtons = [
|
||||||
|
<Link key='POLL' href={prefix + '/post?type=poll'}>
|
||||||
|
<Button variant='info'>poll</Button>
|
||||||
|
</Link>,
|
||||||
|
<Link key='BOUNTY' href={prefix + '/post?type=bounty'}>
|
||||||
|
<Button onClick={checkSession} variant='info'>bounty</Button>
|
||||||
|
</Link>
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
postButtons = postButtons.reduce((acc, cur) => {
|
||||||
|
if (acc.length) acc.push(<span key='OR-post-buttons' className='mx-3 fw-bold text-muted'>or</span>)
|
||||||
|
acc.push(cur)
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
morePostButtons = morePostButtons.reduce((acc, cur) => {
|
||||||
|
if (acc.length) acc.push(<span key='OR-more-post-buttons' className='mx-3 fw-bold text-muted'>or</span>)
|
||||||
|
acc.push(cur)
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='position-relative align-items-center'>
|
<div className='position-relative align-items-center'>
|
||||||
{errorMessage &&
|
{errorMessage &&
|
||||||
<Alert className='position-absolute' style={{ top: '-6rem' }} variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>
|
<Alert className='position-absolute' style={{ top: '-6rem' }} variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</Alert>}
|
</Alert>}
|
||||||
<SubSelect noForm sub={sub?.name} />
|
<SubSelect prependSubs={['pick territory']} className='w-auto d-flex' noForm sub={sub?.name} />
|
||||||
<Link href={prefix + '/post?type=link'}>
|
{postButtons}
|
||||||
<Button variant='secondary'>link</Button>
|
|
||||||
</Link>
|
|
||||||
<span className='mx-3 fw-bold text-muted'>or</span>
|
|
||||||
<Link href={prefix + '/post?type=discussion'}>
|
|
||||||
<Button variant='secondary'>discussion</Button>
|
|
||||||
</Link>
|
|
||||||
<div className='d-flex mt-4'>
|
<div className='d-flex mt-4'>
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
headerColor='#6c757d'
|
headerColor='#6c757d'
|
||||||
header={<div className='fw-bold text-muted'>more types</div>}
|
header={<div className='fw-bold text-muted'>more types</div>}
|
||||||
body={
|
body={
|
||||||
<div className='align-items-center'>
|
<div className='align-items-center'>
|
||||||
<Link href={prefix + '/post?type=poll'}>
|
{morePostButtons}
|
||||||
<Button variant='info'>poll</Button>
|
|
||||||
</Link>
|
|
||||||
<span className='mx-3 fw-bold text-muted'>or</span>
|
|
||||||
<Link href={prefix + '/post?type=bounty'}>
|
|
||||||
<Button onClick={checkSession} variant='info'>bounty</Button>
|
|
||||||
</Link>
|
|
||||||
<div className='mt-3 d-flex justify-content-center'>
|
<div className='mt-3 d-flex justify-content-center'>
|
||||||
<Link href='/~jobs/post'>
|
<Link href='/~jobs/post'>
|
||||||
<Button onClick={checkSession} variant='info'>job</Button>
|
<Button onClick={checkSession} variant='info'>job</Button>
|
||||||
@ -101,7 +157,19 @@ export default function Post ({ sub }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PostForm type={type} sub={sub}>
|
<PostForm type={type} sub={sub}>
|
||||||
{sub?.name !== 'jobs' && <SubSelect label='sub' />}
|
{sub?.name !== 'jobs' &&
|
||||||
|
<SubSelect
|
||||||
|
sub={sub?.name}
|
||||||
|
prependSubs={sub?.name ? undefined : ['pick territory']}
|
||||||
|
filterSubs={s => s.postTypes?.includes(type.toUpperCase())}
|
||||||
|
className='w-auto d-flex'
|
||||||
|
label={
|
||||||
|
<span className='d-flex align-items-center'>
|
||||||
|
territory
|
||||||
|
<SubInfo />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>}
|
||||||
</PostForm>
|
</PostForm>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
import { ITEM_TYPES } from '../lib/constants'
|
import { ITEM_TYPES, ITEM_TYPES_UNIVERSAL } from '../lib/constants'
|
||||||
import { Select } from './form'
|
import { Select } from './form'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
export default function RecentHeader ({ type, sub }) {
|
export default function RecentHeader ({ type, sub }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const prefix = sub ? `/~${sub}` : ''
|
|
||||||
|
|
||||||
const items = ITEM_TYPES(sub)
|
const prefix = sub ? `/~${sub.name}` : ''
|
||||||
|
|
||||||
|
const items = sub
|
||||||
|
? ITEM_TYPES_UNIVERSAL.concat(sub.postTypes.map(p =>
|
||||||
|
['LINK', 'DISCUSSION', 'POLL', 'JOB'].includes(p) ? `${p.toLowerCase()}s` : 'bounties'
|
||||||
|
))
|
||||||
|
: ITEM_TYPES
|
||||||
|
|
||||||
type ||= router.query.type || type || 'posts'
|
type ||= router.query.type || type || 'posts'
|
||||||
return (
|
return (
|
||||||
|
@ -1,68 +0,0 @@
|
|||||||
import { useRouter } from 'next/router'
|
|
||||||
import { Select } from './form'
|
|
||||||
import Info from './info'
|
|
||||||
import { SUBS, SUBS_NO_JOBS } from '../lib/constants'
|
|
||||||
|
|
||||||
export function SubSelectInitial ({ sub }) {
|
|
||||||
const router = useRouter()
|
|
||||||
sub = sub || router.query.sub || 'pick sub'
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubSelect ({ label, sub, setSub, item, ...props }) {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const SubInfo = () => (
|
|
||||||
<Info>
|
|
||||||
<div>
|
|
||||||
<div className='fw-bold'>The sub your post will go in ...</div>
|
|
||||||
<ul>
|
|
||||||
<li>If it's bitcoin related, put it in the bitcoin sub.</li>
|
|
||||||
<li>If it's nostr related, put it in the nostr sub.</li>
|
|
||||||
<li>If it's tech related, put it in the tech sub.</li>
|
|
||||||
<li>If it's stacker news related, put it in the meta sub.</li>
|
|
||||||
<li>If it's a job, put it in the jobs sub.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Info>
|
|
||||||
)
|
|
||||||
|
|
||||||
sub ||= router.query.sub || 'pick sub'
|
|
||||||
const extraProps = props.noForm
|
|
||||||
? {
|
|
||||||
value: sub,
|
|
||||||
items: ['pick sub', ...SUBS]
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
overrideValue: sub,
|
|
||||||
items: item ? SUBS_NO_JOBS : ['pick sub', ...SUBS_NO_JOBS]
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
className='w-auto d-flex'
|
|
||||||
onChange={(formik, e) => {
|
|
||||||
if (!item) {
|
|
||||||
router.push({
|
|
||||||
pathname: e.target.value === 'pick sub' ? '/post' : `/~${e.target.value}/post`,
|
|
||||||
query: router.query?.type ? { type: router.query.type } : undefined
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setSub(e.target.value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
name='sub'
|
|
||||||
size='sm'
|
|
||||||
{...extraProps}
|
|
||||||
label={label &&
|
|
||||||
<>
|
|
||||||
{label}
|
|
||||||
<SubInfo />
|
|
||||||
</>}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
118
components/sub-select.js
Normal file
118
components/sub-select.js
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { Select } from './form'
|
||||||
|
import Info from './info'
|
||||||
|
import { SSR } from '../lib/constants'
|
||||||
|
import { SUBS } from '../fragments/subs'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import styles from './sub-select.module.css'
|
||||||
|
|
||||||
|
export function SubSelectInitial ({ sub }) {
|
||||||
|
const router = useRouter()
|
||||||
|
sub = sub || router.query.sub || 'pick territory'
|
||||||
|
|
||||||
|
return {
|
||||||
|
sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubs ({ prependSubs = [], sub, filterSubs = () => true, appendSubs = [] }) {
|
||||||
|
const { data } = useQuery(SUBS, SSR
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
pollInterval: 300000,
|
||||||
|
nextFetchPolicy: 'cache-and-network'
|
||||||
|
})
|
||||||
|
|
||||||
|
const [subs, setSubs] = useState([
|
||||||
|
...prependSubs.filter(s => s !== sub),
|
||||||
|
sub,
|
||||||
|
...appendSubs.filter(s => s !== sub)])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return
|
||||||
|
setSubs([...prependSubs, ...data.subs.filter(filterSubs).map(s => s.name), ...appendSubs])
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
return subs
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubInfo = () => (
|
||||||
|
<Info>
|
||||||
|
<div>
|
||||||
|
<div className='fw-bold'>The territory your post will go in ...</div>
|
||||||
|
<ul>
|
||||||
|
<li>If it's bitcoin related, put it in the bitcoin territory.</li>
|
||||||
|
<li>If it's nostr related, put it in the nostr territory.</li>
|
||||||
|
<li>If it's tech related, put it in the tech territory.</li>
|
||||||
|
<li>If it's stacker news related, put it in the meta territory.</li>
|
||||||
|
<li>If it's a job, put it in the jobs territory.</li>
|
||||||
|
<li>etc...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Info>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function SubSelect ({ prependSubs, sub, onChange, appendSubs, filterSubs, className, ...props }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const subs = useSubs({ prependSubs, sub, filterSubs, appendSubs })
|
||||||
|
const valueProps = props.noForm
|
||||||
|
? {
|
||||||
|
value: sub
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
overrideValue: sub
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
onChange={onChange || ((_, e) => {
|
||||||
|
const sub = ['home', 'pick territory'].includes(e.target.value) ? undefined : e.target.value
|
||||||
|
if (sub === 'create') {
|
||||||
|
router.push('/territory')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let asPath
|
||||||
|
// are we currently in a sub (ie not home)
|
||||||
|
if (router.query.sub) {
|
||||||
|
// are we going to a sub or home?
|
||||||
|
const subReplace = sub ? `/~${sub}` : ''
|
||||||
|
|
||||||
|
// if we are going to a sub, replace the current sub with the new one
|
||||||
|
asPath = router.asPath.replace(`/~${router.query.sub}`, subReplace)
|
||||||
|
// if we're going to home, just go there directly
|
||||||
|
if (asPath === '') {
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// we're currently on the home sub
|
||||||
|
// are we in a sub aware route?
|
||||||
|
if (router.pathname.startsWith('/~')) {
|
||||||
|
// if we are, go to the same path but in the sub
|
||||||
|
asPath = `/~${sub}` + router.asPath
|
||||||
|
} else {
|
||||||
|
// otherwise, just go to the sub
|
||||||
|
router.push(sub ? `/~${sub}` : '/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const query = {
|
||||||
|
...router.query,
|
||||||
|
sub
|
||||||
|
}
|
||||||
|
delete query.nodata
|
||||||
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query
|
||||||
|
}, asPath)
|
||||||
|
})}
|
||||||
|
name='sub'
|
||||||
|
size='sm'
|
||||||
|
{...valueProps}
|
||||||
|
{...props}
|
||||||
|
className={`${className} ${styles.subSelect}`}
|
||||||
|
items={subs}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
6
components/sub-select.module.css
Normal file
6
components/sub-select.module.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.subSelect {
|
||||||
|
width: 100px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
}
|
183
components/territory-form.js
Normal file
183
components/territory-form.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { Col, InputGroup, Row } from 'react-bootstrap'
|
||||||
|
import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form'
|
||||||
|
import FeeButton, { FeeButtonProvider } from './fee-button'
|
||||||
|
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS } from '../lib/constants'
|
||||||
|
import { territorySchema } from '../lib/validate'
|
||||||
|
import { useMe } from './me'
|
||||||
|
|
||||||
|
export default function TerritoryForm ({ sub }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const client = useApolloClient()
|
||||||
|
const me = useMe()
|
||||||
|
const [upsertSub] = useMutation(
|
||||||
|
gql`
|
||||||
|
mutation upsertSub($name: String!, $desc: String, $baseCost: Int!,
|
||||||
|
$postTypes: [String!]!, $billingType: String!, $hash: String, $hmac: String) {
|
||||||
|
upsertSub(name: $name, desc: $desc, baseCost: $baseCost,
|
||||||
|
postTypes: $postTypes, billingType: $billingType, hash: $hash, hmac: $hmac) {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async ({ ...variables }) => {
|
||||||
|
const { error } = await upsertSub({
|
||||||
|
variables
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error({ message: error.toString() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// modify graphql cache to include new sub
|
||||||
|
client.cache.modify({
|
||||||
|
fields: {
|
||||||
|
subs (existing = []) {
|
||||||
|
console.log('existing', existing, variables.name)
|
||||||
|
return [
|
||||||
|
...existing,
|
||||||
|
{ __typename: 'Sub', name: variables.name }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await router.push(`/~${variables.name}`)
|
||||||
|
}, [client, upsertSub, router]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [billing, setBilling] = useState('monthly')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeeButtonProvider baseLineItems={sub ? undefined : { territory: TERRITORY_BILLING_OPTIONS('first')[billing] }}>
|
||||||
|
<Form
|
||||||
|
initial={{
|
||||||
|
name: sub?.name || '',
|
||||||
|
desc: sub?.desc || '',
|
||||||
|
baseCost: sub?.baseCost || 10,
|
||||||
|
postTypes: sub?.postTypes || POST_TYPES,
|
||||||
|
billingType: sub?.billingType || 'MONTHLY'
|
||||||
|
}}
|
||||||
|
schema={territorySchema({ client, me })}
|
||||||
|
invoiceable
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className='mb-5'
|
||||||
|
storageKeyPrefix={sub ? undefined : 'territory'}
|
||||||
|
>
|
||||||
|
{sub?.name
|
||||||
|
? <Input
|
||||||
|
label={<>name <small className='text-muted ms-2'>read only</small></>}
|
||||||
|
name='name'
|
||||||
|
readOnly
|
||||||
|
prepend={<InputGroup.Text className='text-monospace'>~</InputGroup.Text>}
|
||||||
|
className='text-muted'
|
||||||
|
/>
|
||||||
|
: <Input
|
||||||
|
label='name'
|
||||||
|
name='name'
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
clear
|
||||||
|
maxLength={32}
|
||||||
|
prepend={<InputGroup.Text className='text-monospace'>~</InputGroup.Text>}
|
||||||
|
/>}
|
||||||
|
<MarkdownInput
|
||||||
|
label='description'
|
||||||
|
name='desc'
|
||||||
|
maxLength={MAX_TERRITORY_DESC_LENGTH}
|
||||||
|
required
|
||||||
|
minRows={3}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='post cost'
|
||||||
|
name='baseCost'
|
||||||
|
type='number'
|
||||||
|
required
|
||||||
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
/>
|
||||||
|
<CheckboxGroup label='post types' name='postTypes'>
|
||||||
|
<Row>
|
||||||
|
<Col xs={4} sm='auto'>
|
||||||
|
<Checkbox
|
||||||
|
inline
|
||||||
|
label='links'
|
||||||
|
value='LINK'
|
||||||
|
name='postTypes'
|
||||||
|
groupClassName='ms-1 mb-0'
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={4} sm='auto'>
|
||||||
|
<Checkbox
|
||||||
|
inline
|
||||||
|
label='discussions'
|
||||||
|
value='DISCUSSION'
|
||||||
|
name='postTypes'
|
||||||
|
groupClassName='ms-1 mb-0'
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={4} sm='auto'>
|
||||||
|
<Checkbox
|
||||||
|
inline
|
||||||
|
label='bounties'
|
||||||
|
value='BOUNTY'
|
||||||
|
name='postTypes'
|
||||||
|
groupClassName='ms-1 mb-0'
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={4} sm='auto'>
|
||||||
|
<Checkbox
|
||||||
|
inline
|
||||||
|
label='polls'
|
||||||
|
value='POLL'
|
||||||
|
name='postTypes'
|
||||||
|
groupClassName='ms-1 mb-0'
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</CheckboxGroup>
|
||||||
|
<CheckboxGroup label={sub ? <>name <small className='text-muted ms-2'>read only</small></> : 'billing'} name='billing'>
|
||||||
|
{(!sub?.billingType || sub.billingType === 'MONTHLY') &&
|
||||||
|
<Checkbox
|
||||||
|
type='radio'
|
||||||
|
label='100k sats/month'
|
||||||
|
value='MONTHLY'
|
||||||
|
name='billingType'
|
||||||
|
readOnly={!!sub}
|
||||||
|
handleChange={checked => checked && setBilling('monthly')}
|
||||||
|
groupClassName='ms-1 mb-0'
|
||||||
|
/>}
|
||||||
|
{(!sub?.billingType || sub.billingType === 'YEARLY') &&
|
||||||
|
<Checkbox
|
||||||
|
type='radio'
|
||||||
|
label='1m sats/year'
|
||||||
|
value='YEARLY'
|
||||||
|
name='billingType'
|
||||||
|
readOnly={!!sub}
|
||||||
|
handleChange={checked => checked && setBilling('yearly')}
|
||||||
|
groupClassName='ms-1 mb-0'
|
||||||
|
/>}
|
||||||
|
{(!sub?.billingType || sub.billingType === 'ONCE') &&
|
||||||
|
<Checkbox
|
||||||
|
type='radio'
|
||||||
|
label='3m sats once'
|
||||||
|
value='ONCE'
|
||||||
|
name='billingType'
|
||||||
|
readOnly={!!sub}
|
||||||
|
handleChange={checked => checked && setBilling('once')}
|
||||||
|
groupClassName='ms-1 mb-0'
|
||||||
|
/>}
|
||||||
|
</CheckboxGroup>
|
||||||
|
<div className='mt-3 d-flex justify-content-end'>
|
||||||
|
<FeeButton
|
||||||
|
text={sub ? 'save' : 'found it'}
|
||||||
|
variant='secondary'
|
||||||
|
disabled={sub?.status === 'STOPPED'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</FeeButtonProvider>
|
||||||
|
)
|
||||||
|
}
|
77
components/territory-payment-due.js
Normal file
77
components/territory-payment-due.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { Alert } from 'react-bootstrap'
|
||||||
|
import { useMe } from './me'
|
||||||
|
import FeeButton, { FeeButtonProvider } from './fee-button'
|
||||||
|
import { TERRITORY_BILLING_OPTIONS, TERRITORY_GRACE_DAYS } from '../lib/constants'
|
||||||
|
import { Form } from './form'
|
||||||
|
import { datePivot } from '../lib/time'
|
||||||
|
import { LongCountdown } from './countdown'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useApolloClient, useMutation } from '@apollo/client'
|
||||||
|
import { SUB_PAY } from '../fragments/subs'
|
||||||
|
|
||||||
|
export default function TerritoryPaymentDue ({ sub }) {
|
||||||
|
const me = useMe()
|
||||||
|
const client = useApolloClient()
|
||||||
|
const [paySub] = useMutation(SUB_PAY)
|
||||||
|
|
||||||
|
const dueDate = datePivot(
|
||||||
|
new Date(sub.billedLastAt),
|
||||||
|
sub.billingType === 'MONTHLY'
|
||||||
|
? { months: 1, days: TERRITORY_GRACE_DAYS }
|
||||||
|
: { years: 1, days: TERRITORY_GRACE_DAYS })
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async ({ ...variables }) => {
|
||||||
|
const { error } = await paySub({
|
||||||
|
variables
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error({ message: error.toString() })
|
||||||
|
}
|
||||||
|
}, [client, paySub])
|
||||||
|
|
||||||
|
if (!sub || sub.userId !== Number(me?.id) || sub.status === 'ACTIVE') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert key='danger' variant='danger'>
|
||||||
|
{sub.status === 'STOPPED'
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<Alert.Heading>
|
||||||
|
Your ~{sub.name} territory has been archived!
|
||||||
|
</Alert.Heading>
|
||||||
|
<div>
|
||||||
|
Make a payment to reactivate it.
|
||||||
|
</div>
|
||||||
|
</>)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<Alert.Heading>
|
||||||
|
Your ~{sub.name} territory payment is due!
|
||||||
|
</Alert.Heading>
|
||||||
|
<div>
|
||||||
|
Your territory will be archived in <LongCountdown date={dueDate} />otherwise.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FeeButtonProvider baseLineItems={{ territory: TERRITORY_BILLING_OPTIONS('one')[sub.billingType.toLowerCase()] }}>
|
||||||
|
<Form
|
||||||
|
invoiceable
|
||||||
|
initial={{
|
||||||
|
name: sub.name
|
||||||
|
}}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
|
<div className='d-flex justify-content-end'>
|
||||||
|
<FeeButton
|
||||||
|
text='pay'
|
||||||
|
variant='success'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</FeeButtonProvider>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
@ -32,6 +32,12 @@ export const NOTIFICATIONS = gql`
|
|||||||
text
|
text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
... on Revenue {
|
||||||
|
id
|
||||||
|
sortTime
|
||||||
|
earnedSats
|
||||||
|
subName
|
||||||
|
}
|
||||||
... on ForwardedVotification {
|
... on ForwardedVotification {
|
||||||
id
|
id
|
||||||
sortTime
|
sortTime
|
||||||
|
@ -7,7 +7,27 @@ export const SUB_FIELDS = gql`
|
|||||||
name
|
name
|
||||||
postTypes
|
postTypes
|
||||||
rankingType
|
rankingType
|
||||||
|
billingType
|
||||||
|
billingCost
|
||||||
|
billedLastAt
|
||||||
baseCost
|
baseCost
|
||||||
|
userId
|
||||||
|
desc
|
||||||
|
status
|
||||||
|
}`
|
||||||
|
|
||||||
|
export const SUB_FULL_FIELDS = gql`
|
||||||
|
${SUB_FIELDS}
|
||||||
|
|
||||||
|
fragment SubFullFields on Sub {
|
||||||
|
...SubFields
|
||||||
|
user {
|
||||||
|
name
|
||||||
|
id
|
||||||
|
optional {
|
||||||
|
streak
|
||||||
|
}
|
||||||
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
export const SUB = gql`
|
export const SUB = gql`
|
||||||
@ -19,14 +39,32 @@ export const SUB = gql`
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
export const SUB_ITEMS = gql`
|
export const SUB_FULL = gql`
|
||||||
|
${SUB_FULL_FIELDS}
|
||||||
|
|
||||||
|
query Sub($sub: String) {
|
||||||
|
sub(name: $sub) {
|
||||||
|
...SubFullFields
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
export const SUBS = gql`
|
||||||
${SUB_FIELDS}
|
${SUB_FIELDS}
|
||||||
|
|
||||||
|
query Subs {
|
||||||
|
subs {
|
||||||
|
...SubFields
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
export const SUB_ITEMS = gql`
|
||||||
|
${SUB_FULL_FIELDS}
|
||||||
${ITEM_FIELDS}
|
${ITEM_FIELDS}
|
||||||
${COMMENTS_ITEM_EXT_FIELDS}
|
${COMMENTS_ITEM_EXT_FIELDS}
|
||||||
|
|
||||||
query SubItems($sub: String, $sort: String, $cursor: String, $type: String, $name: String, $when: String, $from: String, $to: String, $by: String, $limit: Limit, $includeComments: Boolean = false) {
|
query SubItems($sub: String, $sort: String, $cursor: String, $type: String, $name: String, $when: String, $from: String, $to: String, $by: String, $limit: Limit, $includeComments: Boolean = false) {
|
||||||
sub(name: $sub) {
|
sub(name: $sub) {
|
||||||
...SubFields
|
...SubFullFields
|
||||||
}
|
}
|
||||||
|
|
||||||
items(sub: $sub, sort: $sort, cursor: $cursor, type: $type, name: $name, when: $when, from: $from, to: $to, by: $by, limit: $limit) {
|
items(sub: $sub, sort: $sort, cursor: $cursor, type: $type, name: $name, when: $when, from: $from, to: $to, by: $by, limit: $limit) {
|
||||||
@ -62,3 +100,11 @@ export const SUB_SEARCH = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const SUB_PAY = gql`
|
||||||
|
${SUB_FULL_FIELDS}
|
||||||
|
mutation paySub($name: String!, $hash: String, $hmac: String) {
|
||||||
|
paySub(name: $name, hash: $hash, hmac: $hmac) {
|
||||||
|
...SubFullFields
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
@ -40,17 +40,16 @@ export const WALLET_HISTORY = gql`
|
|||||||
walletHistory(cursor: $cursor, inc: $inc) {
|
walletHistory(cursor: $cursor, inc: $inc) {
|
||||||
facts {
|
facts {
|
||||||
id
|
id
|
||||||
factId
|
|
||||||
bolt11
|
bolt11
|
||||||
type
|
type
|
||||||
createdAt
|
createdAt
|
||||||
sats
|
sats
|
||||||
satsFee
|
|
||||||
status
|
status
|
||||||
type
|
type
|
||||||
description
|
description
|
||||||
invoiceComment
|
invoiceComment
|
||||||
invoicePayerData
|
invoicePayerData
|
||||||
|
subName
|
||||||
item {
|
item {
|
||||||
...ItemFullFields
|
...ItemFullFields
|
||||||
}
|
}
|
||||||
|
@ -33,6 +33,9 @@ function getClient (uri) {
|
|||||||
cache: new InMemoryCache({
|
cache: new InMemoryCache({
|
||||||
freezeResults: true,
|
freezeResults: true,
|
||||||
typePolicies: {
|
typePolicies: {
|
||||||
|
Sub: {
|
||||||
|
keyFields: ['name']
|
||||||
|
},
|
||||||
User: {
|
User: {
|
||||||
// https://www.apollographql.com/docs/react/caching/cache-field-behavior/#merging-non-normalized-objects
|
// https://www.apollographql.com/docs/react/caching/cache-field-behavior/#merging-non-normalized-objects
|
||||||
fields: {
|
fields: {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// XXX this is temporary until we have so many subs they have
|
// XXX this is temporary until we have so many subs they have
|
||||||
// to be loaded from the server
|
// to be loaded from the server
|
||||||
export const SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
|
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
|
||||||
export const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs')
|
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
|
||||||
|
|
||||||
export const NOFOLLOW_LIMIT = 1000
|
export const NOFOLLOW_LIMIT = 1000
|
||||||
export const BOOST_MULT = 5000
|
export const BOOST_MULT = 5000
|
||||||
@ -17,10 +17,14 @@ export const UPLOAD_TYPES_ALLOW = [
|
|||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/webp'
|
'image/webp'
|
||||||
]
|
]
|
||||||
|
export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL']
|
||||||
|
export const TERRITORY_BILLING_TYPES = ['MONTHLY', 'YEARLY', 'ONCE']
|
||||||
|
export const TERRITORY_GRACE_DAYS = 5
|
||||||
export const COMMENT_DEPTH_LIMIT = 8
|
export const COMMENT_DEPTH_LIMIT = 8
|
||||||
export const MAX_TITLE_LENGTH = 80
|
export const MAX_TITLE_LENGTH = 80
|
||||||
export const MAX_POST_TEXT_LENGTH = 100000 // 100k
|
export const MAX_POST_TEXT_LENGTH = 100000 // 100k
|
||||||
export const MAX_COMMENT_TEXT_LENGTH = 10000 // 10k
|
export const MAX_COMMENT_TEXT_LENGTH = 10000 // 10k
|
||||||
|
export const MAX_TERRITORY_DESC_LENGTH = 140
|
||||||
export const MAX_POLL_CHOICE_LENGTH = 40
|
export const MAX_POLL_CHOICE_LENGTH = 40
|
||||||
export const ITEM_SPAM_INTERVAL = '10m'
|
export const ITEM_SPAM_INTERVAL = '10m'
|
||||||
export const ANON_ITEM_SPAM_INTERVAL = '0'
|
export const ANON_ITEM_SPAM_INTERVAL = '0'
|
||||||
@ -38,17 +42,9 @@ export const COMMENT_TYPE_QUERY = ['comments', 'freebies', 'outlawed', 'borderla
|
|||||||
export const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals']
|
export const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals']
|
||||||
export const ITEM_SORTS = ['zaprank', 'comments', 'sats']
|
export const ITEM_SORTS = ['zaprank', 'comments', 'sats']
|
||||||
export const WHENS = ['day', 'week', 'month', 'year', 'forever', 'custom']
|
export const WHENS = ['day', 'week', 'month', 'year', 'forever', 'custom']
|
||||||
export const ITEM_TYPES = context => {
|
export const ITEM_TYPES_USER = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'jobs', 'bookmarks']
|
||||||
const items = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls']
|
export const ITEM_TYPES = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'bios', 'jobs']
|
||||||
if (!context) {
|
export const ITEM_TYPES_UNIVERSAL = ['all', 'posts', 'comments', 'freebies']
|
||||||
items.push('bios', 'jobs')
|
|
||||||
}
|
|
||||||
items.push('freebies')
|
|
||||||
if (context === 'user') {
|
|
||||||
items.push('jobs', 'bookmarks')
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
export const OLD_ITEM_DAYS = 3
|
export const OLD_ITEM_DAYS = 3
|
||||||
export const ANON_USER_ID = 27
|
export const ANON_USER_ID = 27
|
||||||
export const DELETE_USER_ID = 106
|
export const DELETE_USER_ID = 106
|
||||||
@ -63,6 +59,28 @@ export const GLOBAL_SEED = 616
|
|||||||
export const FREEBIE_BASE_COST_THRESHOLD = 10
|
export const FREEBIE_BASE_COST_THRESHOLD = 10
|
||||||
export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, AD_USER_ID]
|
export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, AD_USER_ID]
|
||||||
|
|
||||||
|
export const TERRITORY_COST_MONTHLY = 100000
|
||||||
|
export const TERRITORY_COST_YEARLY = 1000000
|
||||||
|
export const TERRITORY_COST_ONCE = 3000000
|
||||||
|
|
||||||
|
export const TERRITORY_BILLING_OPTIONS = (labelPrefix) => ({
|
||||||
|
monthly: {
|
||||||
|
term: '+ 100k',
|
||||||
|
label: `${labelPrefix} month`,
|
||||||
|
modifier: cost => cost + TERRITORY_COST_MONTHLY
|
||||||
|
},
|
||||||
|
yearly: {
|
||||||
|
term: '+ 1m',
|
||||||
|
label: `${labelPrefix} year`,
|
||||||
|
modifier: cost => cost + TERRITORY_COST_YEARLY
|
||||||
|
},
|
||||||
|
once: {
|
||||||
|
term: '+ 3m',
|
||||||
|
label: 'one time',
|
||||||
|
modifier: cost => cost + TERRITORY_COST_ONCE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export const FOUND_BLURBS = [
|
export const FOUND_BLURBS = [
|
||||||
'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.',
|
'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.',
|
||||||
'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.',
|
'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.',
|
||||||
|
138
lib/validate.js
138
lib/validate.js
@ -1,10 +1,16 @@
|
|||||||
import { string, ValidationError, number, object, array, addMethod, boolean } from 'yup'
|
import { string, ValidationError, number, object, array, addMethod, boolean } from 'yup'
|
||||||
import { BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, MIN_POLL_NUM_CHOICES, SUBS_NO_JOBS, MAX_FORWARDS, BOOST_MULT, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH } from './constants'
|
import {
|
||||||
|
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
|
||||||
|
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
|
||||||
|
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH
|
||||||
|
} from './constants'
|
||||||
import { URL_REGEXP, WS_REGEXP } from './url'
|
import { URL_REGEXP, WS_REGEXP } from './url'
|
||||||
import { SUPPORTED_CURRENCIES } from './currency'
|
import { SUPPORTED_CURRENCIES } from './currency'
|
||||||
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
|
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
|
||||||
import * as pkg from '../fragments/users'
|
import * as usersFragments from '../fragments/users'
|
||||||
const { NAME_QUERY } = pkg
|
import * as subsFragments from '../fragments/subs'
|
||||||
|
const { SUB } = subsFragments
|
||||||
|
const { NAME_QUERY } = usersFragments
|
||||||
|
|
||||||
export async function ssValidate (schema, data, args) {
|
export async function ssValidate (schema, data, args) {
|
||||||
try {
|
try {
|
||||||
@ -46,6 +52,10 @@ const textValidator = (max) => string().trim().max(
|
|||||||
max,
|
max,
|
||||||
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
|
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
|
||||||
)
|
)
|
||||||
|
const nameValidator = string()
|
||||||
|
.required('required')
|
||||||
|
.matches(/^[\w_]+$/, 'only letters, numbers, and _')
|
||||||
|
.max(32, 'too long')
|
||||||
|
|
||||||
const intValidator = number().typeError('must be a number').integer('must be whole')
|
const intValidator = number().typeError('must be a number').integer('must be whole')
|
||||||
|
|
||||||
@ -64,6 +74,55 @@ async function usernameExists (name, { client, models }) {
|
|||||||
return !!user
|
return !!user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function subExists (name, { client, models, me }) {
|
||||||
|
if (!client && !models) {
|
||||||
|
throw new Error('cannot check for territory')
|
||||||
|
}
|
||||||
|
|
||||||
|
let sub
|
||||||
|
// apollo client
|
||||||
|
if (client) {
|
||||||
|
const { data } = await client.query({ query: SUB, variables: { sub: name } })
|
||||||
|
sub = data?.sub
|
||||||
|
} else {
|
||||||
|
sub = await models.sub.findUnique({ where: { name } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!sub && sub.userId !== Number(me?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subActive (name, { client, models, me }) {
|
||||||
|
if (!client && !models) {
|
||||||
|
throw new Error('cannot check if territory is active')
|
||||||
|
}
|
||||||
|
|
||||||
|
let sub
|
||||||
|
// apollo client
|
||||||
|
if (client) {
|
||||||
|
const { data } = await client.query({ query: SUB, variables: { sub: name } })
|
||||||
|
sub = data?.sub
|
||||||
|
} else {
|
||||||
|
sub = await models.sub.findUnique({ where: { name } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return sub && sub.status !== 'STOPPED'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subHasPostType (name, type, { client, models }) {
|
||||||
|
if (!client && !models) {
|
||||||
|
throw new Error('cannot check for territory')
|
||||||
|
}
|
||||||
|
// apollo client
|
||||||
|
if (client) {
|
||||||
|
const { data } = await client.query({ query: SUB, variables: { name } })
|
||||||
|
return !!(data?.sub?.postTypes?.includes(type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// prisma client
|
||||||
|
const sub = await models.sub.findUnique({ where: { name } })
|
||||||
|
return !!(sub?.postTypes?.includes(type))
|
||||||
|
}
|
||||||
|
|
||||||
export function advPostSchemaMembers ({ me, existingBoost = 0, ...args }) {
|
export function advPostSchemaMembers ({ me, existingBoost = 0, ...args }) {
|
||||||
const boostMin = existingBoost || BOOST_MIN
|
const boostMin = existingBoost || BOOST_MIN
|
||||||
return {
|
return {
|
||||||
@ -108,9 +167,26 @@ export function advPostSchemaMembers ({ me, existingBoost = 0, ...args }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subSelectSchemaMembers () {
|
export function subSelectSchemaMembers (args) {
|
||||||
|
// for subSelectSchemaMembers we want to filter out me
|
||||||
|
// because we want to allow the user to select their own territory
|
||||||
|
const { me, ...filteredArgs } = args
|
||||||
return {
|
return {
|
||||||
sub: string().required('required').oneOf(SUBS_NO_JOBS, 'required')
|
sub: string().required('required').test({
|
||||||
|
name: 'sub',
|
||||||
|
test: async sub => {
|
||||||
|
if (!sub || !sub.length) return false
|
||||||
|
return await subExists(sub, filteredArgs)
|
||||||
|
},
|
||||||
|
message: 'pick valid territory'
|
||||||
|
}).test({
|
||||||
|
name: 'sub',
|
||||||
|
test: async sub => {
|
||||||
|
if (!sub || !sub.length) return false
|
||||||
|
return await subActive(sub, filteredArgs)
|
||||||
|
},
|
||||||
|
message: 'territory is not active'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// for testing advPostSchemaMembers in isolation
|
// for testing advPostSchemaMembers in isolation
|
||||||
@ -128,7 +204,11 @@ export function bountySchema (args) {
|
|||||||
.min(1000, 'must be at least 1000')
|
.min(1000, 'must be at least 1000')
|
||||||
.max(1000000, 'must be at most 1m'),
|
.max(1000000, 'must be at most 1m'),
|
||||||
...advPostSchemaMembers(args),
|
...advPostSchemaMembers(args),
|
||||||
...subSelectSchemaMembers()
|
...subSelectSchemaMembers(args)
|
||||||
|
}).test({
|
||||||
|
name: 'post-type-supported',
|
||||||
|
test: ({ sub }) => subHasPostType(sub, 'BOUNTY', args),
|
||||||
|
message: 'territory does not support bounties'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,7 +217,11 @@ export function discussionSchema (args) {
|
|||||||
title: titleValidator,
|
title: titleValidator,
|
||||||
text: textValidator(MAX_POST_TEXT_LENGTH),
|
text: textValidator(MAX_POST_TEXT_LENGTH),
|
||||||
...advPostSchemaMembers(args),
|
...advPostSchemaMembers(args),
|
||||||
...subSelectSchemaMembers()
|
...subSelectSchemaMembers(args)
|
||||||
|
}).test({
|
||||||
|
name: 'post-type-supported',
|
||||||
|
test: ({ sub }) => subHasPostType(sub, 'DISCUSSION', args),
|
||||||
|
message: 'territory does not support discussions'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,7 +231,11 @@ export function linkSchema (args) {
|
|||||||
text: textValidator(MAX_POST_TEXT_LENGTH),
|
text: textValidator(MAX_POST_TEXT_LENGTH),
|
||||||
url: string().matches(URL_REGEXP, 'invalid url').required('required'),
|
url: string().matches(URL_REGEXP, 'invalid url').required('required'),
|
||||||
...advPostSchemaMembers(args),
|
...advPostSchemaMembers(args),
|
||||||
...subSelectSchemaMembers()
|
...subSelectSchemaMembers(args)
|
||||||
|
}).test({
|
||||||
|
name: 'post-type-supported',
|
||||||
|
test: ({ sub }) => subHasPostType(sub, 'LINK', args),
|
||||||
|
message: 'territory does not support links'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,16 +257,40 @@ export function pollSchema ({ numExistingChoices = 0, ...args }) {
|
|||||||
test: arr => arr.length >= MIN_POLL_NUM_CHOICES - numExistingChoices
|
test: arr => arr.length >= MIN_POLL_NUM_CHOICES - numExistingChoices
|
||||||
}),
|
}),
|
||||||
...advPostSchemaMembers(args),
|
...advPostSchemaMembers(args),
|
||||||
...subSelectSchemaMembers()
|
...subSelectSchemaMembers(args)
|
||||||
|
}).test({
|
||||||
|
name: 'post-type-supported',
|
||||||
|
test: ({ sub }) => subHasPostType(sub, 'POLL', args),
|
||||||
|
message: 'territory does not support polls'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function territorySchema (args) {
|
||||||
|
return object({
|
||||||
|
name: nameValidator
|
||||||
|
.test({
|
||||||
|
name: 'name',
|
||||||
|
test: async name => {
|
||||||
|
if (!name || !name.length) return false
|
||||||
|
return !(await subExists(name, args))
|
||||||
|
},
|
||||||
|
message: 'taken'
|
||||||
|
}),
|
||||||
|
desc: string().required('required').trim().max(
|
||||||
|
MAX_TERRITORY_DESC_LENGTH,
|
||||||
|
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
|
||||||
|
),
|
||||||
|
baseCost: intValidator
|
||||||
|
.min(1, 'must be at least 1')
|
||||||
|
.max(100000, 'must be at most 100k'),
|
||||||
|
postTypes: array().of(string().oneOf(POST_TYPES)).min(1, 'must support at least one post type'),
|
||||||
|
billingType: string().required('required').oneOf(TERRITORY_BILLING_TYPES, 'required')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userSchema (args) {
|
export function userSchema (args) {
|
||||||
return object({
|
return object({
|
||||||
name: string()
|
name: nameValidator
|
||||||
.required('required')
|
|
||||||
.matches(/^[\w_]+$/, 'only letters, numbers, and _')
|
|
||||||
.max(32, 'too long')
|
|
||||||
.test({
|
.test({
|
||||||
name: 'name',
|
name: 'name',
|
||||||
test: async name => {
|
test: async name => {
|
||||||
|
@ -3,7 +3,7 @@ import Items from '../../components/items'
|
|||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { USER, USER_WITH_ITEMS } from '../../fragments/users'
|
import { USER, USER_WITH_ITEMS } from '../../fragments/users'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { COMMENT_TYPE_QUERY, ITEM_SORTS, ITEM_TYPES, WHENS } from '../../lib/constants'
|
import { COMMENT_TYPE_QUERY, ITEM_SORTS, ITEM_TYPES_USER, WHENS } from '../../lib/constants'
|
||||||
import PageLoading from '../../components/page-loading'
|
import PageLoading from '../../components/page-loading'
|
||||||
import { UserLayout } from '.'
|
import { UserLayout } from '.'
|
||||||
import { Form, Select, DatePicker } from '../../components/form'
|
import { Form, Select, DatePicker } from '../../components/form'
|
||||||
@ -45,7 +45,7 @@ function UserItemsHeader ({ type, name }) {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
async function select (values) {
|
async function select (values) {
|
||||||
let { type, ...query } = values
|
let { type, ...query } = values
|
||||||
if (!type || type === 'all' || !ITEM_TYPES('user').includes(type)) type = 'all'
|
if (!type || type === 'all' || !ITEM_TYPES_USER.includes(type)) type = 'all'
|
||||||
if (!query.by || query.by === 'recent' || !ITEM_SORTS.includes(query.by)) delete query.by
|
if (!query.by || query.by === 'recent' || !ITEM_SORTS.includes(query.by)) delete query.by
|
||||||
if (!query.when || query.when === 'forever' || !WHENS.includes(query.when) || query.when === 'forever') delete query.when
|
if (!query.when || query.when === 'forever' || !WHENS.includes(query.when) || query.when === 'forever') delete query.when
|
||||||
if (query.when !== 'custom') { delete query.from; delete query.to }
|
if (query.when !== 'custom') { delete query.from; delete query.to }
|
||||||
@ -74,7 +74,7 @@ function UserItemsHeader ({ type, name }) {
|
|||||||
name='type'
|
name='type'
|
||||||
size='sm'
|
size='sm'
|
||||||
overrideValue={type}
|
overrideValue={type}
|
||||||
items={ITEM_TYPES('user')}
|
items={ITEM_TYPES_USER}
|
||||||
onChange={(formik, e) => select({ ...formik?.values, type: e.target.value })}
|
onChange={(formik, e) => select({ ...formik?.values, type: e.target.value })}
|
||||||
/>
|
/>
|
||||||
by
|
by
|
||||||
|
@ -6,12 +6,12 @@ import { CenterLayout } from '../../../components/layout'
|
|||||||
import JobForm from '../../../components/job-form'
|
import JobForm from '../../../components/job-form'
|
||||||
import { PollForm } from '../../../components/poll-form'
|
import { PollForm } from '../../../components/poll-form'
|
||||||
import { BountyForm } from '../../../components/bounty-form'
|
import { BountyForm } from '../../../components/bounty-form'
|
||||||
import SubSelect from '../../../components/sub-select-form'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import PageLoading from '../../../components/page-loading'
|
import PageLoading from '../../../components/page-loading'
|
||||||
import { FeeButtonProvider } from '../../../components/fee-button'
|
import { FeeButtonProvider } from '../../../components/fee-button'
|
||||||
|
import SubSelect from '../../../components/sub-select'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({
|
export const getServerSideProps = getGetServerSideProps({
|
||||||
query: ITEM,
|
query: ITEM,
|
||||||
@ -30,14 +30,19 @@ export default function PostEdit ({ ssrData }) {
|
|||||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||||
|
|
||||||
let FormType = DiscussionForm
|
let FormType = DiscussionForm
|
||||||
|
let itemType = 'DISCUSSION'
|
||||||
if (item.isJob) {
|
if (item.isJob) {
|
||||||
FormType = JobForm
|
FormType = JobForm
|
||||||
|
itemType = 'JOB'
|
||||||
} else if (item.url) {
|
} else if (item.url) {
|
||||||
FormType = LinkForm
|
FormType = LinkForm
|
||||||
|
itemType = 'LINK'
|
||||||
} else if (item.pollCost) {
|
} else if (item.pollCost) {
|
||||||
FormType = PollForm
|
FormType = PollForm
|
||||||
|
itemType = 'POLL'
|
||||||
} else if (item.bounty) {
|
} else if (item.bounty) {
|
||||||
FormType = BountyForm
|
FormType = BountyForm
|
||||||
|
itemType = 'BOUNTY'
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingBoostLineItem = item.boost
|
const existingBoostLineItem = item.boost
|
||||||
@ -54,7 +59,14 @@ export default function PostEdit ({ ssrData }) {
|
|||||||
<CenterLayout sub={sub}>
|
<CenterLayout sub={sub}>
|
||||||
<FeeButtonProvider baseLineItems={existingBoostLineItem}>
|
<FeeButtonProvider baseLineItems={existingBoostLineItem}>
|
||||||
<FormType item={item} editThreshold={editThreshold}>
|
<FormType item={item} editThreshold={editThreshold}>
|
||||||
{!item.isJob && <SubSelect label='sub' item={item} setSub={setSub} sub={sub} />}
|
{!item.isJob &&
|
||||||
|
<SubSelect
|
||||||
|
className='w-auto d-flex'
|
||||||
|
label='territory'
|
||||||
|
filterSubs={s => s.name !== 'jobs' && s.postTypes?.includes(itemType)}
|
||||||
|
onChange={(_, e) => setSub(e.target.value)}
|
||||||
|
sub={sub}
|
||||||
|
/>}
|
||||||
</FormType>
|
</FormType>
|
||||||
</FeeButtonProvider>
|
</FeeButtonProvider>
|
||||||
</CenterLayout>
|
</CenterLayout>
|
||||||
|
@ -88,7 +88,7 @@ function Detail ({ fact }) {
|
|||||||
if (fact.type === 'earn') {
|
if (fact.type === 'earn') {
|
||||||
return (
|
return (
|
||||||
<Link href={`/rewards/${new Date(fact.createdAt).toISOString().slice(0, 10)}`} className='px-3 text-reset' style={{ lineHeight: '140%' }}>
|
<Link href={`/rewards/${new Date(fact.createdAt).toISOString().slice(0, 10)}`} className='px-3 text-reset' style={{ lineHeight: '140%' }}>
|
||||||
SN distributes the sats it earns back to its best stackers daily. These sats come from <Link href='/~jobs'>jobs</Link>, boosts, posting fees, and donations.
|
SN distributes the sats it earns back to its best stackers daily. These sats come from jobs, boosts, posting fees, and donations.
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -107,6 +107,18 @@ function Detail ({ fact }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fact.type === 'billing') {
|
||||||
|
return (
|
||||||
|
<div className='px-3'>billing for <Link href={`/~${fact.subName}`}>~{fact.subName}</Link></div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fact.type === 'revenue') {
|
||||||
|
return (
|
||||||
|
<div className='px-3'>revenue for <Link href={`/~${fact.subName}`}>~{fact.subName}</Link></div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!fact.item) {
|
if (!fact.item) {
|
||||||
let zap
|
let zap
|
||||||
try {
|
try {
|
||||||
@ -114,7 +126,7 @@ function Detail ({ fact }) {
|
|||||||
} catch { }
|
} catch { }
|
||||||
return (
|
return (
|
||||||
<div className='px-3'>
|
<div className='px-3'>
|
||||||
<Link className={satusClass(fact.status)} href={`/${fact.type}s/${fact.factId}`}>
|
<Link className={satusClass(fact.status)} href={`/${fact.type}s/${fact.id}`}>
|
||||||
{(!fact.bolt11 && <span className='d-block text-muted fw-bold fst-italic'>invoice deleted</span>) ||
|
{(!fact.bolt11 && <span className='d-block text-muted fw-bold fst-italic'>invoice deleted</span>) ||
|
||||||
(zap && <span className='d-block'>nostr zap{zap.content && `: ${zap.content}`}</span>) ||
|
(zap && <span className='d-block'>nostr zap{zap.content && `: ${zap.content}`}</span>) ||
|
||||||
(fact.description && <span className='d-block'>{fact.description}</span>)}
|
(fact.description && <span className='d-block'>{fact.description}</span>)}
|
||||||
@ -217,7 +229,7 @@ export default function Satistics ({ ssrData }) {
|
|||||||
<div className={[styles.type, styles.head].join(' ')}>type</div>
|
<div className={[styles.type, styles.head].join(' ')}>type</div>
|
||||||
<div className={[styles.detail, styles.head].join(' ')}>detail</div>
|
<div className={[styles.detail, styles.head].join(' ')}>detail</div>
|
||||||
<div className={[styles.sats, styles.head].join(' ')}>sats</div>
|
<div className={[styles.sats, styles.head].join(' ')}>sats</div>
|
||||||
{facts.map(f => <Fact key={f.id} fact={f} />)}
|
{facts.map(f => <Fact key={f.type + f.id} fact={f} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MoreFooter cursor={cursor} count={facts?.length} fetchMore={fetchMore} Skeleton={PageLoading} />
|
<MoreFooter cursor={cursor} count={facts?.length} fetchMore={fetchMore} Skeleton={PageLoading} />
|
||||||
|
14
pages/territory.js
Normal file
14
pages/territory.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||||
|
import { CenterLayout } from '../components/layout'
|
||||||
|
import TerritoryForm from '../components/territory-form'
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps({})
|
||||||
|
|
||||||
|
export default function TerritoryPage () {
|
||||||
|
return (
|
||||||
|
<CenterLayout>
|
||||||
|
<h1 className='mt-5'>break new ground</h1>
|
||||||
|
<TerritoryForm />
|
||||||
|
</CenterLayout>
|
||||||
|
)
|
||||||
|
}
|
30
pages/~/edit.js
Normal file
30
pages/~/edit.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { SUB } from '../../fragments/subs'
|
||||||
|
import { getGetServerSideProps } from '../../api/ssrApollo'
|
||||||
|
import { CenterLayout } from '../../components/layout'
|
||||||
|
import TerritoryForm from '../../components/territory-form'
|
||||||
|
import PageLoading from '../../components/page-loading'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import TerritoryPaymentDue from '../../components/territory-payment-due'
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps({
|
||||||
|
query: SUB,
|
||||||
|
notFound: (data, vars, me) => !data.sub || Number(data.sub.userId) !== Number(me?.id),
|
||||||
|
authRequired: true
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function TerritoryPage ({ ssrData }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { data } = useQuery(SUB, { variables: { sub: router.query.sub } })
|
||||||
|
if (!data && !ssrData) return <PageLoading />
|
||||||
|
|
||||||
|
const { sub } = data || ssrData
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenterLayout sub={sub?.name}>
|
||||||
|
<TerritoryPaymentDue sub={sub} />
|
||||||
|
<h1 className='mt-5'>edit territory</h1>
|
||||||
|
<TerritoryForm sub={sub} />
|
||||||
|
</CenterLayout>
|
||||||
|
)
|
||||||
|
}
|
@ -2,9 +2,21 @@ import { useRouter } from 'next/router'
|
|||||||
import { getGetServerSideProps } from '../../api/ssrApollo'
|
import { getGetServerSideProps } from '../../api/ssrApollo'
|
||||||
import Items from '../../components/items'
|
import Items from '../../components/items'
|
||||||
import Layout from '../../components/layout'
|
import Layout from '../../components/layout'
|
||||||
import { SUB_ITEMS } from '../../fragments/subs'
|
import { SUB_FULL, SUB_ITEMS } from '../../fragments/subs'
|
||||||
import Snl from '../../components/snl'
|
import Snl from '../../components/snl'
|
||||||
import WelcomeBanner from '../../components/banners'
|
import WelcomeBanner from '../../components/banners'
|
||||||
|
import { AccordianCard } from '../../components/accordian-item'
|
||||||
|
import Text from '../../components/text'
|
||||||
|
import { useMe } from '../../components/me'
|
||||||
|
import Gear from '../../svgs/settings-5-fill.svg'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import PageLoading from '../../components/page-loading'
|
||||||
|
import CardFooter from 'react-bootstrap/CardFooter'
|
||||||
|
import Hat from '../../components/hat'
|
||||||
|
import styles from '../../components/item.module.css'
|
||||||
|
import TerritoryPaymentDue from '../../components/territory-payment-due'
|
||||||
|
import Badge from 'react-bootstrap/Badge'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({
|
export const getServerSideProps = getGetServerSideProps({
|
||||||
query: SUB_ITEMS,
|
query: SUB_ITEMS,
|
||||||
@ -13,12 +25,49 @@ export const getServerSideProps = getGetServerSideProps({
|
|||||||
|
|
||||||
export default function Sub ({ ssrData }) {
|
export default function Sub ({ ssrData }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const me = useMe()
|
||||||
const variables = { ...router.query }
|
const variables = { ...router.query }
|
||||||
|
const { data } = useQuery(SUB_FULL, { variables })
|
||||||
|
|
||||||
|
if (!data && !ssrData) return <PageLoading />
|
||||||
|
const { sub } = data || ssrData
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout sub={variables.sub}>
|
<Layout sub={variables.sub}>
|
||||||
<Snl />
|
{sub
|
||||||
<WelcomeBanner />
|
? (
|
||||||
|
<>
|
||||||
|
<TerritoryPaymentDue sub={sub} />
|
||||||
|
<div className='mb-3 d-flex'>
|
||||||
|
<div className='flex-grow-1'>
|
||||||
|
<AccordianCard
|
||||||
|
header={<small className='text-muted fw-bold'>territory details{sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>}</small>}
|
||||||
|
>
|
||||||
|
<div className='py-2'>
|
||||||
|
<Text>{sub.desc}</Text>
|
||||||
|
</div>
|
||||||
|
<CardFooter className={`py-1 ${styles.other}`}>
|
||||||
|
<div className='text-muted'>
|
||||||
|
<span>founded by</span>
|
||||||
|
<span> </span>
|
||||||
|
<Link href={`/${sub.user.name}`}>
|
||||||
|
@{sub.user.name}<span> </span><Hat className='fill-grey' user={sub.user} height={12} width={12} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</AccordianCard>
|
||||||
|
</div>
|
||||||
|
{Number(sub.userId) === Number(me?.id) &&
|
||||||
|
<Link href={`/~${sub.name}/edit`} className='d-flex align-items-center flex-shrink-1 ps-2'>
|
||||||
|
<Gear className='fill-grey' width={22} height={22} />
|
||||||
|
</Link>}
|
||||||
|
</div>
|
||||||
|
</>)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<Snl />
|
||||||
|
<WelcomeBanner />
|
||||||
|
</>)}
|
||||||
<Items ssrData={ssrData} variables={variables} />
|
<Items ssrData={ssrData} variables={variables} />
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
@ -3,8 +3,10 @@ import Items from '../../../components/items'
|
|||||||
import { getGetServerSideProps } from '../../../api/ssrApollo'
|
import { getGetServerSideProps } from '../../../api/ssrApollo'
|
||||||
import RecentHeader from '../../../components/recent-header'
|
import RecentHeader from '../../../components/recent-header'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { SUB_ITEMS } from '../../../fragments/subs'
|
import { SUB_FULL, SUB_ITEMS } from '../../../fragments/subs'
|
||||||
import { COMMENT_TYPE_QUERY } from '../../../lib/constants'
|
import { COMMENT_TYPE_QUERY } from '../../../lib/constants'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import PageLoading from '../../../components/page-loading'
|
||||||
|
|
||||||
const staticVariables = { sort: 'recent' }
|
const staticVariables = { sort: 'recent' }
|
||||||
const variablesFunc = vars =>
|
const variablesFunc = vars =>
|
||||||
@ -18,10 +20,14 @@ export const getServerSideProps = getGetServerSideProps({
|
|||||||
export default function Index ({ ssrData }) {
|
export default function Index ({ ssrData }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const variables = variablesFunc(router.query)
|
const variables = variablesFunc(router.query)
|
||||||
|
const { data } = useQuery(SUB_FULL, { variables })
|
||||||
|
|
||||||
|
if (!data && !ssrData) return <PageLoading />
|
||||||
|
const { sub } = data || ssrData
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout sub={variables.sub}>
|
<Layout sub={sub?.name}>
|
||||||
<RecentHeader sub={variables.sub} />
|
<RecentHeader sub={sub} />
|
||||||
<Items
|
<Items
|
||||||
ssrData={ssrData}
|
ssrData={ssrData}
|
||||||
query={SUB_ITEMS}
|
query={SUB_ITEMS}
|
||||||
|
285
prisma/migrations/20231201014953_territory/migration.sql
Normal file
285
prisma/migrations/20231201014953_territory/migration.sql
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `billingCost` to the `Sub` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `billingType` to the `Sub` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `userId` to the `Sub` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "BillingType" AS ENUM ('MONTHLY', 'YEARLY', 'ONCE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SubActType" AS ENUM ('BILLING', 'REVENUE');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "Status" ADD VALUE 'GRACE';
|
||||||
|
|
||||||
|
-- add new columns giving old columns default special default values
|
||||||
|
ALTER TABLE "Sub"
|
||||||
|
ADD COLUMN "billedLastAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "billingCost" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "billingType" "BillingType" NOT NULL DEFAULT 'ONCE',
|
||||||
|
ADD COLUMN "parentName" CITEXT,
|
||||||
|
ADD COLUMN "path" ltree,
|
||||||
|
ADD COLUMN "status" "Status" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
ADD COLUMN "userId" INTEGER NOT NULL DEFAULT 616,
|
||||||
|
ADD COLUMN "rewardsPct" INTEGER NOT NULL DEFAULT 100;
|
||||||
|
|
||||||
|
-- set the default values for the new columns from this point forward
|
||||||
|
ALTER TABLE "Sub"
|
||||||
|
ALTER COLUMN "billingCost" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "billingType" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "userId" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "rewardsPct" SET DEFAULT 50;
|
||||||
|
|
||||||
|
-- constrain percent to be between 0 and 100
|
||||||
|
ALTER TABLE "Sub" ADD CONSTRAINT "rewardsPct" CHECK ("rewardsPct" >= 0 AND "rewardsPct" <= 100) NOT VALID;
|
||||||
|
|
||||||
|
-- we plan to structure subs as a tree
|
||||||
|
UPDATE "Sub" SET "path" = LOWER(name)::ltree;
|
||||||
|
|
||||||
|
-- assign subs to appropriate people
|
||||||
|
-- UPDATE "Sub" SET "userId" = 6030 WHERE name = 'tech';
|
||||||
|
-- UPDATE "Sub" SET "userId" = 4502 WHERE name = 'meta';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SubAct" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"subName" CITEXT NOT NULL,
|
||||||
|
"msats" BIGINT NOT NULL,
|
||||||
|
"type" "SubActType" NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SubAct_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SubAct_userId_idx" ON "SubAct"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SubAct_userId_type_idx" ON "SubAct"("userId", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SubAct_type_idx" ON "SubAct"("type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SubAct_created_at_idx" ON "SubAct"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SubAct_created_at_type_idx" ON "SubAct"("created_at", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SubAct_userId_created_at_type_idx" ON "SubAct"("userId", "created_at", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Sub_parentName_idx" ON "Sub"("parentName");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Sub_created_at_idx" ON "Sub"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Sub_userId_idx" ON "Sub"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Sub_path_idx" ON "Sub" USING GIST ("path" gist_ltree_ops(siglen=2024));
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Sub" ADD CONSTRAINT "Sub_parentName_fkey" FOREIGN KEY ("parentName") REFERENCES "Sub"("name") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Sub" ADD CONSTRAINT "Sub_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SubAct" ADD CONSTRAINT "SubAct_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SubAct" ADD CONSTRAINT "SubAct_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_sub_path() RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
npath ltree;
|
||||||
|
BEGIN
|
||||||
|
IF NEW."parentName" IS NULL THEN
|
||||||
|
SELECT LOWER(NEW.name)::ltree INTO npath;
|
||||||
|
NEW."path" = npath;
|
||||||
|
ELSEIF TG_OP = 'INSERT' OR OLD."parentName" IS NULL OR OLD."parentName" != NEW."parentName" THEN
|
||||||
|
SELECT "path" || LOWER(NEW.name)::text
|
||||||
|
FROM "Sub"
|
||||||
|
WHERE name = NEW."parentName"
|
||||||
|
INTO npath;
|
||||||
|
|
||||||
|
IF npath IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Invalid parent name %', NEW."parentName";
|
||||||
|
END IF;
|
||||||
|
NEW."path" = npath;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER sub_path_tgr
|
||||||
|
BEFORE INSERT OR UPDATE ON "Sub"
|
||||||
|
FOR EACH ROW EXECUTE PROCEDURE update_sub_path();
|
||||||
|
|
||||||
|
-- fix balance limit check
|
||||||
|
CREATE OR REPLACE FUNCTION create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone,
|
||||||
|
msats_req BIGINT, user_id INTEGER, idesc TEXT, comment TEXT, lud18_data JSONB, inv_limit INTEGER, balance_limit_msats BIGINT)
|
||||||
|
RETURNS "Invoice"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
invoice "Invoice";
|
||||||
|
inv_limit_reached BOOLEAN;
|
||||||
|
balance_limit_reached BOOLEAN;
|
||||||
|
inv_pending_msats BIGINT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
-- prevent too many pending invoices
|
||||||
|
SELECT inv_limit > 0 AND count(*) >= inv_limit, COALESCE(sum("msatsRequested"), 0) INTO inv_limit_reached, inv_pending_msats
|
||||||
|
FROM "Invoice"
|
||||||
|
WHERE "userId" = user_id AND "expiresAt" > now_utc() AND "confirmedAt" IS NULL AND cancelled = false;
|
||||||
|
|
||||||
|
IF inv_limit_reached THEN
|
||||||
|
RAISE EXCEPTION 'SN_INV_PENDING_LIMIT';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- prevent pending invoices + msats from exceeding the limit
|
||||||
|
SELECT balance_limit_msats > 0 AND inv_pending_msats+msats_req+msats > balance_limit_msats INTO balance_limit_reached
|
||||||
|
FROM users
|
||||||
|
WHERE id = user_id;
|
||||||
|
|
||||||
|
IF balance_limit_reached THEN
|
||||||
|
RAISE EXCEPTION 'SN_INV_EXCEED_BALANCE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- we good, proceed frens
|
||||||
|
INSERT INTO "Invoice" (hash, bolt11, "expiresAt", "msatsRequested", "userId", created_at, updated_at, "desc", comment, "lud18Data")
|
||||||
|
VALUES (hash, bolt11, expires_at, msats_req, user_id, now_utc(), now_utc(), idesc, comment, lud18_data) RETURNING * INTO invoice;
|
||||||
|
|
||||||
|
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
|
||||||
|
VALUES ('checkInvoice', jsonb_build_object('hash', hash), 21, true, now() + interval '10 seconds');
|
||||||
|
|
||||||
|
RETURN invoice;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
-- get spenders
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS spender_growth_days;
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS spender_growth_days AS
|
||||||
|
SELECT day, count(DISTINCT "userId") as any,
|
||||||
|
count(DISTINCT "userId") FILTER (WHERE act = 'STREAM') as jobs,
|
||||||
|
count(DISTINCT "userId") FILTER (WHERE act = 'BOOST') as boost,
|
||||||
|
count(DISTINCT "userId") FILTER (WHERE act = 'FEE') as fees,
|
||||||
|
count(DISTINCT "userId") FILTER (WHERE act = 'TIP') as tips,
|
||||||
|
count(DISTINCT "userId") FILTER (WHERE act = 'DONATION') as donations,
|
||||||
|
count(DISTINCT "userId") FILTER (WHERE act = 'TERRITORY') as territories
|
||||||
|
FROM days
|
||||||
|
LEFT JOIN
|
||||||
|
((SELECT "ItemAct".created_at, "userId", act::text as act
|
||||||
|
FROM "ItemAct")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, "userId", 'DONATION' as act
|
||||||
|
FROM "Donation")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, "userId", 'TERRITORY' as act
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE type = 'BILLING')
|
||||||
|
) u ON day = date_trunc('day', timezone('America/Chicago', u.created_at at time zone 'UTC'))
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day ASC;
|
||||||
|
|
||||||
|
-- get spending
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS spending_growth_days;
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS spending_growth_days AS
|
||||||
|
SELECT day, coalesce(floor(sum(msats) FILTER (WHERE act = 'STREAM')/1000), 0) as jobs,
|
||||||
|
coalesce(floor(sum(msats) FILTER (WHERE act = 'BOOST')/1000), 0) as boost,
|
||||||
|
coalesce(floor(sum(msats) FILTER (WHERE act NOT IN ('BOOST', 'TIP', 'STREAM', 'DONATION', 'TERRITORY'))/1000), 0) as fees,
|
||||||
|
coalesce(floor(sum(msats) FILTER (WHERE act = 'TIP')/1000), 0) as tips,
|
||||||
|
coalesce(floor(sum(msats) FILTER (WHERE act = 'DONATION')/1000), 0) as donations,
|
||||||
|
coalesce(floor(sum(msats) FILTER (WHERE act = 'TERRITORY')/1000), 0) as territories
|
||||||
|
FROM days
|
||||||
|
LEFT JOIN
|
||||||
|
((SELECT "ItemAct".created_at, msats, act::text as act
|
||||||
|
FROM "ItemAct")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, sats * 1000 as msats, 'DONATION' as act
|
||||||
|
FROM "Donation")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, msats, 'TERRITORY' as act
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE type = 'BILLING')
|
||||||
|
) u ON day = date_trunc('day', timezone('America/Chicago', u.created_at at time zone 'UTC'))
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day ASC;
|
||||||
|
|
||||||
|
-- get stackers
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS stackers_growth_days;
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS stackers_growth_days AS
|
||||||
|
SELECT day, count(distinct user_id) as any,
|
||||||
|
count(distinct user_id) FILTER (WHERE type = 'POST') as posts,
|
||||||
|
count(distinct user_id) FILTER (WHERE type = 'COMMENT') as comments,
|
||||||
|
count(distinct user_id) FILTER (WHERE type = 'EARN') as rewards,
|
||||||
|
count(distinct user_id) FILTER (WHERE type = 'REFERRAL') as referrals,
|
||||||
|
count(distinct user_id) FILTER (WHERE type = 'REVENUE') as territories
|
||||||
|
FROM days
|
||||||
|
LEFT JOIN
|
||||||
|
((SELECT "ItemAct".created_at, "Item"."userId" as user_id, CASE WHEN "Item"."parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type
|
||||||
|
FROM "ItemAct"
|
||||||
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
|
WHERE "ItemAct".act = 'TIP')
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, "userId" as user_id, 'EARN' as type
|
||||||
|
FROM "Earn")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type
|
||||||
|
FROM "ReferralAct")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, "userId" as user_id, 'REVENUE' as type
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE type = 'REVENUE')
|
||||||
|
) u ON day = date_trunc('day', timezone('America/Chicago', u.created_at at time zone 'UTC'))
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day ASC;
|
||||||
|
|
||||||
|
-- get stacking
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS stacking_growth_days;
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS stacking_growth_days AS
|
||||||
|
SELECT day, coalesce(floor(sum(airdrop)/1000),0) as rewards,
|
||||||
|
coalesce(floor(sum(post)/1000),0) as posts,
|
||||||
|
coalesce(floor(sum(comment)/1000),0) as comments,
|
||||||
|
coalesce(floor(sum(referral)/1000),0) as referrals,
|
||||||
|
coalesce(floor(sum(revenue)/1000),0) as territories
|
||||||
|
FROM days
|
||||||
|
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,
|
||||||
|
0 as referral, 0 as revenue
|
||||||
|
FROM "ItemAct"
|
||||||
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
|
WHERE "ItemAct".act = 'TIP')
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral, 0 as revenue
|
||||||
|
FROM "ReferralAct")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral, 0 as revenue
|
||||||
|
FROM "Earn")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, 0 as referral, msats as revenue
|
||||||
|
FROM "SubAct"
|
||||||
|
WHERE type = 'REVENUE')
|
||||||
|
) u ON day = date_trunc('day', timezone('America/Chicago', u.created_at at time zone 'UTC'))
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day ASC;
|
||||||
|
|
||||||
|
-- indices for the other materialized view so we can refresh concurrently
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS spender_growth_days_idx ON spender_growth_days(day);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS spending_growth_days_idx ON spending_growth_days(day);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS stackers_growth_days_idx ON stackers_growth_days(day);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS stacking_growth_days_idx ON stacking_growth_days(day);
|
||||||
|
|
@ -100,6 +100,8 @@ model User {
|
|||||||
muteds Mute[] @relation("muted")
|
muteds Mute[] @relation("muted")
|
||||||
ArcOut Arc[] @relation("fromUser")
|
ArcOut Arc[] @relation("fromUser")
|
||||||
ArcIn Arc[] @relation("toUser")
|
ArcIn Arc[] @relation("toUser")
|
||||||
|
Sub Sub[]
|
||||||
|
SubAct SubAct[]
|
||||||
|
|
||||||
@@index([photoId])
|
@@index([photoId])
|
||||||
@@index([createdAt], map: "users.created_at_index")
|
@@index([createdAt], map: "users.created_at_index")
|
||||||
@ -381,16 +383,66 @@ model PollVote {
|
|||||||
@@index([userId], map: "PollVote.userId_index")
|
@@index([userId], map: "PollVote.userId_index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum BillingType {
|
||||||
|
MONTHLY
|
||||||
|
YEARLY
|
||||||
|
ONCE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RankingType {
|
||||||
|
WOT
|
||||||
|
RECENT
|
||||||
|
AUCTION
|
||||||
|
}
|
||||||
|
|
||||||
model Sub {
|
model Sub {
|
||||||
name String @id @db.Citext
|
name String @id @db.Citext
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
|
userId Int
|
||||||
|
parentName String? @db.Citext
|
||||||
|
path Unsupported("ltree")?
|
||||||
|
|
||||||
postTypes PostType[]
|
postTypes PostType[]
|
||||||
rankingType RankingType
|
rankingType RankingType
|
||||||
baseCost Int @default(1)
|
baseCost Int @default(1)
|
||||||
|
rewardsPct Int @default(50)
|
||||||
desc String?
|
desc String?
|
||||||
|
status Status @default(ACTIVE)
|
||||||
|
billingType BillingType
|
||||||
|
billingCost Int
|
||||||
|
billedLastAt DateTime @default(now())
|
||||||
|
|
||||||
|
parent Sub? @relation("ParentChildren", fields: [parentName], references: [name])
|
||||||
|
children Sub[] @relation("ParentChildren")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
Item Item[]
|
Item Item[]
|
||||||
Subscription Subscription[]
|
Subscription Subscription[]
|
||||||
|
SubAct SubAct[]
|
||||||
|
|
||||||
|
@@index([parentName])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([path], type: Gist)
|
||||||
|
}
|
||||||
|
|
||||||
|
model SubAct {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
|
userId Int
|
||||||
|
subName String @db.Citext
|
||||||
|
msats BigInt
|
||||||
|
type SubActType
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([userId, type])
|
||||||
|
@@index([type])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([createdAt, type])
|
||||||
|
@@index([userId, createdAt, type])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Subscription {
|
model Subscription {
|
||||||
@ -630,10 +682,16 @@ enum EarnType {
|
|||||||
TIP_POST
|
TIP_POST
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SubActType {
|
||||||
|
BILLING
|
||||||
|
REVENUE
|
||||||
|
}
|
||||||
|
|
||||||
enum Status {
|
enum Status {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
STOPPED
|
STOPPED
|
||||||
NOSATS
|
NOSATS
|
||||||
|
GRACE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PostType {
|
enum PostType {
|
||||||
@ -644,12 +702,6 @@ enum PostType {
|
|||||||
BOUNTY
|
BOUNTY
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RankingType {
|
|
||||||
WOT
|
|
||||||
RECENT
|
|
||||||
AUCTION
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ItemActType {
|
enum ItemActType {
|
||||||
VOTE
|
VOTE
|
||||||
BOOST
|
BOOST
|
||||||
|
@ -58,7 +58,7 @@ $close-color: inherit;
|
|||||||
$alert-border-radius: #{33% 2%} / #{11% 74%};
|
$alert-border-radius: #{33% 2%} / #{11% 74%};
|
||||||
$link-color: #007cbe;
|
$link-color: #007cbe;
|
||||||
$link-decoration: none;
|
$link-decoration: none;
|
||||||
$font-size-base: .9rem;
|
$font-size-base: .93rem;
|
||||||
$enable-responsive-font-sizes: true;
|
$enable-responsive-font-sizes: true;
|
||||||
$link-hover-decoration: none;
|
$link-hover-decoration: none;
|
||||||
$dropdown-border-color: #ced4da;
|
$dropdown-border-color: #ced4da;
|
||||||
@ -95,6 +95,41 @@ $grid-gutter-width: 2rem;
|
|||||||
$toast-spacing: .5rem;
|
$toast-spacing: .5rem;
|
||||||
$btn-close-bg: none;
|
$btn-close-bg: none;
|
||||||
|
|
||||||
|
$accordion-padding-y: 0rem;
|
||||||
|
$accordion-padding-x: .75rem;
|
||||||
|
// $accordion-color: var(--#{$prefix}body-color) !default;
|
||||||
|
// $accordion-bg: var(--#{$prefix}body-bg) !default;
|
||||||
|
// $accordion-border-width: var(--#{$prefix}border-width) !default;
|
||||||
|
// $accordion-border-color: var(--#{$prefix}border-color) !default;
|
||||||
|
// $accordion-border-radius: var(--#{$prefix}border-radius) !default;
|
||||||
|
// $accordion-inner-border-radius: subtract($accordion-border-radius, $accordion-border-width) !default;
|
||||||
|
|
||||||
|
// $accordion-body-padding-y: $accordion-padding-y !default;
|
||||||
|
// $accordion-body-padding-x: $accordion-padding-x !default;
|
||||||
|
|
||||||
|
// $accordion-button-padding-y: $accordion-padding-y !default;
|
||||||
|
// $accordion-button-padding-x: $accordion-padding-x !default;
|
||||||
|
// $accordion-button-color: var(--#{$prefix}body-color) !default;
|
||||||
|
// $accordion-button-bg: var(--#{$prefix}accordion-bg) !default;
|
||||||
|
// $accordion-transition: $btn-transition, border-radius .15s ease !default;
|
||||||
|
|
||||||
|
// $accordion-button-focus-border-color: $input-focus-border-color !default;
|
||||||
|
// $accordion-button-focus-box-shadow: $btn-focus-box-shadow !default;
|
||||||
|
|
||||||
|
$accordion-icon-width: 2rem;
|
||||||
|
$accordion-icon-color: $form-select-indicator-color;
|
||||||
|
$accordion-icon-active-color: $form-select-indicator-color;
|
||||||
|
$accordion-icon-color-dark: $form-select-indicator-color;
|
||||||
|
$accordion-icon-active-color-dark: $form-select-indicator-color;
|
||||||
|
$accordion-button-active-bg: var(--theme-clickToContextColor);
|
||||||
|
$accordion-button-active-color: var(--bs-body-color);
|
||||||
|
|
||||||
|
$accordion-button-icon: url("data:image/svg+xml, %3Csvg fill='#{$form-select-indicator-color}' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 15.0006L7.75732 10.758L9.17154 9.34375L12 12.1722L14.8284 9.34375L16.2426 10.758L12 15.0006Z'%3E%3C/path%3E%3C/svg%3E%0A");
|
||||||
|
$accordion-button-active-icon: $accordion-button-icon;
|
||||||
|
$accordion-button-icon-dark: $accordion-button-icon;
|
||||||
|
$accordion-button-active-icon-dark: $accordion-button-icon;
|
||||||
|
|
||||||
|
|
||||||
:root, [data-bs-theme=light] {
|
:root, [data-bs-theme=light] {
|
||||||
--theme-navLink: rgba(0, 0, 0, 0.55);
|
--theme-navLink: rgba(0, 0, 0, 0.55);
|
||||||
--theme-navLinkFocus: rgba(0, 0, 0, 0.7);
|
--theme-navLinkFocus: rgba(0, 0, 0, 0.7);
|
||||||
@ -117,7 +152,7 @@ $btn-close-bg: none;
|
|||||||
[data-bs-theme=dark] {
|
[data-bs-theme=dark] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--theme-inputBg: #121211;
|
--theme-inputBg: #121211;
|
||||||
--theme-inputDisabledBg: #121211;
|
--theme-inputDisabledBg: #2d2d2d;
|
||||||
--theme-navLink: rgba(255, 255, 255, 0.55);
|
--theme-navLink: rgba(255, 255, 255, 0.55);
|
||||||
--theme-navLinkFocus: rgba(255, 255, 255, 0.75);
|
--theme-navLinkFocus: rgba(255, 255, 255, 0.75);
|
||||||
--theme-navLinkActive: rgba(255, 255, 255, 0.9);
|
--theme-navLinkActive: rgba(255, 255, 255, 0.9);
|
||||||
|
1
svgs/settings-5-fill.svg
Normal file
1
svgs/settings-5-fill.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2.13225 13.6308C1.95018 12.5349 1.95619 11.434 2.13313 10.3695C3.23434 10.3963 4.22471 9.86798 4.60963 8.93871C4.99454 8.00944 4.66782 6.93557 3.87024 6.17581C4.49783 5.29798 5.27202 4.51528 6.17568 3.86911C6.93548 4.66716 8.00968 4.99416 8.9392 4.60914C9.86872 4.22412 10.3971 3.23332 10.37 2.13176C11.4659 1.94969 12.5668 1.9557 13.6313 2.13265C13.6045 3.23385 14.1329 4.22422 15.0621 4.60914C15.9914 4.99406 17.0653 4.66733 17.825 3.86975C18.7029 4.49734 19.4856 5.27153 20.1317 6.1752C19.3337 6.93499 19.0067 8.00919 19.3917 8.93871C19.7767 9.86823 20.7675 10.3966 21.8691 10.3695C22.0511 11.4654 22.0451 12.5663 21.8682 13.6308C20.767 13.6041 19.7766 14.1324 19.3917 15.0616C19.0068 15.9909 19.3335 17.0648 20.1311 17.8245C19.5035 18.7024 18.7293 19.4851 17.8256 20.1312C17.0658 19.3332 15.9916 19.0062 15.0621 19.3912C14.1326 19.7762 13.6043 20.767 13.6313 21.8686C12.5354 22.0507 11.4345 22.0447 10.37 21.8677C10.3968 20.7665 9.86847 19.7761 8.9392 19.3912C8.00993 19.0063 6.93605 19.333 6.1763 20.1306C5.29847 19.503 4.51577 18.7288 3.8696 17.8252C4.66765 17.0654 4.99465 15.9912 4.60963 15.0616C4.22461 14.1321 3.23381 13.6038 2.13225 13.6308ZM12.0007 15.0002C13.6575 15.0002 15.0007 13.657 15.0007 12.0002C15.0007 10.3433 13.6575 9.00018 12.0007 9.00018C10.3438 9.00018 9.00066 10.3433 9.00066 12.0002C9.00066 13.657 10.3438 15.0002 12.0007 15.0002Z"></path></svg>
|
After Width: | Height: | Size: 1.4 KiB |
@ -1,28 +1,23 @@
|
|||||||
import serialize from '../api/resolvers/serial.js'
|
import serialize from '../api/resolvers/serial.js'
|
||||||
|
|
||||||
export function auction ({ models }) {
|
export async function auction ({ models }) {
|
||||||
return async function ({ name }) {
|
// get all items we need to check
|
||||||
console.log('running', name)
|
const items = await models.item.findMany(
|
||||||
// get all items we need to check
|
{
|
||||||
const items = await models.item.findMany(
|
where: {
|
||||||
{
|
maxBid: {
|
||||||
where: {
|
not: null
|
||||||
maxBid: {
|
},
|
||||||
not: null
|
status: {
|
||||||
},
|
not: 'STOPPED'
|
||||||
status: {
|
|
||||||
not: 'STOPPED'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// for each item, run serialized auction function
|
// for each item, run serialized auction function
|
||||||
items.forEach(async item => {
|
items.forEach(async item => {
|
||||||
await serialize(models,
|
await serialize(models,
|
||||||
models.$executeRaw`SELECT run_auction(${item.id}::INTEGER)`)
|
models.$executeRaw`SELECT run_auction(${item.id}::INTEGER)`)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('done', name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,23 @@
|
|||||||
import { deleteObjects } from '../api/s3'
|
import { deleteObjects } from '../api/s3'
|
||||||
import { ANON_USER_ID } from '../lib/constants'
|
import { ANON_USER_ID } from '../lib/constants'
|
||||||
|
|
||||||
export function deleteUnusedImages ({ models }) {
|
export async function deleteUnusedImages ({ models }) {
|
||||||
return async function ({ name }) {
|
// delete all images in database and S3 which weren't paid in the last 24 hours
|
||||||
console.log('running', name)
|
const unpaidImages = await models.$queryRaw`
|
||||||
|
SELECT id
|
||||||
|
FROM "Upload"
|
||||||
|
WHERE (paid = 'f'
|
||||||
|
OR (
|
||||||
|
-- for non-textarea images, they are free and paid is null
|
||||||
|
paid IS NULL
|
||||||
|
-- if the image is not used by a user or item (eg jobs), delete it
|
||||||
|
AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id)
|
||||||
|
AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id)
|
||||||
|
))
|
||||||
|
AND created_at < date_trunc('hour', now() - CASE WHEN "userId" = ${ANON_USER_ID} THEN interval '1 hour' ELSE interval '24 hours' END)`
|
||||||
|
|
||||||
// delete all images in database and S3 which weren't paid in the last 24 hours
|
const s3Keys = unpaidImages.map(({ id }) => id)
|
||||||
const unpaidImages = await models.$queryRaw`
|
console.log('deleting images:', s3Keys)
|
||||||
SELECT id
|
await deleteObjects(s3Keys)
|
||||||
FROM "Upload"
|
await models.upload.deleteMany({ where: { id: { in: s3Keys } } })
|
||||||
WHERE (paid = 'f'
|
|
||||||
OR (
|
|
||||||
-- for non-textarea images, they are free and paid is null
|
|
||||||
paid IS NULL
|
|
||||||
-- if the image is not used by a user or item (eg jobs), delete it
|
|
||||||
AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id)
|
|
||||||
AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id)
|
|
||||||
))
|
|
||||||
AND created_at < date_trunc('hour', now() - CASE WHEN "userId" = ${ANON_USER_ID} THEN interval '1 hour' ELSE interval '24 hours' END)`
|
|
||||||
|
|
||||||
const s3Keys = unpaidImages.map(({ id }) => id)
|
|
||||||
console.log('deleting images:', s3Keys)
|
|
||||||
await deleteObjects(s3Keys)
|
|
||||||
await models.upload.deleteMany({ where: { id: { in: s3Keys } } })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
153
worker/earn.js
153
worker/earn.js
@ -8,16 +8,19 @@ const UPVOTE_EACH_REWARD = 4.0
|
|||||||
const TOP_PERCENTILE = 33
|
const TOP_PERCENTILE = 33
|
||||||
const TOTAL_UPPER_BOUND_MSATS = 1000000000
|
const TOTAL_UPPER_BOUND_MSATS = 1000000000
|
||||||
|
|
||||||
export function earn ({ models }) {
|
export async function earn ({ name, models }) {
|
||||||
return async function ({ name }) {
|
// rewards are calculated sitewide still
|
||||||
console.log('running', name)
|
// however for user gen subs currently only 50% of their fees go to rewards
|
||||||
|
// the other 50% goes to the founder of the sub
|
||||||
|
|
||||||
// compute how much sn earned today
|
// compute how much sn earned today
|
||||||
const [{ sum: sumDecimal }] = await models.$queryRaw`
|
const [{ sum: sumDecimal }] = await models.$queryRaw`
|
||||||
SELECT coalesce(sum(msats), 0) as sum
|
SELECT coalesce(sum(msats), 0) as sum
|
||||||
FROM (
|
FROM (
|
||||||
(SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) as msats
|
(SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) * COALESCE("Sub"."rewardsPct", 100) * 0.01 as msats
|
||||||
FROM "ItemAct"
|
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
|
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')
|
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 "ItemAct".act <> 'TIP')
|
||||||
@ -37,32 +40,32 @@ export function earn ({ models }) {
|
|||||||
HAVING COUNT("ItemForward".id) = 0)
|
HAVING COUNT("ItemForward".id) = 0)
|
||||||
) subquery`
|
) 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)
|
||||||
// and Decimal is what prisma maps it to https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#raw-query-type-mapping
|
// and Decimal is what prisma maps it to https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#raw-query-type-mapping
|
||||||
// so check it before coercing to Number
|
// so check it before coercing to Number
|
||||||
if (!sumDecimal || sumDecimal.lessThanOrEqualTo(0)) {
|
if (!sumDecimal || sumDecimal.lessThanOrEqualTo(0)) {
|
||||||
console.log('done', name, 'no sats to award today')
|
console.log('done', name, 'no sats to award today')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// extra sanity check on rewards ... if it's more than upper bound, we
|
// extra sanity check on rewards ... if it's more than upper bound, we
|
||||||
// probably have a bug somewhere or we've grown A LOT
|
// probably have a bug somewhere or we've grown A LOT
|
||||||
if (sumDecimal.greaterThan(TOTAL_UPPER_BOUND_MSATS)) {
|
if (sumDecimal.greaterThan(TOTAL_UPPER_BOUND_MSATS)) {
|
||||||
console.log('done', name, 'error: too many sats to award today', sumDecimal)
|
console.log('done', name, 'error: too many sats to award today', sumDecimal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const sum = Number(sumDecimal)
|
const sum = Number(sumDecimal)
|
||||||
const heads = Math.random() < 0.5
|
const heads = Math.random() < 0.5
|
||||||
// if this category is selected, double its proportion
|
// if this category is selected, double its proportion
|
||||||
// if it isn't select, zero its proportion
|
// if it isn't select, zero its proportion
|
||||||
const itemRewardMult = heads ? 0 : 2.0
|
const itemRewardMult = heads ? 0 : 2.0
|
||||||
const upvoteRewardMult = heads ? 2.0 : 0
|
const upvoteRewardMult = heads ? 2.0 : 0
|
||||||
|
|
||||||
console.log(name, 'giving away', sum, 'msats', 'rewarding', heads ? 'items' : 'upvotes')
|
console.log(name, 'giving away', sum, 'msats', 'rewarding', heads ? 'items' : 'upvotes')
|
||||||
|
|
||||||
/*
|
/*
|
||||||
How earnings (used to) work:
|
How earnings (used to) work:
|
||||||
1/3: top 21% posts over last 36 hours, scored on a relative basis
|
1/3: top 21% posts over last 36 hours, scored on a relative basis
|
||||||
1/3: top 21% comments over last 36 hours, scored on a relative basis
|
1/3: top 21% comments over last 36 hours, scored on a relative basis
|
||||||
@ -75,8 +78,8 @@ export function earn ({ models }) {
|
|||||||
Now: 100% of earnings go to either top 33% of comments/posts or top 33% of upvoters
|
Now: 100% of earnings go to either top 33% of comments/posts or top 33% of upvoters
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 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 21% of posts and comments
|
-- get top 21% of posts and comments
|
||||||
WITH item_ratios AS (
|
WITH item_ratios AS (
|
||||||
SELECT *,
|
SELECT *,
|
||||||
@ -137,42 +140,72 @@ export function earn ({ models }) {
|
|||||||
FROM proportions
|
FROM proportions
|
||||||
WHERE proportion > 0.000001`
|
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
|
||||||
const now = new Date(new Date().getTime())
|
const now = new Date(new Date().getTime())
|
||||||
|
|
||||||
// this is just a sanity check because it seems like a good idea
|
// this is just a sanity check because it seems like a good idea
|
||||||
let total = 0
|
let total = 0
|
||||||
|
|
||||||
const notifications = {}
|
const notifications = {}
|
||||||
for (const earner of earners) {
|
for (const earner of earners) {
|
||||||
const earnings = Math.floor(parseFloat(earner.proportion) * sum)
|
const earnings = Math.floor(parseFloat(earner.proportion) * 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)
|
||||||
return
|
return
|
||||||
}
|
|
||||||
|
|
||||||
console.log('stacker', earner.userId, 'earned', earnings, 'proportion', earner.proportion, 'rank', earner.rank, 'type', earner.type)
|
|
||||||
|
|
||||||
if (earnings > 0) {
|
|
||||||
await serialize(models,
|
|
||||||
models.$executeRaw`SELECT earn(${earner.userId}::INTEGER, ${earnings},
|
|
||||||
${now}::timestamp without time zone, ${earner.type}::"EarnType", ${earner.id}::INTEGER, ${earner.rank}::INTEGER)`)
|
|
||||||
notifications[earner.userId] = {
|
|
||||||
...notifications[earner.userId],
|
|
||||||
total: earnings + (notifications[earner.userId]?.total || 0),
|
|
||||||
[earner.type]: { msats: earnings, rank: earner.rank }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.allSettled(Object.entries(notifications).map(([userId, earnings]) =>
|
console.log('stacker', earner.userId, 'earned', earnings, 'proportion', earner.proportion, 'rank', earner.rank, 'type', earner.type)
|
||||||
sendUserNotification(parseInt(userId, 10), buildUserNotification(earnings))
|
|
||||||
)).catch(console.error)
|
|
||||||
|
|
||||||
console.log('done', name)
|
if (earnings > 0) {
|
||||||
|
await serialize(models,
|
||||||
|
models.$executeRaw`SELECT earn(${earner.userId}::INTEGER, ${earnings},
|
||||||
|
${now}::timestamp without time zone, ${earner.type}::"EarnType", ${earner.id}::INTEGER, ${earner.rank}::INTEGER)`)
|
||||||
|
notifications[earner.userId] = {
|
||||||
|
...notifications[earner.userId],
|
||||||
|
total: earnings + (notifications[earner.userId]?.total || 0),
|
||||||
|
[earner.type]: { msats: earnings, rank: earner.rank }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await territoryRevenue({ models })
|
||||||
|
|
||||||
|
Promise.allSettled(Object.entries(notifications).map(([userId, earnings]) =>
|
||||||
|
sendUserNotification(parseInt(userId, 10), buildUserNotification(earnings))
|
||||||
|
)).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
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'
|
||||||
|
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) {
|
||||||
|
@ -1,13 +1,5 @@
|
|||||||
import { deleteItemByAuthor } from '../lib/item.js'
|
import { deleteItemByAuthor } from '../lib/item.js'
|
||||||
|
|
||||||
export function deleteItem ({ models }) {
|
export async function deleteItem ({ data: { id }, models }) {
|
||||||
return async function ({ data: eventData }) {
|
await deleteItemByAuthor({ models, id })
|
||||||
console.log('deleteItem', eventData)
|
|
||||||
const { id } = eventData
|
|
||||||
try {
|
|
||||||
await deleteItemByAuthor({ models, id })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to delete item', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -53,32 +53,22 @@ function decodeOriginalUrl (imgproxyUrl) {
|
|||||||
return originalUrl
|
return originalUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
export function imgproxy ({ models }) {
|
export async function imgproxy ({ data: { id, forceFetch = false }, models }) {
|
||||||
return async function ({ data: { id, forceFetch = false } }) {
|
if (!imgProxyEnabled) return
|
||||||
if (!imgProxyEnabled) return
|
|
||||||
|
|
||||||
console.log('running imgproxy job', id)
|
const item = await models.item.findUnique({ where: { id } })
|
||||||
|
|
||||||
const item = await models.item.findUnique({ where: { id } })
|
let imgproxyUrls = {}
|
||||||
|
if (item.text) {
|
||||||
let imgproxyUrls = {}
|
imgproxyUrls = await createImgproxyUrls(id, item.text, { models, forceFetch })
|
||||||
try {
|
|
||||||
if (item.text) {
|
|
||||||
imgproxyUrls = await createImgproxyUrls(id, item.text, { models, forceFetch })
|
|
||||||
}
|
|
||||||
if (item.url && !isJob(item)) {
|
|
||||||
imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { models, forceFetch })) }
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log('[imgproxy] error:', err)
|
|
||||||
// rethrow for retry
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[imgproxy] updating item', id, 'with urls', imgproxyUrls)
|
|
||||||
|
|
||||||
await models.item.update({ where: { id }, data: { imgproxyUrls } })
|
|
||||||
}
|
}
|
||||||
|
if (item.url && !isJob(item)) {
|
||||||
|
imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { models, forceFetch })) }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[imgproxy] updating item', id, 'with urls', imgproxyUrls)
|
||||||
|
|
||||||
|
await models.item.update({ where: { id }, data: { imgproxyUrls } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
|
export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
|
||||||
|
@ -17,6 +17,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'
|
||||||
|
|
||||||
const { loadEnvConfig } = nextEnv
|
const { loadEnvConfig } = nextEnv
|
||||||
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
|
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
|
||||||
@ -54,25 +55,39 @@ async function work () {
|
|||||||
|
|
||||||
boss.on('error', error => console.error(error))
|
boss.on('error', error => console.error(error))
|
||||||
|
|
||||||
|
function jobWrapper (fn) {
|
||||||
|
return async function (job) {
|
||||||
|
console.log(`running ${job.name} with args`, job.data)
|
||||||
|
try {
|
||||||
|
await fn({ ...job, ...args })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`error running ${job.name}`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
console.log(`finished ${job.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await boss.start()
|
await boss.start()
|
||||||
await boss.work('checkInvoice', checkInvoice(args))
|
await boss.work('checkInvoice', jobWrapper(checkInvoice))
|
||||||
await boss.work('checkWithdrawal', checkWithdrawal(args))
|
await boss.work('checkWithdrawal', jobWrapper(checkWithdrawal))
|
||||||
await boss.work('autoDropBolt11s', autoDropBolt11s(args))
|
await boss.work('autoDropBolt11s', jobWrapper(autoDropBolt11s))
|
||||||
await boss.work('repin-*', repin(args))
|
await boss.work('repin-*', jobWrapper(repin))
|
||||||
await boss.work('trust', trust(args))
|
await boss.work('trust', jobWrapper(trust))
|
||||||
await boss.work('timestampItem', timestampItem(args))
|
await boss.work('timestampItem', jobWrapper(timestampItem))
|
||||||
await boss.work('indexItem', indexItem(args))
|
await boss.work('indexItem', jobWrapper(indexItem))
|
||||||
await boss.work('indexAllItems', indexAllItems(args))
|
await boss.work('indexAllItems', jobWrapper(indexAllItems))
|
||||||
await boss.work('auction', auction(args))
|
await boss.work('auction', jobWrapper(auction))
|
||||||
await boss.work('earn', earn(args))
|
await boss.work('earn', jobWrapper(earn))
|
||||||
await boss.work('streak', computeStreaks(args))
|
await boss.work('streak', jobWrapper(computeStreaks))
|
||||||
await boss.work('checkStreak', checkStreak(args))
|
await boss.work('checkStreak', jobWrapper(checkStreak))
|
||||||
await boss.work('nip57', nip57(args))
|
await boss.work('nip57', jobWrapper(nip57))
|
||||||
await boss.work('views', views(args))
|
await boss.work('views', jobWrapper(views))
|
||||||
await boss.work('rankViews', rankViews(args))
|
await boss.work('rankViews', jobWrapper(rankViews))
|
||||||
await boss.work('imgproxy', imgproxy(args))
|
await boss.work('imgproxy', jobWrapper(imgproxy))
|
||||||
await boss.work('deleteItem', deleteItem(args))
|
await boss.work('deleteItem', jobWrapper(deleteItem))
|
||||||
await boss.work('deleteUnusedImages', deleteUnusedImages(args))
|
await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages))
|
||||||
|
await boss.work('territoryBilling', jobWrapper(territoryBilling))
|
||||||
|
|
||||||
console.log('working jobs')
|
console.log('working jobs')
|
||||||
}
|
}
|
||||||
|
141
worker/nostr.js
141
worker/nostr.js
@ -3,84 +3,79 @@ import { Relay, signId, calculateId, getPublicKey } from 'nostr'
|
|||||||
|
|
||||||
const nostrOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
|
const nostrOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
|
||||||
|
|
||||||
export function nip57 ({ boss, lnd, models }) {
|
export async function nip57 ({ data: { hash }, boss, lnd, models }) {
|
||||||
return async function ({ data: { hash } }) {
|
let inv, lnInv
|
||||||
console.log('running nip57')
|
try {
|
||||||
|
lnInv = await getInvoice({ id: hash, lnd })
|
||||||
let inv, lnInv
|
inv = await models.invoice.findUnique({
|
||||||
try {
|
where: {
|
||||||
lnInv = await getInvoice({ id: hash, lnd })
|
hash
|
||||||
inv = await models.invoice.findUnique({
|
|
||||||
where: {
|
|
||||||
hash
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err)
|
|
||||||
// on lnd related errors, we manually retry which so we don't exponentially backoff
|
|
||||||
await boss.send('nip57', { hash }, nostrOptions)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if invoice still exists since HODL invoices get deleted after usage
|
|
||||||
if (!inv) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
// if parsing fails it's not a zap
|
|
||||||
console.log('zapping', inv.desc)
|
|
||||||
const desc = JSON.parse(inv.desc)
|
|
||||||
const ptag = desc.tags.filter(t => t?.length >= 2 && t[0] === 'p')[0]
|
|
||||||
const etag = desc.tags.filter(t => t?.length >= 2 && t[0] === 'e')[0]
|
|
||||||
const atag = desc.tags.filter(t => t?.length >= 2 && t[0] === 'a')[0]
|
|
||||||
const relays = desc.tags.find(t => t?.length >= 2 && t[0] === 'relays').slice(1)
|
|
||||||
|
|
||||||
const tags = [ptag]
|
|
||||||
if (etag) tags.push(etag)
|
|
||||||
if (atag) tags.push(atag)
|
|
||||||
tags.push(['bolt11', lnInv.request])
|
|
||||||
tags.push(['description', inv.desc])
|
|
||||||
tags.push(['preimage', lnInv.secret])
|
|
||||||
|
|
||||||
const e = {
|
|
||||||
kind: 9735,
|
|
||||||
pubkey: getPublicKey(process.env.NOSTR_PRIVATE_KEY),
|
|
||||||
created_at: Math.floor(new Date(lnInv.confirmed_at).getTime() / 1000),
|
|
||||||
content: '',
|
|
||||||
tags
|
|
||||||
}
|
}
|
||||||
e.id = await calculateId(e)
|
})
|
||||||
e.sig = await signId(process.env.NOSTR_PRIVATE_KEY, e.id)
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
// on lnd related errors, we manually retry which so we don't exponentially backoff
|
||||||
|
await boss.send('nip57', { hash }, nostrOptions)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
console.log('zap note', e, relays)
|
// check if invoice still exists since HODL invoices get deleted after usage
|
||||||
await Promise.allSettled(
|
if (!inv) return
|
||||||
relays.map(r => new Promise((resolve, reject) => {
|
|
||||||
const timeout = 1000
|
|
||||||
const relay = Relay(r)
|
|
||||||
|
|
||||||
function timedout () {
|
try {
|
||||||
relay.close()
|
// if parsing fails it's not a zap
|
||||||
console.log('failed to send to', r)
|
console.log('zapping', inv.desc)
|
||||||
reject(new Error('relay timeout'))
|
const desc = JSON.parse(inv.desc)
|
||||||
}
|
const ptag = desc.tags.filter(t => t?.length >= 2 && t[0] === 'p')[0]
|
||||||
|
const etag = desc.tags.filter(t => t?.length >= 2 && t[0] === 'e')[0]
|
||||||
|
const atag = desc.tags.filter(t => t?.length >= 2 && t[0] === 'a')[0]
|
||||||
|
const relays = desc.tags.find(t => t?.length >= 2 && t[0] === 'relays').slice(1)
|
||||||
|
|
||||||
let timer = setTimeout(timedout, timeout)
|
const tags = [ptag]
|
||||||
|
if (etag) tags.push(etag)
|
||||||
|
if (atag) tags.push(atag)
|
||||||
|
tags.push(['bolt11', lnInv.request])
|
||||||
|
tags.push(['description', inv.desc])
|
||||||
|
tags.push(['preimage', lnInv.secret])
|
||||||
|
|
||||||
relay.on('open', () => {
|
const e = {
|
||||||
clearTimeout(timer)
|
kind: 9735,
|
||||||
timer = setTimeout(timedout, timeout)
|
pubkey: getPublicKey(process.env.NOSTR_PRIVATE_KEY),
|
||||||
relay.send(['EVENT', e])
|
created_at: Math.floor(new Date(lnInv.confirmed_at).getTime() / 1000),
|
||||||
})
|
content: '',
|
||||||
|
tags
|
||||||
relay.on('ok', () => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
relay.close()
|
|
||||||
console.log('sent zap to', r)
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})))
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
}
|
}
|
||||||
console.log('done running nip57')
|
e.id = await calculateId(e)
|
||||||
|
e.sig = await signId(process.env.NOSTR_PRIVATE_KEY, e.id)
|
||||||
|
|
||||||
|
console.log('zap note', e, relays)
|
||||||
|
await Promise.allSettled(
|
||||||
|
relays.map(r => new Promise((resolve, reject) => {
|
||||||
|
const timeout = 1000
|
||||||
|
const relay = Relay(r)
|
||||||
|
|
||||||
|
function timedout () {
|
||||||
|
relay.close()
|
||||||
|
console.log('failed to send to', r)
|
||||||
|
reject(new Error('relay timeout'))
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = setTimeout(timedout, timeout)
|
||||||
|
|
||||||
|
relay.on('open', () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(timedout, timeout)
|
||||||
|
relay.send(['EVENT', e])
|
||||||
|
})
|
||||||
|
|
||||||
|
relay.on('ok', () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
relay.close()
|
||||||
|
console.log('sent zap to', r)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})))
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,39 +12,33 @@ const ITEM_OTS_FIELDS = gql`
|
|||||||
url
|
url
|
||||||
}`
|
}`
|
||||||
|
|
||||||
export function timestampItem ({ apollo, models }) {
|
export async function timestampItem ({ data: { id }, apollo, models }) {
|
||||||
return async function ({ data: { id } }) {
|
const { data: { item: { parentId, parentOtsHash, title, text, url } } } = await apollo.query({
|
||||||
console.log('timestamping item', id)
|
query: gql`
|
||||||
|
|
||||||
const { data: { item: { parentId, parentOtsHash, title, text, url } } } = await apollo.query({
|
|
||||||
query: gql`
|
|
||||||
${ITEM_OTS_FIELDS}
|
${ITEM_OTS_FIELDS}
|
||||||
query Item {
|
query Item {
|
||||||
item(id: ${id}) {
|
item(id: ${id}) {
|
||||||
...ItemOTSFields
|
...ItemOTSFields
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
})
|
})
|
||||||
|
|
||||||
if (parentId && !parentOtsHash) {
|
if (parentId && !parentOtsHash) {
|
||||||
console.log('no parent hash available ... skipping')
|
console.log('no parent hash available ... skipping')
|
||||||
return
|
return
|
||||||
}
|
|
||||||
|
|
||||||
// SHA256 hash item using a canonical serialization format { parentHash, title, text, url }
|
|
||||||
const itemString = stringifyCanon({ parentHash: parentOtsHash, title, text, url })
|
|
||||||
const otsHash = createHash('sha256').update(itemString).digest()
|
|
||||||
const detached = Ots.DetachedTimestampFile.fromHash(new Ots.Ops.OpSHA256(), otsHash)
|
|
||||||
|
|
||||||
// timestamp it
|
|
||||||
await Ots.stamp(detached)
|
|
||||||
|
|
||||||
// get proof
|
|
||||||
const otsFile = Buffer.from(detached.serializeToBytes())
|
|
||||||
|
|
||||||
// store in item
|
|
||||||
await models.item.update({ where: { id }, data: { otsHash: otsHash.toString('hex'), otsFile } })
|
|
||||||
|
|
||||||
console.log('done timestamping item', id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SHA256 hash item using a canonical serialization format { parentHash, title, text, url }
|
||||||
|
const itemString = stringifyCanon({ parentHash: parentOtsHash, title, text, url })
|
||||||
|
const otsHash = createHash('sha256').update(itemString).digest()
|
||||||
|
const detached = Ots.DetachedTimestampFile.fromHash(new Ots.Ops.OpSHA256(), otsHash)
|
||||||
|
|
||||||
|
// timestamp it
|
||||||
|
await Ots.stamp(detached)
|
||||||
|
|
||||||
|
// get proof
|
||||||
|
const otsFile = Buffer.from(detached.serializeToBytes())
|
||||||
|
|
||||||
|
// store in item
|
||||||
|
await models.item.update({ where: { id }, data: { otsHash: otsHash.toString('hex'), otsFile } })
|
||||||
}
|
}
|
||||||
|
@ -1,42 +1,38 @@
|
|||||||
export function repin ({ models }) {
|
export async function repin ({ name, models }) {
|
||||||
return async function ({ name }) {
|
// get the id
|
||||||
console.log('doing', name)
|
const id = name.slice('repin-'.length)
|
||||||
|
if (id.length === 0 || isNaN(id)) {
|
||||||
// get the id
|
console.log('repin id not found in', name)
|
||||||
const id = name.slice('repin-'.length)
|
return
|
||||||
if (id.length === 0 || isNaN(id)) {
|
|
||||||
console.log('repin id not found in', name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the latest item with this id
|
|
||||||
const pinId = Number(id)
|
|
||||||
const current = await models.item.findFirst(
|
|
||||||
{
|
|
||||||
where: {
|
|
||||||
pinId
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!current) {
|
|
||||||
console.log('could not find existing item for', name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// create a new item with matching 1) title, text, and url and 2) setting pinId
|
|
||||||
await models.item.create({
|
|
||||||
data: {
|
|
||||||
title: current.title,
|
|
||||||
text: current.text,
|
|
||||||
url: current.url,
|
|
||||||
userId: current.userId,
|
|
||||||
subName: current.subName,
|
|
||||||
pinId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get the latest item with this id
|
||||||
|
const pinId = Number(id)
|
||||||
|
const current = await models.item.findFirst(
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
pinId
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
console.log('could not find existing item for pin', name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a new item with matching 1) title, text, and url and 2) setting pinId
|
||||||
|
await models.item.create({
|
||||||
|
data: {
|
||||||
|
title: current.title,
|
||||||
|
text: current.text,
|
||||||
|
url: current.url,
|
||||||
|
userId: current.userId,
|
||||||
|
subName: current.subName,
|
||||||
|
pinId
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
@ -37,7 +37,6 @@ const ITEM_SEARCH_FIELDS = gql`
|
|||||||
|
|
||||||
async function _indexItem (item, { models }) {
|
async function _indexItem (item, { models }) {
|
||||||
console.log('indexing item', item.id)
|
console.log('indexing item', item.id)
|
||||||
|
|
||||||
// HACK: modify the title for jobs so that company/location are searchable
|
// HACK: modify the title for jobs so that company/location are searchable
|
||||||
// and highlighted without further modification
|
// and highlighted without further modification
|
||||||
const itemcp = { ...item }
|
const itemcp = { ...item }
|
||||||
@ -78,38 +77,33 @@ async function _indexItem (item, { models }) {
|
|||||||
console.log(e)
|
console.log(e)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
console.log('done indexing item', item.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function indexItem ({ apollo, models }) {
|
export async function indexItem ({ data: { id }, apollo, models }) {
|
||||||
return async function ({ data: { id } }) {
|
// 1. grab item from database
|
||||||
console.log('indexing item, fetching ...', id)
|
// could use apollo to avoid duping logic
|
||||||
// 1. grab item from database
|
// when grabbing sats and user name, etc
|
||||||
// could use apollo to avoid duping logic
|
const { data: { item } } = await apollo.query({
|
||||||
// when grabbing sats and user name, etc
|
query: gql`
|
||||||
const { data: { item } } = await apollo.query({
|
|
||||||
query: gql`
|
|
||||||
${ITEM_SEARCH_FIELDS}
|
${ITEM_SEARCH_FIELDS}
|
||||||
query Item {
|
query Item {
|
||||||
item(id: ${id}) {
|
item(id: ${id}) {
|
||||||
...ItemSearchFields
|
...ItemSearchFields
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. index it with external version based on updatedAt
|
// 2. index it with external version based on updatedAt
|
||||||
await _indexItem(item, { models })
|
await _indexItem(item, { models })
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function indexAllItems ({ apollo, models }) {
|
export async function indexAllItems ({ apollo, models }) {
|
||||||
return async function () {
|
// cursor over all items in the Item table
|
||||||
// cursor over all items in the Item table
|
let items = []; let cursor = null
|
||||||
let items = []; let cursor = null
|
do {
|
||||||
do {
|
// query for items
|
||||||
// query for items
|
({ data: { items: { items, cursor } } } = await apollo.query({
|
||||||
({ data: { items: { items, cursor } } } = await apollo.query({
|
query: gql`
|
||||||
query: gql`
|
|
||||||
${ITEM_SEARCH_FIELDS}
|
${ITEM_SEARCH_FIELDS}
|
||||||
query AllItems($cursor: String) {
|
query AllItems($cursor: String) {
|
||||||
items(cursor: $cursor, sort: "recent", limit: 1000, type: "all") {
|
items(cursor: $cursor, sort: "recent", limit: 1000, type: "all") {
|
||||||
@ -119,16 +113,15 @@ export function indexAllItems ({ apollo, models }) {
|
|||||||
cursor
|
cursor
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
variables: { cursor }
|
variables: { cursor }
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// for all items, index them
|
// for all items, index them
|
||||||
try {
|
try {
|
||||||
items.forEach(i => _indexItem(i, { models }))
|
items.forEach(i => _indexItem(i, { models }))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore errors
|
// ignore errors
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
} while (cursor)
|
} while (cursor)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
227
worker/streak.js
227
worker/streak.js
@ -3,136 +3,123 @@ import { FOUND_BLURBS, LOST_BLURBS } from '../lib/constants'
|
|||||||
|
|
||||||
const STREAK_THRESHOLD = 100
|
const STREAK_THRESHOLD = 100
|
||||||
|
|
||||||
export function computeStreaks ({ models }) {
|
export async function computeStreaks ({ models }) {
|
||||||
return async function () {
|
// get all eligible users in the last day
|
||||||
console.log('computing streaks')
|
// if the user doesn't have an active streak, add one
|
||||||
|
// if they have an active streak but didn't maintain it, end it
|
||||||
|
const endingStreaks = await models.$queryRaw`
|
||||||
|
WITH day_streaks (id) AS (
|
||||||
|
SELECT "userId"
|
||||||
|
FROM
|
||||||
|
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
|
||||||
|
FROM "ItemAct"
|
||||||
|
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date
|
||||||
|
GROUP BY "userId")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT "userId", sats as sats_spent
|
||||||
|
FROM "Donation"
|
||||||
|
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date
|
||||||
|
)) spending
|
||||||
|
GROUP BY "userId"
|
||||||
|
HAVING sum(sats_spent) >= 100
|
||||||
|
), existing_streaks (id, started_at) AS (
|
||||||
|
SELECT "userId", "startedAt"
|
||||||
|
FROM "Streak"
|
||||||
|
WHERE "Streak"."endedAt" IS NULL
|
||||||
|
), new_streaks (id) AS (
|
||||||
|
SELECT day_streaks.id
|
||||||
|
FROM day_streaks
|
||||||
|
LEFT JOIN existing_streaks ON existing_streaks.id = day_streaks.id
|
||||||
|
WHERE existing_streaks.id IS NULL
|
||||||
|
), ending_streaks (id) AS (
|
||||||
|
SELECT existing_streaks.id
|
||||||
|
FROM existing_streaks
|
||||||
|
LEFT JOIN day_streaks ON existing_streaks.id = day_streaks.id
|
||||||
|
WHERE day_streaks.id IS NULL
|
||||||
|
), extending_streaks (id, started_at) AS (
|
||||||
|
SELECT existing_streaks.id, existing_streaks.started_at
|
||||||
|
FROM existing_streaks
|
||||||
|
JOIN day_streaks ON existing_streaks.id = day_streaks.id
|
||||||
|
),
|
||||||
|
-- a bunch of mutations
|
||||||
|
streak_insert AS (
|
||||||
|
INSERT INTO "Streak" ("userId", "startedAt", created_at, updated_at)
|
||||||
|
SELECT id, (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, now_utc(), now_utc()
|
||||||
|
FROM new_streaks
|
||||||
|
), user_update_new_streaks AS (
|
||||||
|
UPDATE users SET streak = 1 FROM new_streaks WHERE new_streaks.id = users.id
|
||||||
|
), user_update_end_streaks AS (
|
||||||
|
UPDATE users SET streak = NULL FROM ending_streaks WHERE ending_streaks.id = users.id
|
||||||
|
), user_update_extend_streaks AS (
|
||||||
|
UPDATE users
|
||||||
|
SET streak = (now() AT TIME ZONE 'America/Chicago')::date - extending_streaks.started_at
|
||||||
|
FROM extending_streaks WHERE extending_streaks.id = users.id
|
||||||
|
)
|
||||||
|
UPDATE "Streak"
|
||||||
|
SET "endedAt" = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, updated_at = now_utc()
|
||||||
|
FROM ending_streaks
|
||||||
|
WHERE ending_streaks.id = "Streak"."userId" AND "endedAt" IS NULL
|
||||||
|
RETURNING "Streak".id, ending_streaks."id" AS "userId"`
|
||||||
|
|
||||||
// get all eligible users in the last day
|
Promise.allSettled(
|
||||||
// if the user doesn't have an active streak, add one
|
endingStreaks.map(({ id, userId }) => {
|
||||||
// if they have an active streak but didn't maintain it, end it
|
const index = id % LOST_BLURBS.length
|
||||||
const endingStreaks = await models.$queryRaw`
|
const blurb = LOST_BLURBS[index]
|
||||||
WITH day_streaks (id) AS (
|
return sendUserNotification(userId, {
|
||||||
|
title: 'you lost your cowboy hat',
|
||||||
|
body: blurb,
|
||||||
|
tag: 'STREAK-LOST'
|
||||||
|
}).catch(console.error)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkStreak ({ data: { id }, models }) {
|
||||||
|
// if user is actively streaking skip
|
||||||
|
let streak = await models.streak.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: Number(id),
|
||||||
|
endedAt: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (streak) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
[streak] = await models.$queryRaw`
|
||||||
|
WITH streak_started (id) AS (
|
||||||
SELECT "userId"
|
SELECT "userId"
|
||||||
FROM
|
FROM
|
||||||
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
|
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date
|
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date
|
||||||
|
AND "userId" = ${Number(id)}
|
||||||
GROUP BY "userId")
|
GROUP BY "userId")
|
||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT "userId", sats as sats_spent
|
(SELECT "userId", sats as sats_spent
|
||||||
FROM "Donation"
|
FROM "Donation"
|
||||||
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date
|
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date
|
||||||
|
AND "userId" = ${Number(id)}
|
||||||
)) spending
|
)) spending
|
||||||
GROUP BY "userId"
|
GROUP BY "userId"
|
||||||
HAVING sum(sats_spent) >= 100
|
HAVING sum(sats_spent) >= ${STREAK_THRESHOLD}
|
||||||
), existing_streaks (id, started_at) AS (
|
), user_start_streak AS (
|
||||||
SELECT "userId", "startedAt"
|
UPDATE users SET streak = 0 FROM streak_started WHERE streak_started.id = users.id
|
||||||
FROM "Streak"
|
|
||||||
WHERE "Streak"."endedAt" IS NULL
|
|
||||||
), new_streaks (id) AS (
|
|
||||||
SELECT day_streaks.id
|
|
||||||
FROM day_streaks
|
|
||||||
LEFT JOIN existing_streaks ON existing_streaks.id = day_streaks.id
|
|
||||||
WHERE existing_streaks.id IS NULL
|
|
||||||
), ending_streaks (id) AS (
|
|
||||||
SELECT existing_streaks.id
|
|
||||||
FROM existing_streaks
|
|
||||||
LEFT JOIN day_streaks ON existing_streaks.id = day_streaks.id
|
|
||||||
WHERE day_streaks.id IS NULL
|
|
||||||
), extending_streaks (id, started_at) AS (
|
|
||||||
SELECT existing_streaks.id, existing_streaks.started_at
|
|
||||||
FROM existing_streaks
|
|
||||||
JOIN day_streaks ON existing_streaks.id = day_streaks.id
|
|
||||||
),
|
|
||||||
-- a bunch of mutations
|
|
||||||
streak_insert AS (
|
|
||||||
INSERT INTO "Streak" ("userId", "startedAt", created_at, updated_at)
|
|
||||||
SELECT id, (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, now_utc(), now_utc()
|
|
||||||
FROM new_streaks
|
|
||||||
), user_update_new_streaks AS (
|
|
||||||
UPDATE users SET streak = 1 FROM new_streaks WHERE new_streaks.id = users.id
|
|
||||||
), user_update_end_streaks AS (
|
|
||||||
UPDATE users SET streak = NULL FROM ending_streaks WHERE ending_streaks.id = users.id
|
|
||||||
), user_update_extend_streaks AS (
|
|
||||||
UPDATE users
|
|
||||||
SET streak = (now() AT TIME ZONE 'America/Chicago')::date - extending_streaks.started_at
|
|
||||||
FROM extending_streaks WHERE extending_streaks.id = users.id
|
|
||||||
)
|
|
||||||
UPDATE "Streak"
|
|
||||||
SET "endedAt" = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, updated_at = now_utc()
|
|
||||||
FROM ending_streaks
|
|
||||||
WHERE ending_streaks.id = "Streak"."userId" AND "endedAt" IS NULL
|
|
||||||
RETURNING "Streak".id, ending_streaks."id" AS "userId"`
|
|
||||||
|
|
||||||
Promise.allSettled(
|
|
||||||
endingStreaks.map(({ id, userId }) => {
|
|
||||||
const index = id % LOST_BLURBS.length
|
|
||||||
const blurb = LOST_BLURBS[index]
|
|
||||||
return sendUserNotification(userId, {
|
|
||||||
title: 'you lost your cowboy hat',
|
|
||||||
body: blurb,
|
|
||||||
tag: 'STREAK-LOST'
|
|
||||||
}).catch(console.error)
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
INSERT INTO "Streak" ("userId", "startedAt", created_at, updated_at)
|
||||||
|
SELECT id, (now() AT TIME ZONE 'America/Chicago')::date, now_utc(), now_utc()
|
||||||
|
FROM streak_started
|
||||||
|
RETURNING "Streak".id`
|
||||||
|
|
||||||
console.log('done computing streaks')
|
if (!streak) return
|
||||||
}
|
|
||||||
}
|
// new streak started for user
|
||||||
|
const index = streak.id % FOUND_BLURBS.length
|
||||||
export function checkStreak ({ models }) {
|
const blurb = FOUND_BLURBS[index]
|
||||||
return async function ({ data: { id } }) {
|
sendUserNotification(id, {
|
||||||
console.log('checking streak', id)
|
title: 'you found a cowboy hat',
|
||||||
|
body: blurb,
|
||||||
// if user is actively streaking skip
|
tag: 'STREAK-FOUND'
|
||||||
let streak = await models.streak.findFirst({
|
}).catch(console.error)
|
||||||
where: {
|
|
||||||
userId: Number(id),
|
|
||||||
endedAt: null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (streak) {
|
|
||||||
console.log('done checking streak', id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
[streak] = await models.$queryRaw`
|
|
||||||
WITH streak_started (id) AS (
|
|
||||||
SELECT "userId"
|
|
||||||
FROM
|
|
||||||
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
|
|
||||||
FROM "ItemAct"
|
|
||||||
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date
|
|
||||||
AND "userId" = ${Number(id)}
|
|
||||||
GROUP BY "userId")
|
|
||||||
UNION ALL
|
|
||||||
(SELECT "userId", sats as sats_spent
|
|
||||||
FROM "Donation"
|
|
||||||
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date
|
|
||||||
AND "userId" = ${Number(id)}
|
|
||||||
)) spending
|
|
||||||
GROUP BY "userId"
|
|
||||||
HAVING sum(sats_spent) >= ${STREAK_THRESHOLD}
|
|
||||||
), user_start_streak AS (
|
|
||||||
UPDATE users SET streak = 0 FROM streak_started WHERE streak_started.id = users.id
|
|
||||||
)
|
|
||||||
INSERT INTO "Streak" ("userId", "startedAt", created_at, updated_at)
|
|
||||||
SELECT id, (now() AT TIME ZONE 'America/Chicago')::date, now_utc(), now_utc()
|
|
||||||
FROM streak_started
|
|
||||||
RETURNING "Streak".id`
|
|
||||||
|
|
||||||
console.log('done checking streak', id)
|
|
||||||
|
|
||||||
if (!streak) return
|
|
||||||
|
|
||||||
// new streak started for user
|
|
||||||
const index = streak.id % FOUND_BLURBS.length
|
|
||||||
const blurb = FOUND_BLURBS[index]
|
|
||||||
sendUserNotification(id, {
|
|
||||||
title: 'you found a cowboy hat',
|
|
||||||
body: blurb,
|
|
||||||
tag: 'STREAK-FOUND'
|
|
||||||
}).catch(console.error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
30
worker/territory.js
Normal file
30
worker/territory.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import serialize from '../api/resolvers/serial'
|
||||||
|
import { paySubQueries } from '../api/resolvers/sub'
|
||||||
|
import { TERRITORY_GRACE_DAYS } from '../lib/constants'
|
||||||
|
import { datePivot } from '../lib/time'
|
||||||
|
|
||||||
|
export async function territoryBilling ({ data: { subName }, boss, models }) {
|
||||||
|
const sub = await models.sub.findUnique({
|
||||||
|
where: {
|
||||||
|
name: subName
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queries = paySubQueries(sub, models)
|
||||||
|
await serialize(models, ...queries)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
|
||||||
|
await models.sub.update({
|
||||||
|
where: {
|
||||||
|
name: subName
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: sub.billedLastAt >= datePivot(new Date(), { days: -TERRITORY_GRACE_DAYS }) ? 'GRACE' : 'STOPPED'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// retry billing in one day
|
||||||
|
await boss.send('territoryBilling', { subName }, { startAfter: datePivot(new Date(), { days: 1 }) })
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +1,17 @@
|
|||||||
import * as math from 'mathjs'
|
import * as math from 'mathjs'
|
||||||
import { ANON_USER_ID, SN_USER_IDS } from '../lib/constants.js'
|
import { ANON_USER_ID, SN_USER_IDS } from '../lib/constants.js'
|
||||||
|
|
||||||
export function trust ({ boss, models }) {
|
export async function trust ({ boss, models }) {
|
||||||
return async function () {
|
try {
|
||||||
try {
|
console.time('trust')
|
||||||
console.time('trust')
|
console.timeLog('trust', 'getting graph')
|
||||||
console.timeLog('trust', 'getting graph')
|
const graph = await getGraph(models)
|
||||||
const graph = await getGraph(models)
|
console.timeLog('trust', 'computing trust')
|
||||||
console.timeLog('trust', 'computing trust')
|
const [vGlobal, mPersonal] = await trustGivenGraph(graph)
|
||||||
const [vGlobal, mPersonal] = await trustGivenGraph(graph)
|
console.timeLog('trust', 'storing trust')
|
||||||
console.timeLog('trust', 'storing trust')
|
await storeTrust(models, graph, vGlobal, mPersonal)
|
||||||
await storeTrust(models, graph, vGlobal, mPersonal)
|
} finally {
|
||||||
} catch (e) {
|
console.timeEnd('trust')
|
||||||
console.error(e)
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
console.timeEnd('trust')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,27 +1,15 @@
|
|||||||
// this is intended to be run everyday after midnight CT
|
// this is intended to be run everyday after midnight CT
|
||||||
export function views ({ models }) {
|
export async function views ({ models }) {
|
||||||
return async function () {
|
for (const view of ['reg_growth_days', 'spender_growth_days', 'item_growth_days',
|
||||||
console.log('refreshing stats views')
|
'spending_growth_days', 'stackers_growth_days', 'stacking_growth_days',
|
||||||
|
'user_stats_days']) {
|
||||||
for (const view of ['reg_growth_days', 'spender_growth_days', 'item_growth_days',
|
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
|
||||||
'spending_growth_days', 'stackers_growth_days', 'stacking_growth_days',
|
|
||||||
'user_stats_days']) {
|
|
||||||
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('done refreshing stats views')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// this should be run regularly ... like, every 5 minutes
|
// this should be run regularly ... like, every 5 minutes
|
||||||
export function rankViews ({ models }) {
|
export async function rankViews ({ models }) {
|
||||||
return async function () {
|
for (const view of ['zap_rank_personal_view']) {
|
||||||
console.log('refreshing rank views')
|
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
|
||||||
|
|
||||||
for (const view of ['zap_rank_personal_view']) {
|
|
||||||
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('done refreshing rank views')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
222
worker/wallet.js
222
worker/wallet.js
@ -8,131 +8,119 @@ import { INVOICE_RETENTION_DAYS } from '../lib/constants'
|
|||||||
const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
|
const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
|
||||||
|
|
||||||
// TODO this should all be done via websockets
|
// TODO this should all be done via websockets
|
||||||
export function checkInvoice ({ boss, models, lnd }) {
|
export async function checkInvoice ({ data: { hash, isHeldSet }, boss, models, lnd }) {
|
||||||
return async function ({ data: { hash, isHeldSet } }) {
|
let inv
|
||||||
let inv
|
try {
|
||||||
try {
|
inv = await getInvoice({ id: hash, lnd })
|
||||||
inv = await getInvoice({ id: hash, lnd })
|
} catch (err) {
|
||||||
} catch (err) {
|
console.log(err, hash)
|
||||||
console.log(err, hash)
|
// on lnd related errors, we manually retry so we don't exponentially backoff
|
||||||
|
await boss.send('checkInvoice', { hash }, walletOptions)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(inv)
|
||||||
|
|
||||||
|
// check if invoice still exists since HODL invoices get deleted after usage
|
||||||
|
const dbInv = await models.invoice.findUnique({ where: { hash } })
|
||||||
|
if (!dbInv) return
|
||||||
|
|
||||||
|
const expired = new Date(inv.expires_at) <= new Date()
|
||||||
|
|
||||||
|
if (inv.is_confirmed && !inv.is_held) {
|
||||||
|
// never mark hodl invoices as confirmed here because
|
||||||
|
// we manually confirm them when we settle them
|
||||||
|
await serialize(models,
|
||||||
|
models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`)
|
||||||
|
sendUserNotification(dbInv.userId, {
|
||||||
|
title: `${numWithUnits(msatsToSats(inv.received_mtokens), { abbreviate: false })} were deposited in your account`,
|
||||||
|
body: dbInv.comment || undefined,
|
||||||
|
tag: 'DEPOSIT',
|
||||||
|
data: { sats: msatsToSats(inv.received_mtokens) }
|
||||||
|
}).catch(console.error)
|
||||||
|
return boss.send('nip57', { hash })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inv.is_canceled) {
|
||||||
|
return serialize(models,
|
||||||
|
models.invoice.update({
|
||||||
|
where: {
|
||||||
|
hash: inv.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
cancelled: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inv.is_held && !isHeldSet) {
|
||||||
|
// this is basically confirm_invoice without setting confirmed_at since it's not settled yet
|
||||||
|
// and without setting the user balance since that's done inside the same tx as the HODL invoice action.
|
||||||
|
await serialize(models,
|
||||||
|
models.invoice.update({ where: { hash }, data: { msatsReceived: Number(inv.received_mtokens), isHeld: true } }))
|
||||||
|
// remember that we already executed this if clause
|
||||||
|
// (even though the query above is idempotent but imo, this makes the flow more clear)
|
||||||
|
isHeldSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expired) {
|
||||||
|
// recheck in 5 seconds if the invoice is younger than 5 minutes
|
||||||
|
// otherwise recheck in 60 seconds
|
||||||
|
const startAfter = new Date(inv.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60
|
||||||
|
await boss.send('checkInvoice', { hash, isHeldSet }, { ...walletOptions, startAfter })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expired && inv.is_held) {
|
||||||
|
await cancelHodlInvoice({ id: hash, lnd })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkWithdrawal ({ data: { id, hash }, boss, models, lnd }) {
|
||||||
|
let wdrwl
|
||||||
|
let notFound = false
|
||||||
|
try {
|
||||||
|
wdrwl = await getPayment({ id: hash, lnd })
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
if (err[1] === 'SentPaymentNotFound') {
|
||||||
|
notFound = true
|
||||||
|
} else {
|
||||||
// on lnd related errors, we manually retry so we don't exponentially backoff
|
// on lnd related errors, we manually retry so we don't exponentially backoff
|
||||||
await boss.send('checkInvoice', { hash }, walletOptions)
|
await boss.send('checkWithdrawal', { id, hash }, walletOptions)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log(inv)
|
|
||||||
|
|
||||||
// check if invoice still exists since HODL invoices get deleted after usage
|
|
||||||
const dbInv = await models.invoice.findUnique({ where: { hash } })
|
|
||||||
if (!dbInv) return
|
|
||||||
|
|
||||||
const expired = new Date(inv.expires_at) <= new Date()
|
|
||||||
|
|
||||||
if (inv.is_confirmed && !inv.is_held) {
|
|
||||||
// never mark hodl invoices as confirmed here because
|
|
||||||
// we manually confirm them when we settle them
|
|
||||||
await serialize(models,
|
|
||||||
models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`)
|
|
||||||
sendUserNotification(dbInv.userId, {
|
|
||||||
title: `${numWithUnits(msatsToSats(inv.received_mtokens), { abbreviate: false })} were deposited in your account`,
|
|
||||||
body: dbInv.comment || undefined,
|
|
||||||
tag: 'DEPOSIT',
|
|
||||||
data: { sats: msatsToSats(inv.received_mtokens) }
|
|
||||||
}).catch(console.error)
|
|
||||||
return boss.send('nip57', { hash })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inv.is_canceled) {
|
|
||||||
return serialize(models,
|
|
||||||
models.invoice.update({
|
|
||||||
where: {
|
|
||||||
hash: inv.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
cancelled: true
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inv.is_held && !isHeldSet) {
|
|
||||||
// this is basically confirm_invoice without setting confirmed_at since it's not settled yet
|
|
||||||
// and without setting the user balance since that's done inside the same tx as the HODL invoice action.
|
|
||||||
await serialize(models,
|
|
||||||
models.invoice.update({ where: { hash }, data: { msatsReceived: Number(inv.received_mtokens), isHeld: true } }))
|
|
||||||
// remember that we already executed this if clause
|
|
||||||
// (even though the query above is idempotent but imo, this makes the flow more clear)
|
|
||||||
isHeldSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!expired) {
|
|
||||||
// recheck in 5 seconds if the invoice is younger than 5 minutes
|
|
||||||
// otherwise recheck in 60 seconds
|
|
||||||
const startAfter = new Date(inv.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60
|
|
||||||
await boss.send('checkInvoice', { hash, isHeldSet }, { ...walletOptions, startAfter })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expired && inv.is_held) {
|
|
||||||
await cancelHodlInvoice({ id: hash, lnd })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function checkWithdrawal ({ boss, models, lnd }) {
|
if (wdrwl?.is_confirmed) {
|
||||||
return async function ({ data: { id, hash } }) {
|
const fee = Number(wdrwl.payment.fee_mtokens)
|
||||||
let wdrwl
|
const paid = Number(wdrwl.payment.mtokens) - fee
|
||||||
let notFound = false
|
await serialize(models, models.$executeRaw`
|
||||||
try {
|
|
||||||
wdrwl = await getPayment({ id: hash, lnd })
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err)
|
|
||||||
if (err[1] === 'SentPaymentNotFound') {
|
|
||||||
notFound = true
|
|
||||||
} else {
|
|
||||||
// on lnd related errors, we manually retry so we don't exponentially backoff
|
|
||||||
await boss.send('checkWithdrawal', { id, hash }, walletOptions)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(wdrwl)
|
|
||||||
|
|
||||||
if (wdrwl?.is_confirmed) {
|
|
||||||
const fee = Number(wdrwl.payment.fee_mtokens)
|
|
||||||
const paid = Number(wdrwl.payment.mtokens) - fee
|
|
||||||
await serialize(models, models.$executeRaw`
|
|
||||||
SELECT confirm_withdrawl(${id}::INTEGER, ${paid}, ${fee})`)
|
SELECT confirm_withdrawl(${id}::INTEGER, ${paid}, ${fee})`)
|
||||||
} else if (wdrwl?.is_failed || notFound) {
|
} else if (wdrwl?.is_failed || notFound) {
|
||||||
let status = 'UNKNOWN_FAILURE'
|
let status = 'UNKNOWN_FAILURE'
|
||||||
if (wdrwl?.failed.is_insufficient_balance) {
|
if (wdrwl?.failed.is_insufficient_balance) {
|
||||||
status = 'INSUFFICIENT_BALANCE'
|
status = 'INSUFFICIENT_BALANCE'
|
||||||
} else if (wdrwl?.failed.is_invalid_payment) {
|
} else if (wdrwl?.failed.is_invalid_payment) {
|
||||||
status = 'INVALID_PAYMENT'
|
status = 'INVALID_PAYMENT'
|
||||||
} else if (wdrwl?.failed.is_pathfinding_timeout) {
|
} else if (wdrwl?.failed.is_pathfinding_timeout) {
|
||||||
status = 'PATHFINDING_TIMEOUT'
|
status = 'PATHFINDING_TIMEOUT'
|
||||||
} else if (wdrwl?.failed.is_route_not_found) {
|
} else if (wdrwl?.failed.is_route_not_found) {
|
||||||
status = 'ROUTE_NOT_FOUND'
|
status = 'ROUTE_NOT_FOUND'
|
||||||
}
|
|
||||||
await serialize(models, models.$executeRaw`
|
|
||||||
SELECT reverse_withdrawl(${id}::INTEGER, ${status}::"WithdrawlStatus")`)
|
|
||||||
} else {
|
|
||||||
// we need to requeue to check again in 5 seconds
|
|
||||||
const startAfter = new Date(wdrwl.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60
|
|
||||||
await boss.send('checkWithdrawal', { id, hash }, { ...walletOptions, startAfter })
|
|
||||||
}
|
}
|
||||||
|
await serialize(models, models.$executeRaw`
|
||||||
|
SELECT reverse_withdrawl(${id}::INTEGER, ${status}::"WithdrawlStatus")`)
|
||||||
|
} else {
|
||||||
|
// we need to requeue to check again in 5 seconds
|
||||||
|
const startAfter = new Date(wdrwl.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60
|
||||||
|
await boss.send('checkWithdrawal', { id, hash }, { ...walletOptions, startAfter })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function autoDropBolt11s ({ models }) {
|
export async function autoDropBolt11s ({ models }) {
|
||||||
return async function () {
|
await serialize(models, models.$executeRaw`
|
||||||
console.log('deleting invoices')
|
UPDATE "Withdrawl"
|
||||||
try {
|
SET hash = NULL, bolt11 = NULL
|
||||||
await serialize(models, models.$executeRaw`
|
WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s")
|
||||||
UPDATE "Withdrawl"
|
AND now() > created_at + interval '${INVOICE_RETENTION_DAYS} days'
|
||||||
SET hash = NULL, bolt11 = NULL
|
AND hash IS NOT NULL;`
|
||||||
WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s")
|
)
|
||||||
AND now() > created_at + interval '${INVOICE_RETENTION_DAYS} days'
|
|
||||||
AND hash IS NOT NULL;`
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user