finish up invites

This commit is contained in:
keyan 2021-10-15 18:07:51 -05:00
parent 3a52f8967a
commit 955d1aa1b2
12 changed files with 156 additions and 6 deletions

View File

@ -10,6 +10,16 @@ export default {
return await models.invite.findMany({ return await models.invite.findMany({
where: { where: {
userId: me.id userId: me.id
},
orderBy: {
createdAt: 'desc'
}
})
},
invite: async (parent, { id }, { me, models }) => {
return await models.invite.findUnique({
where: {
id
} }
}) })
} }
@ -44,6 +54,13 @@ export default {
Invite: { Invite: {
invitees: async (invite, args, { me, models }) => { invitees: async (invite, args, { me, models }) => {
return await models.user.findMany({ where: { inviteId: invite.id } }) return await models.user.findMany({ where: { inviteId: invite.id } })
},
user: async (invite, args, { me, models }) => {
return await models.user.findUnique({ where: { id: invite.userId } })
},
poor: async (invite, args, { me, models }) => {
const user = await models.user.findUnique({ where: { id: invite.userId } })
return user.msats < invite.gift * 1000
} }
} }
} }

View File

@ -23,6 +23,12 @@ async function serialize (models, call) {
if (error.message.includes('SN_PENDING_WITHDRAWL_EXISTS')) { if (error.message.includes('SN_PENDING_WITHDRAWL_EXISTS')) {
bail(new Error('withdrawal invoice exists and is pending')) bail(new Error('withdrawal invoice exists and is pending'))
} }
if (error.message.includes('SN_INELIGIBLE')) {
bail(new Error('user ineligible for gift'))
}
if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) {
bail(new Error('faucet has been revoked or is exhausted'))
}
if (error.message.includes('40001')) { if (error.message.includes('40001')) {
throw new Error('wallet balance serialization failure - retry again') throw new Error('wallet balance serialization failure - retry again')
} }

View File

