Custom invite code and note (#1649)
* Custom invite code and note * disable autocomplete and hide invite code under advanced * show invite description only to the owner * note->description and move unser advanced * Update lib/validate.js Co-authored-by: ekzyis <ek@stacker.news> * Update lib/webPush.js Co-authored-by: ekzyis <ek@stacker.news> * Update api/typeDefs/invite.js Co-authored-by: ekzyis <ek@stacker.news> * Update pages/invites/index.js Co-authored-by: ekzyis <ek@stacker.news> * Update pages/invites/index.js Co-authored-by: ekzyis <ek@stacker.news> * fix * apply review suggestions * change limits * Update lib/validate.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * don't show invite id in push notification * remove invoice metadata from push notifications * fix form reset, jsx/dom attrs, accidental uncontrolled prop warnings * support underscores as we claim * increase default gift to fit inflation --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
		
							parent
							
								
									76e384b188
								
							
						
					
					
						commit
						7f11792111
					
				@ -2,6 +2,7 @@ import { inviteSchema, validateSchema } from '@/lib/validate'
 | 
			
		||||
import { msatsToSats } from '@/lib/format'
 | 
			
		||||
import assertApiKeyNotPermitted from './apiKey'
 | 
			
		||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
 | 
			
		||||
import { Prisma } from '@prisma/client'
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  Query: {
 | 
			
		||||
@ -9,7 +10,6 @@ export default {
 | 
			
		||||
      if (!me) {
 | 
			
		||||
        throw new GqlAuthenticationError()
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return await models.invite.findMany({
 | 
			
		||||
        where: {
 | 
			
		||||
          userId: me.id
 | 
			
		||||
@ -29,17 +29,31 @@ export default {
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  Mutation: {
 | 
			
		||||
    createInvite: async (parent, { gift, limit }, { me, models }) => {
 | 
			
		||||
    createInvite: async (parent, { id, gift, limit, description }, { me, models }) => {
 | 
			
		||||
      if (!me) {
 | 
			
		||||
        throw new GqlAuthenticationError()
 | 
			
		||||
      }
 | 
			
		||||
      assertApiKeyNotPermitted({ me })
 | 
			
		||||
 | 
			
		||||
      await validateSchema(inviteSchema, { gift, limit })
 | 
			
		||||
 | 
			
		||||
      return await models.invite.create({
 | 
			
		||||
        data: { gift, limit, userId: me.id }
 | 
			
		||||
      })
 | 
			
		||||
      await validateSchema(inviteSchema, { id, gift, limit, description })
 | 
			
		||||
      try {
 | 
			
		||||
        return await models.invite.create({
 | 
			
		||||
          data: {
 | 
			
		||||
            id,
 | 
			
		||||
            gift,
 | 
			
		||||
            limit,
 | 
			
		||||
            userId: me.id,
 | 
			
		||||
            description
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        if (error instanceof Prisma.PrismaClientKnownRequestError) {
 | 
			
		||||
          if (error.code === 'P2002' && error.meta.target.includes('id')) {
 | 
			
		||||
            throw new GqlInputError('an invite with this code already exists')
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        throw error
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    revokeInvite: async (parent, { id }, { me, models }) => {
 | 
			
		||||
      if (!me) {
 | 
			
		||||
@ -70,6 +84,9 @@ export default {
 | 
			
		||||
    poor: async (invite, args, { me, models }) => {
 | 
			
		||||
      const user = await models.user.findUnique({ where: { id: invite.userId } })
 | 
			
		||||
      return msatsToSats(user.msats) < invite.gift
 | 
			
		||||
    },
 | 
			
		||||
    description: (invite, args, { me }) => {
 | 
			
		||||
      return invite.userId === me?.id ? invite.description : undefined
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ export default gql`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  extend type Mutation {
 | 
			
		||||
    createInvite(gift: Int!, limit: Int): Invite
 | 
			
		||||
    createInvite(id: String, gift: Int!, limit: Int, description: String): Invite
 | 
			
		||||
    revokeInvite(id: ID!): Invite
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -20,5 +20,6 @@ export default gql`
 | 
			
		||||
    user: User!
 | 
			
		||||
    revoked: Boolean!
 | 
			
		||||
    poor: Boolean!
 | 
			
		||||
    description: String
 | 
			
		||||
  }
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
@ -20,6 +20,7 @@ export default function Invite ({ invite, active }) {
 | 
			
		||||
    <div
 | 
			
		||||
      className={styles.invite}
 | 
			
		||||
    >
 | 
			
		||||
      {invite.description && <small className='text-muted'>{invite.description}</small>}
 | 
			
		||||
      <CopyInput
 | 
			
		||||
        groupClassName='mb-1'
 | 
			
		||||
        size='sm' type='text'
 | 
			
		||||
 | 
			
		||||
@ -283,7 +283,7 @@ function Invitification ({ n }) {
 | 
			
		||||
    <>
 | 
			
		||||
      <NoteHeader color='secondary'>
 | 
			
		||||
        your invite has been redeemed by
 | 
			
		||||
        {numWithUnits(n.invite.invitees.length, {
 | 
			
		||||
        {' ' + numWithUnits(n.invite.invitees.length, {
 | 
			
		||||
          abbreviate: false,
 | 
			
		||||
          unitSingular: 'stacker',
 | 
			
		||||
          unitPlural: 'stackers'
 | 
			
		||||
 | 
			
		||||
@ -113,7 +113,7 @@ export default forwardRef(function Reply ({
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    onSuccessfulSubmit: (data, { resetForm }) => {
 | 
			
		||||
      resetForm({ text: '' })
 | 
			
		||||
      resetForm({ values: { text: '' } })
 | 
			
		||||
      setReply(replyOpen || false)
 | 
			
		||||
    },
 | 
			
		||||
    navigateOnSubmit: false
 | 
			
		||||
 | 
			
		||||
@ -19,5 +19,6 @@ export const INVITE_FIELDS = gql`
 | 
			
		||||
      ...StreakFields
 | 
			
		||||
    }
 | 
			
		||||
    poor
 | 
			
		||||
    description
 | 
			
		||||
  }
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
@ -478,7 +478,9 @@ export const bioSchema = object({
 | 
			
		||||
 | 
			
		||||
export const inviteSchema = object({
 | 
			
		||||
  gift: intValidator.positive('must be greater than 0').required('required'),
 | 
			
		||||
  limit: intValidator.positive('must be positive')
 | 
			
		||||
  limit: intValidator.positive('must be positive'),
 | 
			
		||||
  description: string().trim().max(40, 'must be at most 40 characters'),
 | 
			
		||||
  id: string().matches(/^[\w-_]+$/, 'only letters, numbers, underscores, and hyphens').min(4, 'must be at least 4 characters').max(32, 'must be at most 32 characters')
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export const pushSubscriptionSchema = object({
 | 
			
		||||
 | 
			
		||||
@ -9,6 +9,8 @@ import Invite from '@/components/invite'
 | 
			
		||||
import { inviteSchema } from '@/lib/validate'
 | 
			
		||||
import { SSR } from '@/lib/constants'
 | 
			
		||||
import { getGetServerSideProps } from '@/api/ssrApollo'
 | 
			
		||||
import Info from '@/components/info'
 | 
			
		||||
import Text from '@/components/text'
 | 
			
		||||
 | 
			
		||||
// force SSR to include CSP nonces
 | 
			
		||||
export const getServerSideProps = getGetServerSideProps({ query: null })
 | 
			
		||||
@ -17,8 +19,8 @@ function InviteForm () {
 | 
			
		||||
  const [createInvite] = useMutation(
 | 
			
		||||
    gql`
 | 
			
		||||
      ${INVITE_FIELDS}
 | 
			
		||||
      mutation createInvite($gift: Int!, $limit: Int) {
 | 
			
		||||
        createInvite(gift: $gift, limit: $limit) {
 | 
			
		||||
      mutation createInvite($id: String, $gift: Int!, $limit: Int, $description: String) {
 | 
			
		||||
        createInvite(id: $id, gift: $gift, limit: $limit, description: $description) {
 | 
			
		||||
          ...InviteFields
 | 
			
		||||
        }
 | 
			
		||||
      }`, {
 | 
			
		||||
@ -39,20 +41,28 @@ function InviteForm () {
 | 
			
		||||
    }
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const initialValues = {
 | 
			
		||||
    id: '',
 | 
			
		||||
    gift: 1000,
 | 
			
		||||
    limit: 1,
 | 
			
		||||
    description: ''
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Form
 | 
			
		||||
      initial={{
 | 
			
		||||
        gift: 100,
 | 
			
		||||
        limit: 1
 | 
			
		||||
      }}
 | 
			
		||||
      initial={initialValues}
 | 
			
		||||
      schema={inviteSchema}
 | 
			
		||||
      onSubmit={async ({ limit, gift }) => {
 | 
			
		||||
      onSubmit={async ({ id, gift, limit, description }, { resetForm }) => {
 | 
			
		||||
        const { error } = await createInvite({
 | 
			
		||||
          variables: {
 | 
			
		||||
            gift: Number(gift), limit: limit ? Number(limit) : limit
 | 
			
		||||
            id: id || undefined,
 | 
			
		||||
            gift: Number(gift),
 | 
			
		||||
            limit: limit ? Number(limit) : limit,
 | 
			
		||||
            description: description || undefined
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        if (error) throw error
 | 
			
		||||
        resetForm({ values: initialValues })
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <Input
 | 
			
		||||
@ -65,8 +75,40 @@ function InviteForm () {
 | 
			
		||||
        label={<>invitee limit <small className='text-muted ms-2'>optional</small></>}
 | 
			
		||||
        name='limit'
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <SubmitButton variant='secondary'>create</SubmitButton>
 | 
			
		||||
      <AccordianItem
 | 
			
		||||
        headerColor='#6c757d' header='advanced' body={
 | 
			
		||||
          <>
 | 
			
		||||
            <Input
 | 
			
		||||
              prepend={<InputGroup.Text className='text-muted'>{`${process.env.NEXT_PUBLIC_URL}/invites/`}</InputGroup.Text>}
 | 
			
		||||
              label={<>invite code <small className='text-muted ms-2'>optional</small></>}
 | 
			
		||||
              name='id'
 | 
			
		||||
              autoComplete='off'
 | 
			
		||||
            />
 | 
			
		||||
            <Input
 | 
			
		||||
              label={
 | 
			
		||||
                <>
 | 
			
		||||
                  <div className='d-flex align-items-center'>
 | 
			
		||||
                    description <small className='text-muted ms-2'>optional</small>
 | 
			
		||||
                    <Info>
 | 
			
		||||
                      <Text>
 | 
			
		||||
                        A brief description to keep track of the invite purpose, such as "Shared in group chat".
 | 
			
		||||
                        This description is private and visible only to you.
 | 
			
		||||
                      </Text>
 | 
			
		||||
                    </Info>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </>
 | 
			
		||||
              }
 | 
			
		||||
              name='description'
 | 
			
		||||
              autoComplete='off'
 | 
			
		||||
            />
 | 
			
		||||
          </>
 | 
			
		||||
      }
 | 
			
		||||
      />
 | 
			
		||||
      <SubmitButton
 | 
			
		||||
        className='mt-4'
 | 
			
		||||
        variant='secondary'
 | 
			
		||||
      >create
 | 
			
		||||
      </SubmitButton>
 | 
			
		||||
    </Form>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,2 @@
 | 
			
		||||
-- AlterTable
 | 
			
		||||
ALTER TABLE "Invite" ADD COLUMN     "description" TEXT;
 | 
			
		||||
@ -477,6 +477,8 @@ model Invite {
 | 
			
		||||
  user      User     @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
 | 
			
		||||
  invitees  User[]
 | 
			
		||||
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  @@index([createdAt], map: "Invite.created_at_index")
 | 
			
		||||
  @@index([userId], map: "Invite.userId_index")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user