make item creation easier

This commit is contained in:
keyan 2023-08-23 19:06:26 -05:00
parent 137e99cf7f
commit a847b16b2c
9 changed files with 310 additions and 185 deletions

View File

@ -5,10 +5,9 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import domino from 'domino' import domino from 'domino'
import { import {
BOOST_MIN, ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD,
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, 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' } from '../../lib/constants'
import { msatsToSats, numWithUnits } from '../../lib/format' import { msatsToSats, numWithUnits } from '../../lib/format'
import { parse } from 'tldts' import { parse } from 'tldts'
@ -604,57 +603,34 @@ export default {
return await models.item.update({ where: { id: Number(id) }, data }) return await models.item.update({ where: { id: Number(id) }, data })
}, },
upsertLink: async (parent, args, { me, models }) => { upsertLink: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
const { id, ...data } = args await ssValidate(linkSchema, item, models)
data.url = ensureProtocol(data.url)
data.url = removeTracking(data.url)
await ssValidate(linkSchema, data, models)
if (id) { if (id) {
return await updateItem(parent, { id, data }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models })
} else { } 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 }) => { upsertDiscussion: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
const { id, ...data } = args await ssValidate(discussionSchema, item, models)
await ssValidate(discussionSchema, data, models)
if (id) { if (id) {
return await updateItem(parent, { id, data }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models })
} else { } 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 }) => { upsertBounty: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
const { id, ...data } = args await ssValidate(bountySchema, item, models)
await ssValidate(bountySchema, data, models)
if (id) { if (id) {
return await updateItem(parent, { id, data }, { me, models }) return await updateItem(parent, { id, ...item }, { me, models })
} else { } 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 }) => { upsertPoll: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
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' } })
}
const optionCount = id const optionCount = id
? await models.pollOption.count({ ? await models.pollOption.count({
where: { where: {
@ -663,93 +639,60 @@ export default {
}) })
: 0 : 0
await ssValidate(pollSchema, data, models, optionCount) await ssValidate(pollSchema, item, models, optionCount)
const fwdUsers = await getForwardUsers(models, forward)
if (id) { if (id) {
const old = await models.item.findUnique({ where: { id: Number(id) } }) return await updateItem(parent, { id, ...item }, { me, models })
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
} else { } else {
const [query] = await serialize(models, item.pollCost = item.pollCost || POLL_COST
models.$queryRawUnsafe( return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
`${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
} }
}, },
upsertJob: async (parent, { id, ...data }, { me, models }) => { upsertJob: async (parent, { id, ...item }, { me, models }) => {
if (!me) { if (!me) {
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } }) 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 } }) item.location = item.location?.toLowerCase() === 'remote' ? undefined : item.location
if (!fullSub) { await ssValidate(jobSchema, item, models)
throw new GraphQLError('not a valid sub', { extensions: { code: 'BAD_INPUT' } }) 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) { if (id) {
const old = await models.item.findUnique({ where: { id: Number(id) } }) return await updateItem(parent, { id, ...item }, { me, models })
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)))
} else { } else {
([item] = await serialize(models, return await createItem(parent, item, { me, 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))))
} }
await createMentions(item, models)
return item
}, },
createComment: async (parent, data, { me, models }) => { upsertComment: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
await ssValidate(commentSchema, data) await ssValidate(commentSchema, item)
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 } })
if (id) {
return await updateItem(parent, { id, ...item }, { me, models })
} else {
const rItem = await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
const notify = async () => {
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
const parents = await models.$queryRawUnsafe( 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', '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)) Number(item.parentId), Number(user.id))
Promise.allSettled( Promise.allSettled(
parents.map(({ userId }) => sendUserNotification(userId, { parents.map(({ userId }) => sendUserNotification(userId, {
title: `@${user.name} replied to you`, title: `@${user.name} replied to you`,
body: data.text, body: item.text,
item, item,
tag: 'REPLY' tag: 'REPLY'
})) }))
) )
}
notify().catch(e => console.error(e))
return item return rItem
}, }
updateComment: async (parent, { id, ...data }, { me, models }) => {
await ssValidate(commentSchema, data)
return await updateItem(parent, { id, data }, { me, models })
}, },
pollVote: async (parent, { id }, { me, models }) => { pollVote: async (parent, { id }, { me, models }) => {
if (!me) { 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 // 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)) { if (Number(old.userId) !== Number(me?.id)) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) 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 // if it's not the FAQ, not their bio, and older than 10 minutes
const user = await models.user.findUnique({ where: { id: me.id } }) 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' } }) throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } })
} }
if (boost && boost < BOOST_MIN) { if (item.text) {
throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } }) item.text = await proxyImages(item.text)
} }
if (item.url && typeof item.maxBid === 'undefined') {
if (!old.parentId && title.length > MAX_TITLE_LENGTH) { item.url = ensureProtocol(item.url)
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } }) item.url = removeTracking(item.url)
item.url = await proxyImages(item.url)
} }
item = { subName, userId: me.id, ...item }
const fwdUsers = await getForwardUsers(models, forward) const fwdUsers = await getForwardUsers(models, forward)
url = await proxyImages(url)
text = await proxyImages(text)
const [item] = await serialize(models, const [rItem] = await serialize(models,
models.$queryRawUnsafe( models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB) AS "Item"`,
`${SELECT} FROM update_item($1, $2::INTEGER, $3, $4, $5, $6::INTEGER, $7::INTEGER, $8::JSON) AS "Item"`, JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)))
old.parentId ? null : sub || 'bitcoin', Number(id), title, url, text,
Number(boost || 0), bounty ? Number(bounty) : null, JSON.stringify(fwdUsers)))
await createMentions(item, models) await createMentions(rItem, models)
item.comments = []
return item return item
} }
const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash, invoiceHmac }) => { const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, invoiceHash, invoiceHmac }) => {
let author = me
let spamInterval = ITEM_SPAM_INTERVAL let spamInterval = ITEM_SPAM_INTERVAL
const trx = [] const trx = []
if (!me && invoiceHash) {
const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) // rename to match column name
author = invoice.user 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 spamInterval = ANON_ITEM_SPAM_INTERVAL
trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) 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) const fwdUsers = await getForwardUsers(models, forward)
url = await proxyImages(url) if (item.text) {
text = await proxyImages(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,
models.$queryRawUnsafe( models.$queryRawUnsafe(`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`,
`${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::JSON, '${spamInterval}') AS "Item"`, JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)),
parentId ? null : sub || 'bitcoin',
title,
url,
text,
Number(boost || 0),
bounty ? Number(bounty) : null,
Number(parentId),
Number(author.id),
JSON.stringify(fwdUsers)),
...trx) ...trx)
const item = trx.length > 0 ? query[0] : query item = Array.isArray(result) ? result[0] : result
await createMentions(item, models) await createMentions(item, models)

