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:
parent
b03295ce59
commit
687d71f246
|
@ -315,6 +315,60 @@ export default {
|
||||||
notifyTerritoryTransfer({ models, sub, to: user })
|
notifyTerritoryTransfer({ models, sub, to: user })
|
||||||
|
|
||||||
return updatedSub
|
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: {
|
Sub: {
|
||||||
|
|
|
@ -23,6 +23,10 @@ export default gql`
|
||||||
toggleMuteSub(name: String!): Boolean!
|
toggleMuteSub(name: String!): Boolean!
|
||||||
toggleSubSubscription(name: String!): Boolean!
|
toggleSubSubscription(name: String!): Boolean!
|
||||||
transferTerritory(subName: String!, userName: String!): Sub
|
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 {
|
type Sub {
|
||||||
|
|
|
@ -403,7 +403,7 @@ function FormGroup ({ className, label, children }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputInner ({
|
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,
|
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
|
@ -452,7 +452,7 @@ function InputInner ({
|
||||||
// not assume this is invalid
|
// not assume this is invalid
|
||||||
const isNumeric = /^[0-9]+$/.test(draft)
|
const isNumeric = /^[0-9]+$/.test(draft)
|
||||||
const numericExpected = typeof field.value === 'number'
|
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 } })
|
onChange && onChange(formik, { target: { value: draft } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -518,7 +518,12 @@ function InputInner ({
|
||||||
{hint}
|
{hint}
|
||||||
</BootstrapForm.Text>
|
</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'}>
|
<BootstrapForm.Text className={remaining < 0 ? 'text-danger' : 'text-muted'}>
|
||||||
{`${numWithUnits(remaining, { abbreviate: false, unitSingular: 'character', unitPlural: 'characters' })} remaining`}
|
{`${numWithUnits(remaining, { abbreviate: false, unitSingular: 'character', unitPlural: 'characters' })} remaining`}
|
||||||
</BootstrapForm.Text>
|
</BootstrapForm.Text>
|
||||||
|
|
|
@ -2,7 +2,7 @@ import AccordianItem from './accordian-item'
|
||||||
import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootstrap'
|
import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootstrap'
|
||||||
import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form'
|
import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form'
|
||||||
import FeeButton, { FeeButtonProvider } from './fee-button'
|
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 { useCallback, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS, TERRITORY_PERIOD_COST } from '../lib/constants'
|
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 Info from './info'
|
||||||
import { abbrNum } from '../lib/format'
|
import { abbrNum } from '../lib/format'
|
||||||
import { purchasedType } from '../lib/territory'
|
import { purchasedType } from '../lib/territory'
|
||||||
|
import { SUB } from '../fragments/subs'
|
||||||
|
|
||||||
export default function TerritoryForm ({ sub }) {
|
export default function TerritoryForm ({ sub }) {
|
||||||
const router = useRouter()
|
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(
|
const onSubmit = useCallback(
|
||||||
async ({ ...variables }) => {
|
async ({ ...variables }) => {
|
||||||
const { error } = await upsertSub({
|
const { error } = archived
|
||||||
variables: { oldName: sub?.name, ...variables }
|
? await unarchiveTerritory({ variables })
|
||||||
})
|
: await upsertSub({ variables: { oldName: sub?.name, ...variables } })
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new Error({ message: error.toString() })
|
throw new Error({ message: error.toString() })
|
||||||
|
@ -52,7 +77,7 @@ export default function TerritoryForm ({ sub }) {
|
||||||
})
|
})
|
||||||
|
|
||||||
await router.push(`/~${variables.name}`)
|
await router.push(`/~${variables.name}`)
|
||||||
}, [client, upsertSub, router]
|
}, [client, upsertSub, unarchiveTerritory, router, archived]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [billing, setBilling] = useState((sub?.billingType || 'MONTHLY').toLowerCase())
|
const [billing, setBilling] = useState((sub?.billingType || 'MONTHLY').toLowerCase())
|
||||||
|
@ -86,7 +111,7 @@ export default function TerritoryForm ({ sub }) {
|
||||||
moderated: sub?.moderated || false,
|
moderated: sub?.moderated || false,
|
||||||
nsfw: sub?.nsfw || false
|
nsfw: sub?.nsfw || false
|
||||||
}}
|
}}
|
||||||
schema={territorySchema({ client, me, sub })}
|
schema={schema}
|
||||||
invoiceable
|
invoiceable
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className='mb-5'
|
className='mb-5'
|
||||||
|
@ -100,6 +125,17 @@ export default function TerritoryForm ({ sub }) {
|
||||||
clear
|
clear
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
prepend={<InputGroup.Text className='text-monospace'>~</InputGroup.Text>}
|
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
|
<MarkdownInput
|
||||||
label='description'
|
label='description'
|
||||||
|
|
|
@ -214,7 +214,7 @@ async function subActive (name, { client, models, me }) {
|
||||||
sub = await models.sub.findUnique({ where: { name } })
|
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 }) {
|
async function subHasPostType (name, type, { client, models }) {
|
||||||
|
@ -411,14 +411,12 @@ export function territorySchema (args) {
|
||||||
name: 'name',
|
name: 'name',
|
||||||
test: async name => {
|
test: async name => {
|
||||||
if (!name || !name.length) return false
|
if (!name || !name.length) return false
|
||||||
const edit = !!args.sub
|
const editing = !!args.sub?.name
|
||||||
let exists
|
// don't block submission on edits or unarchival
|
||||||
if (edit) {
|
const isEdit = sub => sub.name === args.sub.name
|
||||||
// ignore the sub we are currently editing
|
const isArchived = sub => sub.status === 'STOPPED'
|
||||||
exists = await subExists(name, { ...args, filter: sub => sub.name !== args.sub.name })
|
const filter = sub => editing ? !isEdit(sub) : !isArchived(sub)
|
||||||
} else {
|
const exists = await subExists(name, { ...args, filter })
|
||||||
exists = await subExists(name, args)
|
|
||||||
}
|
|
||||||
return !exists
|
return !exists
|
||||||
},
|
},
|
||||||
message: 'taken'
|
message: 'taken'
|
||||||
|
|
Loading…
Reference in New Issue