Purchase archived territories (#897)

* Handle archived territories in territory form

* Use dedicated mutation

* Add sanity check for eternal territories

* Fix fields and cost ignored

* Remove no longer needed manual validation in upsertSub

* Remove founder check

* Always check if sub is archived

Using { abortEarly: false } now since previously, if no description was not given, we wouldn't detect if the sub was archived since validation would abort on empty descriptions.

Only on submission all fields would get validated but since we ignore archived errors during submission, the user would never see that the sub is archived before submission
+ the wrong mutation would run if archived is not already true before submission.

Hence, we need to validate all fields always.

There is currently still a bug where the validation does not immediately run but maybe this can be fixed by simply using validateImmediately on the Formik component.

* Fix archived warning not shown after first render

* Only create transfers if owner actually changes

* Reuse helper functions in lib/territory.js

* Rename var to editing

* Use onChange instead of validation override

* Run same validation on server for unarchiving

* Fix 'territory archived' shown during edits

* Use && instead of ternary operator for conditional query

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
ekzyis 2024-03-19 23:23:59 +01:00 committed by GitHub
parent b03295ce59
commit 687d71f246
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 115 additions and 18 deletions

View File

@ -315,6 +315,60 @@ export default {
notifyTerritoryTransfer({ models, sub, to: user })
return updatedSub
},
unarchiveTerritory: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const { name } = data
await ssValidate(territorySchema, data, { models, me, sub: { name } })
const oldSub = await models.sub.findUnique({ where: { name } })
if (!oldSub) {
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
}
if (oldSub.status !== 'STOPPED') {
throw new GraphQLError('sub is not archived', { extensions: { code: 'BAD_INPUT' } })
}
if (oldSub.billingType === 'ONCE') {
// sanity check. this should never happen but leaving this comment here
// to stop error propagation just in case and document that this should never happen.
// #defensivecode
throw new GraphQLError('sub should not be archived', { extensions: { code: 'BAD_INPUT' } })
}
const billingCost = TERRITORY_PERIOD_COST(data.billingType)
const billPaidUntil = nextBilling(new Date(), data.billingType)
const cost = BigInt(1000) * BigInt(billingCost)
const newSub = { ...data, billPaidUntil, billingCost, userId: me.id, status: 'ACTIVE' }
await serializeInvoicable([
models.user.update({
where: {
id: me.id
},
data: {
msats: {
decrement: cost
}
}
}),
models.subAct.create({
data: {
subName: name,
userId: me.id,
msats: cost,
type: 'BILLING'
}
}),
models.sub.update({ where: { name }, data: newSub }),
oldSub.userId !== me.id && models.territoryTransfer.create({ data: { subName: name, oldUserId: oldSub.userId, newUserId: me.id } })
].filter(q => !!q),
{ models, lnd, hash, hmac, me, enforceFee: billingCost })
if (oldSub.userId !== me.id) notifyTerritoryTransfer({ models, sub: newSub, to: me.id })
}
},
Sub: {

View File

@ -23,6 +23,10 @@ export default gql`
toggleMuteSub(name: String!): Boolean!
toggleSubSubscription(name: String!): Boolean!
transferTerritory(subName: String!, userName: String!): Sub
unarchiveTerritory(name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): Sub
}
type Sub {

View File

@ -403,7 +403,7 @@ function FormGroup ({ className, label, children }) {
}
function InputInner ({
prepend, append, hint, showValid, onChange, onBlur, overrideValue, appendValue,
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength,
...props
}) {
@ -452,7 +452,7 @@ function InputInner ({
// not assume this is invalid
const isNumeric = /^[0-9]+$/.test(draft)
const numericExpected = typeof field.value === 'number'
helpers.setValue(isNumeric && numericExpected ? parseInt(draft) : draft, false)
helpers.setValue(isNumeric && numericExpected ? parseInt(draft) : draft)
onChange && onChange(formik, { target: { value: draft } })
}
}
@ -518,7 +518,12 @@ function InputInner ({
{hint}
</BootstrapForm.Text>
)}
{maxLength && !(meta.touched && meta.error && invalid) && (
{warn && (
<BootstrapForm.Text className='text-warning'>
{warn}
</BootstrapForm.Text>
)}
{!warn && maxLength && !(meta.touched && meta.error && invalid) && (
<BootstrapForm.Text className={remaining < 0 ? 'text-danger' : 'text-muted'}>
{`${numWithUnits(remaining, { abbreviate: false, unitSingular: 'character', unitPlural: 'characters' })} remaining`}
</BootstrapForm.Text>

View File

@ -2,7 +2,7 @@ import AccordianItem from './accordian-item'
import { Col, InputGroup, Row, Form as BootstrapForm, Badge } 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 { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS, TERRITORY_PERIOD_COST } from '../lib/constants'
@ -11,6 +11,7 @@ import { useMe } from './me'
import Info from './info'
import { abbrNum } from '../lib/format'
import { purchasedType } from '../lib/territory'
import { SUB } from '../fragments/subs'
export default function TerritoryForm ({ sub }) {
const router = useRouter()
@ -28,12 +29,36 @@ export default function TerritoryForm ({ sub }) {
}
}`
)
const [unarchiveTerritory] = useMutation(
gql`
mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $hash: String, $hmac: String, $nsfw: Boolean!) {
unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, hash: $hash, hmac: $hmac, nsfw: $nsfw) {
name
}
}`
)
const schema = territorySchema({ client, me, sub })
const [fetchSub] = useLazyQuery(SUB)
const [archived, setArchived] = useState(false)
const onNameChange = useCallback(async (formik, e) => {
// never show "territory archived" warning during edits
if (sub) return
const name = e.target.value
const { data } = await fetchSub({ variables: { sub: name } })
setArchived(data?.sub?.status === 'STOPPED')
}, [fetchSub, setArchived])
const onSubmit = useCallback(
async ({ ...variables }) => {
const { error } = await upsertSub({
variables: { oldName: sub?.name, ...variables }
})
const { error } = archived
? await unarchiveTerritory({ variables })
: await upsertSub({ variables: { oldName: sub?.name, ...variables } })
if (error) {
throw new Error({ message: error.toString() })
@ -52,7 +77,7 @@ export default function TerritoryForm ({ sub }) {
})
await router.push(`/~${variables.name}`)
}, [client, upsertSub, router]
}, [client, upsertSub, unarchiveTerritory, router, archived]
)
const [billing, setBilling] = useState((sub?.billingType || 'MONTHLY').toLowerCase())
@ -86,7 +111,7 @@ export default function TerritoryForm ({ sub }) {
moderated: sub?.moderated || false,
nsfw: sub?.nsfw || false
}}
schema={territorySchema({ client, me, sub })}
schema={schema}
invoiceable
onSubmit={onSubmit}
className='mb-5'
@ -100,6 +125,17 @@ export default function TerritoryForm ({ sub }) {
clear
maxLength={32}
prepend={<InputGroup.Text className='text-monospace'>~</InputGroup.Text>}
onChange={onNameChange}
warn={archived && (
<div className='d-flex align-items-center'>this territory is archived
<Info>
<ul className='fw-bold'>
<li>This territory got archived because the previous founder did not pay for the upkeep</li>
<li>You can proceed but will inherit the old content</li>
</ul>
</Info>
</div>
)}
/>
<MarkdownInput
label='description'

View File

@ -214,7 +214,7 @@ async function subActive (name, { client, models, me }) {
sub = await models.sub.findUnique({ where: { name } })
}
return sub && sub.status !== 'STOPPED'
return sub ? sub.status !== 'STOPPED' : undefined
}
async function subHasPostType (name, type, { client, models }) {
@ -411,14 +411,12 @@ export function territorySchema (args) {
name: 'name',
test: async name => {
if (!name || !name.length) return false
const edit = !!args.sub
let exists
if (edit) {
// ignore the sub we are currently editing
exists = await subExists(name, { ...args, filter: sub => sub.name !== args.sub.name })
} else {
exists = await subExists(name, args)
}
const editing = !!args.sub?.name
// don't block submission on edits or unarchival
const isEdit = sub => sub.name === args.sub.name
const isArchived = sub => sub.status === 'STOPPED'
const filter = sub => editing ? !isEdit(sub) : !isArchived(sub)
const exists = await subExists(name, { ...args, filter })
return !exists
},
message: 'taken'