View File

@ -32,8 +32,7 @@ export default gql`
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! 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! 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! upsertComment(id:ID, text: String!, parentId: ID, invoiceHash: String, invoiceHmac: String): Item!
updateComment(id: ID!, text: String!): Item!
dontLikeThis(id: ID!): Boolean! dontLikeThis(id: ID!): Boolean!
act(id: ID!, sats: Int, invoiceHash: String, invoiceHmac: String): ItemActResult! act(id: ID!, sats: Int, invoiceHash: String, invoiceHmac: String): ItemActResult!
pollVote(id: ID!): ID! pollVote(id: ID!): ID!

View File

@ -7,19 +7,19 @@ import Delete from './delete'
import { commentSchema } from '../lib/validate' import { commentSchema } from '../lib/validate'
export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) { export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) {
const [updateComment] = useMutation( const [upsertComment] = useMutation(
gql` gql`
mutation updateComment($id: ID! $text: String!) { mutation upsertComment($id: ID! $text: String!) {
updateComment(id: $id, text: $text) { upsertComment(id: $id, text: $text) {
text text
} }
}`, { }`, {
update (cache, { data: { updateComment } }) { update (cache, { data: { upsertComment } }) {
cache.modify({ cache.modify({
id: `Item:${comment.id}`, id: `Item:${comment.id}`,
fields: { fields: {
text () { text () {
return updateComment.text return upsertComment.text
} }
} }
}) })
@ -35,7 +35,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
}} }}
schema={commentSchema} schema={commentSchema}
onSubmit={async (values, { resetForm }) => { onSubmit={async (values, { resetForm }) => {
const { error } = await updateComment({ variables: { ...values, id: comment.id } }) const { error } = await upsertComment({ variables: { ...values, id: comment.id } })
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }

View File

@ -43,24 +43,24 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
setReply(replyOpen || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) setReply(replyOpen || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text'))
}, []) }, [])
const [createComment] = useMutation( const [upsertComment] = useMutation(
gql` gql`
${COMMENTS} ${COMMENTS}
mutation createComment($text: String!, $parentId: ID!, $invoiceHash: String, $invoiceHmac: String) { mutation upsertComment($text: String!, $parentId: ID!, $invoiceHash: String, $invoiceHmac: String) {
createComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { upsertComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
...CommentFields ...CommentFields
comments { comments {
...CommentsRecursive ...CommentsRecursive
} }
} }
}`, { }`, {
update (cache, { data: { createComment } }) { update (cache, { data: { upsertComment } }) {
cache.modify({ cache.modify({
id: `Item:${parentId}`, id: `Item:${parentId}`,
fields: { fields: {
comments (existingCommentRefs = []) { comments (existingCommentRefs = []) {
const newCommentRef = cache.writeFragment({ const newCommentRef = cache.writeFragment({
data: createComment, data: upsertComment,
fragment: COMMENTS, fragment: COMMENTS,
fragmentName: 'CommentsRecursive' 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 // 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 // but we also have record num comments, in case someone else commented when we did
const root = ancestors[0] const root = ancestors[0]
commentsViewedAfterComment(root, createComment.createdAt) commentsViewedAfterComment(root, upsertComment.createdAt)
} }
} }
) )
const submitComment = useCallback( const submitComment = useCallback(
async (_, values, parentId, resetForm, invoiceHash, invoiceHmac) => { 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) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }
resetForm({ text: '' }) resetForm({ text: '' })
setReply(replyOpen || false) setReply(replyOpen || false)
}, [createComment, setReply]) }, [upsertComment, setReply])
const invoiceableCreateComment = useInvoiceable(submitComment) const invoiceableCreateComment = useInvoiceable(submitComment)

View File

@ -26,6 +26,7 @@ module.exports = {
ANON_BALANCE_LIMIT_MSATS: 0, // disable ANON_BALANCE_LIMIT_MSATS: 0, // disable
MAX_POLL_NUM_CHOICES: 10, MAX_POLL_NUM_CHOICES: 10,
MIN_POLL_NUM_CHOICES: 2, MIN_POLL_NUM_CHOICES: 2,
POLL_COST: 1,
ITEM_FILTER_THRESHOLD: 1.2, ITEM_FILTER_THRESHOLD: 1.2,
DONT_LIKE_THIS_COST: 1, DONT_LIKE_THIS_COST: 1,
COMMENT_TYPE_QUERY: ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks'], COMMENT_TYPE_QUERY: ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks'],

View File

@ -1,4 +1,5 @@
export function ensureProtocol (value) { export function ensureProtocol (value) {
if (!value) return value
value = value.trim() value = value.trim()
if (!/^([a-z0-9]+:\/\/|mailto:)/.test(value)) { if (!/^([a-z0-9]+:\/\/|mailto:)/.test(value)) {
value = 'http://' + value value = 'http://' + value
@ -11,6 +12,7 @@ export function isExternal (url) {
} }
export function removeTracking (value) { export function removeTracking (value) {
if (!value) return value
const exprs = [ const exprs = [
// twitter URLs // twitter URLs
/^(?<url>https?:\/\/twitter\.com\/(?:#!\/)?(?<user>\w+)\/status(?:es)?\/(?<id>\d+))/ /^(?<url>https?:\/\/twitter\.com\/(?:#!\/)?(?<user>\w+)\/status(?:es)?\/(?<id>\d+))/

View File

@ -63,10 +63,7 @@ export function advPostSchemaMembers (client) {
boost: intValidator boost: intValidator
.min(BOOST_MIN, `must be blank or at least ${BOOST_MIN}`).test({ .min(BOOST_MIN, `must be blank or at least ${BOOST_MIN}`).test({
name: 'boost', name: 'boost',
test: async boost => { test: async boost => !boost || boost % BOOST_MIN === 0,
if (!boost || boost % BOOST_MIN === 0) return true
return false
},
message: `must be divisble be ${BOOST_MIN}` message: `must be divisble be ${BOOST_MIN}`
}), }),
// XXX this lets you forward to youself (it's financially equivalent but it should be disallowed) // XXX this lets you forward to youself (it's financially equivalent but it should be disallowed)

View File

@ -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;
$$;

View File

@ -376,7 +376,7 @@ model ReferralAct {
model ItemAct { model ItemAct {
id Int @id(map: "Vote_pkey") @default(autoincrement()) id Int @id(map: "Vote_pkey") @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
msats BigInt msats BigInt
act ItemActType act ItemActType
itemId Int itemId Int
@ -400,7 +400,7 @@ model ItemAct {
model Mention { model Mention {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
itemId Int itemId Int
userId Int userId Int
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
@ -415,7 +415,7 @@ model Mention {
model Invoice { model Invoice {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int userId Int
hash String @unique(map: "Invoice.hash_unique") hash String @unique(map: "Invoice.hash_unique")
bolt11 String bolt11 String
@ -434,7 +434,7 @@ model Invoice {
model Withdrawl { model Withdrawl {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int userId Int
hash String hash String
bolt11 String bolt11 String
@ -452,7 +452,7 @@ model Withdrawl {
model Account { model Account {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") 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") userId Int @map("user_id")
type String @map("provider_type") type String @map("provider_type")
provider String @map("provider_id") provider String @map("provider_id")
@ -480,7 +480,7 @@ model Session {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
sessionToken String @unique(map: "sessions.session_token_unique") @map("session_token") sessionToken String @unique(map: "sessions.session_token_unique") @map("session_token")
createdAt DateTime @default(now()) @map("created_at") 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") userId Int @map("user_id")
expires DateTime expires DateTime
@ -492,7 +492,7 @@ model Session {
model VerificationToken { model VerificationToken {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
identifier String identifier String
token String @unique(map: "verification_requests.token_unique") token String @unique(map: "verification_requests.token_unique")
expires DateTime expires DateTime