stacker.news/api/resolvers/item.js

1038 lines
34 KiB
JavaScript
Raw Normal View History

2021-04-12 18:05:09 +00:00
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
2022-11-15 23:51:00 +00:00
import { ensureProtocol, removeTracking } from '../../lib/url'
2021-05-20 01:09:32 +00:00
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
2021-08-22 15:25:17 +00:00
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import domino from 'domino'
2022-09-21 19:57:36 +00:00
import {
BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES,
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST
} from '../../lib/constants'
2022-11-15 20:51:55 +00:00
import { msatsToSats } from '../../lib/format'
2021-06-22 17:47:49 +00:00
2022-09-21 19:57:36 +00:00
async function comments (me, models, id, sort) {
2021-12-21 21:29:42 +00:00
let orderBy
switch (sort) {
case 'top':
orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".msats DESC, "Item".id DESC`
2021-12-21 21:29:42 +00:00
break
case 'recent':
orderBy = 'ORDER BY "Item".created_at DESC, "Item".msats DESC, "Item".id DESC'
2021-12-21 21:29:42 +00:00
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`
2021-12-21 21:29:42 +00:00
break
}
2021-04-29 15:56:28 +00:00
const flat = await models.$queryRaw(`
WITH RECURSIVE base AS (
2021-12-21 21:29:42 +00:00
${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path
2021-04-29 15:56:28 +00:00
FROM "Item"
WHERE "parentId" = $1
2022-09-21 19:57:36 +00:00
${await filterClause(me, models)}
2021-04-29 15:56:28 +00:00
UNION ALL
2021-12-21 21:29:42 +00:00
${SELECT}, p.sort_path || row_number() OVER (${orderBy}, "Item".path)
2021-04-29 15:56:28 +00:00
FROM base p
2022-09-21 19:57:36 +00:00
JOIN "Item" ON "Item"."parentId" = p.id
WHERE true
${await filterClause(me, models)})
SELECT * FROM base ORDER BY sort_path`, Number(id))
2021-04-29 15:56:28 +00:00
return nestComments(flat, id)[0]
}
2022-09-21 19:57:36 +00:00
export async function getItem (parent, { id }, { me, models }) {
2021-09-23 17:42:00 +00:00
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = $1`, Number(id))
return item
}
2021-12-16 23:05:31 +00:00
function topClause (within) {
2022-04-02 22:39:15 +00:00
let interval = ' AND "Item".created_at >= $1 - INTERVAL '
2021-12-16 23:05:31 +00:00
switch (within) {
2022-10-25 21:35:32 +00:00
case 'forever':
interval = ''
2021-12-16 23:05:31 +00:00
break
case 'week':
interval += "'7 days'"
break
case 'month':
interval += "'1 month'"
break
case 'year':
interval += "'1 year'"
break
default:
2022-10-25 21:35:32 +00:00
interval += "'1 day'"
2021-12-16 23:05:31 +00:00
break
}
return interval
}
2022-10-25 21:35:32 +00:00
async function topOrderClause (sort, me, models) {
switch (sort) {
case 'comments':
return 'ORDER BY ncomments DESC'
case 'sats':
2022-11-15 20:51:55 +00:00
return 'ORDER BY msats DESC'
2022-10-25 21:35:32 +00:00
default:
return await topOrderByWeightedSats(me, models)
}
}
2022-09-21 19:57:36 +00:00
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) {
2022-09-27 21:19:15 +00:00
// by default don't include freebies unless they have upvotes
let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'
2022-09-21 19:57:36 +00:00
if (me) {
const user = await models.user.findUnique({ where: { id: me.id } })
2022-09-27 21:19:15 +00:00
// wild west mode has everything
2022-09-21 19:57:36 +00:00
if (user.wildWestMode) {
return ''
}
2022-09-27 21:19:15 +00:00
// 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 += ')'
2022-09-21 19:57:36 +00:00
}
// if the item is above the threshold or is mine
2022-09-27 21:19:15 +00:00
clause += ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
2022-09-21 19:57:36 +00:00
if (me) {
clause += ` OR "Item"."userId" = ${me.id}`
}
clause += ')'
return clause
}
2022-12-01 22:22:13 +00:00
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'
default:
return ''
}
}
2021-04-12 18:05:09 +00:00
export default {
Query: {
2022-08-10 15:06:31 +00:00
itemRepetition: async (parent, { parentId }, { me, models }) => {
if (!me) return 0
// how many of the parents starting at parentId belong to me
2022-09-01 21:06:11 +00:00
const [{ item_spam: count }] = await models.$queryRaw(`SELECT item_spam($1, $2, '${ITEM_SPAM_INTERVAL}')`,
Number(parentId), Number(me.id))
2022-08-10 15:06:31 +00:00
return count
},
2022-10-25 21:35:32 +00:00
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
${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
${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
}
},
2022-12-01 22:22:13 +00:00
items: async (parent, { sub, sort, type, cursor, name, within }, { me, models }) => {
2021-06-22 17:47:49 +00:00
const decodedCursor = decodeCursor(cursor)
2022-02-17 17:23:43 +00:00
let items; let user; let pins; let subFull
const subClause = (num) => {
2022-02-28 20:09:21 +00:00
return sub ? ` AND "subName" = $${num} ` : ` AND ("subName" IS NULL OR "subName" = $${num}) `
2022-02-26 16:41:30 +00:00
}
const activeOrMine = () => {
2022-06-02 23:25:21 +00:00
return me ? ` AND (status <> 'STOPPED' OR "userId" = ${me.id}) ` : ' AND status <> \'STOPPED\' '
2022-02-17 17:23:43 +00:00
}
2021-10-26 20:49:37 +00:00
2021-06-24 23:56:01 +00:00
switch (sort) {
case 'user':
2021-10-26 20:49:37 +00:00
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' })
}
2021-06-24 23:56:01 +00:00
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
2022-01-07 16:32:31 +00:00
AND "pinId" IS NULL
2022-02-26 16:41:30 +00:00
${activeOrMine()}
2022-09-21 19:57:36 +00:00
${await filterClause(me, models)}
2021-06-24 23:56:01 +00:00
ORDER BY created_at DESC
OFFSET $3
2021-10-26 20:49:37 +00:00
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
2021-06-24 23:56:01 +00:00
break
2022-02-17 17:23:43 +00:00
case 'recent':
2021-06-24 23:56:01 +00:00
items = await models.$queryRaw(`
2021-06-22 17:47:49 +00:00
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
2022-02-17 17:23:43 +00:00
${subClause(3)}
2022-02-26 16:41:30 +00:00
${activeOrMine()}
2022-09-21 19:57:36 +00:00
${await filterClause(me, models)}
2022-12-01 22:22:13 +00:00
${recentClause(type)}
2021-06-22 17:47:49 +00:00
ORDER BY created_at DESC
OFFSET $2
2022-02-17 17:23:43 +00:00
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
break
case 'top':
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
2022-04-02 22:39:15 +00:00
WHERE "parentId" IS NULL AND "Item".created_at <= $1
2022-02-17 17:23:43 +00:00
AND "pinId" IS NULL
${topClause(within)}
2022-09-21 19:57:36 +00:00
${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
2022-02-17 17:23:43 +00:00
OFFSET $2
2021-06-22 17:47:49 +00:00
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
2021-06-24 23:56:01 +00:00
break
2022-02-17 17:23:43 +00:00
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(`
2022-07-21 22:55:05 +00:00
SELECT *
FROM (
(${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(3)}
2022-09-29 20:42:33 +00:00
AND status = 'ACTIVE' AND "maxBid" > 0
2022-07-21 22:55:05 +00:00
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)}
2022-09-29 20:42:33 +00:00
AND ((status = 'ACTIVE' AND "maxBid" = 0) OR status = 'NOSATS')
2022-07-21 22:55:05 +00:00
ORDER BY created_at DESC)
) a
2022-02-17 17:23:43 +00:00
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"
2022-04-02 22:39:15 +00:00
WHERE "parentId" IS NULL AND "Item".created_at <= $1 AND "Item".created_at > $3
2022-09-27 21:19:15 +00:00
AND "pinId" IS NULL AND NOT bio
2022-02-28 20:09:21 +00:00
${subClause(4)}
2022-09-21 19:57:36 +00:00
${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
2022-02-17 17:23:43 +00:00
OFFSET $2
2022-04-02 22:39:15 +00:00
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date().setDate(new Date().getDate() - 5)), sub || 'NULL')
2022-02-17 17:23:43 +00:00
}
if (decodedCursor.offset !== 0 || items?.length < LIMIT) {
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
2022-04-02 22:39:15 +00:00
WHERE "parentId" IS NULL AND "Item".created_at <= $1
2022-09-27 21:19:15 +00:00
AND "pinId" IS NULL AND NOT bio
2022-02-28 20:09:21 +00:00
${subClause(3)}
2022-09-21 19:57:36 +00:00
${await filterClause(me, models)}
${await newTimedOrderByWeightedSats(me, models, 1)}
2022-02-17 17:23:43 +00:00
OFFSET $2
2022-02-28 20:09:21 +00:00
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
2022-02-17 17:23:43 +00:00
}
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
2021-06-24 23:56:01 +00:00
}
2021-06-22 17:47:49 +00:00
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
2022-01-07 16:32:31 +00:00
items,
pins
2021-06-22 17:47:49 +00:00
}
2021-04-24 21:05:07 +00:00
},
2022-09-21 19:57:36 +00:00
allItems: async (parent, { cursor }, { me, models }) => {
2022-01-25 19:34:51 +00:00
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
}
},
2022-09-22 18:44:50 +00:00
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
}
},
2022-09-22 20:42:04 +00:00
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
}
},
2022-09-27 21:19:15 +00:00
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
}
},
2021-12-16 23:05:31 +00:00
moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => {
2021-06-24 23:56:01 +00:00
const decodedCursor = decodeCursor(cursor)
2021-08-18 22:20:33 +00:00
2021-12-16 23:05:31 +00:00
let comments, user
switch (sort) {
2022-08-18 22:05:58 +00:00
case 'recent':
comments = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NOT NULL AND created_at <= $1
2022-09-21 19:57:36 +00:00
${await filterClause(me, models)}
2022-08-18 22:05:58 +00:00
ORDER BY created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
2021-12-16 23:05:31 +00:00
case 'user':
if (!name) {
throw new UserInputError('must supply name', { argumentName: 'name' })
}
2021-10-26 20:49:37 +00:00
2021-12-16 23:05:31 +00:00
user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new UserInputError('no user has that name', { argumentName: 'name' })
}
2021-08-18 22:20:33 +00:00
2021-12-16 23:05:31 +00:00
comments = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NOT NULL
AND created_at <= $2
2022-09-21 19:57:36 +00:00
${await filterClause(me, models)}
2021-12-16 23:05:31 +00:00
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
2022-04-02 22:39:15 +00:00
AND "Item".created_at <= $1
2021-12-16 23:05:31 +00:00
${topClause(within)}
2022-09-21 19:57:36 +00:00
${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
2021-12-16 23:05:31 +00:00
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
default:
throw new UserInputError('invalid sort type', { argumentName: 'sort' })
}
2021-08-18 22:20:33 +00:00
2021-06-24 23:56:01 +00:00
return {
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
comments
}
},
2021-09-23 17:42:00 +00:00
item: getItem,
2021-08-22 15:25:17 +00:00
pageTitle: async (parent, { url }, { models }) => {
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 })
return metadata?.title
} catch (e) {
return null
}
2021-10-28 20:49:51 +00:00
},
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
let similar = `(http(s)?://)?${uri}/?`
const whitelist = ['news.ycombinator.com/item', 'bitcointalk.org/index.php']
2023-01-11 20:58:52 +00:00
const youtube = ['www.youtube.com', '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)
2023-01-11 20:58:52 +00:00
similar = `(http(s)?://)?((www.)?youtube.com/watch\\?v=${matches?.groups?.id}|youtu.be/${matches?.groups?.id})`
} else {
2022-05-18 18:21:24 +00:00
similar += '((\\?|#)%)?'
}
2021-10-28 20:49:51 +00:00
return await models.$queryRaw(`
${SELECT}
FROM "Item"
2023-01-11 21:05:30 +00:00
WHERE LOWER(url) SIMILAR TO LOWER($1)
2021-10-28 20:49:51 +00:00
ORDER BY created_at DESC
LIMIT 3`, similar)
2021-12-21 21:29:42 +00:00
},
2022-09-21 19:57:36 +00:00
comments: async (parent, { id, sort }, { me, models }) => {
return comments(me, models, id, sort)
2022-01-26 15:35:14 +00:00
},
2022-09-29 20:42:33 +00:00
auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
2022-10-05 18:55:30 +00:00
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
let where
2022-09-29 20:42:33 +00:00
if (bid > 0) {
2022-10-05 18:55:30 +00:00
// 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 } }
]
}
2022-09-29 20:42:33 +00:00
} else {
2022-10-05 18:55:30 +00:00
// 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' } }
]
}
2022-09-29 20:42:33 +00:00
}
2022-10-05 18:55:30 +00:00
where.subName = sub
2022-02-17 17:23:43 +00:00
if (id) {
2022-10-05 18:55:30 +00:00
where.id = { not: Number(id) }
2022-02-17 17:23:43 +00:00
}
2022-10-05 18:55:30 +00:00
return await models.item.count({ where }) + 1
2021-04-12 18:05:09 +00:00
}
},
Mutation: {
upsertLink: async (parent, args, { me, models }) => {
const { id, ...data } = args
data.url = ensureProtocol(data.url)
2022-11-15 23:51:00 +00:00
data.url = removeTracking(data.url)
2021-08-11 20:13:10 +00:00
if (id) {
2022-08-18 18:15:24 +00:00
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
2021-08-11 20:13:10 +00:00
}
},
upsertDiscussion: async (parent, args, { me, models }) => {
const { id, ...data } = args
2021-08-11 20:13:10 +00:00
if (id) {
2022-08-18 18:15:24 +00:00
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
2021-08-11 20:13:10 +00:00
}
},
2022-07-30 13:25:46 +00:00
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' })
}
2022-08-18 18:15:24 +00:00
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
}
}
2022-07-30 13:25:46 +00:00
2022-08-18 18:15:24 +00:00
if (id) {
const optionCount = await models.pollOption.count({
where: {
itemId: Number(id)
}
2022-07-30 13:25:46 +00:00
})
2022-08-18 18:15:24 +00:00
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,
2022-09-27 21:19:15 +00:00
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)))
2022-08-18 18:15:24 +00:00
2022-07-30 13:25:46 +00:00
return item
} else {
2022-08-18 18:15:24 +00:00
if (options.length < 2 || options.length > MAX_POLL_NUM_CHOICES) {
throw new UserInputError(`choices must be >2 and <${MAX_POLL_NUM_CHOICES}`, { argumentName: 'options' })
2022-07-30 13:25:46 +00:00
}
const [item] = await serialize(models,
2022-09-27 21:19:15 +00:00
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)))
2022-08-18 18:15:24 +00:00
await createMentions(item, models)
2022-07-30 13:25:46 +00:00
item.comments = []
return item
}
},
2022-07-21 22:55:05 +00:00
upsertJob: async (parent, {
id, sub, title, company, location, remote,
text, url, maxBid, status, logo
}, { me, models }) => {
2022-02-17 17:23:43 +00:00
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' })
}
2022-09-29 20:42:33 +00:00
if (maxBid < 0) {
throw new UserInputError('bid must be at least 0', { argumentName: 'maxBid' })
}
2022-03-07 21:50:13 +00:00
if (!location && !remote) {
throw new UserInputError('must specify location or remote', { argumentName: 'location' })
}
2022-09-29 20:42:33 +00:00
location = location.toLowerCase() === 'remote' ? undefined : location
2022-02-17 17:23:43 +00:00
2022-09-29 20:42:33 +00:00
let item
2022-02-17 17:23:43 +00:00
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')
}
2022-09-29 20:42:33 +00:00
([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))))
2022-02-17 17:23:43 +00:00
}
2022-09-29 20:42:33 +00:00
await createMentions(item, models)
return item
2022-02-17 17:23:43 +00:00
},
2021-04-14 00:57:32 +00:00
createComment: async (parent, { text, parentId }, { me, models }) => {
return await createItem(parent, { text, parentId }, { me, models })
},
2021-08-10 22:59:06 +00:00
updateComment: async (parent, { id, text }, { me, models }) => {
2021-08-18 22:20:33 +00:00
return await updateItem(parent, { id, data: { text } }, { me, models })
2021-08-10 22:59:06 +00:00
},
2022-07-30 13:25:46 +00:00
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')
}
2021-04-27 21:30:58 +00:00
if (sats <= 0) {
throw new UserInputError('sats must be positive', { argumentName: 'sats' })
2021-04-27 21:30:58 +00:00
}
// 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')
2021-09-10 21:13:52 +00:00
}
const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'TIP', ${Number(sats)})`)
2021-09-10 21:13:52 +00:00
return {
vote,
sats
2021-09-10 21:13:52 +00:00
}
2022-09-21 19:57:36 +00:00
},
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
2021-04-12 18:05:09 +00:00
}
},
Item: {
2022-11-15 20:51:55 +00:00
sats: async (item, args, { models }) => {
return msatsToSats(item.msats)
},
commentSats: async (item, args, { models }) => {
return msatsToSats(item.commentMsats)
},
2022-09-29 20:42:33 +00:00
isJob: async (item, args, { models }) => {
return item.subName === 'jobs'
},
2022-02-17 17:23:43 +00:00
sub: async (item, args, { models }) => {
if (!item.subName) {
return null
}
return await models.sub.findUnique({ where: { name: item.subName } })
},
2022-01-07 16:32:31 +00:00
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
},
2022-07-30 13:25:46 +00:00
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
},
2021-04-12 18:05:09 +00:00
user: async (item, args, { models }) =>
await models.user.findUnique({ where: { id: item.userId } }),
2022-04-19 18:32:39 +00:00
fwdUser: async (item, args, { models }) => {
if (!item.fwdUserId) {
return null
}
return await models.user.findUnique({ where: { id: item.fwdUserId } })
},
2022-09-21 19:57:36 +00:00
comments: async (item, args, { me, models }) => {
if (item.comments) {
return item.comments
}
2022-09-21 19:57:36 +00:00
return comments(me, models, item.id, 'hot')
},
upvotes: async (item, args, { models }) => {
2022-11-23 18:12:09 +00:00
const [{ count }] = await models.$queryRaw(`
SELECT COUNT(DISTINCT "userId") as count
FROM "ItemAct"
WHERE act = 'TIP' AND "itemId" = $1`, Number(item.id))
2021-09-10 21:13:52 +00:00
2022-11-15 20:51:55 +00:00
return count
2021-09-10 21:13:52 +00:00
},
boost: async (item, args, { models }) => {
2022-11-15 20:51:55 +00:00
const { sum: { msats } } = await models.itemAct.aggregate({
sum: {
2022-11-15 20:51:55 +00:00
msats: true
},
where: {
2022-01-27 19:18:48 +00:00
itemId: Number(item.id),
act: 'BOOST'
}
})
2022-11-15 20:51:55 +00:00
return (msats && msatsToSats(msats)) || 0
2021-07-08 00:15:27 +00:00
},
2022-10-28 15:58:31 +00:00
wvotes: async (item) => {
return item.weightedVotes - item.weightedDownVotes
},
2021-12-05 17:37:55 +00:00
meSats: async (item, args, { me, models }) => {
2021-09-10 21:13:52 +00:00
if (!me) return 0
2022-11-15 20:51:55 +00:00
const { sum: { msats } } = await models.itemAct.aggregate({
2021-09-10 21:13:52 +00:00
sum: {
2022-11-15 20:51:55 +00:00
msats: true
2021-09-10 21:13:52 +00:00
},
where: {
2022-01-27 19:18:48 +00:00
itemId: Number(item.id),
2021-09-10 21:13:52 +00:00
userId: me.id,
2021-12-05 17:37:55 +00:00
OR: [
{
act: 'TIP'
},
{
2022-11-23 18:12:09 +00:00
act: 'FEE'
2021-12-05 17:37:55 +00:00
}
]
2021-09-10 21:13:52 +00:00
}
})
2022-11-15 20:51:55 +00:00
return (msats && msatsToSats(msats)) || 0
2021-09-10 21:13:52 +00:00
},
2022-09-21 19:57:36 +00:00
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
},
2022-09-22 18:44:50 +00:00
outlawed: async (item, args, { me, models }) => {
if (me && Number(item.userId) === Number(me.id)) {
return false
}
return item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD
},
2021-12-05 17:37:55 +00:00
mine: async (item, args, { me, models }) => {
return me?.id === item.userId
},
2021-07-08 00:15:27 +00:00
root: async (item, args, { models }) => {
if (!item.parentId) {
return null
}
return (await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = (
SELECT ltree2text(subltree(path, 0, 1))::integer
FROM "Item"
WHERE id = $1)`, Number(item.id)))[0]
},
parent: async (item, args, { models }) => {
if (!item.parentId) {
return null
}
return await models.item.findUnique({ where: { id: item.parentId } })
}
}
}
2021-08-18 22:20:33 +00:00
const namePattern = /\B@[\w_]+/gi
2021-09-23 17:42:00 +00:00
export const createMentions = async (item, models) => {
2021-08-18 22:20:33 +00:00
// 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 {
2021-08-19 19:53:11 +00:00
const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
if (mentions?.length > 0) {
2021-08-18 22:20:33 +00:00
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)
}
}
2022-08-18 18:15:24 +00:00
export const updateItem = async (parent, { id, data: { title, url, text, boost, forward, 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')
}
2022-08-18 18:15:24 +00:00
// if it's not the FAQ, not their bio, and older than 10 minutes
const user = await models.user.findUnique({ where: { id: me.id } })
2022-10-23 15:43:39 +00:00
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')
}
2022-08-18 18:15:24 +00:00
if (boost && boost < BOOST_MIN) {
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
2022-08-10 15:06:31 +00:00
}
2022-08-27 02:57:41 +00:00
if (!old.parentId && title.length > MAX_TITLE_LENGTH) {
2022-08-26 23:31:51 +00:00
throw new UserInputError('title too long')
}
2022-08-18 18:15:24 +00:00
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(
2022-09-27 21:19:15 +00:00
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6) AS "Item"`,
Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id)))
2021-08-18 22:20:33 +00:00
await createMentions(item, models)
return item
}
const createItem = async (parent, { title, url, text, boost, forward, parentId }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
2022-03-09 19:44:50 +00:00
if (boost && boost < BOOST_MIN) {
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
2021-09-11 21:52:19 +00:00
}
2022-08-27 01:27:57 +00:00
if (!parentId && title.length > MAX_TITLE_LENGTH) {
2022-08-26 23:31:51 +00:00
throw new UserInputError('title too long')
}
2022-04-19 18:32:39 +00:00
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
}
}
2021-09-11 21:52:19 +00:00
const [item] = await serialize(models,
2022-08-10 15:06:31 +00:00
models.$queryRaw(
2022-09-27 21:19:15 +00:00
`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
2022-08-10 15:06:31 +00:00
title, url, text, Number(boost || 0), Number(parentId), Number(me.id),
2022-09-27 21:19:15 +00:00
Number(fwdUser?.id)))
2021-08-18 22:20:33 +00:00
await createMentions(item, models)
2021-05-20 01:09:32 +00:00
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
}
2021-04-12 18:05:09 +00:00
}
return [result, added]
2021-04-12 18:05:09 +00:00
}
// we have to do our own query because ltree is unsupported
2021-09-23 17:42:00 +00:00
export const SELECT =
2021-04-27 21:30:58 +00:00
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
2022-04-19 18:32:39 +00:00
"Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
2022-03-07 21:50:13 +00:00
"Item".company, "Item".location, "Item".remote,
2022-09-27 21:19:15 +00:00
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost",
2022-11-15 20:51:55 +00:00
"Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
2022-09-27 21:19:15 +00:00
"Item"."weightedDownVotes", "Item".freebie, ltree2text("Item"."path") AS "path"`
2021-04-27 21:30:58 +00:00
2022-09-21 19:57:36 +00:00
async function newTimedOrderByWeightedSats (me, models, num) {
2022-04-02 22:39:15 +00:00
return `
2022-10-31 17:56:48 +00:00
ORDER BY (${await orderByNumerator(me, models)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600), 1.3) +
2022-07-24 00:00:57 +00:00
("Item".boost/${BOOST_MIN}::float)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 2.6)) DESC NULLS LAST, "Item".id DESC`
2022-01-17 22:38:40 +00:00
}
2022-09-21 19:57:36 +00:00
async function topOrderByWeightedSats (me, models) {
return `ORDER BY ${await orderByNumerator(me, models)} DESC NULLS LAST, "Item".id DESC`
}