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 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
}
}
}

View File

@ -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
}
`

View File

@ -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'

View File

@ -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'

View File

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

View File

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

View File

@ -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({

View File

@ -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>
)
}

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)
invitees User[]
description String?
@@index([createdAt], map: "Invite.created_at_index")
@@index([userId], map: "Invite.userId_index")
}