diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index ee34e733..19f73581 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -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: { diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 806f139d..3de01720 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -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 { diff --git a/components/form.js b/components/form.js index 7abad582..c9b2915d 100644 --- a/components/form.js +++ b/components/form.js @@ -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} )} - {maxLength && !(meta.touched && meta.error && invalid) && ( + {warn && ( + + {warn} + + )} + {!warn && maxLength && !(meta.touched && meta.error && invalid) && ( {`${numWithUnits(remaining, { abbreviate: false, unitSingular: 'character', unitPlural: 'characters' })} remaining`} diff --git a/components/territory-form.js b/components/territory-form.js index 70dc3157..7db8cbea 100644 --- a/components/territory-form.js +++ b/components/territory-form.js @@ -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={~} + onChange={onNameChange} + warn={archived && ( +
this territory is archived + + + +
+ )} /> { 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'