make jobs great again

This commit is contained in:
keyan 2022-09-29 15:42:33 -05:00
parent 52fab60cda
commit 46ea2f661c
14 changed files with 229 additions and 122 deletions

View File

@ -201,7 +201,7 @@ export default {
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
AND status = 'ACTIVE'
AND status = 'ACTIVE' AND "maxBid" > 0
ORDER BY "maxBid" DESC, created_at ASC)
UNION ALL
(${SELECT}
@ -209,7 +209,7 @@ export default {
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
AND status = 'NOSATS'
AND ((status = 'ACTIVE' AND "maxBid" = 0) OR status = 'NOSATS')
ORDER BY created_at DESC)
) a
OFFSET $2
@ -456,11 +456,19 @@ export default {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: { match: { status: 'ACTIVE' } },
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
},
{
bool: {
should: [
@ -544,19 +552,26 @@ export default {
items
}
},
auctionPosition: async (parent, { id, sub, bid }, { models }) => {
auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
// count items that have a bid gte to the current bid or
// gte current bid and older
const where = {
where: {
subName: sub,
status: 'ACTIVE',
maxBid: {
gte: bid
}
status: { not: 'STOPPED' }
}
}
if (bid > 0) {
where.where.maxBid = { gte: bid }
} else {
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
where.where.OR = [
{ maxBid: { gt: 0 } },
{ createdAt: { gt: createdAt } }
]
}
if (id) {
where.where.id = { not: Number(id) }
}
@ -646,62 +661,36 @@ export default {
throw new UserInputError('not a valid sub', { argumentName: 'sub' })
}
if (fullSub.baseCost > maxBid) {
throw new UserInputError(`bid must be at least ${fullSub.baseCost}`, { argumentName: 'maxBid' })
if (maxBid < 0) {
throw new UserInputError('bid must be at least 0', { argumentName: 'maxBid' })
}
if (!location && !remote) {
throw new UserInputError('must specify location or remote', { argumentName: 'location' })
}
const checkSats = async () => {
// check if the user has the funds to run for the first minute
const minuteMsats = maxBid * 1000
const user = await models.user.findUnique({ where: { id: me.id } })
if (user.msats < minuteMsats) {
throw new UserInputError('insufficient funds')
}
}
const data = {
title,
company,
location: location.toLowerCase() === 'remote' ? undefined : location,
remote,
text,
url,
maxBid,
subName: sub,
userId: me.id,
uploadId: logo
}
location = location.toLowerCase() === 'remote' ? undefined : location
let item
if (id) {
if (status) {
data.status = status
// if the job is changing to active, we need to check they have funds
if (status === 'ACTIVE') {
await checkSats()
}
}
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
throw new AuthenticationError('item does not belong to you')
}
return await models.item.update({
where: { id: Number(id) },
data
})
([item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM update_job($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) AS "Item"`,
Number(id), title, url, text, Number(maxBid), company, location, remote, Number(logo), status)))
} else {
([item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM create_job($1, $2, $3, $4, $5, $6, $7, $8, $9) AS "Item"`,
title, url, text, Number(me.id), Number(maxBid), company, location, remote, Number(logo))))
}
// before creating job, check the sats
await checkSats()
return await models.item.create({
data
})
await createMentions(item, models)
return item
},
createComment: async (parent, { text, parentId }, { me, models }) => {
return await createItem(parent, { text, parentId }, { me, models })
@ -767,6 +756,9 @@ export default {
}
},
Item: {
isJob: async (item, args, { models }) => {
return item.subName === 'jobs'
},
sub: async (item, args, { models }) => {
if (!item.subName) {
return null

View File

@ -98,7 +98,6 @@ export default {
FROM "Item"
WHERE "Item"."userId" = $1
AND "maxBid" IS NOT NULL
AND status <> 'STOPPED'
AND "statusUpdatedAt" <= $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`

View File

@ -337,9 +337,6 @@ export default {
const job = await models.item.findFirst({
where: {
status: {
not: 'STOPPED'
},
maxBid: {
not: null
},

View File

@ -92,6 +92,7 @@ export default gql`
position: Int
prior: Int
maxBid: Int
isJob: Boolean!
pollCost: Int
poll: Poll
company: String

View File

@ -83,7 +83,7 @@ function ItemEmbed ({ item }) {
}
function TopLevelItem ({ item, noReply, ...props }) {
const ItemComponent = item.maxBid ? ItemJob : Item
const ItemComponent = item.isJob ? ItemJob : Item
return (
<ItemComponent item={item} toc showFwdUser {...props}>

View File

@ -18,7 +18,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
{rank}
</div>)
: <div />}
<div className={`${styles.item} ${item.status === 'NOSATS' && !item.mine ? styles.itemDead : ''}`}>
<div className={`${styles.item}`}>
<Link href={`/items/${item.id}`} passHref>
<a>
<Image
@ -38,11 +38,6 @@ export default function ItemJob ({ item, toc, rank, children }) {
</Link>
</div>
<div className={`${styles.other}`}>
{item.status === 'NOSATS' &&
<>
<span>expired</span>
{item.company && <span> \ </span>}
</>}
{item.company &&
<>
{item.company}
@ -72,7 +67,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
edit
</a>
</Link>
{item.status !== 'ACTIVE' && <span className='font-weight-bold text-danger'> {item.status}</span>}
{item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>}
</>}
</div>
</div>

View File

@ -32,7 +32,7 @@ export default function MixedItems ({ rank, items, cursor, fetchMore }) {
<Comment item={item} noReply includeParent clickToContext />
</div>
</>)
: (item.maxBid
: (item.isJob
? <ItemJob item={item} rank={rank && i + 1} />
: <Item item={item} rank={rank && i + 1} />)}
</React.Fragment>

View File

@ -28,7 +28,7 @@ export default function Items ({ variables = {}, rank, items, pins, cursor }) {
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
{item.parentId
? <><div /><div className='pb-3'><Comment item={item} noReply includeParent /></div></>
: (item.maxBid
: (item.isJob
? <ItemJob item={item} rank={rank && i + 1} />
: (item.title
? <Item item={item} rank={rank && i + 1} />

View File

@ -11,6 +11,8 @@ import { useRouter } from 'next/router'
import Link from 'next/link'
import { usePrice } from './price'
import Avatar from './avatar'
import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert'
Yup.addMethod(Yup.string, 'or', function (schemas, msg) {
return this.test({
@ -34,7 +36,7 @@ function satsMin2Mo (minute) {
function PriceHint ({ monthly }) {
const price = usePrice()
if (!price) {
if (!price || !monthly) {
return null
}
const fixed = (n, f) => Number.parseFloat(n).toFixed(f)
@ -47,13 +49,7 @@ function PriceHint ({ monthly }) {
export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const router = useRouter()
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || sub.baseCost))
const [logoId, setLogoId] = useState(item?.uploadId)
const [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'network-only' })
const [upsertJob] = useMutation(gql`
mutation upsertJob($id: ID, $title: String!, $company: String!, $location: String,
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) {
@ -72,8 +68,8 @@ export default function JobForm ({ item, sub }) {
url: Yup.string()
.or([Yup.string().email(), Yup.string().url()], 'invalid url or email')
.required('required'),
maxBid: Yup.number('must be number')
.integer('must be whole').min(sub.baseCost, `must be at least ${sub.baseCost}`)
maxBid: Yup.number().typeError('must be a number')
.integer('must be whole').min(0, 'must be positive')
.required('required'),
location: Yup.string().test(
'no-remote',
@ -85,14 +81,6 @@ export default function JobForm ({ item, sub }) {
})
})
const position = data?.auctionPosition
useEffect(() => {
const initialMaxBid = Number(item?.maxBid || localStorage.getItem(storageKeyPrefix + '-maxBid')) || sub.baseCost
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
setMonthly(satsMin2Mo(initialMaxBid))
}, [])
return (
<>
<Form
@ -104,7 +92,7 @@ export default function JobForm ({ item, sub }) {
remote: item?.remote || false,
text: item?.text || '',
url: item?.url || '',
maxBid: item?.maxBid || sub.baseCost,
maxBid: item?.maxBid || 0,
stop: false,
start: false
}}
@ -188,32 +176,7 @@ export default function JobForm ({ item, sub }) {
required
clear
/>
<Input
label={
<div className='d-flex align-items-center'>bid
<Info>
<ol className='font-weight-bold'>
<li>The higher your bid the higher your job will rank</li>
<li>The minimum bid is {sub.baseCost} sats/min</li>
<li>You can increase or decrease your bid, and edit or stop your job at anytime</li>
<li>Your job will be hidden if your wallet runs out of sats and can be unhidden by filling your wallet again</li>
</ol>
</Info>
</div>
}
name='maxBid'
onChange={async (formik, e) => {
if (e.target.value >= sub.baseCost && e.target.value <= 100000000) {
setMonthly(satsMin2Mo(e.target.value))
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
} else {
setMonthly(satsMin2Mo(sub.baseCost))
}
}}
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
hint={<PriceHint monthly={monthly} />}
/>
<><div className='font-weight-bold text-muted'>This bid puts your job in position: {position}</div></>
<PromoteJob item={item} sub={sub} storageKeyPrefix={storageKeyPrefix} />
{item && <StatusControl item={item} />}
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
</Form>
@ -221,6 +184,61 @@ export default function JobForm ({ item, sub }) {
)
}
function PromoteJob ({ item, sub, storageKeyPrefix }) {
const [monthly, setMonthly] = useState(satsMin2Mo(item?.maxBid || 0))
const [getAuctionPosition, { data }] = useLazyQuery(gql`
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${sub.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'network-only' })
const position = data?.auctionPosition
useEffect(() => {
const initialMaxBid = Number(item?.maxBid || localStorage.getItem(storageKeyPrefix + '-maxBid')) || 0
getAuctionPosition({ variables: { id: item?.id, bid: initialMaxBid } })
setMonthly(satsMin2Mo(initialMaxBid))
}, [])
return (
<AccordianItem
show={item?.maxBid > 0}
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>promote</div>}
body={
<>
<Input
label={
<div className='d-flex align-items-center'>bid
<Info>
<ol className='font-weight-bold'>
<li>The higher your bid the higher your job will rank</li>
<li>You can increase, decrease, or remove your bid at anytime</li>
<li>You can edit or stop your job at anytime</li>
<li>If you run out of sats, your job will stop being promoted until you fill your wallet again</li>
</ol>
</Info>
<small className='text-muted ml-2'>optional</small>
</div>
}
name='maxBid'
onChange={async (formik, e) => {
if (e.target.value >= 0 && e.target.value <= 100000000) {
setMonthly(satsMin2Mo(e.target.value))
getAuctionPosition({ variables: { id: item?.id, bid: Number(e.target.value) } })
} else {
setMonthly(satsMin2Mo(0))
}
}}
append={<InputGroup.Text className='text-monospace'>sats/min</InputGroup.Text>}
hint={<PriceHint monthly={monthly} />}
storageKeyPrefix={storageKeyPrefix}
/>
<><div className='font-weight-bold text-muted'>This bid puts your job in position: {position}</div></>
</>
}
/>
)
}
function StatusControl ({ item }) {
let StatusComp
@ -241,7 +259,7 @@ function StatusControl ({ item }) {
</>
)
}
} else {
} else if (item.status === 'STOPPED') {
StatusComp = () => {
return (
<AccordianItem
@ -258,12 +276,13 @@ function StatusControl ({ item }) {
}
return (
<div className='my-2'>
{item.status === 'NOSATS' &&
<div className='text-danger font-weight-bold my-1'>
you have no sats! <Link href='/wallet?type=fund' passHref><a className='text-reset text-underline'>fund your wallet</a></Link> to resume your job
</div>}
<StatusComp />
<div className='my-3 border border-3 rounded'>
<div className='p-3'>
<BootstrapForm.Label>job control</BootstrapForm.Label>
{item.status === 'NOSATS' &&
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' passHref><a className='text-reset text-underline'>fund your wallet</a></Link> or reduce bid to continue promoting your job</Alert>}
<StatusComp />
</div>
</div>
)
}

View File

@ -105,13 +105,15 @@ function Notification ({ n }) {
you were mentioned in
</small>}
{n.__typename === 'JobChanged' &&
<small className={`font-weight-bold text-${n.item.status === 'NOSATS' ? 'danger' : 'success'} ml-1`}>
{n.item.status === 'NOSATS'
? 'your job ran out of sats'
: 'your job is active again'}
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
{n.item.status === 'ACTIVE'
? 'your job is active again'
: (n.item.status === 'NOSATS'
? 'your job promotion ran out of sats'
: 'your job has been stopped')}
</small>}
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
{n.item.maxBid
{n.item.isJob
? <ItemJob item={n.item} />
: n.item.title
? <Item item={n.item} />

View File

@ -28,6 +28,7 @@ export const ITEM_FIELDS = gql`
commentSats
lastCommentAt
maxBid
isJob
company
location
remote

View File

@ -14,7 +14,7 @@ export default function PostEdit ({ data: { item } }) {
return (
<LayoutCenter sub={item.sub?.name}>
{item.maxBid
{item.isJob
? <JobForm item={item} sub={item.sub} />
: (item.url
? <LinkForm item={item} editThreshold={editThreshold} adv />

View File

@ -6,7 +6,7 @@ import { getGetServerSideProps } from '../../../api/ssrApollo'
import { useQuery } from '@apollo/client'
export const getServerSideProps = getGetServerSideProps(ITEM_FULL, null,
data => !data.item || (data.item.status !== 'ACTIVE' && !data.item.mine))
data => !data.item || (data.item.status === 'STOPPED' && !data.item.mine))
export default function AnItem ({ data: { item } }) {
const { data } = useQuery(ITEM_FULL, {

View File

@ -0,0 +1,101 @@
-- charge the user for the auction item
CREATE OR REPLACE FUNCTION run_auction(item_id INTEGER) RETURNS void AS $$
DECLARE
bid INTEGER;
user_id INTEGER;
user_msats INTEGER;
item_status "Status";
status_updated_at timestamp(3);
BEGIN
PERFORM ASSERT_SERIALIZED();
-- extract data we need
SELECT "maxBid" * 1000, "userId", status, "statusUpdatedAt" INTO bid, user_id, item_status, status_updated_at FROM "Item" WHERE id = item_id;
SELECT msats INTO user_msats FROM users WHERE id = user_id;
-- 0 bid items expire after 30 days unless updated
IF bid = 0 THEN
IF item_status <> 'STOPPED' AND status_updated_at < now_utc() - INTERVAL '30 days' THEN
UPDATE "Item" SET status = 'STOPPED', "statusUpdatedAt" = now_utc() WHERE id = item_id;
END IF;
RETURN;
END IF;
-- check if user wallet has enough sats
IF bid > user_msats THEN
-- if not, set status = NOSATS and statusUpdatedAt to now_utc if not already set
IF item_status <> 'NOSATS' THEN
UPDATE "Item" SET status = 'NOSATS', "statusUpdatedAt" = now_utc() WHERE id = item_id;
END IF;
ELSE
-- if so, deduct from user
UPDATE users SET msats = msats - bid WHERE id = user_id;
-- create an item act
INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at)
VALUES (bid / 1000, item_id, user_id, 'STREAM', now_utc(), now_utc());
-- update item status = ACTIVE and statusUpdatedAt = now_utc if NOSATS
IF item_status = 'NOSATS' THEN
UPDATE "Item" SET status = 'ACTIVE', "statusUpdatedAt" = now_utc() WHERE id = item_id;
END IF;
END IF;
END;
$$ LANGUAGE plpgsql;
-- when creating free item, set freebie flag so can be optionally viewed
CREATE OR REPLACE FUNCTION create_job(
title TEXT, url TEXT, text TEXT, user_id INTEGER, job_bid INTEGER, job_company TEXT,
job_location TEXT, job_remote BOOLEAN, job_upload_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
-- create item
SELECT * INTO item FROM create_item(title, url, text, 0, NULL, user_id, NULL, '0');
-- update by adding additional fields
UPDATE "Item"
SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id, "subName" = 'jobs'
WHERE id = item.id RETURNING * INTO item;
-- run_auction
EXECUTE run_auction(item.id);
RETURN item;
END;
$$;
CREATE OR REPLACE FUNCTION update_job(item_id INTEGER,
item_title TEXT, item_url TEXT, item_text TEXT, job_bid INTEGER, job_company TEXT,
job_location TEXT, job_remote BOOLEAN, job_upload_id INTEGER, job_status "Status")
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
-- update item
SELECT * INTO item FROM update_item(item_id, item_title, item_url, item_text, 0, NULL);
IF item.status <> job_status THEN
UPDATE "Item"
SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id, status = job_status, "statusUpdatedAt" = now_utc()
WHERE id = item.id RETURNING * INTO item;
ELSE
UPDATE "Item"
SET "maxBid" = job_bid, company = job_company, location = job_location, remote = job_remote, "uploadId" = job_upload_id
WHERE id = item.id RETURNING * INTO item;
END IF;
-- run_auction
EXECUTE run_auction(item.id);
RETURN item;
END;
$$;