@ -84,6 +84,12 @@ export default {
bio: async (user, args, { models }) => { bio: async (user, args, { models }) => {
return getItem(user, { id: user.bioId }, { models }) return getItem(user, { id: user.bioId }, { models })
}, },
hasInvites: async (user, args, { models }) => {
const anInvite = await models.invite.findFirst({
where: { userId: user.id }
})
return !!anInvite
},
hasNewNotes: async (user, args, { models }) => { hasNewNotes: async (user, args, { models }) => {
// check if any votes have been cast for them since checkedNotesAt // check if any votes have been cast for them since checkedNotesAt
const votes = await models.$queryRaw(` const votes = await models.$queryRaw(`

View File

@ -3,6 +3,7 @@ import { gql } from 'apollo-server-micro'
export default gql` export default gql`
extend type Query { extend type Query {
invites: [Invite!]! invites: [Invite!]!
invite(id: ID!): Invite
} }
extend type Mutation { extend type Mutation {
@ -16,6 +17,8 @@ export default gql`
invitees: [User!]! invitees: [User!]!
gift: Int! gift: Int!
limit: Int limit: Int
user: User!
revoked: Boolean! revoked: Boolean!
poor: Boolean!
} }
` `

View File

@ -23,6 +23,7 @@ export default gql`
freePosts: Int! freePosts: Int!
freeComments: Int! freeComments: Int!
hasNewNotes: Boolean! hasNewNotes: Boolean!
hasInvites: Boolean!
tipDefault: Int! tipDefault: Int!
bio: Item bio: Item
sats: Int! sats: Int!

View File

@ -64,7 +64,12 @@ export default function Header () {
</Link> </Link>
<NavDropdown.Divider /> <NavDropdown.Divider />
<Link href='/invites' passHref> <Link href='/invites' passHref>
<NavDropdown.Item>invites</NavDropdown.Item> <NavDropdown.Item>invites
{me && !me.hasInvites &&
<div className='p-1 d-inline-block bg-success ml-1'>
<span className='invisible'>{' '}</span>
</div>}
</NavDropdown.Item>
</Link> </Link>
<div> <div>
<NavDropdown.Divider /> <NavDropdown.Divider />

View File

@ -17,7 +17,7 @@ export const EmailSchema = Yup.object({
email: Yup.string().email('email is no good').required('required').trim() email: Yup.string().email('email is no good').required('required').trim()
}) })
export default function Login ({ providers, callbackUrl, error }) { export default function Login ({ providers, callbackUrl, error, Header }) {
const errors = { const errors = {
Signin: 'Try signing with a different account.', Signin: 'Try signing with a different account.',
OAuthSignin: 'Try signing with a different account.', OAuthSignin: 'Try signing with a different account.',
@ -37,6 +37,7 @@ export default function Login ({ providers, callbackUrl, error }) {
return ( return (
<LayoutCenter noFooter> <LayoutCenter noFooter>
<div className={styles.login}> <div className={styles.login}>
{Header && <Header />}
{errorMessage && {errorMessage &&
<Alert variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>{errorMessage}</Alert>} <Alert variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>{errorMessage}</Alert>}
{router.query.type === 'lightning' {router.query.type === 'lightning'

View File

@ -20,6 +20,7 @@ export function MeProvider ({ children }) {
bio { bio {
id id
} }
hasInvites
} }
}` }`
const { data } = useQuery(query, { pollInterval: 1000 }) const { data } = useQuery(query, { pollInterval: 1000 })

View File

@ -11,5 +11,10 @@ export const INVITE_FIELDS = gql`
gift gift
limit limit
revoked revoked
user {
name
id
}
poor
} }
` `

View File

@ -1,11 +1,42 @@
import Login from '../../components/login' import Login from '../../components/login'
import { providers, getSession } from 'next-auth/client' import { providers, getSession } from 'next-auth/client'
import models from '../../api/models'
import serialize from '../../api/resolvers/serial'
import { gql } from '@apollo/client'
import { INVITE_FIELDS } from '../../fragments/invites'
import getSSRApolloClient from '../../api/ssrApollo'
import Link from 'next/link'
export async function getServerSideProps ({ req, res, query: { id, error = null } }) { export async function getServerSideProps ({ req, res, query: { id, error = null } }) {
const session = await getSession({ req }) const session = await getSession({ req })
const client = await getSSRApolloClient(req)
const { data } = await client.query({
query: gql`
${INVITE_FIELDS}
{
invite(id: "${id}") {
...InviteFields
}
}`
})
if (!data?.invite) {
return {
notFound: true
}
}
if (session && res) { if (session && res) {
// send down the userid and the invite to the db try {
// attempt to send gift
// catch any errors and just ignore them for now
await serialize(models,
models.$queryRaw('SELECT invite_drain($1, $2)', session.user.id, id))
} catch (e) {
console.log(e)
}
res.writeHead(302, { res.writeHead(302, {
Location: '/' Location: '/'
}) })
@ -17,9 +48,36 @@ export async function getServerSideProps ({ req, res, query: { id, error = null
props: { props: {
providers: await providers({ req, res }), providers: await providers({ req, res }),
callbackUrl: process.env.SELF_URL + req.url, callbackUrl: process.env.SELF_URL + req.url,
invite: data.invite,
error error
} }
} }
} }
export default Login function InviteHeader ({ invite }) {
console.log(invite.poor)
let Inner
if (invite.revoked) {
Inner = () => <div className='text-danger'>this invite link expired</div>
} else if ((invite.limit && invite.limit <= invite.invitees.length) || invite.poor) {
Inner = () => <div className='text-danger'>this invite link has no more sats</div>
} else {
Inner = () => (
<div>
get <span className='text-success'>{invite.gift} free sats</span> from{' '}
<Link href={`/${invite.user.name}`} passHref><a>@{invite.user.name}</a></Link>{' '}
when you sign up today
</div>
)
}
return (
<h2 className='text-center pb-3'>
<Inner />
</h2>
)
}
export default function Invite ({ invite, ...props }) {
return <Login Header={() => <InviteHeader invite={invite} />} {...props} />
}

View File

@ -46,10 +46,10 @@ function InviteForm () {
limit: undefined limit: undefined
}} }}
schema={InviteSchema} schema={InviteSchema}
onSubmit={async ({ limit, ...values }) => { onSubmit={async ({ limit, gift }) => {
const { error } = await createInvite({ const { error } = await createInvite({
variables: { variables: {
...values, limit: limit ? Number(limit) : limit gift: Number(gift), limit: limit ? Number(limit) : limit
} }
}) })
if (error) { if (error) {
@ -95,6 +95,8 @@ function Invite ({ invite, active }) {
placeholder={`https://stacker.news/invites/${invite.id}`} readOnly placeholder={`https://stacker.news/invites/${invite.id}`} readOnly
/> />
<div className={styles.other}> <div className={styles.other}>
<span>{invite.gift} sat gift</span>
<span> \ </span>
<span>{invite.invitees.length} joined{invite.limit ? ` of ${invite.limit}` : ''}</span> <span>{invite.invitees.length} joined{invite.limit ? ` of ${invite.limit}` : ''}</span>
{active {active
? ( ? (

View File

@ -0,0 +1,45 @@
CREATE OR REPLACE FUNCTION invite_drain(user_id INTEGER, invite_id TEXT)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
inviter_id INTEGER;
inviter_sats INTEGER;
gift INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
-- check user was created in last hour
-- check user did not already redeem an invite
PERFORM FROM users
WHERE id = user_id AND users.created_at >= NOW() AT TIME ZONE 'UTC' - INTERVAL '1 HOUR'
AND users."inviteId" IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'SN_INELIGIBLE';
END IF;
-- check that invite has not reached limit
-- check that invite is not revoked
SELECT "Invite"."userId", "Invite".gift INTO inviter_id, gift FROM "Invite"
LEFT JOIN users ON users."inviteId" = invite_id
WHERE "Invite".id = invite_id AND NOT "Invite".revoked
GROUP BY "Invite".id
HAVING COUNT(DISTINCT users.id) < "Invite".limit OR "Invite".limit IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'SN_REVOKED_OR_EXHAUSTED';
END IF;
-- check that inviter has sufficient balance
SELECT (msats / 1000) INTO inviter_sats
FROM users WHERE id = inviter_id;
IF inviter_sats < gift THEN
RAISE EXCEPTION 'SN_REVOKED_OR_EXHAUSTED';
END IF;
-- subtract amount from inviter
UPDATE users SET msats = msats - (1000 * gift) WHERE id = inviter_id;
-- add amount to invitee
UPDATE users SET msats = msats + (1000 * gift), "inviteId" = invite_id WHERE id = user_id;
RETURN 0;
END;
$$;