territories

This commit is contained in:
keyan 2023-11-21 17:32:22 -06:00
parent 82a1b450f5
commit 10203f565c
59 changed files with 2334 additions and 922 deletions

View File

@ -81,7 +81,8 @@ export default {
json_build_object('name', 'boost', 'value', floor(avg(boost))),
json_build_object('name', 'fees', 'value', floor(avg(fees))),
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
FROM 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', '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', '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
FROM times
LEFT JOIN
@ -107,7 +109,12 @@ export default {
UNION ALL
(SELECT created_at, "userId", 'DONATION' as act
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
ORDER BY time ASC`, ...range)
},
@ -151,7 +158,8 @@ export default {
json_build_object('name', 'boost', 'value', sum(boost)),
json_build_object('name', 'fees', 'value', sum(fees)),
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
FROM spending_growth_days
WHERE ${viewIntervalClause(range, 'spending_growth_days')}
@ -164,9 +172,10 @@ export default {
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', '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', '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
FROM times
LEFT JOIN
@ -176,7 +185,12 @@ export default {
UNION ALL
(SELECT created_at, sats * 1000 as msats, 'DONATION' as act
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
ORDER BY time ASC`, ...range)
},
@ -190,7 +204,8 @@ export default {
json_build_object('name', 'posts', 'value', floor(avg(posts))),
json_build_object('name', 'comments', 'value', floor(floor(avg(comments)))),
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
FROM 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', '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', '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
FROM times
LEFT JOIN
@ -217,6 +233,10 @@ export default {
(SELECT created_at, "userId" as user_id, 'EARN' as type
FROM "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
(SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type
FROM "ReferralAct"
@ -233,7 +253,8 @@ export default {
json_build_object('name', 'rewards', 'value', sum(rewards)),
json_build_object('name', 'posts', 'value', sum(posts)),
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
FROM 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', 'posts', 'value', coalesce(floor(sum(post)/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
FROM times
LEFT JOIN
((SELECT "ItemAct".created_at, 0 as airdrop,
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".msats END as comment,
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post,
0 as referral
0 as referral, 0 as revenue
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE ${intervalClause(range, 'ItemAct')} AND "ItemAct".act = 'TIP')
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"
WHERE ${intervalClause(range, 'ReferralAct')})
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"
WHERE ${intervalClause(range, 'Earn')})) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
GROUP BY time

View File

@ -466,23 +466,21 @@ export default {
orderBy: 'ORDER BY rank DESC'
}, decodedCursor.offset, limit, ...subArr)
// XXX this is just for migration purposes ... can remove after initial deployment
// and views have been populated
if (items.length === 0) {
// XXX this is just for subs that are really empty
if (decodedCursor.offset === 0 && items.length < limit) {
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}, rank
${SELECT}
FROM "Item"
JOIN zap_rank_tender_view ON "Item".id = zap_rank_tender_view.id
${whereClause(
subClause(sub, 3, 'Item', true),
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
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)
}

View File

@ -229,6 +229,15 @@ export default {
AND created_at <= $2
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) {
@ -330,6 +339,17 @@ export default {
JobChanged: {
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: {
days: async (n, args, { models }) => {
const res = await models.$queryRaw`

View File

@ -15,7 +15,13 @@ export default async function serialize (models, ...calls) {
return calls.length > 1 ? result : result[0]
} catch (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' } }))
}
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')
}
let trx = [query]
let trx = Array.isArray(query) ? query : [query]
let invoice
if (hash) {
invoice = await checkInvoice(models, hash, hmac, enforceFee)
trx = [
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() } })
]
}
const results = await serialize(models, ...trx)
const result = trx.length > 1 ? results[1][0] : results[0]
let results = await serialize(models, ...trx)
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) {

View File

@ -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 {
Query: {
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 }) => {
const latest = await models.item.findFirst({
where: {
@ -32,5 +106,164 @@ export default {
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
}
}

View File

@ -106,45 +106,44 @@ export default {
if (include.has('invoice')) {
queries.push(
`(SELECT ('invoice' || id) as id, id as "factId", bolt11, created_at as "createdAt",
COALESCE("msatsReceived", "msatsRequested") as msats, NULL as "msatsFee",
CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED'
`(SELECT
id, created_at as "createdAt", COALESCE("msatsReceived", "msatsRequested") as msats,
'invoice' as type,
jsonb_build_object(
'bolt11', bolt11,
'status', CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED'
WHEN "expiresAt" <= $2 THEN 'EXPIRED'
WHEN cancelled THEN 'CANCELLED'
ELSE 'PENDING' END as status,
"desc" as description,
comment as "invoiceComment",
"lud18Data" as "invoicePayerData",
'invoice' as type
ELSE 'PENDING' END,
'description', "desc",
'invoiceComment', comment,
'invoicePayerData', "lud18Data") as other
FROM "Invoice"
WHERE "userId" = $1
AND created_at <= $2)`)
AND created_at <= $2)`
)
}
if (include.has('withdrawal')) {
queries.push(
`(SELECT ('withdrawal' || id) as id, id as "factId", bolt11, created_at as "createdAt",
CASE WHEN status = 'CONFIRMED' THEN "msatsPaid"
ELSE "msatsPaying" END as msats,
CASE WHEN status = 'CONFIRMED' THEN "msatsFeePaid"
ELSE "msatsFeePaying" END as "msatsFee",
COALESCE(status::text, 'PENDING') as status,
NULL as description,
NULL as "invoiceComment",
NULL as "invoicePayerData",
'withdrawal' as type
`(SELECT
id, created_at as "createdAt",
COALESCE("msatsPaid", "msatsPaying") as msats,
'withdrawal' as type,
jsonb_build_object(
'status', COALESCE(status::text, 'PENDING'),
'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other
FROM "Withdrawl"
WHERE "userId" = $1
AND created_at <= $2)`)
AND created_at <= $2)`
)
}
if (include.has('stacked')) {
// query1 - get all sats stacked as OP or as a forward
queries.push(
`(SELECT
('stacked' || "Item".id) AS id,
"Item".id AS "factId",
NULL AS bolt11,
"Item".id,
MAX("ItemAct".created_at) AS "createdAt",
FLOOR(
SUM("ItemAct".msats)
@ -153,13 +152,8 @@ export default {
ELSE
(SELECT pct FROM "ItemForward" WHERE "itemId" = "Item".id AND "userId" = $1) / 100.0
END)
) AS "msats",
0 AS "msatsFee",
NULL AS status,
NULL as description,
NULL as "invoiceComment",
NULL as "invoicePayerData",
'stacked' AS type
) AS msats,
'stacked' AS type, NULL::JSONB AS other
FROM "ItemAct"
JOIN "Item" ON "ItemAct"."itemId" = "Item".id
-- only join to with item forward for items where we aren't the OP
@ -170,37 +164,50 @@ export default {
GROUP BY "Item".id)`
)
queries.push(
`(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11,
created_at as "createdAt", sum(msats),
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'earn' as type
`(SELECT
min("Earn".id) as id, created_at as "createdAt",
sum(msats) as msats, 'earn' as type, NULL::JSONB AS other
FROM "Earn"
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
GROUP BY "userId", created_at)`)
GROUP BY "userId", created_at)`
)
queries.push(
`(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11,
created_at as "createdAt", msats,
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'referral' as type
`(SELECT id, created_at as "createdAt", msats, 'referral' as type, NULL::JSONB AS other
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')) {
queries.push(
`(SELECT ('spent' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats,
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'spent' as type
`(SELECT "Item".id, MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats,
'spent' as type, NULL::JSONB AS other
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" = $1
AND "ItemAct".created_at <= $2
GROUP BY "Item".id)`)
GROUP BY "Item".id)`
)
queries.push(
`(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11,
created_at as "createdAt", sats * 1000 as msats,
0 as "msatsFee", NULL as status, NULL as description, NULL as "invoiceComment", NULL as "invoicePayerData", 'donation' as type
`(SELECT id, created_at as "createdAt", sats * 1000 as msats,'donation' as type, NULL::JSONB AS other
FROM "Donation"
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) {
@ -214,9 +221,12 @@ export default {
${queries.join(' UNION ALL ')}
ORDER BY "createdAt" DESC
OFFSET $3
LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset)
LIMIT ${LIMIT}`,
me.id, decodedCursor.time, decodedCursor.offset)
history = history.map(f => {
f = { ...f, ...f.other }
if (f.bolt11) {
const inv = lnpr.decode(f.bolt11)
if (inv) {
@ -230,14 +240,14 @@ export default {
}
}
}
switch (f.type) {
case 'withdrawal':
f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee)
break
case 'spent':
f.msats *= -1
break
case 'donation':
case 'billing':
f.msats *= -1
break
default:
@ -281,6 +291,8 @@ export default {
expires_at: expiresAt
})
console.log('invoice', balanceLimit)
const [inv] = await serialize(models,
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request},
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL,
@ -411,12 +423,11 @@ export default {
const [item] = await models.$queryRawUnsafe(`
${SELECT}
FROM "Item"
WHERE id = $1`, Number(fact.factId))
WHERE id = $1`, Number(fact.id))
return item
},
sats: fact => msatsToSatsDecimal(fact.msats),
satsFee: fact => msatsToSatsDecimal(fact.msatsFee)
sats: fact => msatsToSatsDecimal(fact.msats)
}
}

View File

@ -105,7 +105,7 @@ export function getGetServerSideProps (
console.error(e)
}
if (error || !data || (notFound && notFound(data, vars))) {
if (error || !data || (notFound && notFound(data, vars, me))) {
return {
notFound: true
}

View File

@ -77,6 +77,13 @@ export default gql`
sources: EarnSources
}
type Revenue {
id: ID!
earnedSats: Int!
sortTime: Date!
subName: String!
}
type InvoicePaid {
id: ID!
earnedSats: Int!
@ -91,7 +98,7 @@ export default gql`
union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | Referral
| Streak | FollowActivity | ForwardedVotification
| Streak | FollowActivity | ForwardedVotification | Revenue
type Notifications {
lastChecked: Date

View File

@ -4,14 +4,28 @@ export default gql`
extend type Query {
sub(name: String): Sub
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 {
name: String!
name: ID!
createdAt: Date!
userId: Int!
user: User!
desc: String
updatedAt: Date!
postTypes: [String!]!
billingCost: Int!
billingType: String!
rankingType: String!
billedLastAt: Date!
baseCost: Int!
status: String!
}
`

View File

@ -49,17 +49,16 @@ export default gql`
type Fact {
id: ID!
factId: ID!
bolt11: String
createdAt: Date!
sats: Float!
satsFee: Float
status: String
type: String!
bolt11: String
status: String
description: String
item: Item
invoiceComment: String
invoicePayerData: JSONObject
subName: String
}
type History {

View File

@ -31,3 +31,16 @@ export default function AccordianItem ({ header, body, headerColor = 'var(--them
</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>
)
}

View File

@ -5,7 +5,7 @@ import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import InputGroup from 'react-bootstrap/InputGroup'
import { bountySchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react'
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
import { MAX_TITLE_LENGTH } from '../lib/constants'

View File

@ -11,3 +11,23 @@ export default function SimpleCountdown ({ className, onComplete, date }) {
</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>
)
}

View File

@ -7,7 +7,7 @@ import { ITEM_FIELDS } from '../fragments/items'
import AccordianItem from './accordian-item'
import Item from './item'
import { discussionSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react'
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
import { MAX_TITLE_LENGTH } from '../lib/constants'

View File

@ -4,7 +4,7 @@ import ActionTooltip from './action-tooltip'
import Info from './info'
import styles from './fee-button.module.css'
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 { useMe } from './me'
import AnonIcon from '../svgs/spy-fill.svg'
@ -95,7 +95,8 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = ()
}
export function useFeeButton () {
return useContext(FeeButtonContext)
const context = useContext(FeeButtonContext)
return context
}
function FreebieDialog () {
@ -114,9 +115,7 @@ export default function FeeButton ({ ChildButton = SubmitButton, variant, text,
const me = useMe()
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
const free = total === lines.baseCost?.modifier(0) &&
total <= FREEBIE_BASE_COST_THRESHOLD &&
me?.privates?.sats < FREEBIE_BASE_COST_THRESHOLD
const free = total === lines.baseCost?.modifier(0) && me?.privates?.sats < total
const feeText = free
? 'free'
: total > 1

View File

@ -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.
// Formik does this too! When you specify `type` to useField(), it will
// return the correct bag of props for you
const [field,, helpers] = useField({ ...props, type: 'checkbox' })
const [field, meta, helpers] = useField({ ...props, type })
return (
<FormGroup className={groupClassName}>
{hiddenLabel && <BootstrapForm.Label className='invisible'>{label}</BootstrapForm.Label>}
@ -709,7 +712,8 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
inline={inline}
>
<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)
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()
export function Form ({
@ -832,7 +849,17 @@ export function Select ({ label, items, groupClassName, onChange, noForm, overri
}}
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.Control.Feedback type='invalid'>
{meta.touched && meta.error}

View File

@ -16,10 +16,9 @@ import { abbrNum } from '../lib/format'
import NoteIcon from '../svgs/notification-4-fill.svg'
import { useQuery } from '@apollo/client'
import LightningIcon from '../svgs/bolt.svg'
import { Select } from './form'
import SearchIcon from '../svgs/search-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 { HAS_NOTIFICATIONS } from '../fragments/notifications'
import AnonIcon from '../svgs/spy-fill.svg'
@ -27,6 +26,7 @@ import Hat from './hat'
import HiddenWalletSummary from './hidden-wallet-summary'
import { clearNotifications } from '../lib/badge'
import { useServiceWorker } from './serviceworker'
import SubSelect from './sub-select'
function WalletSummary ({ me }) {
if (!me) return null
@ -213,57 +213,17 @@ function LurkerCorner ({ path }) {
</div>
}
const PREPEND_SUBS = ['home']
const APPEND_SUBS = [{ label: '--------', items: ['create'] }]
function NavItems ({ className, sub, prefix }) {
const router = useRouter()
sub ||= 'home'
return (
<>
<Nav.Item className={className}>
<Select
<SubSelect
sub={sub} prependSubs={PREPEND_SUBS} appendSubs={APPEND_SUBS} noForm
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 className={className}>

View File

@ -9,9 +9,9 @@ import Item from './item'
import AccordianItem from './accordian-item'
import { linkSchema } from '../lib/validate'
import Moon from '../svgs/moon-fill.svg'
import { SubSelectInitial } from './sub-select-form'
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
import { useToast } from './toast'
import { SubSelectInitial } from './sub-select'
import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useMe } from './me'
import { ItemButtonBar } from './post'

View File

@ -25,6 +25,7 @@ import { nostrZapDetails } from '../lib/nostr'
import Text from './text'
import NostrIcon from '../svgs/nostr.svg'
import { numWithUnits } from '../lib/format'
import BountyIcon from '../svgs/bounty-bag.svg'
function Notification ({ n, fresh }) {
const type = n.__typename
@ -33,6 +34,7 @@ function Notification ({ n, fresh }) {
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
{
(type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
(type === 'Invitification' && <Invitification n={n} />) ||
(type === 'InvoicePaid' && (n.invoice.nostr ? <NostrZap n={n} /> : <InvoicePaid n={n} />)) ||
(type === 'Referral' && <Referral n={n} />) ||
@ -83,6 +85,7 @@ const defaultOnClick = n => {
href += dayMonthYear(new Date(n.sortTime))
return { href }
}
if (type === 'Revenue') return { href: `/~${n.subName}` }
if (type === 'Invitification') return { href: '/invites' }
if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
if (type === 'Referral') return { href: '/referrals/month' }
@ -135,10 +138,10 @@ function Streak ({ n }) {
}
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 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>
</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 }) {
return (
<>

View File

@ -5,7 +5,7 @@ import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../lib/constants'
import { pollSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react'
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
import { useMe } from './me'

View File

@ -9,7 +9,7 @@ import { DiscussionForm } from './discussion-form'
import { LinkForm } from './link-form'
import { PollForm } from './poll-form'
import { BountyForm } from './bounty-form'
import SubSelect from './sub-select-form'
import SubSelect, { SubInfo } from './sub-select'
import { useCallback, useState } from 'react'
import FeeButton, { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
import Delete from './delete'
@ -29,33 +29,89 @@ export function PostForm ({ type, sub, children }) {
}, [me, setErrorMessage])
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 (
<div className='position-relative align-items-center'>
{errorMessage &&
<Alert className='position-absolute' style={{ top: '-6rem' }} variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>
{errorMessage}
</Alert>}
<SubSelect noForm sub={sub?.name} />
<Link href={prefix + '/post?type=link'}>
<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>
<SubSelect prependSubs={['pick territory']} className='w-auto d-flex' noForm sub={sub?.name} />
{postButtons}
<div className='d-flex mt-4'>
<AccordianItem
headerColor='#6c757d'
header={<div className='fw-bold text-muted'>more types</div>}
body={
<div className='align-items-center'>
<Link href={prefix + '/post?type=poll'}>
<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>
{morePostButtons}
<div className='mt-3 d-flex justify-content-center'>
<Link href='/~jobs/post'>
<Button onClick={checkSession} variant='info'>job</Button>
@ -101,7 +157,19 @@ export default function Post ({ sub }) {
return (
<>
<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>
</>
)

View File

@ -1,12 +1,17 @@
import { ITEM_TYPES } from '../lib/constants'
import { ITEM_TYPES, ITEM_TYPES_UNIVERSAL } from '../lib/constants'
import { Select } from './form'
import { useRouter } from 'next/router'
export default function RecentHeader ({ type, sub }) {
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'
return (

View File

@ -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
View 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}
/>
)
}

View File

@ -0,0 +1,6 @@
.subSelect {
width: 100px !important;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis !important;
}

View 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>
)
}

View 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>
)
}

View File

@ -32,6 +32,12 @@ export const NOTIFICATIONS = gql`
text
}
}
... on Revenue {
id
sortTime
earnedSats
subName
}
... on ForwardedVotification {
id
sortTime

View File

@ -7,7 +7,27 @@ export const SUB_FIELDS = gql`
name
postTypes
rankingType
billingType
billingCost
billedLastAt
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`
@ -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}
query Subs {
subs {
...SubFields
}
}`
export const SUB_ITEMS = gql`
${SUB_FULL_FIELDS}
${ITEM_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) {
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) {
@ -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
}
}`

View File

@ -40,17 +40,16 @@ export const WALLET_HISTORY = gql`
walletHistory(cursor: $cursor, inc: $inc) {
facts {
id
factId
bolt11
type
createdAt
sats
satsFee
status
type
description
invoiceComment
invoicePayerData
subName
item {
...ItemFullFields
}

View File

@ -33,6 +33,9 @@ function getClient (uri) {
cache: new InMemoryCache({
freezeResults: true,
typePolicies: {
Sub: {
keyFields: ['name']
},
User: {
// https://www.apollographql.com/docs/react/caching/cache-field-behavior/#merging-non-normalized-objects
fields: {

View File

@ -1,7 +1,7 @@
// XXX this is temporary until we have so many subs they have
// to be loaded from the server
export const SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
export const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs')
export const DEFAULT_SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
export const DEFAULT_SUBS_NO_JOBS = DEFAULT_SUBS.filter(s => s !== 'jobs')
export const NOFOLLOW_LIMIT = 1000
export const BOOST_MULT = 5000
@ -17,10 +17,14 @@ export const UPLOAD_TYPES_ALLOW = [
'image/jpeg',
'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 MAX_TITLE_LENGTH = 80
export const MAX_POST_TEXT_LENGTH = 100000 // 100k
export const MAX_COMMENT_TEXT_LENGTH = 10000 // 10k
export const MAX_TERRITORY_DESC_LENGTH = 140
export const MAX_POLL_CHOICE_LENGTH = 40
export const ITEM_SPAM_INTERVAL = '10m'
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 ITEM_SORTS = ['zaprank', 'comments', 'sats']
export const WHENS = ['day', 'week', 'month', 'year', 'forever', 'custom']
export const ITEM_TYPES = context => {
const items = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls']
if (!context) {
items.push('bios', 'jobs')
}
items.push('freebies')
if (context === 'user') {
items.push('jobs', 'bookmarks')
}
return items
}
export const ITEM_TYPES_USER = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'jobs', 'bookmarks']
export const ITEM_TYPES = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'bios', 'jobs']
export const ITEM_TYPES_UNIVERSAL = ['all', 'posts', 'comments', 'freebies']
export const OLD_ITEM_DAYS = 3
export const ANON_USER_ID = 27
export const DELETE_USER_ID = 106
@ -63,6 +59,28 @@ export const GLOBAL_SEED = 616
export const FREEBIE_BASE_COST_THRESHOLD = 10
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 = [
'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.',

View File

@ -1,10 +1,16 @@
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 { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
import * as pkg from '../fragments/users'
const { NAME_QUERY } = pkg
import * as usersFragments from '../fragments/users'
import * as subsFragments from '../fragments/subs'
const { SUB } = subsFragments
const { NAME_QUERY } = usersFragments
export async function ssValidate (schema, data, args) {
try {
@ -46,6 +52,10 @@ const textValidator = (max) => string().trim().max(
max,
({ 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')
@ -64,6 +74,55 @@ async function usernameExists (name, { client, models }) {
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 }) {
const boostMin = existingBoost || BOOST_MIN
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 {
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
@ -128,7 +204,11 @@ export function bountySchema (args) {
.min(1000, 'must be at least 1000')
.max(1000000, 'must be at most 1m'),
...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,
text: textValidator(MAX_POST_TEXT_LENGTH),
...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),
url: string().matches(URL_REGEXP, 'invalid url').required('required'),
...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
}),
...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) {
return object({
name: string()
.required('required')
.matches(/^[\w_]+$/, 'only letters, numbers, and _')
.max(32, 'too long')
name: nameValidator
.test({
name: 'name',
test: async name => {

View File

@ -3,7 +3,7 @@ import Items from '../../components/items'
import { useRouter } from 'next/router'
import { USER, USER_WITH_ITEMS } from '../../fragments/users'
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 { UserLayout } from '.'
import { Form, Select, DatePicker } from '../../components/form'
@ -45,7 +45,7 @@ function UserItemsHeader ({ type, name }) {
const router = useRouter()
async function select (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.when || query.when === 'forever' || !WHENS.includes(query.when) || query.when === 'forever') delete query.when
if (query.when !== 'custom') { delete query.from; delete query.to }
@ -74,7 +74,7 @@ function UserItemsHeader ({ type, name }) {
name='type'
size='sm'
overrideValue={type}
items={ITEM_TYPES('user')}
items={ITEM_TYPES_USER}
onChange={(formik, e) => select({ ...formik?.values, type: e.target.value })}
/>
by

View File

@ -6,12 +6,12 @@ import { CenterLayout } from '../../../components/layout'
import JobForm from '../../../components/job-form'
import { PollForm } from '../../../components/poll-form'
import { BountyForm } from '../../../components/bounty-form'
import SubSelect from '../../../components/sub-select-form'
import { useState } from 'react'
import { useQuery } from '@apollo/client'
import { useRouter } from 'next/router'
import PageLoading from '../../../components/page-loading'
import { FeeButtonProvider } from '../../../components/fee-button'
import SubSelect from '../../../components/sub-select'
export const getServerSideProps = getGetServerSideProps({
query: ITEM,
@ -30,14 +30,19 @@ export default function PostEdit ({ ssrData }) {
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
let FormType = DiscussionForm
let itemType = 'DISCUSSION'
if (item.isJob) {
FormType = JobForm
itemType = 'JOB'
} else if (item.url) {
FormType = LinkForm
itemType = 'LINK'
} else if (item.pollCost) {
FormType = PollForm
itemType = 'POLL'
} else if (item.bounty) {
FormType = BountyForm
itemType = 'BOUNTY'
}
const existingBoostLineItem = item.boost
@ -54,7 +59,14 @@ export default function PostEdit ({ ssrData }) {
<CenterLayout sub={sub}>
<FeeButtonProvider baseLineItems={existingBoostLineItem}>
<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>
</FeeButtonProvider>
</CenterLayout>

View File

@ -88,7 +88,7 @@ function Detail ({ fact }) {
if (fact.type === 'earn') {
return (
<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>
)
}
@ -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) {
let zap
try {
@ -114,7 +126,7 @@ function Detail ({ fact }) {
} catch { }
return (
<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>) ||
(zap && <span className='d-block'>nostr zap{zap.content && `: ${zap.content}`}</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.detail, styles.head].join(' ')}>detail</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>
<MoreFooter cursor={cursor} count={facts?.length} fetchMore={fetchMore} Skeleton={PageLoading} />

14
pages/territory.js Normal file
View 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
View 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>
)
}

View File

@ -2,9 +2,21 @@ import { useRouter } from 'next/router'
import { getGetServerSideProps } from '../../api/ssrApollo'
import Items from '../../components/items'
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 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({
query: SUB_ITEMS,
@ -13,12 +25,49 @@ export const getServerSideProps = getGetServerSideProps({
export default function Sub ({ ssrData }) {
const router = useRouter()
const me = useMe()
const variables = { ...router.query }
const { data } = useQuery(SUB_FULL, { variables })
if (!data && !ssrData) return <PageLoading />
const { sub } = data || ssrData
return (
<Layout sub={variables.sub}>
{sub
? (
<>
<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} />
</Layout>
)

View File

@ -3,8 +3,10 @@ import Items from '../../../components/items'
import { getGetServerSideProps } from '../../../api/ssrApollo'
import RecentHeader from '../../../components/recent-header'
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 { useQuery } from '@apollo/client'
import PageLoading from '../../../components/page-loading'
const staticVariables = { sort: 'recent' }
const variablesFunc = vars =>
@ -18,10 +20,14 @@ export const getServerSideProps = getGetServerSideProps({
export default function Index ({ ssrData }) {
const router = useRouter()
const variables = variablesFunc(router.query)
const { data } = useQuery(SUB_FULL, { variables })
if (!data && !ssrData) return <PageLoading />
const { sub } = data || ssrData
return (
<Layout sub={variables.sub}>
<RecentHeader sub={variables.sub} />
<Layout sub={sub?.name}>
<RecentHeader sub={sub} />
<Items
ssrData={ssrData}
query={SUB_ITEMS}

View 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);

View File

@ -100,6 +100,8 @@ model User {
muteds Mute[] @relation("muted")
ArcOut Arc[] @relation("fromUser")
ArcIn Arc[] @relation("toUser")
Sub Sub[]
SubAct SubAct[]
@@index([photoId])
@@index([createdAt], map: "users.created_at_index")
@ -381,16 +383,66 @@ model PollVote {
@@index([userId], map: "PollVote.userId_index")
}
enum BillingType {
MONTHLY
YEARLY
ONCE
}
enum RankingType {
WOT
RECENT
AUCTION
}
model Sub {
name String @id @db.Citext
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
parentName String? @db.Citext
path Unsupported("ltree")?
postTypes PostType[]
rankingType RankingType
baseCost Int @default(1)
rewardsPct Int @default(50)
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[]
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 {
@ -630,10 +682,16 @@ enum EarnType {
TIP_POST
}
enum SubActType {
BILLING
REVENUE
}
enum Status {
ACTIVE
STOPPED
NOSATS
GRACE
}
enum PostType {
@ -644,12 +702,6 @@ enum PostType {
BOUNTY
}
enum RankingType {
WOT
RECENT
AUCTION
}
enum ItemActType {
VOTE
BOOST

View File

@ -58,7 +58,7 @@ $close-color: inherit;
$alert-border-radius: #{33% 2%} / #{11% 74%};
$link-color: #007cbe;
$link-decoration: none;
$font-size-base: .9rem;
$font-size-base: .93rem;
$enable-responsive-font-sizes: true;
$link-hover-decoration: none;
$dropdown-border-color: #ced4da;
@ -95,6 +95,41 @@ $grid-gutter-width: 2rem;
$toast-spacing: .5rem;
$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] {
--theme-navLink: rgba(0, 0, 0, 0.55);
--theme-navLinkFocus: rgba(0, 0, 0, 0.7);
@ -117,7 +152,7 @@ $btn-close-bg: none;
[data-bs-theme=dark] {
color-scheme: dark;
--theme-inputBg: #121211;
--theme-inputDisabledBg: #121211;
--theme-inputDisabledBg: #2d2d2d;
--theme-navLink: rgba(255, 255, 255, 0.55);
--theme-navLinkFocus: rgba(255, 255, 255, 0.75);
--theme-navLinkActive: rgba(255, 255, 255, 0.9);

1
svgs/settings-5-fill.svg Normal file
View 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

View File

@ -1,8 +1,6 @@
import serialize from '../api/resolvers/serial.js'
export function auction ({ models }) {
return async function ({ name }) {
console.log('running', name)
export async function auction ({ models }) {
// get all items we need to check
const items = await models.item.findMany(
{
@ -22,7 +20,4 @@ export function auction ({ models }) {
await serialize(models,
models.$executeRaw`SELECT run_auction(${item.id}::INTEGER)`)
})
console.log('done', name)
}
}

View File

@ -1,10 +1,7 @@
import { deleteObjects } from '../api/s3'
import { ANON_USER_ID } from '../lib/constants'
export function deleteUnusedImages ({ models }) {
return async function ({ name }) {
console.log('running', name)
export async function deleteUnusedImages ({ models }) {
// delete all images in database and S3 which weren't paid in the last 24 hours
const unpaidImages = await models.$queryRaw`
SELECT id
@ -23,5 +20,4 @@ export function deleteUnusedImages ({ models }) {
console.log('deleting images:', s3Keys)
await deleteObjects(s3Keys)
await models.upload.deleteMany({ where: { id: { in: s3Keys } } })
}
}

View File

@ -8,16 +8,19 @@ const UPVOTE_EACH_REWARD = 4.0
const TOP_PERCENTILE = 33
const TOTAL_UPPER_BOUND_MSATS = 1000000000
export function earn ({ models }) {
return async function ({ name }) {
console.log('running', name)
export async function earn ({ name, models }) {
// rewards are calculated sitewide still
// however for user gen subs currently only 50% of their fees go to rewards
// the other 50% goes to the founder of the sub
// compute how much sn earned today
const [{ sum: sumDecimal }] = await models.$queryRaw`
SELECT coalesce(sum(msats), 0) as sum
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"
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id
WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago')
AND "ItemAct".act <> 'TIP')
@ -167,12 +170,42 @@ export function earn ({ models }) {
}
}
await territoryRevenue({ models })
Promise.allSettled(Object.entries(notifications).map(([userId, earnings]) =>
sendUserNotification(parseInt(userId, 10), buildUserNotification(earnings))
)).catch(console.error)
}
console.log('done', name)
}
async function territoryRevenue ({ models }) {
await serialize(models,
models.$executeRaw`
WITH revenue AS (
SELECT coalesce(sum(msats), 0) as revenue, "subName", "userId"
FROM (
SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) * (1 - (COALESCE("Sub"."rewardsPct", 100) * 0.01)) as msats,
"Sub"."name" as "subName", "Sub"."userId" as "userId"
FROM "ItemAct"
JOIN "Item" ON "Item"."id" = "ItemAct"."itemId"
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) {

View File

@ -1,13 +1,5 @@
import { deleteItemByAuthor } from '../lib/item.js'
export function deleteItem ({ models }) {
return async function ({ data: eventData }) {
console.log('deleteItem', eventData)
const { id } = eventData
try {
export async function deleteItem ({ data: { id }, models }) {
await deleteItemByAuthor({ models, id })
} catch (err) {
console.error('failed to delete item', err)
}
}
}

View File

@ -53,32 +53,22 @@ function decodeOriginalUrl (imgproxyUrl) {
return originalUrl
}
export function imgproxy ({ models }) {
return async function ({ data: { id, forceFetch = false } }) {
export async function imgproxy ({ data: { id, forceFetch = false }, models }) {
if (!imgProxyEnabled) return
console.log('running imgproxy job', id)
const item = await models.item.findUnique({ where: { id } })
let imgproxyUrls = {}
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 } })
}
}
export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {

View File

@ -17,6 +17,7 @@ import { views, rankViews } from './views.js'
import { imgproxy } from './imgproxy.js'
import { deleteItem } from './ephemeralItems.js'
import { deleteUnusedImages } from './deleteUnusedImages.js'
import { territoryBilling } from './territory.js'
const { loadEnvConfig } = nextEnv
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
@ -54,25 +55,39 @@ async function work () {
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.work('checkInvoice', checkInvoice(args))
await boss.work('checkWithdrawal', checkWithdrawal(args))
await boss.work('autoDropBolt11s', autoDropBolt11s(args))
await boss.work('repin-*', repin(args))
await boss.work('trust', trust(args))
await boss.work('timestampItem', timestampItem(args))
await boss.work('indexItem', indexItem(args))
await boss.work('indexAllItems', indexAllItems(args))
await boss.work('auction', auction(args))
await boss.work('earn', earn(args))
await boss.work('streak', computeStreaks(args))
await boss.work('checkStreak', checkStreak(args))
await boss.work('nip57', nip57(args))
await boss.work('views', views(args))
await boss.work('rankViews', rankViews(args))
await boss.work('imgproxy', imgproxy(args))
await boss.work('deleteItem', deleteItem(args))
await boss.work('deleteUnusedImages', deleteUnusedImages(args))
await boss.work('checkInvoice', jobWrapper(checkInvoice))
await boss.work('checkWithdrawal', jobWrapper(checkWithdrawal))
await boss.work('autoDropBolt11s', jobWrapper(autoDropBolt11s))
await boss.work('repin-*', jobWrapper(repin))
await boss.work('trust', jobWrapper(trust))
await boss.work('timestampItem', jobWrapper(timestampItem))
await boss.work('indexItem', jobWrapper(indexItem))
await boss.work('indexAllItems', jobWrapper(indexAllItems))
await boss.work('auction', jobWrapper(auction))
await boss.work('earn', jobWrapper(earn))
await boss.work('streak', jobWrapper(computeStreaks))
await boss.work('checkStreak', jobWrapper(checkStreak))
await boss.work('nip57', jobWrapper(nip57))
await boss.work('views', jobWrapper(views))
await boss.work('rankViews', jobWrapper(rankViews))
await boss.work('imgproxy', jobWrapper(imgproxy))
await boss.work('deleteItem', jobWrapper(deleteItem))
await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages))
await boss.work('territoryBilling', jobWrapper(territoryBilling))
console.log('working jobs')
}

View File

@ -3,10 +3,7 @@ import { Relay, signId, calculateId, getPublicKey } from 'nostr'
const nostrOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
export function nip57 ({ boss, lnd, models }) {
return async function ({ data: { hash } }) {
console.log('running nip57')
export async function nip57 ({ data: { hash }, boss, lnd, models }) {
let inv, lnInv
try {
lnInv = await getInvoice({ id: hash, lnd })
@ -81,6 +78,4 @@ export function nip57 ({ boss, lnd, models }) {
} catch (e) {
console.log(e)
}
console.log('done running nip57')
}
}

View File

@ -12,10 +12,7 @@ const ITEM_OTS_FIELDS = gql`
url
}`
export function timestampItem ({ apollo, models }) {
return async function ({ data: { id } }) {
console.log('timestamping item', id)
export async function timestampItem ({ data: { id }, apollo, models }) {
const { data: { item: { parentId, parentOtsHash, title, text, url } } } = await apollo.query({
query: gql`
${ITEM_OTS_FIELDS}
@ -44,7 +41,4 @@ export function timestampItem ({ apollo, models }) {
// store in item
await models.item.update({ where: { id }, data: { otsHash: otsHash.toString('hex'), otsFile } })
console.log('done timestamping item', id)
}
}

View File

@ -1,7 +1,4 @@
export function repin ({ models }) {
return async function ({ name }) {
console.log('doing', name)
export async function repin ({ name, models }) {
// get the id
const id = name.slice('repin-'.length)
if (id.length === 0 || isNaN(id)) {
@ -23,7 +20,7 @@ export function repin ({ models }) {
)
if (!current) {
console.log('could not find existing item for', name)
console.log('could not find existing item for pin', name)
return
}
@ -38,5 +35,4 @@ export function repin ({ models }) {
pinId
}
})
}
}

View File

@ -37,7 +37,6 @@ const ITEM_SEARCH_FIELDS = gql`
async function _indexItem (item, { models }) {
console.log('indexing item', item.id)
// HACK: modify the title for jobs so that company/location are searchable
// and highlighted without further modification
const itemcp = { ...item }
@ -78,12 +77,9 @@ async function _indexItem (item, { models }) {
console.log(e)
throw e
}
console.log('done indexing item', item.id)
}
export function indexItem ({ apollo, models }) {
return async function ({ data: { id } }) {
console.log('indexing item, fetching ...', id)
export async function indexItem ({ data: { id }, apollo, models }) {
// 1. grab item from database
// could use apollo to avoid duping logic
// when grabbing sats and user name, etc
@ -99,11 +95,9 @@ export function indexItem ({ apollo, models }) {
// 2. index it with external version based on updatedAt
await _indexItem(item, { models })
}
}
export function indexAllItems ({ apollo, models }) {
return async function () {
export async function indexAllItems ({ apollo, models }) {
// cursor over all items in the Item table
let items = []; let cursor = null
do {
@ -130,5 +124,4 @@ export function indexAllItems ({ apollo, models }) {
console.log(e)
}
} while (cursor)
}
}

View File

@ -3,10 +3,7 @@ import { FOUND_BLURBS, LOST_BLURBS } from '../lib/constants'
const STREAK_THRESHOLD = 100
export function computeStreaks ({ models }) {
return async function () {
console.log('computing streaks')
export async function computeStreaks ({ models }) {
// get all eligible users in the last day
// if the user doesn't have an active streak, add one
// if they have an active streak but didn't maintain it, end it
@ -75,15 +72,9 @@ export function computeStreaks ({ models }) {
}).catch(console.error)
})
)
console.log('done computing streaks')
}
}
export function checkStreak ({ models }) {
return async function ({ data: { id } }) {
console.log('checking streak', id)
export async function checkStreak ({ data: { id }, models }) {
// if user is actively streaking skip
let streak = await models.streak.findFirst({
where: {
@ -93,7 +84,6 @@ export function checkStreak ({ models }) {
})
if (streak) {
console.log('done checking streak', id)
return
}
@ -122,8 +112,6 @@ export function checkStreak ({ models }) {
FROM streak_started
RETURNING "Streak".id`
console.log('done checking streak', id)
if (!streak) return
// new streak started for user
@ -134,5 +122,4 @@ export function checkStreak ({ models }) {
body: blurb,
tag: 'STREAK-FOUND'
}).catch(console.error)
}
}

30
worker/territory.js Normal file
View 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 }) })
}
}

View File

@ -1,8 +1,7 @@
import * as math from 'mathjs'
import { ANON_USER_ID, SN_USER_IDS } from '../lib/constants.js'
export function trust ({ boss, models }) {
return async function () {
export async function trust ({ boss, models }) {
try {
console.time('trust')
console.timeLog('trust', 'getting graph')
@ -11,13 +10,9 @@ export function trust ({ boss, models }) {
const [vGlobal, mPersonal] = await trustGivenGraph(graph)
console.timeLog('trust', 'storing trust')
await storeTrust(models, graph, vGlobal, mPersonal)
} catch (e) {
console.error(e)
throw e
} finally {
console.timeEnd('trust')
}
}
}
const MAX_DEPTH = 10

View File

@ -1,27 +1,15 @@
// this is intended to be run everyday after midnight CT
export function views ({ models }) {
return async function () {
console.log('refreshing stats views')
export async function views ({ models }) {
for (const view of ['reg_growth_days', 'spender_growth_days', 'item_growth_days',
'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
export function rankViews ({ models }) {
return async function () {
console.log('refreshing rank views')
export async function rankViews ({ models }) {
for (const view of ['zap_rank_personal_view']) {
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
}
console.log('done refreshing rank views')
}
}

View File

@ -8,8 +8,7 @@ import { INVOICE_RETENTION_DAYS } from '../lib/constants'
const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
// TODO this should all be done via websockets
export function checkInvoice ({ boss, models, lnd }) {
return async function ({ data: { hash, isHeldSet } }) {
export async function checkInvoice ({ data: { hash, isHeldSet }, boss, models, lnd }) {
let inv
try {
inv = await getInvoice({ id: hash, lnd })
@ -73,11 +72,9 @@ export function checkInvoice ({ boss, models, lnd }) {
if (expired && inv.is_held) {
await cancelHodlInvoice({ id: hash, lnd })
}
}
}
export function checkWithdrawal ({ boss, models, lnd }) {
return async function ({ data: { id, hash } }) {
export async function checkWithdrawal ({ data: { id, hash }, boss, models, lnd }) {
let wdrwl
let notFound = false
try {
@ -92,7 +89,6 @@ export function checkWithdrawal ({ boss, models, lnd }) {
return
}
}
console.log(wdrwl)
if (wdrwl?.is_confirmed) {
const fee = Number(wdrwl.payment.fee_mtokens)
@ -117,13 +113,9 @@ export function checkWithdrawal ({ boss, models, lnd }) {
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 }) {
return async function () {
console.log('deleting invoices')
try {
export async function autoDropBolt11s ({ models }) {
await serialize(models, models.$executeRaw`
UPDATE "Withdrawl"
SET hash = NULL, bolt11 = NULL
@ -131,8 +123,4 @@ export function autoDropBolt11s ({ models }) {
AND now() > created_at + interval '${INVOICE_RETENTION_DAYS} days'
AND hash IS NOT NULL;`
)
} catch (err) {
console.log(err)
}
}
}