stacker.news/api/resolvers/item.js

553 lines
17 KiB
JavaScript
Raw Normal View History

2021-04-12 18:05:09 +00:00
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
import { ensureProtocol } 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'
2021-06-22 17:47:49 +00:00
2021-04-29 15:56:28 +00:00
async function comments (models, id) {
const flat = await models.$queryRaw(`
WITH RECURSIVE base AS (
${SELECT}, ARRAY[row_number() OVER (${ORDER_BY_SATS}, "Item".path)] AS sort_path
FROM "Item"
${LEFT_JOIN_SATS}
WHERE "parentId" = $1
2021-04-29 15:56:28 +00:00
UNION ALL
${SELECT}, p.sort_path || row_number() OVER (${ORDER_BY_SATS}, "Item".path)
FROM base p
JOIN "Item" ON ltree2text(subpath("Item"."path", 0, -1)) = p."path"
${LEFT_JOIN_SATS})
SELECT * FROM base ORDER BY sort_path`, Number(id))
2021-04-29 15:56:28 +00:00
return nestComments(flat, id)[0]
}
2021-09-23 17:42:00 +00:00
export async function getItem (parent, { id }, { models }) {
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = $1`, Number(id))
if (item) {
item.comments = comments(models, id)
}
return item
}
2021-04-12 18:05:09 +00:00
export default {
Query: {
2021-10-26 20:49:37 +00:00
moreItems: async (parent, { sort, cursor, name, within }, { me, models }) => {
2021-06-22 17:47:49 +00:00
const decodedCursor = decodeCursor(cursor)
2021-10-26 20:49:37 +00:00
let items; let user; let interval = 'INTERVAL '
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
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
case 'hot':
2021-10-07 01:50:18 +00:00
// 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"
${timedLeftJoinSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1 AND created_at > $3
${timedOrderBySats(1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date() - 7))
}
2021-10-26 20:49:37 +00:00
if (decodedCursor.offset !== 0 || items?.length < LIMIT) {
2021-10-07 01:50:18 +00:00
items = await models.$queryRaw(`
2021-06-22 17:47:49 +00:00
${SELECT}
FROM "Item"
${timedLeftJoinSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1
${timedOrderBySats(1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
2021-10-07 01:50:18 +00:00
}
2021-06-24 23:56:01 +00:00
break
2021-10-21 22:05:06 +00:00
case 'top':
2021-10-26 20:49:37 +00:00
switch (within?.pop()) {
2021-10-21 22:05:06 +00:00
case 'day':
interval += "'1 day'"
break
case 'week':
interval += "'7 days'"
break
case 'month':
interval += "'1 month'"
break
case 'year':
interval += "'1 year'"
break
}
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
${timedLeftJoinSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1
${within ? ` AND created_at >= $1 - ${interval}` : ''}
ORDER BY x.sats DESC NULLS LAST, created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
break
2021-06-24 23:56:01 +00:00
default:
items = await models.$queryRaw(`
2021-06-22 17:47:49 +00:00
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
ORDER BY created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
2021-06-24 23:56:01 +00:00
break
}
2021-06-22 17:47:49 +00:00
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
2021-04-24 21:05:07 +00:00
},
2021-10-26 20:49:37 +00:00
moreFlatComments: async (parent, { cursor, name }, { me, models }) => {
2021-06-24 23:56:01 +00:00
const decodedCursor = decodeCursor(cursor)
2021-08-18 22:20:33 +00:00
2021-10-26 20:49:37 +00:00
if (!name) {
throw new UserInputError('must supply name', { argumentName: 'name' })
}
const 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
}
2021-08-18 22:20:33 +00:00
const comments = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NOT NULL
AND created_at <= $2
ORDER BY created_at DESC
OFFSET $3
2021-10-26 20:49:37 +00:00
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
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))
return await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE url LIKE ($1 || '?%') OR url = $1
ORDER BY created_at DESC
LIMIT 3`, urlObj.origin + urlObj.pathname)
2021-04-12 18:05:09 +00:00
}
},
Mutation: {
2021-09-11 21:52:19 +00:00
createLink: async (parent, { title, url, boost }, { me, models }) => {
2021-04-14 00:57:32 +00:00
if (!title) {
throw new UserInputError('link must have title', { argumentName: 'title' })
2021-04-12 18:05:09 +00:00
}
2021-04-14 00:57:32 +00:00
if (!url) {
throw new UserInputError('link must have url', { argumentName: 'url' })
2021-04-12 18:05:09 +00:00
}
2021-09-11 21:52:19 +00:00
return await createItem(parent, { title, url: ensureProtocol(url), boost }, { me, models })
2021-04-14 00:57:32 +00:00
},
2021-08-11 20:13:10 +00:00
updateLink: async (parent, { id, title, url }, { me, models }) => {
if (!id) {
throw new UserInputError('link must have id', { argumentName: 'id' })
}
2021-04-14 00:57:32 +00:00
if (!title) {
throw new UserInputError('link must have title', { argumentName: 'title' })
2021-04-12 18:05:09 +00:00
}
2021-08-11 20:13:10 +00:00
if (!url) {
throw new UserInputError('link must have url', { argumentName: 'url' })
}
// update iff this item belongs to me
const item = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(item.userId) !== Number(me.id)) {
throw new AuthenticationError('item does not belong to you')
}
if (Date.now() > new Date(item.createdAt).getTime() + 10 * 60000) {
throw new UserInputError('item can no longer be editted')
}
2021-08-18 22:20:33 +00:00
return await updateItem(parent, { id, data: { title, url: ensureProtocol(url) } }, { me, models })
2021-08-11 20:13:10 +00:00
},
2021-09-11 21:52:19 +00:00
createDiscussion: async (parent, { title, text, boost }, { me, models }) => {
2021-08-11 20:13:10 +00:00
if (!title) {
throw new UserInputError('discussion must have title', { argumentName: 'title' })
}
2021-09-11 21:52:19 +00:00
return await createItem(parent, { title, text, boost }, { me, models })
2021-04-14 00:57:32 +00:00
},
2021-08-11 20:13:10 +00:00
updateDiscussion: async (parent, { id, title, text }, { me, models }) => {
if (!id) {
throw new UserInputError('discussion must have id', { argumentName: 'id' })
}
if (!title) {
throw new UserInputError('discussion must have title', { argumentName: 'title' })
}
// update iff this item belongs to me
const item = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(item.userId) !== Number(me.id)) {
throw new AuthenticationError('item does not belong to you')
}
2021-10-30 19:59:21 +00:00
// if it's not the FAQ and older than 10 minutes
if (item.id !== 349 && Date.now() > new Date(item.createdAt).getTime() + 10 * 60000) {
2021-08-11 20:13:10 +00:00
throw new UserInputError('item can no longer be editted')
}
2021-08-18 22:20:33 +00:00
return await updateItem(parent, { id, data: { title, text } }, { me, models })
2021-08-11 20:13:10 +00:00
},
2021-04-14 00:57:32 +00:00
createComment: async (parent, { text, parentId }, { me, models }) => {
if (!text) {
throw new UserInputError('comment must have text', { argumentName: 'text' })
2021-04-14 00:57:32 +00:00
}
if (!parentId) {
2021-08-10 22:59:06 +00:00
throw new UserInputError('comment must have parent', { argumentName: 'parentId' })
2021-04-12 18:05:09 +00:00
}
2021-04-14 00:57:32 +00:00
return await createItem(parent, { text, parentId }, { me, models })
},
2021-08-10 22:59:06 +00:00
updateComment: async (parent, { id, text }, { me, models }) => {
if (!text) {
throw new UserInputError('comment must have text', { argumentName: 'text' })
}
if (!id) {
throw new UserInputError('comment must have id', { argumentName: 'id' })
}
// update iff this comment belongs to me
const comment = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(comment.userId) !== Number(me.id)) {
2021-08-11 20:13:10 +00:00
throw new AuthenticationError('comment does not belong to you')
2021-08-10 22:59:06 +00:00
}
if (Date.now() > new Date(comment.createdAt).getTime() + 10 * 60000) {
throw new UserInputError('comment can no longer be editted')
}
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
},
2021-09-12 16:55:38 +00:00
act: async (parent, { id, act, sats, tipDefault }, { 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
}
2021-09-10 21:13:52 +00:00
// if we are tipping disallow self tips
if (act === 'TIP') {
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-12 16:55:38 +00:00
// if tipDefault, set on user
if (tipDefault) {
await models.user.update({ where: { id: me.id }, data: { tipDefault: sats } })
}
2021-09-10 21:13:52 +00:00
}
2021-09-08 21:51:23 +00:00
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, ${act}, ${Number(sats)})`)
2021-09-10 21:13:52 +00:00
return {
sats,
act
}
2021-04-12 18:05:09 +00:00
}
},
Item: {
user: async (item, args, { models }) =>
await models.user.findUnique({ where: { id: item.userId } }),
2021-04-14 23:56:29 +00:00
ncomments: async (item, args, { models }) => {
const [{ count }] = await models.$queryRaw`
SELECT count(*)
FROM "Item"
2021-04-15 19:41:02 +00:00
WHERE path <@ text2ltree(${item.path}) AND id != ${item.id}`
2021-05-11 15:52:50 +00:00
return count || 0
2021-04-14 23:56:29 +00:00
},
sats: async (item, args, { models }) => {
2021-09-08 21:51:23 +00:00
const { sum: { sats } } = await models.itemAct.aggregate({
sum: {
sats: true
},
where: {
2021-04-27 21:30:58 +00:00
itemId: item.id,
2021-09-08 21:51:23 +00:00
act: 'VOTE'
2021-04-27 21:30:58 +00:00
}
})
2021-05-11 15:52:50 +00:00
return sats || 0
2021-04-27 21:30:58 +00:00
},
boost: async (item, args, { models }) => {
2021-09-08 21:51:23 +00:00
const { sum: { sats } } = await models.itemAct.aggregate({
2021-04-27 21:30:58 +00:00
sum: {
sats: true
},
where: {
itemId: item.id,
2021-09-08 21:51:23 +00:00
act: 'BOOST'
}
})
2021-05-11 15:52:50 +00:00
return sats || 0
},
2021-09-10 21:13:52 +00:00
tips: async (item, args, { models }) => {
const { sum: { sats } } = await models.itemAct.aggregate({
sum: {
sats: true
},
where: {
itemId: item.id,
act: 'TIP'
}
})
return sats || 0
},
meVote: async (item, args, { me, models }) => {
if (!me) return 0
2021-09-08 21:51:23 +00:00
const { sum: { sats } } = await models.itemAct.aggregate({
sum: {
sats: true
},
where: {
itemId: item.id,
2021-09-08 21:51:23 +00:00
userId: me.id,
act: 'VOTE'
}
})
2021-05-11 15:52:50 +00:00
return sats || 0
2021-07-08 00:15:27 +00:00
},
2021-09-10 21:13:52 +00:00
meBoost: async (item, args, { me, models }) => {
if (!me) return 0
const { sum: { sats } } = await models.itemAct.aggregate({
sum: {
sats: true
},
where: {
itemId: item.id,
userId: me.id,
act: 'BOOST'
}
})
return sats || 0
},
meTip: async (item, args, { me, models }) => {
if (!me) return 0
const { sum: { sats } } = await models.itemAct.aggregate({
sum: {
sats: true
},
where: {
itemId: item.id,
userId: me.id,
act: 'TIP'
}
})
return sats || 0
},
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)
}
}
const updateItem = async (parent, { id, data }, { me, models }) => {
const item = await models.item.update({
where: { id: Number(id) },
data
})
await createMentions(item, models)
return item
}
2021-09-11 21:52:19 +00:00
const createItem = async (parent, { title, url, text, boost, parentId }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
2021-09-11 21:52:19 +00:00
if (boost && boost < 0) {
throw new UserInputError('boost must be positive', { argumentName: 'boost' })
}
const [item] = await serialize(models,
2021-09-14 17:55:59 +00:00
models.$queryRaw(`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6) AS "Item"`,
title, url, text, Number(boost || 0), Number(parentId), Number(me.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,
"Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS "path"`
2021-09-08 21:51:23 +00:00
const LEFT_JOIN_SATS_SELECT = 'SELECT i.id, SUM(CASE WHEN "ItemAct".act = \'VOTE\' THEN "ItemAct".sats ELSE 0 END) as sats, SUM(CASE WHEN "ItemAct".act = \'BOOST\' THEN "ItemAct".sats ELSE 0 END) as boost'
2021-07-09 19:12:35 +00:00
2021-06-22 17:47:49 +00:00
function timedLeftJoinSats (num) {
2021-07-09 19:12:35 +00:00
return `LEFT JOIN (${LEFT_JOIN_SATS_SELECT}
2021-06-22 17:47:49 +00:00
FROM "Item" i
2021-09-08 21:51:23 +00:00
JOIN "ItemAct" ON i.id = "ItemAct"."itemId" AND "ItemAct".created_at <= $${num}
2021-06-22 17:47:49 +00:00
GROUP BY i.id) x ON "Item".id = x.id`
}
2021-04-27 21:30:58 +00:00
const LEFT_JOIN_SATS =
2021-07-09 19:12:35 +00:00
`LEFT JOIN (${LEFT_JOIN_SATS_SELECT}
2021-04-27 21:30:58 +00:00
FROM "Item" i
2021-09-08 21:51:23 +00:00
JOIN "ItemAct" ON i.id = "ItemAct"."itemId"
2021-04-27 21:30:58 +00:00
GROUP BY i.id) x ON "Item".id = x.id`
2021-10-26 20:49:37 +00:00
/* NOTE: because many items will have the same rank, we need to tie break with a unique field so pagination works */
2021-06-22 17:47:49 +00:00
function timedOrderBySats (num) {
2021-07-09 19:12:35 +00:00
return `ORDER BY ((x.sats-1)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 1.5) +
2021-10-26 20:49:37 +00:00
(x.boost)/POWER(EXTRACT(EPOCH FROM ($${num} - "Item".created_at))/3600+2, 5)) DESC NULLS LAST, "Item".id DESC`
2021-06-22 17:47:49 +00:00
}
2021-04-27 21:30:58 +00:00
const ORDER_BY_SATS =
2021-07-09 19:12:35 +00:00
`ORDER BY ((x.sats-1)/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE 'UTC') - "Item".created_at))/3600+2, 1.5) +
2021-10-26 20:49:37 +00:00
(x.boost)/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE 'UTC') - "Item".created_at))/3600+2, 5)) DESC NULLS LAST, "Item".id DESC`