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 { 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -19,5 +19,6 @@ export const INVITE_FIELDS = gql`
|
||||||
...StreakFields
|
...StreakFields
|
||||||
}
|
}
|
||||||
poor
|
poor
|
||||||
|
description
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
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")
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue