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:
Riccardo Balbo 2024-12-01 23:31:47 +01:00 committed by GitHub
parent 76e384b188
commit 7f11792111
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 89 additions and 21 deletions

View File

@ -2,6 +2,7 @@ import { inviteSchema, validateSchema } from '@/lib/validate'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error' import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { Prisma } from '@prisma/client'
export default { export default {
Query: { Query: {
@ -9,7 +10,6 @@ export default {
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
} }
return await models.invite.findMany({ return await models.invite.findMany({
where: { where: {
userId: me.id userId: me.id
@ -29,17 +29,31 @@ export default {
}, },
Mutation: { Mutation: {
createInvite: async (parent, { gift, limit }, { me, models }) => { createInvite: async (parent, { id, gift, limit, description }, { me, models }) => {
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
} }
assertApiKeyNotPermitted({ me }) assertApiKeyNotPermitted({ me })
await validateSchema(inviteSchema, { gift, limit }) await validateSchema(inviteSchema, { id, gift, limit, description })
try {
return await models.invite.create({ return await models.invite.create({
data: { gift, limit, userId: me.id } 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 }) => { revokeInvite: async (parent, { id }, { me, models }) => {
if (!me) { if (!me) {
@ -70,6 +84,9 @@ export default {
poor: async (invite, args, { me, models }) => { poor: async (invite, args, { me, models }) => {
const user = await models.user.findUnique({ where: { id: invite.userId } }) const user = await models.user.findUnique({ where: { id: invite.userId } })
return msatsToSats(user.msats) < invite.gift return msatsToSats(user.msats) < invite.gift
},
description: (invite, args, { me }) => {
return invite.userId === me?.id ? invite.description : undefined
} }
} }
} }

View File

@ -7,7 +7,7 @@ export default gql`
} }
extend type Mutation { extend type Mutation {
createInvite(gift: Int!, limit: Int): Invite createInvite(id: String, gift: Int!, limit: Int, description: String): Invite
revokeInvite(id: ID!): Invite revokeInvite(id: ID!): Invite
} }
@ -20,5 +20,6 @@ export default gql`
user: User! user: User!
revoked: Boolean! revoked: Boolean!
poor: Boolean! poor: Boolean!
description: String
} }
` `

View File

@ -20,6 +20,7 @@ export default function Invite ({ invite, active }) {
<div <div
className={styles.invite} className={styles.invite}
> >
{invite.description && <small className='text-muted'>{invite.description}</small>}
<CopyInput <CopyInput
groupClassName='mb-1' groupClassName='mb-1'
size='sm' type='text' size='sm' type='text'

View File

@ -283,7 +283,7 @@ function Invitification ({ n }) {
<> <>
<NoteHeader color='secondary'> <NoteHeader color='secondary'>
your invite has been redeemed by your invite has been redeemed by
{numWithUnits(n.invite.invitees.length, { {' ' + numWithUnits(n.invite.invitees.length, {
abbreviate: false, abbreviate: false,
unitSingular: 'stacker', unitSingular: 'stacker',
unitPlural: 'stackers' unitPlural: 'stackers'

View File

@ -113,7 +113,7 @@ export default forwardRef(function Reply ({
} }
}, },
onSuccessfulSubmit: (data, { resetForm }) => { onSuccessfulSubmit: (data, { resetForm }) => {
resetForm({ text: '' }) resetForm({ values: { text: '' } })
setReply(replyOpen || false) setReply(replyOpen || false)
}, },
navigateOnSubmit: false navigateOnSubmit: false

View File

@ -19,5 +19,6 @@ export const INVITE_FIELDS = gql`
...StreakFields ...StreakFields
} }
poor poor
description
} }
` `

View File

@ -478,7 +478,9 @@ export const bioSchema = object({
export const inviteSchema = object({ export const inviteSchema = object({
gift: intValidator.positive('must be greater than 0').required('required'), 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({ export const pushSubscriptionSchema = object({

View File

@ -9,6 +9,8 @@ import Invite from '@/components/invite'
import { inviteSchema } from '@/lib/validate' import { inviteSchema } from '@/lib/validate'
import { SSR } from '@/lib/constants' import { SSR } from '@/lib/constants'
import { getGetServerSideProps } from '@/api/ssrApollo' import { getGetServerSideProps } from '@/api/ssrApollo'
import Info from '@/components/info'
import Text from '@/components/text'
// force SSR to include CSP nonces // force SSR to include CSP nonces
export const getServerSideProps = getGetServerSideProps({ query: null }) export const getServerSideProps = getGetServerSideProps({ query: null })
@ -17,8 +19,8 @@ function InviteForm () {
const [createInvite] = useMutation( const [createInvite] = useMutation(
gql` gql`
${INVITE_FIELDS} ${INVITE_FIELDS}
mutation createInvite($gift: Int!, $limit: Int) { mutation createInvite($id: String, $gift: Int!, $limit: Int, $description: String) {
createInvite(gift: $gift, limit: $limit) { createInvite(id: $id, gift: $gift, limit: $limit, description: $description) {
...InviteFields ...InviteFields
} }
}`, { }`, {
@ -39,20 +41,28 @@ function InviteForm () {
} }
) )
const initialValues = {
id: '',
gift: 1000,
limit: 1,
description: ''
}
return ( return (
<Form <Form
initial={{ initial={initialValues}
gift: 100,
limit: 1
}}
schema={inviteSchema} schema={inviteSchema}
onSubmit={async ({ limit, gift }) => { onSubmit={async ({ id, gift, limit, description }, { resetForm }) => {
const { error } = await createInvite({ const { error } = await createInvite({
variables: { 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 if (error) throw error
resetForm({ values: initialValues })
}} }}
> >
<Input <Input
@ -65,8 +75,40 @@ function InviteForm () {
label={<>invitee limit <small className='text-muted ms-2'>optional</small></>} label={<>invitee limit <small className='text-muted ms-2'>optional</small></>}
name='limit' name='limit'
/> />
<AccordianItem
<SubmitButton variant='secondary'>create</SubmitButton> 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> </Form>
) )
} }

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Invite" ADD COLUMN "description" TEXT;

View File

@ -477,6 +477,8 @@ model Invite {
user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade) user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
invitees User[] invitees User[]
description String?
@@index([createdAt], map: "Invite.created_at_index") @@index([createdAt], map: "Invite.created_at_index")
@@index([userId], map: "Invite.userId_index") @@index([userId], map: "Invite.userId_index")
} }