501885cfa0
* Ignore if sub belongs to user during existence check * Remove code no longer needed * Fix territory edit Territory edits were broken because validation failed for existing territories and if you edit an territory, it obviously already exists. This commit fixes this by ignoring the territory that we're currently editing. * Fix existence check using stale cache --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
502 lines
15 KiB
JavaScript
502 lines
15 KiB
JavaScript
import { GraphQLError } from 'graphql'
|
|
import { serializeInvoicable } from './serial'
|
|
import { TERRITORY_COST_MONTHLY, TERRITORY_COST_ONCE, TERRITORY_COST_YEARLY, TERRITORY_PERIOD_COST } from '../../lib/constants'
|
|
import { datePivot, whenRange } from '../../lib/time'
|
|
import { ssValidate, territorySchema } from '../../lib/validate'
|
|
import { nextBilling, proratedBillingCost } from '../../lib/territory'
|
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
|
import { subViewGroup } from './growth'
|
|
import { notifyTerritoryTransfer } from '../../lib/push-notifications'
|
|
|
|
export function paySubQueries (sub, models) {
|
|
if (sub.billingType === 'ONCE') {
|
|
return []
|
|
}
|
|
|
|
// if in active or grace, consider we are billing them from where they are paid up
|
|
// and use grandfathered cost
|
|
let billedLastAt = sub.billPaidUntil
|
|
let billingCost = sub.billingCost
|
|
|
|
// if the sub is archived, they are paying to reactivate it
|
|
if (sub.status === 'STOPPED') {
|
|
// get non-grandfathered cost and reset their billing to start now
|
|
billedLastAt = new Date()
|
|
billingCost = TERRITORY_PERIOD_COST(sub.billingType)
|
|
}
|
|
|
|
const billPaidUntil = nextBilling(billedLastAt, sub.billingType)
|
|
const cost = BigInt(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,
|
|
billPaidUntil,
|
|
billingCost,
|
|
status: 'ACTIVE'
|
|
}
|
|
}),
|
|
// record 'em
|
|
models.subAct.create({
|
|
data: {
|
|
userId: sub.userId,
|
|
subName: sub.name,
|
|
msats: cost,
|
|
type: 'BILLING'
|
|
}
|
|
})
|
|
]
|
|
}
|
|
|
|
export async function getSub (parent, { name }, { models, me }) {
|
|
if (!name) return null
|
|
|
|
return await models.sub.findUnique({
|
|
where: {
|
|
name
|
|
},
|
|
...(me
|
|
? {
|
|
include: {
|
|
MuteSub: {
|
|
where: {
|
|
userId: Number(me?.id)
|
|
}
|
|
},
|
|
SubSubscription: {
|
|
where: {
|
|
userId: Number(me?.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
: {})
|
|
})
|
|
}
|
|
|
|
export default {
|
|
Query: {
|
|
sub: getSub,
|
|
subs: async (parent, args, { models, me }) => {
|
|
if (me) {
|
|
const currentUser = await models.user.findUnique({ where: { id: me.id } })
|
|
const showNsfw = currentUser ? currentUser.nsfwMode : false
|
|
return await models.$queryRawUnsafe(`
|
|
SELECT "Sub".*, COALESCE(json_agg("MuteSub".*) FILTER (WHERE "MuteSub"."userId" IS NOT NULL), '[]') AS "MuteSub"
|
|
FROM "Sub"
|
|
LEFT JOIN "MuteSub" ON "Sub".name = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}::INTEGER
|
|
WHERE status <> 'STOPPED' ${showNsfw ? '' : 'AND "Sub"."nsfw" = FALSE'}
|
|
GROUP BY "Sub".name, "MuteSub"."userId"
|
|
ORDER BY "Sub".name ASC
|
|
`)
|
|
}
|
|
|
|
return await models.sub.findMany({
|
|
where: {
|
|
status: {
|
|
not: 'STOPPED'
|
|
},
|
|
nsfw: false
|
|
},
|
|
orderBy: {
|
|
name: 'asc'
|
|
}
|
|
})
|
|
},
|
|
subLatestPost: async (parent, { name }, { models, me }) => {
|
|
const latest = await models.item.findFirst({
|
|
where: {
|
|
subName: name
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc'
|
|
}
|
|
})
|
|
|
|
return latest?.createdAt
|
|
},
|
|
topSubs: async (parent, { cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
|
|
const decodedCursor = decodeCursor(cursor)
|
|
const range = whenRange(when, from, to || decodeCursor.time)
|
|
|
|
let column
|
|
switch (by) {
|
|
case 'revenue': column = 'revenue'; break
|
|
case 'spent': column = 'spent'; break
|
|
case 'posts': column = 'nposts'; break
|
|
case 'comments': column = 'ncomments'; break
|
|
default: column = 'stacked'; break
|
|
}
|
|
|
|
const subs = await models.$queryRawUnsafe(`
|
|
SELECT "Sub".*,
|
|
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
|
|
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
|
|
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
|
|
COALESCE(sum(posts), 0) as nposts,
|
|
COALESCE(sum(comments), 0) as ncomments
|
|
FROM ${subViewGroup(range)} ss
|
|
JOIN "Sub" on "Sub".name = ss.sub_name
|
|
GROUP BY "Sub".name
|
|
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
|
|
OFFSET $3
|
|
LIMIT $4`, ...range, decodedCursor.offset, limit)
|
|
|
|
return {
|
|
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
|
|
subs
|
|
}
|
|
},
|
|
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models }) => {
|
|
if (!name) {
|
|
throw new GraphQLError('must supply user name', { extensions: { code: 'BAD_INPUT' } })
|
|
}
|
|
|
|
const user = await models.user.findUnique({ where: { name } })
|
|
if (!user) {
|
|
throw new GraphQLError('no user has that name', { extensions: { code: 'BAD_INPUT' } })
|
|
}
|
|
|
|
const decodedCursor = decodeCursor(cursor)
|
|
const range = whenRange(when, from, to || decodeCursor.time)
|
|
|
|
let column
|
|
switch (by) {
|
|
case 'revenue': column = 'revenue'; break
|
|
case 'spent': column = 'spent'; break
|
|
case 'posts': column = 'nposts'; break
|
|
case 'comments': column = 'ncomments'; break
|
|
default: column = 'stacked'; break
|
|
}
|
|
|
|
const subs = await models.$queryRawUnsafe(`
|
|
SELECT "Sub".*,
|
|
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
|
|
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
|
|
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
|
|
COALESCE(sum(posts), 0) as nposts,
|
|
COALESCE(sum(comments), 0) as ncomments
|
|
FROM ${subViewGroup(range)} ss
|
|
JOIN "Sub" on "Sub".name = ss.sub_name
|
|
WHERE "Sub"."userId" = $3
|
|
AND "Sub".status = 'ACTIVE'
|
|
GROUP BY "Sub".name
|
|
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
|
|
OFFSET $4
|
|
LIMIT $5`, ...range, user.id, decodedCursor.offset, limit)
|
|
|
|
return {
|
|
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
|
|
subs
|
|
}
|
|
}
|
|
},
|
|
Mutation: {
|
|
upsertSub: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => {
|
|
if (!me) {
|
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
|
}
|
|
|
|
await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } })
|
|
|
|
if (data.oldName) {
|
|
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]
|
|
},
|
|
toggleMuteSub: async (parent, { name }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
|
}
|
|
|
|
const lookupData = { userId: Number(me.id), subName: name }
|
|
const where = { userId_subName: lookupData }
|
|
const existing = await models.muteSub.findUnique({ where })
|
|
if (existing) {
|
|
await models.muteSub.delete({ where })
|
|
return false
|
|
} else {
|
|
await models.muteSub.create({ data: { ...lookupData } })
|
|
return true
|
|
}
|
|
},
|
|
toggleSubSubscription: async (sub, { name }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
|
}
|
|
|
|
const lookupData = { userId: me.id, subName: name }
|
|
const where = { userId_subName: lookupData }
|
|
const existing = await models.subSubscription.findUnique({ where })
|
|
if (existing) {
|
|
await models.subSubscription.delete({ where })
|
|
return false
|
|
} else {
|
|
await models.subSubscription.create({ data: lookupData })
|
|
return true
|
|
}
|
|
},
|
|
transferTerritory: async (parent, { subName, userName }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
|
}
|
|
|
|
const sub = await models.sub.findUnique({
|
|
where: {
|
|
name: subName
|
|
}
|
|
})
|
|
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' } })
|
|
}
|
|
|
|
const user = await models.user.findFirst({ where: { name: userName } })
|
|
if (!user) {
|
|
throw new GraphQLError('user not found', { extensions: { code: 'BAD_INPUT' } })
|
|
}
|
|
if (user.id === me.id) {
|
|
throw new GraphQLError('cannot transfer territory to yourself', { extensions: { code: 'BAD_INPUT' } })
|
|
}
|
|
|
|
const [, updatedSub] = await models.$transaction([
|
|
models.territoryTransfer.create({ data: { subName, oldUserId: me.id, newUserId: user.id } }),
|
|
models.sub.update({ where: { name: subName }, data: { userId: user.id } })
|
|
])
|
|
|
|
notifyTerritoryTransfer({ models, sub, to: user })
|
|
|
|
return updatedSub
|
|
}
|
|
},
|
|
Sub: {
|
|
optional: sub => sub,
|
|
user: async (sub, args, { models }) => {
|
|
if (sub.user) {
|
|
return sub.user
|
|
}
|
|
return await models.user.findUnique({ where: { id: sub.userId } })
|
|
},
|
|
meMuteSub: async (sub, args, { models }) => {
|
|
return sub.meMuteSub || sub.MuteSub?.length > 0
|
|
},
|
|
nposts: async (sub, { when, from, to }, { models }) => {
|
|
if (typeof sub.nposts !== 'undefined') {
|
|
return sub.nposts
|
|
}
|
|
},
|
|
ncomments: async (sub, { when, from, to }, { models }) => {
|
|
if (typeof sub.ncomments !== 'undefined') {
|
|
return sub.ncomments
|
|
}
|
|
},
|
|
meSubscription: async (sub, args, { me, models }) => {
|
|
return sub.meSubscription || sub.SubSubscription?.length > 0
|
|
}
|
|
}
|
|
}
|
|
|
|
async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
|
|
const { billingType } = data
|
|
let billingCost = TERRITORY_COST_MONTHLY
|
|
let billPaidUntil = datePivot(new Date(), { months: 1 })
|
|
|
|
if (billingType === 'ONCE') {
|
|
billingCost = TERRITORY_COST_ONCE
|
|
billPaidUntil = null
|
|
} else if (billingType === 'YEARLY') {
|
|
billingCost = TERRITORY_COST_YEARLY
|
|
billPaidUntil = 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,
|
|
billPaidUntil,
|
|
billingCost,
|
|
rankingType: 'WOT',
|
|
userId: me.id
|
|
}
|
|
}),
|
|
// record 'em
|
|
models.subAct.create({
|
|
data: {
|
|
userId: me.id,
|
|
subName: data.name,
|
|
msats: cost,
|
|
type: 'BILLING'
|
|
}
|
|
}),
|
|
// notify 'em (in the future)
|
|
models.subSubscription.create({
|
|
data: {
|
|
userId: me.id,
|
|
subName: data.name
|
|
}
|
|
})
|
|
], { 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, { oldName, ...data }, { me, models, lnd, hash, hmac }) {
|
|
const oldSub = await models.sub.findUnique({
|
|
where: {
|
|
name: oldName,
|
|
userId: me.id,
|
|
// this function's logic is only valid if the sub is not stopped
|
|
// so prevent updates to stopped subs
|
|
status: {
|
|
not: 'STOPPED'
|
|
}
|
|
}
|
|
})
|
|
|
|
if (!oldSub) {
|
|
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
|
|
}
|
|
|
|
try {
|
|
// if the cost is changing, record the new cost and update billing job
|
|
if (oldSub.billingType !== data.billingType) {
|
|
// make sure the current cost is recorded so they are grandfathered in
|
|
data.billingCost = TERRITORY_PERIOD_COST(data.billingType)
|
|
|
|
// we never want to bill them again if they are changing to ONCE
|
|
if (data.billingType === 'ONCE') {
|
|
data.billPaidUntil = null
|
|
data.billingAutoRenew = false
|
|
}
|
|
|
|
// if they are changing to YEARLY, bill them in a year
|
|
// if they are changing to MONTHLY from YEARLY, do nothing
|
|
if (oldSub.billingType === 'MONTHLY' && data.billingType === 'YEARLY') {
|
|
data.billPaidUntil = datePivot(new Date(oldSub.billedLastAt), { years: 1 })
|
|
}
|
|
|
|
// if this billing change makes their bill paid up, set them to active
|
|
if (data.billPaidUntil === null || data.billPaidUntil >= new Date()) {
|
|
data.status = 'ACTIVE'
|
|
}
|
|
|
|
// if the billing type is changing such that it's more expensive, bill 'em the difference
|
|
const proratedCost = proratedBillingCost(oldSub, data.billingType)
|
|
if (proratedCost > 0) {
|
|
const cost = BigInt(1000) * BigInt(proratedCost)
|
|
const results = await serializeInvoicable([
|
|
models.user.update({
|
|
where: {
|
|
id: me.id
|
|
},
|
|
data: {
|
|
msats: {
|
|
decrement: cost
|
|
}
|
|
}
|
|
}),
|
|
models.subAct.create({
|
|
data: {
|
|
userId: me.id,
|
|
subName: oldName,
|
|
msats: cost,
|
|
type: 'BILLING'
|
|
}
|
|
}),
|
|
models.sub.update({
|
|
data,
|
|
where: {
|
|
name: oldName,
|
|
userId: me.id
|
|
}
|
|
})
|
|
], { models, lnd, hash, hmac, me, enforceFee: proratedCost })
|
|
return results[2]
|
|
}
|
|
}
|
|
|
|
// if we get here they are changin in a way that doesn't cost them anything
|
|
return await models.sub.update({
|
|
data,
|
|
where: {
|
|
name: oldName,
|
|
userId: me.id
|
|
}
|
|
})
|
|
} catch (error) {
|
|
if (error.code === 'P2002') {
|
|
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
|
|
}
|
|
throw error
|
|
}
|
|
}
|