From 46ea2f661c545daef71bab887268d752cfb34b6a Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 29 Sep 2022 15:42:33 -0500 Subject: [PATCH] make jobs great again --- api/resolvers/item.js | 92 ++++++------- api/resolvers/notifications.js | 1 - api/resolvers/user.js | 3 - api/typeDefs/item.js | 1 + components/item-full.js | 2 +- components/item-job.js | 9 +- components/items-mixed.js | 2 +- components/items.js | 2 +- components/job-form.js | 121 ++++++++++-------- components/notifications.js | 12 +- fragments/items.js | 1 + pages/items/[id]/edit.js | 2 +- pages/items/[id]/index.js | 2 +- .../20220929183848_job_funcs/migration.sql | 101 +++++++++++++++ 14 files changed, 229 insertions(+), 122 deletions(-) create mode 100644 prisma/migrations/20220929183848_job_funcs/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index e329d687..b3de372d 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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 diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index aa08603a..7e630494 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -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)` diff --git a/api/resolvers/user.js b/api/resolvers/user.js index d4d81ac8..41c3ebea 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -337,9 +337,6 @@ export default { const job = await models.item.findFirst({ where: { - status: { - not: 'STOPPED' - }, maxBid: { not: null }, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 2d7e0217..2ee07a68 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -92,6 +92,7 @@ export default gql` position: Int prior: Int maxBid: Int + isJob: Boolean! pollCost: Int poll: Poll company: String diff --git a/components/item-full.js b/components/item-full.js index 60f6c1f2..14900764 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -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 ( diff --git a/components/item-job.js b/components/item-job.js index 5c55fc36..7061f5a1 100644 --- a/components/item-job.js +++ b/components/item-job.js @@ -18,7 +18,7 @@ export default function ItemJob ({ item, toc, rank, children }) { {rank} ) :
} -
+
- {item.status === 'NOSATS' && - <> - expired - {item.company && \ } - } {item.company && <> {item.company} @@ -72,7 +67,7 @@ export default function ItemJob ({ item, toc, rank, children }) { edit - {item.status !== 'ACTIVE' && {item.status}} + {item.status !== 'ACTIVE' && {item.status}} }
diff --git a/components/items-mixed.js b/components/items-mixed.js index efa3f79f..678ec080 100644 --- a/components/items-mixed.js +++ b/components/items-mixed.js @@ -32,7 +32,7 @@ export default function MixedItems ({ rank, items, cursor, fetchMore }) {
) - : (item.maxBid + : (item.isJob ? : )} diff --git a/components/items.js b/components/items.js index b7fa711b..beb7f719 100644 --- a/components/items.js +++ b/components/items.js @@ -28,7 +28,7 @@ export default function Items ({ variables = {}, rank, items, pins, cursor }) { {pinMap && pinMap[i + 1] && } {item.parentId ? <>
- : (item.maxBid + : (item.isJob ? : (item.title ? diff --git a/components/job-form.js b/components/job-form.js index 12c2d99a..46ae30be 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -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 ( <>
- bid - -
    -
  1. The higher your bid the higher your job will rank
  2. -
  3. The minimum bid is {sub.baseCost} sats/min
  4. -
  5. You can increase or decrease your bid, and edit or stop your job at anytime
  6. -
  7. Your job will be hidden if your wallet runs out of sats and can be unhidden by filling your wallet again
  8. -
-
-
- } - 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={sats/min} - hint={} - /> - <>
This bid puts your job in position: {position}
+ {item && } {item ? 'save' : 'post'} @@ -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 ( + 0} + header={
promote
} + body={ + <> + bid + +
    +
  1. The higher your bid the higher your job will rank
  2. +
  3. You can increase, decrease, or remove your bid at anytime
  4. +
  5. You can edit or stop your job at anytime
  6. +
  7. If you run out of sats, your job will stop being promoted until you fill your wallet again
  8. +
+
+ optional + + } + 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={sats/min} + hint={} + storageKeyPrefix={storageKeyPrefix} + /> + <>
This bid puts your job in position: {position}
+ + } + /> + ) +} + function StatusControl ({ item }) { let StatusComp @@ -241,7 +259,7 @@ function StatusControl ({ item }) { ) } - } else { + } else if (item.status === 'STOPPED') { StatusComp = () => { return ( - {item.status === 'NOSATS' && -
- you have no sats! fund your wallet to resume your job -
} - +
+
+ job control + {item.status === 'NOSATS' && + your promotion ran out of sats. fund your wallet or reduce bid to continue promoting your job} + +
) } diff --git a/components/notifications.js b/components/notifications.js index 79d853f2..4b4fd57c 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -105,13 +105,15 @@ function Notification ({ n }) { you were mentioned in } {n.__typename === 'JobChanged' && - - {n.item.status === 'NOSATS' - ? 'your job ran out of sats' - : 'your job is active again'} + + {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')} }
- {n.item.maxBid + {n.item.isJob ? : n.item.title ? diff --git a/fragments/items.js b/fragments/items.js index 763a355e..2737f9e0 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -28,6 +28,7 @@ export const ITEM_FIELDS = gql` commentSats lastCommentAt maxBid + isJob company location remote diff --git a/pages/items/[id]/edit.js b/pages/items/[id]/edit.js index 4e8328e7..40028457 100644 --- a/pages/items/[id]/edit.js +++ b/pages/items/[id]/edit.js @@ -14,7 +14,7 @@ export default function PostEdit ({ data: { item } }) { return ( - {item.maxBid + {item.isJob ? : (item.url ? diff --git a/pages/items/[id]/index.js b/pages/items/[id]/index.js index e06e8a92..7b758f58 100644 --- a/pages/items/[id]/index.js +++ b/pages/items/[id]/index.js @@ -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, { diff --git a/prisma/migrations/20220929183848_job_funcs/migration.sql b/prisma/migrations/20220929183848_job_funcs/migration.sql new file mode 100644 index 00000000..040cbedf --- /dev/null +++ b/prisma/migrations/20220929183848_job_funcs/migration.sql @@ -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; +$$; \ No newline at end of file