From a847b16b2c79b9d12b7939f637cde0b15117b5be Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 23 Aug 2023 19:06:26 -0500 Subject: [PATCH] make item creation easier --- api/resolvers/item.js | 253 +++++++----------- api/typeDefs/item.js | 3 +- components/comment-edit.js | 12 +- components/reply.js | 16 +- lib/constants.js | 1 + lib/url.js | 2 + lib/validate.js | 5 +- .../migration.sql | 189 +++++++++++++ prisma/schema.prisma | 14 +- 9 files changed, 310 insertions(+), 185 deletions(-) create mode 100644 prisma/migrations/20230824064857_new_create_item/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index d3a9ed2b..62652684 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -5,10 +5,9 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import domino from 'domino' import { - BOOST_MIN, ITEM_SPAM_INTERVAL, - MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, + ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, - ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE, ANON_ITEM_SPAM_INTERVAL + ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE, ANON_ITEM_SPAM_INTERVAL, POLL_COST } from '../../lib/constants' import { msatsToSats, numWithUnits } from '../../lib/format' import { parse } from 'tldts' @@ -604,57 +603,34 @@ export default { return await models.item.update({ where: { id: Number(id) }, data }) }, - upsertLink: async (parent, args, { me, models }) => { - const { id, ...data } = args - data.url = ensureProtocol(data.url) - data.url = removeTracking(data.url) - - await ssValidate(linkSchema, data, models) + upsertLink: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => { + await ssValidate(linkSchema, item, models) if (id) { - return await updateItem(parent, { id, data }, { me, models }) + return await updateItem(parent, { id, ...item }, { me, models }) } else { - return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash, invoiceHmac: args.invoiceHmac }) + return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac }) } }, - upsertDiscussion: async (parent, args, { me, models }) => { - const { id, ...data } = args - - await ssValidate(discussionSchema, data, models) + upsertDiscussion: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => { + await ssValidate(discussionSchema, item, models) if (id) { - return await updateItem(parent, { id, data }, { me, models }) + return await updateItem(parent, { id, ...item }, { me, models }) } else { - return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash, invoiceHmac: args.invoiceHmac }) + return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac }) } }, - upsertBounty: async (parent, args, { me, models }) => { - const { id, ...data } = args - - await ssValidate(bountySchema, data, models) + upsertBounty: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => { + await ssValidate(bountySchema, item, models) if (id) { - return await updateItem(parent, { id, data }, { me, models }) + return await updateItem(parent, { id, ...item }, { me, models }) } else { - return await createItem(parent, data, { me, models }) + return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac }) } }, - upsertPoll: async (parent, { id, ...data }, { me, models }) => { - const { sub, forward, boost, title, text, options, invoiceHash, invoiceHmac } = data - let author = me - let spamInterval = ITEM_SPAM_INTERVAL - const trx = [] - if (!me && invoiceHash) { - const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, ANON_POST_FEE) - author = invoice.user - spamInterval = ANON_ITEM_SPAM_INTERVAL - trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) - } - - if (!author) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) - } - + upsertPoll: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => { const optionCount = id ? await models.pollOption.count({ where: { @@ -663,93 +639,60 @@ export default { }) : 0 - await ssValidate(pollSchema, data, models, optionCount) - - const fwdUsers = await getForwardUsers(models, forward) + await ssValidate(pollSchema, item, models, optionCount) if (id) { - const old = await models.item.findUnique({ where: { id: Number(id) } }) - if (Number(old.userId) !== Number(author.id)) { - throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) - } - const [item] = await serialize(models, - models.$queryRawUnsafe(`${SELECT} FROM update_poll($1, $2::INTEGER, $3, $4, $5::INTEGER, $6, $7::JSON) AS "Item"`, - sub || 'bitcoin', Number(id), title, text, Number(boost || 0), options, JSON.stringify(fwdUsers))) - - await createMentions(item, models) - item.comments = [] - return item + return await updateItem(parent, { id, ...item }, { me, models }) } else { - const [query] = await serialize(models, - models.$queryRawUnsafe( - `${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::JSON, '${spamInterval}') AS "Item"`, - sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, JSON.stringify(fwdUsers)), ...trx) - const item = trx.length > 0 ? query[0] : query - - await createMentions(item, models) - item.comments = [] - return item + item.pollCost = item.pollCost || POLL_COST + return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac }) } }, - upsertJob: async (parent, { id, ...data }, { me, models }) => { + upsertJob: async (parent, { id, ...item }, { me, models }) => { if (!me) { throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } }) } - const { sub, title, company, location, remote, text, url, maxBid, status, logo } = data - const fullSub = await models.sub.findUnique({ where: { name: sub } }) - if (!fullSub) { - throw new GraphQLError('not a valid sub', { extensions: { code: 'BAD_INPUT' } }) + item.location = item.location?.toLowerCase() === 'remote' ? undefined : item.location + await ssValidate(jobSchema, item, models) + if (item.logo) { + item.uploadId = item.logo + delete item.logo } + item.maxBid ??= 0 - await ssValidate(jobSchema, data, models) - const loc = location.toLowerCase() === 'remote' ? undefined : location - - let item if (id) { - const old = await models.item.findUnique({ where: { id: Number(id) } }) - if (Number(old.userId) !== Number(me?.id)) { - throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) - } - ([item] = await serialize(models, - models.$queryRawUnsafe( - `${SELECT} FROM update_job($1::INTEGER, $2, $3, $4, $5::INTEGER, $6, $7, $8, $9::INTEGER, $10::"Status") AS "Item"`, - Number(id), title, url, text, Number(maxBid), company, loc, remote, Number(logo), status))) + return await updateItem(parent, { id, ...item }, { me, models }) } else { - ([item] = await serialize(models, - models.$queryRawUnsafe( - `${SELECT} FROM create_job($1, $2, $3, $4::INTEGER, $5::INTEGER, $6, $7, $8, $9::INTEGER) AS "Item"`, - title, url, text, Number(me.id), Number(maxBid), company, loc, remote, Number(logo)))) + return await createItem(parent, item, { me, models }) } - - await createMentions(item, models) - - return item }, - createComment: async (parent, data, { me, models }) => { - await ssValidate(commentSchema, data) - const item = await createItem(parent, data, - { me, models, invoiceHash: data.invoiceHash, invoiceHmac: data.invoiceHmac }) - // fetch user to get up-to-date name - const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) + upsertComment: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => { + await ssValidate(commentSchema, item) - const parents = await models.$queryRawUnsafe( - 'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2', - Number(item.parentId), Number(user.id)) - Promise.allSettled( - parents.map(({ userId }) => sendUserNotification(userId, { - title: `@${user.name} replied to you`, - body: data.text, - item, - tag: 'REPLY' - })) - ) + if (id) { + return await updateItem(parent, { id, ...item }, { me, models }) + } else { + const rItem = await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac }) - return item - }, - updateComment: async (parent, { id, ...data }, { me, models }) => { - await ssValidate(commentSchema, data) - return await updateItem(parent, { id, data }, { me, models }) + const notify = async () => { + const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) + const parents = await models.$queryRawUnsafe( + 'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2', + Number(item.parentId), Number(user.id)) + Promise.allSettled( + parents.map(({ userId }) => sendUserNotification(userId, { + title: `@${user.name} replied to you`, + body: item.text, + item, + tag: 'REPLY' + })) + ) + } + notify().catch(e => console.error(e)) + + return rItem + } }, pollVote: async (parent, { id }, { me, models }) => { if (!me) { @@ -1118,84 +1061,78 @@ export const createMentions = async (item, models) => { } } -export const updateItem = async (parent, { id, data: { sub, title, url, text, boost, forward, bounty, parentId } }, { me, models }) => { +export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models }) => { // update iff this item belongs to me - const old = await models.item.findUnique({ where: { id: Number(id) } }) + const old = await models.item.findUnique({ where: { id: Number(item.id) } }) if (Number(old.userId) !== Number(me?.id)) { throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) } // if it's not the FAQ, not their bio, and older than 10 minutes const user = await models.user.findUnique({ where: { id: me.id } }) - if (![349, 76894, 78763, 81862].includes(old.id) && user.bioId !== id && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { + if (![349, 76894, 78763, 81862].includes(old.id) && user.bioId !== old.id && + typeof item.maxBid === 'undefined' && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } }) } - if (boost && boost < BOOST_MIN) { - throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } }) - } - - if (!old.parentId && title.length > MAX_TITLE_LENGTH) { - throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } }) + if (item.text) { + item.text = await proxyImages(item.text) + } + if (item.url && typeof item.maxBid === 'undefined') { + item.url = ensureProtocol(item.url) + item.url = removeTracking(item.url) + item.url = await proxyImages(item.url) } + item = { subName, userId: me.id, ...item } const fwdUsers = await getForwardUsers(models, forward) - url = await proxyImages(url) - text = await proxyImages(text) - const [item] = await serialize(models, - models.$queryRawUnsafe( - `${SELECT} FROM update_item($1, $2::INTEGER, $3, $4, $5, $6::INTEGER, $7::INTEGER, $8::JSON) AS "Item"`, - old.parentId ? null : sub || 'bitcoin', Number(id), title, url, text, - Number(boost || 0), bounty ? Number(bounty) : null, JSON.stringify(fwdUsers))) + const [rItem] = await serialize(models, + models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB) AS "Item"`, + JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options))) - await createMentions(item, models) + await createMentions(rItem, models) + item.comments = [] return item } -const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash, invoiceHmac }) => { - let author = me +const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, invoiceHash, invoiceHmac }) => { let spamInterval = ITEM_SPAM_INTERVAL const trx = [] - if (!me && invoiceHash) { - const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) - author = invoice.user + + // rename to match column name + item.subName = item.sub + delete item.sub + + if (me) { + item.userId = Number(me.id) + } else { + if (!invoiceHash) { + throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } }) + } + const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, item.parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) + item.userId = invoice.user.id spamInterval = ANON_ITEM_SPAM_INTERVAL trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } - if (!author) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) - } - - if (boost && boost < BOOST_MIN) { - throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } }) - } - - if (!parentId && title.length > MAX_TITLE_LENGTH) { - throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } }) - } - const fwdUsers = await getForwardUsers(models, forward) - url = await proxyImages(url) - text = await proxyImages(text) + if (item.text) { + item.text = await proxyImages(item.text) + } + if (item.url && typeof item.maxBid === 'undefined') { + item.url = ensureProtocol(item.url) + item.url = removeTracking(item.url) + item.url = await proxyImages(item.url) + } - const [query] = await serialize( + const [result] = await serialize( models, - models.$queryRawUnsafe( - `${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::JSON, '${spamInterval}') AS "Item"`, - parentId ? null : sub || 'bitcoin', - title, - url, - text, - Number(boost || 0), - bounty ? Number(bounty) : null, - Number(parentId), - Number(author.id), - JSON.stringify(fwdUsers)), + models.$queryRawUnsafe(`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`, + JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)), ...trx) - const item = trx.length > 0 ? query[0] : query + item = Array.isArray(result) ? result[0] : result await createMentions(item, models) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index f2fe3a2b..e92c102c 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -32,8 +32,7 @@ export default gql` upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item! - createComment(text: String!, parentId: ID!, invoiceHash: String, invoiceHmac: String): Item! - updateComment(id: ID!, text: String!): Item! + upsertComment(id:ID, text: String!, parentId: ID, invoiceHash: String, invoiceHmac: String): Item! dontLikeThis(id: ID!): Boolean! act(id: ID!, sats: Int, invoiceHash: String, invoiceHmac: String): ItemActResult! pollVote(id: ID!): ID! diff --git a/components/comment-edit.js b/components/comment-edit.js index 2fbf4df2..1fac5c97 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -7,19 +7,19 @@ import Delete from './delete' import { commentSchema } from '../lib/validate' export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) { - const [updateComment] = useMutation( + const [upsertComment] = useMutation( gql` - mutation updateComment($id: ID! $text: String!) { - updateComment(id: $id, text: $text) { + mutation upsertComment($id: ID! $text: String!) { + upsertComment(id: $id, text: $text) { text } }`, { - update (cache, { data: { updateComment } }) { + update (cache, { data: { upsertComment } }) { cache.modify({ id: `Item:${comment.id}`, fields: { text () { - return updateComment.text + return upsertComment.text } } }) @@ -35,7 +35,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc }} schema={commentSchema} onSubmit={async (values, { resetForm }) => { - const { error } = await updateComment({ variables: { ...values, id: comment.id } }) + const { error } = await upsertComment({ variables: { ...values, id: comment.id } }) if (error) { throw new Error({ message: error.toString() }) } diff --git a/components/reply.js b/components/reply.js index 62ba1e2f..d1c107f8 100644 --- a/components/reply.js +++ b/components/reply.js @@ -43,24 +43,24 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold setReply(replyOpen || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) }, []) - const [createComment] = useMutation( + const [upsertComment] = useMutation( gql` ${COMMENTS} - mutation createComment($text: String!, $parentId: ID!, $invoiceHash: String, $invoiceHmac: String) { - createComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { + mutation upsertComment($text: String!, $parentId: ID!, $invoiceHash: String, $invoiceHmac: String) { + upsertComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { ...CommentFields comments { ...CommentsRecursive } } }`, { - update (cache, { data: { createComment } }) { + update (cache, { data: { upsertComment } }) { cache.modify({ id: `Item:${parentId}`, fields: { comments (existingCommentRefs = []) { const newCommentRef = cache.writeFragment({ - data: createComment, + data: upsertComment, fragment: COMMENTS, fragmentName: 'CommentsRecursive' }) @@ -86,20 +86,20 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold // so that we don't see indicator for our own comments, we record this comments as the latest time // but we also have record num comments, in case someone else commented when we did const root = ancestors[0] - commentsViewedAfterComment(root, createComment.createdAt) + commentsViewedAfterComment(root, upsertComment.createdAt) } } ) const submitComment = useCallback( async (_, values, parentId, resetForm, invoiceHash, invoiceHmac) => { - const { error } = await createComment({ variables: { ...values, parentId, invoiceHash, invoiceHmac } }) + const { error } = await upsertComment({ variables: { ...values, parentId, invoiceHash, invoiceHmac } }) if (error) { throw new Error({ message: error.toString() }) } resetForm({ text: '' }) setReply(replyOpen || false) - }, [createComment, setReply]) + }, [upsertComment, setReply]) const invoiceableCreateComment = useInvoiceable(submitComment) diff --git a/lib/constants.js b/lib/constants.js index 5ca533e6..d24f813d 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -26,6 +26,7 @@ module.exports = { ANON_BALANCE_LIMIT_MSATS: 0, // disable MAX_POLL_NUM_CHOICES: 10, MIN_POLL_NUM_CHOICES: 2, + POLL_COST: 1, ITEM_FILTER_THRESHOLD: 1.2, DONT_LIKE_THIS_COST: 1, COMMENT_TYPE_QUERY: ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks'], diff --git a/lib/url.js b/lib/url.js index ecc08d5a..a77db98c 100644 --- a/lib/url.js +++ b/lib/url.js @@ -1,4 +1,5 @@ export function ensureProtocol (value) { + if (!value) return value value = value.trim() if (!/^([a-z0-9]+:\/\/|mailto:)/.test(value)) { value = 'http://' + value @@ -11,6 +12,7 @@ export function isExternal (url) { } export function removeTracking (value) { + if (!value) return value const exprs = [ // twitter URLs /^(?https?:\/\/twitter\.com\/(?:#!\/)?(?\w+)\/status(?:es)?\/(?\d+))/ diff --git a/lib/validate.js b/lib/validate.js index 355823ce..88ca62a5 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -63,10 +63,7 @@ export function advPostSchemaMembers (client) { boost: intValidator .min(BOOST_MIN, `must be blank or at least ${BOOST_MIN}`).test({ name: 'boost', - test: async boost => { - if (!boost || boost % BOOST_MIN === 0) return true - return false - }, + test: async boost => !boost || boost % BOOST_MIN === 0, message: `must be divisble be ${BOOST_MIN}` }), // XXX this lets you forward to youself (it's financially equivalent but it should be disallowed) diff --git a/prisma/migrations/20230824064857_new_create_item/migration.sql b/prisma/migrations/20230824064857_new_create_item/migration.sql new file mode 100644 index 00000000..dc017ea6 --- /dev/null +++ b/prisma/migrations/20230824064857_new_create_item/migration.sql @@ -0,0 +1,189 @@ +-- AlterTable +ALTER TABLE "Invoice" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "ItemAct" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Mention" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Withdrawl" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "accounts" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "sessions" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "verification_requests" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- remove boost denormalization +DROP TRIGGER IF EXISTS boost_after_act ON "ItemAct"; +DROP FUNCTION IF EXISTS boost_after_act(); + +-- remove functions that are hereto unused +DROP FUNCTION IF EXISTS create_bio(title text, text text, user_id integer); +DROP FUNCTION IF EXISTS create_item(sub text, title text, url text, text text, boost integer, bounty integer, parent_id integer, user_id integer, forward json, spam_within interval); +DROP FUNCTION IF EXISTS create_poll(sub text, title text, text text, poll_cost integer, boost integer, user_id integer, options text[], forward json, spam_within interval); +DROP FUNCTION IF EXISTS 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); +DROP FUNCTION IF EXISTS update_item( + sub TEXT, item_id INTEGER, item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER, + item_bounty INTEGER, forward JSON); +DROP FUNCTION IF EXISTS 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"); +DROP FUNCTION IF EXISTS update_poll( + sub TEXT, id INTEGER, title TEXT, text TEXT, boost INTEGER, + options TEXT[], forward JSON); + +-- remove type because table "ItemForward" has an implicit type already +DROP TYPE IF EXISTS ItemForwardType; + +-- only have one function to create items +CREATE OR REPLACE FUNCTION create_item( + jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats BIGINT; + cost_msats BIGINT; + freebie BOOLEAN; + item "Item"; + med_votes FLOAT; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + -- access fields with appropriate types + item := jsonb_populate_record(NULL::"Item", jitem); + + SELECT msats INTO user_msats FROM users WHERE id = item."userId"; + + IF item."maxBid" IS NOT NULL THEN + cost_msats := 1000000; + ELSE + cost_msats := 1000 * POWER(10, item_spam(item."parentId", item."userId", spam_within)); + END IF; + -- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0 + freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (item.boost = 0); + + IF NOT freebie AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- get this user's median item score + SELECT COALESCE( + percentile_cont(0.5) WITHIN GROUP( + ORDER BY "weightedVotes" - "weightedDownVotes"), 0) + INTO med_votes FROM "Item" WHERE "userId" = item."userId"; + + -- if their median votes are positive, start at 0 + -- if the median votes are negative, start their post with that many down votes + -- basically: if their median post is bad, presume this post is too + -- addendum: if they're an anon poster, always start at 0 + IF med_votes >= 0 OR item."userId" = 27 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + -- there's no great way to set default column values when using json_populate_record + -- so we need to only select fields with non-null values that way when func input + -- does not include a value, the default value is used instead of null + SELECT string_agg('"' || key || '"', ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key); + -- insert the item + EXECUTE format($fmt$ + INSERT INTO "Item" (%s, "weightedDownVotes") + SELECT %1$s, %L + FROM jsonb_populate_record(NULL::"Item", %L) RETURNING * + $fmt$, select_clause, med_votes, jitem) INTO item; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + IF NOT freebie THEN + UPDATE users SET msats = msats - cost_msats WHERE id = item."userId"; + + INSERT INTO "ItemAct" (msats, "itemId", "userId", act) + VALUES (cost_msats, item.id, item."userId", 'FEE'); + END IF; + + -- if this item has boost + IF item.boost > 0 THEN + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + -- if this is a bio + IF item.bio THEN + UPDATE users SET "bioId" = item.id WHERE id = item."userId"; + END IF; + + RETURN item; +END; +$$; + +-- only have one function to update items +CREATE OR REPLACE FUNCTION update_item( + jitem JSONB, forward JSONB, poll_options JSONB) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + item "Item"; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + item := jsonb_populate_record(NULL::"Item", jitem); + + IF item.boost > 0 THEN + UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id; + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + IF item.status IS NOT NULL THEN + UPDATE "Item" SET "statusUpdatedAt" = now_utc() + WHERE id = item.id AND status <> item.status; + END IF; + + SELECT string_agg('"' || key || '"', ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key) + WHERE key <> 'boost'; + + EXECUTE format($fmt$ + UPDATE "Item" SET (%s) = ( + SELECT %1$s + FROM jsonb_populate_record(NULL::"Item", %L) + ) WHERE id = %L RETURNING * + $fmt$, select_clause, jitem, item.id) INTO item; + + -- Delete all old forward entries, to recreate in next command + DELETE FROM "ItemForward" WHERE "itemId" = item.id; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + RETURN item; +END; +$$; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 38f33a3f..5d661821 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -376,7 +376,7 @@ model ReferralAct { model ItemAct { id Int @id(map: "Vote_pkey") @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") msats BigInt act ItemActType itemId Int @@ -400,7 +400,7 @@ model ItemAct { model Mention { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") itemId Int userId Int item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) @@ -415,7 +415,7 @@ model Mention { model Invoice { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") userId Int hash String @unique(map: "Invoice.hash_unique") bolt11 String @@ -434,7 +434,7 @@ model Invoice { model Withdrawl { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") userId Int hash String bolt11 String @@ -452,7 +452,7 @@ model Withdrawl { model Account { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") userId Int @map("user_id") type String @map("provider_type") provider String @map("provider_id") @@ -480,7 +480,7 @@ model Session { id Int @id @default(autoincrement()) sessionToken String @unique(map: "sessions.session_token_unique") @map("session_token") createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") userId Int @map("user_id") expires DateTime @@ -492,7 +492,7 @@ model Session { model VerificationToken { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") identifier String token String @unique(map: "verification_requests.token_unique") expires DateTime