stacker.news/api/resolvers/item.js

1129 lines
37 KiB
JavaScript

import { UserInputError, AuthenticationError } from 'apollo-server-micro'
import { ensureProtocol, removeTracking } from '../../lib/url'
import serialize from './serial'
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_POLL_NUM_CHOICES,
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST
} from '../../lib/constants'
import { msatsToSats } from '../../lib/format'
import { parse } from 'tldts'
import uu from 'url-unshort'
async function comments (me, models, id, sort) {
let orderBy
switch (sort) {
case 'top':
orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".msats DESC, "Item".id DESC`
break
case 'recent':
orderBy = 'ORDER BY "Item".created_at DESC, "Item".msats DESC, "Item".id DESC'
break
default:
orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, "Item".id DESC`
break
}
const flat = await models.$queryRaw(`
WITH RECURSIVE base AS (
${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path
FROM "Item"
WHERE "parentId" = $1
${await filterClause(me, models)}
UNION ALL
${SELECT}, p.sort_path || row_number() OVER (${orderBy}, "Item".path)
FROM base p
JOIN "Item" ON "Item"."parentId" = p.id
WHERE true
${await filterClause(me, models)})
SELECT * FROM base ORDER BY sort_path`, Number(id))
return nestComments(flat, id)[0]
}
export async function getItem (parent, { id }, { me, models }) {
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = $1`, Number(id))
return item
}
function topClause (within) {
let interval = ' AND "Item".created_at >= $1 - INTERVAL '
switch (within) {
case 'forever':
interval = ''
break
case 'week':
interval += "'7 days'"
break
case 'month':
interval += "'1 month'"
break
case 'year':
interval += "'1 year'"
break
default:
interval += "'1 day'"
break
}
return interval
}
async function topOrderClause (sort, me, models) {
switch (sort) {
case 'comments':
return 'ORDER BY ncomments DESC'
case 'sats':
return 'ORDER BY msats DESC'
default:
return await topOrderByWeightedSats(me, models)
}
}
export async function orderByNumerator (me, models) {
if (me) {
const user = await models.user.findUnique({ where: { id: me.id } })
if (user.wildWestMode) {
return 'GREATEST("Item"."weightedVotes", POWER("Item"."weightedVotes", 1.2))'
}
}
return `(CASE WHEN "Item"."weightedVotes" > "Item"."weightedDownVotes"
THEN 1
ELSE -1 END
* GREATEST(ABS("Item"."weightedVotes" - "Item"."weightedDownVotes"), POWER(ABS("Item"."weightedVotes" - "Item"."weightedDownVotes"), 1.2)))`
}
export async function filterClause (me, models) {
// by default don't include freebies unless they have upvotes
let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'
if (me) {
const user = await models.user.findUnique({ where: { id: me.id } })
// wild west mode has everything
if (user.wildWestMode) {
return ''
}
// greeter mode includes freebies if feebies haven't been flagged
if (user.greeterMode) {
clause = 'AND (NOT "Item".freebie OR ("Item"."weightedVotes" - "Item"."weightedDownVotes" >= 0 AND "Item".freebie)'
}
// always include if it's mine
clause += ` OR "Item"."userId" = ${me.id})`
} else {
// close default freebie clause
clause += ')'
}
// if the item is above the threshold or is mine
clause += ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
if (me) {
clause += ` OR "Item"."userId" = ${me.id}`
}
clause += ')'
return clause
}
function recentClause (type) {
switch (type) {
case 'links':
return ' AND url IS NOT NULL'
case 'discussions':
return ' AND url IS NULL AND bio = false AND "pollCost" IS NULL'
case 'polls':
return ' AND "pollCost" IS NOT NULL'
case 'bios':
return ' AND bio = true'
case 'bounties':
return ' AND bounty IS NOT NULL'
default:
return ''
}
}
export default {
Query: {
itemRepetition: async (parent, { parentId }, { me, models }) => {
if (!me) return 0
// how many of the parents starting at parentId belong to me
const [{ item_spam: count }] = await models.$queryRaw(`SELECT item_spam($1, $2, '${ITEM_SPAM_INTERVAL}')`,
Number(parentId), Number(me.id))
return count
},
topItems: async (parent, { cursor, sort, when }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL AND "deletedAt" IS NULL
${topClause(when)}
${await filterClause(me, models)}
${await topOrderClause(sort, me, models)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
topComments: async (parent, { cursor, sort, when }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const comments = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NOT NULL
AND "Item".created_at <= $1 AND "deletedAt" IS NULL
${topClause(when)}
${await filterClause(me, models)}
${await topOrderClause(sort, me, models)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
return {
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
comments
}
},
items: async (parent, { sub, sort, type, cursor, name, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let items; let user; let pins; let subFull
const subClause = (num) => {
return sub ? ` AND "subName" = $${num} ` : ` AND ("subName" IS NULL OR "subName" = $${num}) `
}
const activeOrMine = () => {
return me ? ` AND (status <> 'STOPPED' OR "userId" = ${me.id}) ` : ' AND status <> \'STOPPED\' '
}
switch (sort) {
case 'user':
if (!name) {
throw new UserInputError('must supply name', { argumentName: 'name' })
}
user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new UserInputError('no user has that name', { argumentName: 'name' })
}
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
AND "pinId" IS NULL
${activeOrMine()}
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
break
case 'recent':
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
${subClause(3)}
${activeOrMine()}
${await filterClause(me, models)}
${recentClause(type)}
ORDER BY created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
break
case 'top':
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL AND "deletedAt" IS NULL
${topClause(within)}
${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
default:
// sub so we know the default ranking
if (sub) {
subFull = await models.sub.findUnique({ where: { name: sub } })
}
switch (subFull?.rankingType) {
case 'AUCTION':
items = await models.$queryRaw(`
SELECT *
FROM (
(${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
AND status = 'ACTIVE' AND "maxBid" > 0
ORDER BY "maxBid" DESC, created_at ASC)
UNION ALL
(${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
AND ((status = 'ACTIVE' AND "maxBid" = 0) OR status = 'NOSATS')
ORDER BY created_at DESC)
) a
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub)
break
default:
// HACK we can speed hack the first hot page, by limiting our query to only
// the most recently created items so that the tables doesn't have to
// fully be computed
// if the offset is 0, we limit our search to posts from the last week
// if there are 21 items, return them ... if not do the unrestricted query
// instead of doing this we should materialize a view ... but this is easier for now
if (decodedCursor.offset === 0) {
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "Item".created_at > $3
AND "pinId" IS NULL AND NOT bio AND "deletedAt" IS NULL
${subClause(4)}
${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date().setDate(new Date().getDate() - 5)), sub || 'NULL')
}
if (decodedCursor.offset !== 0 || items?.length < LIMIT) {
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL AND NOT bio AND "deletedAt" IS NULL
${subClause(3)}
${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
}
if (decodedCursor.offset === 0) {
// get pins for the page and return those separately
pins = await models.$queryRaw(`SELECT rank_filter.*
FROM (
${SELECT},
rank() OVER (
PARTITION BY "pinId"
ORDER BY created_at DESC
)
FROM "Item"
WHERE "pinId" IS NOT NULL
) rank_filter WHERE RANK = 1`)
}
break
}
break
}
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items,
pins
}
},
allItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
outlawedItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const notMine = () => {
return me ? ` AND "userId" <> ${me.id} ` : ''
}
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}
${notMine()}
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
borderlandItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const notMine = () => {
return me ? ` AND "userId" <> ${me.id} ` : ''
}
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0
AND "Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}
${notMine()}
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
freebieItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "Item".freebie
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
getBountiesByUserName: async (parent, { name, cursor, limit }, { models }) => {
const decodedCursor = decodeCursor(cursor)
const user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new UserInputError('user not found', {
argumentName: 'name'
})
}
const items = await models.$queryRaw(
`${SELECT}
FROM "Item"
WHERE "userId" = $1
AND "bounty" IS NOT NULL
ORDER BY created_at DESC
OFFSET $2
LIMIT $3`,
user.id, decodedCursor.offset, limit || LIMIT
)
return {
cursor: items.length === (limit || LIMIT) ? nextCursorEncoded(decodedCursor) : null,
items
}
},
moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let comments, user
switch (sort) {
case 'recent':
comments = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NOT NULL AND created_at <= $1
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
case 'user':
if (!name) {
throw new UserInputError('must supply name', { argumentName: 'name' })
}
user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new UserInputError('no user has that name', { argumentName: 'name' })
}
comments = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NOT NULL
AND created_at <= $2
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
break
case 'top':
comments = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NOT NULL AND "deletedAt" IS NULL
AND "Item".created_at <= $1
${topClause(within)}
${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
default:
throw new UserInputError('invalid sort type', { argumentName: 'sort' })
}
return {
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
comments
}
},
item: getItem,
pageTitleAndUnshorted: async (parent, { url }, { models }) => {
const res = {}
try {
const response = await fetch(ensureProtocol(url), { redirect: 'follow' })
const html = await response.text()
const doc = domino.createWindow(html).document
const metadata = getMetadata(doc, url, { title: metadataRuleSets.title })
res.title = metadata?.title
} catch { }
try {
const unshorted = await uu().expand(url)
if (unshorted) {
res.unshorted = unshorted
}
} catch { }
return res
},
dupes: async (parent, { url }, { models }) => {
const urlObj = new URL(ensureProtocol(url))
let uri = urlObj.hostname + urlObj.pathname
uri = uri.endsWith('/') ? uri.slice(0, -1) : uri
const parseResult = parse(urlObj.hostname)
if (parseResult?.subdomain?.length) {
const { subdomain } = parseResult
uri = uri.replace(subdomain, '(%)?')
} else {
uri = `(%.)?${uri}`
}
let similar = `(http(s)?://)?${uri}/?`
const whitelist = ['news.ycombinator.com/item', 'bitcointalk.org/index.php']
const youtube = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'youtu.be']
if (whitelist.includes(uri)) {
similar += `\\${urlObj.search}`
} else if (youtube.includes(urlObj.hostname)) {
// extract id and create both links
const matches = url.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)/i)
similar = `(http(s)?://)?((www.|m.)?youtube.com/(watch\\?v=|v/|live/)${matches?.groups?.id}|youtu.be/${matches?.groups?.id})((\\?|&|#)%)?`
} else {
similar += '((\\?|#)%)?'
}
return await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE LOWER(url) SIMILAR TO LOWER($1)
ORDER BY created_at DESC
LIMIT 3`, similar)
},
comments: async (parent, { id, sort }, { me, models }) => {
return comments(me, models, id, sort)
},
auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
let where
if (bid > 0) {
// if there's a bid
// it's ACTIVE and has a larger bid than ours, or has an equal bid and is older
// count items: (bid > ours.bid OR (bid = ours.bid AND create_at < ours.created_at)) AND status = 'ACTIVE'
where = {
status: 'ACTIVE',
OR: [
{ maxBid: { gt: bid } },
{ maxBid: bid, createdAt: { lt: createdAt } }
]
}
} else {
// else
// it's an active with a bid gt ours, or its newer than ours and not STOPPED
// count items: ((bid > ours.bid AND status = 'ACTIVE') OR (created_at > ours.created_at AND status <> 'STOPPED'))
where = {
OR: [
{ maxBid: { gt: 0 }, status: 'ACTIVE' },
{ createdAt: { gt: createdAt }, status: { not: 'STOPPED' } }
]
}
}
where.subName = sub
if (id) {
where.id = { not: Number(id) }
}
return await models.item.count({ where }) + 1
}
},
Mutation: {
deleteItem: async (parent, { id }, { me, models }) => {
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')
}
const data = { deletedAt: new Date() }
if (old.text) {
data.text = '*deleted by author*'
}
if (old.title) {
data.title = 'deleted by author'
}
if (old.url) {
data.url = null
}
if (old.pollCost) {
data.pollCost = null
}
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)
if (id) {
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
}
},
upsertDiscussion: async (parent, args, { me, models }) => {
const { id, ...data } = args
if (id) {
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
}
},
upsertBounty: async (parent, args, { me, models }) => {
const { id, ...data } = args
const { bounty } = data
if (bounty < 1000 || bounty > 1000000) {
throw new UserInputError('invalid bounty amount', { argumentName: 'bounty' })
}
if (id) {
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
}
},
upsertPoll: async (parent, { id, forward, boost, title, text, options }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
if (boost && boost < BOOST_MIN) {
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
}
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
}
}
if (id) {
const optionCount = await models.pollOption.count({
where: {
itemId: Number(id)
}
})
if (options.length + optionCount > MAX_POLL_NUM_CHOICES) {
throw new UserInputError(`total choices must be <${MAX_POLL_NUM_CHOICES}`, { argumentName: 'options' })
}
const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6) AS "Item"`,
Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id)))
return item
} else {
if (options.length < 2 || options.length > MAX_POLL_NUM_CHOICES) {
throw new UserInputError(`choices must be >2 and <${MAX_POLL_NUM_CHOICES}`, { argumentName: 'options' })
}
const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM create_poll($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id)))
await createMentions(item, models)
item.comments = []
return item
}
},
upsertJob: async (parent, {
id, sub, title, company, location, remote,
text, url, maxBid, status, logo
}, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in to create job')
}
const fullSub = await models.sub.findUnique({ where: { name: sub } })
if (!fullSub) {
throw new UserInputError('not a valid sub', { argumentName: 'sub' })
}
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' })
}
location = 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 AuthenticationError('item does not belong to you')
}
([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))))
}
await createMentions(item, models)
return item
},
createComment: async (parent, { text, parentId }, { me, models }) => {
return await createItem(parent, { text, parentId }, { me, models })
},
updateComment: async (parent, { id, text }, { me, models }) => {
return await updateItem(parent, { id, data: { text } }, { me, models })
},
pollVote: async (parent, { id }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
await serialize(models,
models.$queryRaw(`${SELECT} FROM poll_vote($1, $2) AS "Item"`,
Number(id), Number(me.id)))
return id
},
act: async (parent, { id, sats }, { me, models }) => {
// need to make sure we are logged in
if (!me) {
throw new AuthenticationError('you must be logged in')
}
if (sats <= 0) {
throw new UserInputError('sats must be positive', { argumentName: 'sats' })
}
// disallow self tips
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
if (item) {
throw new UserInputError('cannot tip your self')
}
const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'TIP', ${Number(sats)})`)
return {
vote,
sats
}
},
dontLikeThis: async (parent, { id }, { me, models }) => {
// need to make sure we are logged in
if (!me) {
throw new AuthenticationError('you must be logged in')
}
// disallow self down votes
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
if (item) {
throw new UserInputError('cannot downvote your self')
}
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST})`)
return true
}
},
Item: {
sats: async (item, args, { models }) => {
return msatsToSats(item.msats)
},
commentSats: async (item, args, { models }) => {
return msatsToSats(item.commentMsats)
},
isJob: async (item, args, { models }) => {
return item.subName === 'jobs'
},
sub: async (item, args, { models }) => {
if (!item.subName) {
return null
}
return await models.sub.findUnique({ where: { name: item.subName } })
},
position: async (item, args, { models }) => {
if (!item.pinId) {
return null
}
const pin = await models.pin.findUnique({ where: { id: item.pinId } })
if (!pin) {
return null
}
return pin.position
},
prior: async (item, args, { models }) => {
if (!item.pinId) {
return null
}
const prior = await models.item.findFirst({
where: {
pinId: item.pinId,
createdAt: {
lt: item.createdAt
}
},
orderBy: {
createdAt: 'desc'
}
})
if (!prior) {
return null
}
return prior.id
},
poll: async (item, args, { models, me }) => {
if (!item.pollCost) {
return null
}
const options = await models.$queryRaw`
SELECT "PollOption".id, option, count("PollVote"."userId") as count,
coalesce(bool_or("PollVote"."userId" = ${me?.id}), 'f') as "meVoted"
FROM "PollOption"
LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id
WHERE "PollOption"."itemId" = ${item.id}
GROUP BY "PollOption".id
ORDER BY "PollOption".id ASC
`
const poll = {}
poll.options = options
poll.meVoted = options.some(o => o.meVoted)
poll.count = options.reduce((t, o) => t + o.count, 0)
return poll
},
user: async (item, args, { models }) =>
await models.user.findUnique({ where: { id: item.userId } }),
fwdUser: async (item, args, { models }) => {
if (!item.fwdUserId) {
return null
}
return await models.user.findUnique({ where: { id: item.fwdUserId } })
},
comments: async (item, args, { me, models }) => {
if (item.comments) {
return item.comments
}
return comments(me, models, item.id, 'hot')
},
upvotes: async (item, args, { models }) => {
const [{ count }] = await models.$queryRaw(`
SELECT COUNT(DISTINCT "userId") as count
FROM "ItemAct"
WHERE act = 'TIP' AND "itemId" = $1`, Number(item.id))
return count
},
boost: async (item, args, { models }) => {
const { sum: { msats } } = await models.itemAct.aggregate({
sum: {
msats: true
},
where: {
itemId: Number(item.id),
act: 'BOOST'
}
})
return (msats && msatsToSats(msats)) || 0
},
wvotes: async (item) => {
return item.weightedVotes - item.weightedDownVotes
},
meSats: async (item, args, { me, models }) => {
if (!me) return 0
const { sum: { msats } } = await models.itemAct.aggregate({
sum: {
msats: true
},
where: {
itemId: Number(item.id),
userId: me.id,
OR: [
{
act: 'TIP'
},
{
act: 'FEE'
}
]
}
})
return (msats && msatsToSats(msats)) || 0
},
meDontLike: async (item, args, { me, models }) => {
if (!me) return false
const dontLike = await models.itemAct.findFirst({
where: {
itemId: Number(item.id),
userId: me.id,
act: 'DONT_LIKE_THIS'
}
})
return !!dontLike
},
outlawed: async (item, args, { me, models }) => {
if (me && Number(item.userId) === Number(me.id)) {
return false
}
return item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD
},
mine: async (item, args, { me, models }) => {
return me?.id === item.userId
},
root: async (item, args, { models }) => {
if (!item.rootId) {
return null
}
return await models.item.findUnique({ where: { id: item.rootId } })
},
parent: async (item, args, { models }) => {
if (!item.parentId) {
return null
}
return await models.item.findUnique({ where: { id: item.parentId } })
},
parentOtsHash: async (item, args, { models }) => {
if (!item.parentId) {
return null
}
const parent = await models.item.findUnique({ where: { id: item.parentId } })
return parent.otsHash
}
}
}
const namePattern = /\B@[\w_]+/gi
export const createMentions = async (item, models) => {
// if we miss a mention, in the rare circumstance there's some kind of
// failure, it's not a big deal so we don't do it transactionally
// ideally, we probably would
if (!item.text) {
return
}
try {
const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
if (mentions?.length > 0) {
const users = await models.user.findMany({
where: {
name: { in: mentions }
}
})
users.forEach(async user => {
const data = {
itemId: item.id,
userId: user.id
}
await models.mention.upsert({
where: {
itemId_userId: data
},
update: data,
create: data
})
})
}
} catch (e) {
console.log('mention failure', e)
}
}
export const updateItem = async (parent, { id, data: { title, url, text, boost, forward, bounty, parentId } }, { me, models }) => {
// update iff this item belongs to me
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')
}
// 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) {
throw new UserInputError('item can no longer be editted')
}
if (boost && boost < BOOST_MIN) {
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
}
if (!old.parentId && title.length > MAX_TITLE_LENGTH) {
throw new UserInputError('title too long')
}
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
}
}
const [item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
Number(id), title, url, text, Number(boost || 0), bounty ? Number(bounty) : null, Number(fwdUser?.id)))
await createMentions(item, models)
return item
}
const createItem = async (parent, { title, url, text, boost, forward, bounty, parentId }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
if (boost && boost < BOOST_MIN) {
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
}
if (!parentId && title.length > MAX_TITLE_LENGTH) {
throw new UserInputError('title too long')
}
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
}
}
const [item] = await serialize(
models,
models.$queryRaw(
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
title,
url,
text,
Number(boost || 0),
bounty ? Number(bounty) : null,
Number(parentId),
Number(me.id),
Number(fwdUser?.id)))
await createMentions(item, models)
item.comments = []
return item
}
function nestComments (flat, parentId) {
const result = []
let added = 0
for (let i = 0; i < flat.length;) {
if (!flat[i].comments) flat[i].comments = []
if (Number(flat[i].parentId) === Number(parentId)) {
result.push(flat[i])
added++
i++
} else if (result.length > 0) {
const item = result[result.length - 1]
const [nested, newAdded] = nestComments(flat.slice(i), item.id)
if (newAdded === 0) {
break
}
item.comments.push(...nested)
i += newAdded
added += newAdded
} else {
break
}
}
return [result, added]
}
// we have to do our own query because ltree is unsupported
export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
"Item".text, "Item".url, "Item"."bounty", "Item"."userId", "Item"."fwdUserId", "Item"."parentId",
"Item"."pinId", "Item"."maxBid", "Item"."rootId",
"Item".company, "Item".location, "Item".remote, "Item"."deletedAt",
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost",
"Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
"Item"."weightedDownVotes", "Item".freebie, "Item"."otsHash", "Item"."bountyPaidTo", ltree2text("Item"."path") AS "path"`
async function newTimedOrderByWeightedSats (me, models, num) {
return `
ORDER BY (${await orderByNumerator(me, models)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600), 1.3) +
("Item".boost/${BOOST_MIN}::float)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 2.6)) DESC NULLS LAST, "Item".id DESC`
}
async function topOrderByWeightedSats (me, models) {
return `ORDER BY ${await orderByNumerator(me, models)} DESC NULLS LAST, "Item".id DESC`
}