If an item has a lot of comments, it gets ranked higher. When an item is now downzapped, it can become outlawed but still show up on the front page because of the comments. This fixes it by filtering outlawed items out instead of relying on the ranking to make them not show up.
1467 lines
51 KiB
JavaScript
1467 lines
51 KiB
JavaScript
import { ensureProtocol, removeTracking, stripTrailingSlash } from '@/lib/url'
|
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
|
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
|
|
import { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper'
|
|
import domino from 'domino'
|
|
import {
|
|
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
|
|
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
|
|
USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED,
|
|
NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS,
|
|
BOOST_MULT
|
|
} from '@/lib/constants'
|
|
import { msatsToSats } from '@/lib/format'
|
|
import { parse } from 'tldts'
|
|
import uu from 'url-unshort'
|
|
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate'
|
|
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
|
|
import { datePivot, whenRange } from '@/lib/time'
|
|
import { uploadIdsFromText } from './upload'
|
|
import assertGofacYourself from './ofac'
|
|
import assertApiKeyNotPermitted from './apiKey'
|
|
import performPaidAction from '../paidAction'
|
|
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
|
import { verifyHmac } from './wallet'
|
|
|
|
function commentsOrderByClause (me, models, sort) {
|
|
if (sort === 'recent') {
|
|
return 'ORDER BY ("Item"."deletedAt" IS NULL) DESC, ("Item".cost > 0 OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0) DESC, "Item".created_at DESC, "Item".id DESC'
|
|
}
|
|
|
|
if (me && sort === 'hot') {
|
|
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
|
|
personal_hot_score,
|
|
${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
|
|
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
|
} else {
|
|
if (sort === 'top') {
|
|
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
|
} else {
|
|
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
|
}
|
|
}
|
|
}
|
|
|
|
async function comments (me, models, id, sort) {
|
|
const orderBy = commentsOrderByClause(me, models, sort)
|
|
|
|
if (me) {
|
|
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) `
|
|
const [{ item_comments_zaprank_with_me: comments }] = await models.$queryRawUnsafe(
|
|
'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)',
|
|
Number(id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
|
return comments
|
|
}
|
|
|
|
const filter = ' AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = \'PAID\') '
|
|
const [{ item_comments: comments }] = await models.$queryRawUnsafe(
|
|
'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)', Number(id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
|
return comments
|
|
}
|
|
|
|
export async function getItem (parent, { id }, { me, models }) {
|
|
const [item] = await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${SELECT}
|
|
FROM "Item"
|
|
${whereClause(
|
|
'"Item".id = $1',
|
|
activeOrMine(me)
|
|
)}`
|
|
}, Number(id))
|
|
return item
|
|
}
|
|
|
|
export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { me, models }) {
|
|
return (await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${SELECT}
|
|
FROM "Item"
|
|
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
|
${whereClause(
|
|
'"parentId" IS NULL',
|
|
'"Item"."pinId" IS NULL',
|
|
'"Item"."deletedAt" IS NULL',
|
|
'"Item"."parentId" IS NULL',
|
|
'"Item".bio = false',
|
|
'"Item".boost > 0',
|
|
activeOrMine(),
|
|
subClause(sub, 1, 'Item', me, showNsfw),
|
|
muteClause(me))}
|
|
ORDER BY boost desc, "Item".created_at ASC
|
|
LIMIT 1`
|
|
}, ...subArr))?.[0] || null
|
|
}
|
|
|
|
const orderByClause = (by, me, models, type) => {
|
|
switch (by) {
|
|
case 'comments':
|
|
return 'ORDER BY "Item".ncomments DESC'
|
|
case 'sats':
|
|
return 'ORDER BY "Item".msats DESC'
|
|
case 'zaprank':
|
|
return topOrderByWeightedSats(me, models)
|
|
case 'boost':
|
|
return 'ORDER BY "Item".boost DESC'
|
|
case 'random':
|
|
return 'ORDER BY RANDOM()'
|
|
default:
|
|
return `ORDER BY ${type === 'bookmarks' ? '"bookmarkCreatedAt"' : '"Item".created_at'} DESC`
|
|
}
|
|
}
|
|
|
|
export function orderByNumerator ({ models, commentScaler = 0.5, considerBoost = false }) {
|
|
return `((CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
|
|
GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2))
|
|
ELSE
|
|
"Item"."weightedVotes" - "Item"."weightedDownVotes"
|
|
END + "Item"."weightedComments"*${commentScaler}) + ${considerBoost ? `("Item".boost / ${BOOST_MULT})` : 0})`
|
|
}
|
|
|
|
export function joinZapRankPersonalView (me, models) {
|
|
let join = ` JOIN zap_rank_personal_view g ON g.id = "Item".id AND g."viewerId" = ${GLOBAL_SEED} `
|
|
|
|
if (me) {
|
|
join += ` LEFT JOIN zap_rank_personal_view l ON l.id = g.id AND l."viewerId" = ${me.id} `
|
|
}
|
|
|
|
return join
|
|
}
|
|
|
|
// this grabs all the stuff we need to display the item list and only
|
|
// hits the db once ... orderBy needs to be duplicated on the outer query because
|
|
// joining does not preserve the order of the inner query
|
|
export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args) {
|
|
if (!me) {
|
|
return await models.$queryRawUnsafe(`
|
|
SELECT "Item".*, to_json(users.*) as user, to_jsonb("Sub".*) as sub
|
|
FROM (
|
|
${query}
|
|
) "Item"
|
|
JOIN users ON "Item"."userId" = users.id
|
|
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
|
${orderBy}`, ...args)
|
|
} else {
|
|
return await models.$queryRawUnsafe(`
|
|
SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user,
|
|
COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats",
|
|
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark",
|
|
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward",
|
|
to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL)
|
|
|| jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub
|
|
FROM (
|
|
${query}
|
|
) "Item"
|
|
JOIN users ON "Item"."userId" = users.id
|
|
LEFT JOIN "Mute" ON "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId"
|
|
LEFT JOIN "Bookmark" b ON b."itemId" = "Item".id AND b."userId" = ${me.id}
|
|
LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."itemId" = "Item".id AND "ThreadSubscription"."userId" = ${me.id}
|
|
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = ${me.id}
|
|
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
|
LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}
|
|
LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id}
|
|
LEFT JOIN LATERAL (
|
|
SELECT "itemId",
|
|
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND (act = 'FEE' OR act = 'TIP')) AS "meMsats",
|
|
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM 'PENDING' AND (act = 'FEE' OR act = 'TIP') AND "Item"."userId" <> ${me.id}) AS "mePendingMsats",
|
|
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND act = 'DONT_LIKE_THIS') AS "meDontLikeMsats"
|
|
FROM "ItemAct"
|
|
WHERE "ItemAct"."userId" = ${me.id}
|
|
AND "ItemAct"."itemId" = "Item".id
|
|
GROUP BY "ItemAct"."itemId"
|
|
) "ItemAct" ON true
|
|
${orderBy}`, ...args)
|
|
}
|
|
}
|
|
|
|
const relationClause = (type) => {
|
|
let clause = ''
|
|
switch (type) {
|
|
case 'comments':
|
|
clause += ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id LEFT JOIN "Sub" ON "Sub"."name" = root."subName" '
|
|
break
|
|
case 'bookmarks':
|
|
clause += ' FROM "Item" JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" LEFT JOIN "Item" root ON "Item"."rootId" = root.id LEFT JOIN "Sub" ON "Sub"."name" = COALESCE(root."subName", "Item"."subName") '
|
|
break
|
|
case 'outlawed':
|
|
case 'borderland':
|
|
case 'freebies':
|
|
case 'all':
|
|
clause += ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id LEFT JOIN "Sub" ON "Sub"."name" = COALESCE(root."subName", "Item"."subName") '
|
|
break
|
|
default:
|
|
clause += ' FROM "Item" LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" '
|
|
}
|
|
|
|
return clause
|
|
}
|
|
|
|
const selectClause = (type) => type === 'bookmarks'
|
|
? `${SELECT}, "Bookmark"."created_at" as "bookmarkCreatedAt"`
|
|
: SELECT
|
|
|
|
const subClauseTable = (type) => COMMENT_TYPE_QUERY.includes(type) ? 'root' : 'Item'
|
|
|
|
export const whereClause = (...clauses) => {
|
|
const clause = clauses.flat(Infinity).filter(c => c).join(' AND ')
|
|
return clause ? ` WHERE ${clause} ` : ''
|
|
}
|
|
|
|
function whenClause (when, table) {
|
|
return `"${table}".created_at <= $2 and "${table}".created_at >= $1`
|
|
}
|
|
|
|
export const activeOrMine = (me) => {
|
|
return me
|
|
? [`("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id})`,
|
|
`("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id})`]
|
|
: ['("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = \'PAID\')', '"Item".status <> \'STOPPED\'']
|
|
}
|
|
|
|
export const muteClause = me =>
|
|
me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : ''
|
|
|
|
const HIDE_NSFW_CLAUSE = '("Sub"."nsfw" = FALSE OR "Sub"."nsfw" IS NULL)'
|
|
|
|
export const nsfwClause = showNsfw => showNsfw ? '' : HIDE_NSFW_CLAUSE
|
|
|
|
const subClause = (sub, num, table = 'Item', me, showNsfw) => {
|
|
// Intentionally show nsfw posts (i.e. no nsfw clause) when viewing a specific nsfw sub
|
|
if (sub) {
|
|
const tables = [...new Set(['Item', table])].map(t => `"${t}".`)
|
|
return `(${tables.map(t => `${t}"subName" = $${num}::CITEXT`).join(' OR ')})`
|
|
}
|
|
|
|
if (!me) { return HIDE_NSFW_CLAUSE }
|
|
|
|
const excludeMuted = `NOT EXISTS (SELECT 1 FROM "MuteSub" WHERE "MuteSub"."userId" = ${me.id} AND "MuteSub"."subName" = ${table ? `"${table}".` : ''}"subName")`
|
|
if (showNsfw) return excludeMuted
|
|
|
|
return excludeMuted + ' AND ' + HIDE_NSFW_CLAUSE
|
|
}
|
|
|
|
function investmentClause (sats) {
|
|
return `(
|
|
CASE WHEN "Item"."parentId" IS NULL
|
|
THEN ("Item".cost + "Item".boost + ("Item".msats / 1000)) >= ${sats}
|
|
ELSE ("Item".cost + "Item".boost + ("Item".msats / 1000)) >= ${Math.min(sats, 1)}
|
|
END
|
|
)`
|
|
}
|
|
|
|
export async function filterClause (me, models, type) {
|
|
// if you are explicitly asking for marginal content, don't filter them
|
|
if (['outlawed', 'borderland', 'freebies'].includes(type)) {
|
|
if (me && ['outlawed', 'borderland'].includes(type)) {
|
|
// unless the item is mine
|
|
return `"Item"."userId" <> ${me.id}`
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
// handle freebies
|
|
// by default don't include freebies unless they have upvotes
|
|
let satsFilter = investmentClause(10)
|
|
if (me) {
|
|
const user = await models.user.findUnique({ where: { id: me.id } })
|
|
|
|
satsFilter = `(${investmentClause(user.satsFilter)} OR "Item"."userId" = ${me.id})`
|
|
|
|
if (user.wildWestMode) {
|
|
return satsFilter
|
|
}
|
|
}
|
|
|
|
// handle outlawed
|
|
// if the item is above the threshold or is mine
|
|
const outlawClauses = [`"Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD} AND NOT "Item".outlawed`]
|
|
if (me) {
|
|
outlawClauses.push(`"Item"."userId" = ${me.id}`)
|
|
}
|
|
const outlawClause = '(' + outlawClauses.join(' OR ') + ')'
|
|
|
|
return [satsFilter, outlawClause]
|
|
}
|
|
|
|
function typeClause (type) {
|
|
switch (type) {
|
|
case 'links':
|
|
return ['"Item".url IS NOT NULL', '"Item"."parentId" IS NULL']
|
|
case 'discussions':
|
|
return ['"Item".url IS NULL', '"Item".bio = false', '"Item"."pollCost" IS NULL', '"Item"."parentId" IS NULL']
|
|
case 'polls':
|
|
return ['"Item"."pollCost" IS NOT NULL', '"Item"."parentId" IS NULL']
|
|
case 'bios':
|
|
return ['"Item".bio = true', '"Item"."parentId" IS NULL']
|
|
case 'bounties':
|
|
return ['"Item".bounty IS NOT NULL', '"Item"."parentId" IS NULL']
|
|
case 'comments':
|
|
return '"Item"."parentId" IS NOT NULL'
|
|
case 'freebies':
|
|
return '"Item".cost = 0'
|
|
case 'outlawed':
|
|
return `"Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} OR "Item".outlawed`
|
|
case 'borderland':
|
|
return '"Item"."weightedVotes" - "Item"."weightedDownVotes" < 0'
|
|
case 'all':
|
|
case 'bookmarks':
|
|
return ''
|
|
case 'jobs':
|
|
return '"Item"."subName" = \'jobs\''
|
|
default:
|
|
return '"Item"."parentId" IS NULL'
|
|
}
|
|
}
|
|
|
|
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.$queryRawUnsafe(`SELECT item_spam($1::INTEGER, $2::INTEGER, '${ITEM_SPAM_INTERVAL}')`,
|
|
Number(parentId), Number(me.id))
|
|
|
|
return count
|
|
},
|
|
items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => {
|
|
const decodedCursor = decodeCursor(cursor)
|
|
let items, user, pins, subFull, table, ad
|
|
|
|
// special authorization for bookmarks depending on owning users' privacy settings
|
|
if (type === 'bookmarks' && name && me?.name !== name) {
|
|
// the calling user is either not logged in, or not the user upon which the query is made,
|
|
// so we need to check authz
|
|
user = await models.user.findUnique({ where: { name } })
|
|
// additionally check if the user ids are not the same since if the nym changed
|
|
// since the last session update we would hide bookmarks from their owners
|
|
// see https://github.com/stackernews/stacker.news/issues/586
|
|
if (user?.hideBookmarks && user.id !== me.id) {
|
|
// early return with no results if bookmarks are hidden
|
|
return {
|
|
cursor: null,
|
|
items: [],
|
|
pins: []
|
|
}
|
|
}
|
|
}
|
|
|
|
// HACK we want to optionally include the subName in the query
|
|
// but the query planner doesn't like unused parameters
|
|
const subArr = sub ? [sub] : []
|
|
|
|
const currentUser = me ? await models.user.findUnique({ where: { id: me.id } }) : null
|
|
const showNsfw = currentUser ? currentUser.nsfwMode : false
|
|
|
|
switch (sort) {
|
|
case 'user':
|
|
if (!name) {
|
|
throw new GqlInputError('must supply name')
|
|
}
|
|
|
|
user ??= await models.user.findUnique({ where: { name } })
|
|
if (!user) {
|
|
throw new GqlInputError('no user has that name')
|
|
}
|
|
|
|
table = type === 'bookmarks' ? 'Bookmark' : 'Item'
|
|
items = await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${selectClause(type)}
|
|
${relationClause(type)}
|
|
${whereClause(
|
|
`"${table}"."userId" = $3`,
|
|
activeOrMine(me),
|
|
nsfwClause(showNsfw),
|
|
typeClause(type),
|
|
by === 'boost' && '"Item".boost > 0',
|
|
whenClause(when || 'forever', table))}
|
|
${orderByClause(by, me, models, type)}
|
|
OFFSET $4
|
|
LIMIT $5`,
|
|
orderBy: orderByClause(by, me, models, type)
|
|
}, ...whenRange(when, from, to || decodedCursor.time), user.id, decodedCursor.offset, limit)
|
|
break
|
|
case 'recent':
|
|
items = await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${SELECT}
|
|
${relationClause(type)}
|
|
${whereClause(
|
|
'"Item".created_at <= $1',
|
|
'"Item"."deletedAt" IS NULL',
|
|
subClause(sub, 4, subClauseTable(type), me, showNsfw),
|
|
activeOrMine(me),
|
|
await filterClause(me, models, type),
|
|
typeClause(type),
|
|
muteClause(me)
|
|
)}
|
|
ORDER BY "Item".created_at DESC
|
|
OFFSET $2
|
|
LIMIT $3`,
|
|
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
|
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
|
break
|
|
case 'top':
|
|
items = await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${selectClause(type)}
|
|
${relationClause(type)}
|
|
${whereClause(
|
|
'"Item"."deletedAt" IS NULL',
|
|
type === 'posts' && '"Item"."subName" IS NOT NULL',
|
|
subClause(sub, 5, subClauseTable(type), me, showNsfw),
|
|
typeClause(type),
|
|
whenClause(when, 'Item'),
|
|
await filterClause(me, models, type),
|
|
by === 'boost' && '"Item".boost > 0',
|
|
muteClause(me))}
|
|
${orderByClause(by || 'zaprank', me, models, type)}
|
|
OFFSET $3
|
|
LIMIT $4`,
|
|
orderBy: orderByClause(by || 'zaprank', me, models, type)
|
|
}, ...whenRange(when, from, to || decodedCursor.time), decodedCursor.offset, limit, ...subArr)
|
|
break
|
|
case 'random':
|
|
items = await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${selectClause(type)}
|
|
${relationClause(type)}
|
|
${whereClause(
|
|
'"Item"."deletedAt" IS NULL',
|
|
'"Item"."weightedVotes" - "Item"."weightedDownVotes" > 2',
|
|
'"Item"."ncomments" > 0',
|
|
'"Item"."parentId" IS NULL',
|
|
'"Item".bio = false',
|
|
type === 'posts' && '"Item"."subName" IS NOT NULL',
|
|
subClause(sub, 3, subClauseTable(type), me, showNsfw),
|
|
typeClause(type),
|
|
await filterClause(me, models, type),
|
|
activeOrMine(me),
|
|
muteClause(me))}
|
|
${orderByClause('random', me, models, type)}
|
|
OFFSET $1
|
|
LIMIT $2`,
|
|
orderBy: orderByClause('random', me, models, type)
|
|
}, decodedCursor.offset, limit, ...subArr)
|
|
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 itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${SELECT},
|
|
(boost IS NOT NULL AND boost > 0)::INT AS group_rank,
|
|
CASE WHEN boost IS NOT NULL AND boost > 0
|
|
THEN rank() OVER (ORDER BY boost DESC, created_at ASC)
|
|
ELSE rank() OVER (ORDER BY created_at DESC) END AS rank
|
|
FROM "Item"
|
|
${whereClause(
|
|
'"parentId" IS NULL',
|
|
'"Item"."deletedAt" IS NULL',
|
|
'"Item"."status" = \'ACTIVE\'',
|
|
'created_at <= $1',
|
|
'"pinId" IS NULL',
|
|
subClause(sub, 4)
|
|
)}
|
|
ORDER BY group_rank DESC, rank
|
|
OFFSET $2
|
|
LIMIT $3`,
|
|
orderBy: 'ORDER BY group_rank DESC, rank'
|
|
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
|
break
|
|
default:
|
|
if (decodedCursor.offset === 0) {
|
|
// get pins for the page and return those separately
|
|
pins = await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
SELECT rank_filter.*
|
|
FROM (
|
|
${SELECT}, position,
|
|
rank() OVER (
|
|
PARTITION BY "pinId"
|
|
ORDER BY "Item".created_at DESC
|
|
)
|
|
FROM "Item"
|
|
JOIN "Pin" ON "Item"."pinId" = "Pin".id
|
|
${whereClause(
|
|
'"pinId" IS NOT NULL',
|
|
'"parentId" IS NULL',
|
|
sub ? '"subName" = $1' : '"subName" IS NULL',
|
|
muteClause(me))}
|
|
) rank_filter WHERE RANK = 1
|
|
ORDER BY position ASC`,
|
|
orderBy: 'ORDER BY position ASC'
|
|
}, ...subArr)
|
|
|
|
ad = await getAd(parent, { sub, subArr, showNsfw }, { me, models })
|
|
}
|
|
|
|
items = await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank
|
|
FROM "Item"
|
|
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
|
${joinZapRankPersonalView(me, models)}
|
|
${whereClause(
|
|
// in "home" (sub undefined), we want to show pinned items (but without the pin icon)
|
|
sub ? '"Item"."pinId" IS NULL' : '',
|
|
'"Item"."deletedAt" IS NULL',
|
|
'"Item"."parentId" IS NULL',
|
|
'"Item".outlawed = false',
|
|
'"Item".bio = false',
|
|
ad ? `"Item".id <> ${ad.id}` : '',
|
|
activeOrMine(me),
|
|
await filterClause(me, models, type),
|
|
subClause(sub, 3, 'Item', me, showNsfw),
|
|
muteClause(me))}
|
|
ORDER BY rank DESC
|
|
OFFSET $1
|
|
LIMIT $2`,
|
|
orderBy: 'ORDER BY rank DESC'
|
|
}, decodedCursor.offset, limit, ...subArr)
|
|
|
|
// XXX this is mostly for subs that are really empty
|
|
if (items.length < limit) {
|
|
items = await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${SELECT}
|
|
FROM "Item"
|
|
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
|
${whereClause(
|
|
subClause(sub, 3, 'Item', me, showNsfw),
|
|
muteClause(me),
|
|
// in "home" (sub undefined), we want to show pinned items (but without the pin icon)
|
|
sub ? '"Item"."pinId" IS NULL' : '',
|
|
'"Item"."deletedAt" IS NULL',
|
|
'"Item"."parentId" IS NULL',
|
|
'"Item".bio = false',
|
|
ad ? `"Item".id <> ${ad.id}` : '',
|
|
activeOrMine(me),
|
|
await filterClause(me, models, type))}
|
|
ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
|
|
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC
|
|
OFFSET $1
|
|
LIMIT $2`,
|
|
orderBy: `ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
|
|
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
|
}, decodedCursor.offset, limit, ...subArr)
|
|
}
|
|
break
|
|
}
|
|
break
|
|
}
|
|
return {
|
|
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
|
|
items,
|
|
pins,
|
|
ad
|
|
}
|
|
},
|
|
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, publicationDate: publicationDateRuleSet })
|
|
const dateHint = ` (${metadata.publicationDate?.getFullYear()})`
|
|
const moreThanOneYearAgo = metadata.publicationDate && metadata.publicationDate < datePivot(new Date(), { years: -1 })
|
|
|
|
res.title = metadata?.title
|
|
if (moreThanOneYearAgo) res.title += dateHint
|
|
} catch { }
|
|
|
|
try {
|
|
const unshorted = await uu().expand(url)
|
|
if (unshorted) {
|
|
res.unshorted = unshorted
|
|
}
|
|
} catch { }
|
|
|
|
return res
|
|
},
|
|
dupes: async (parent, { url }, { me, models }) => {
|
|
const urlObj = new URL(ensureProtocol(url))
|
|
let { hostname, pathname } = urlObj
|
|
|
|
// remove subdomain from hostname
|
|
const parseResult = parse(urlObj.hostname)
|
|
if (parseResult?.subdomain?.length > 0) {
|
|
hostname = hostname.replace(`${parseResult.subdomain}.`, '')
|
|
}
|
|
// hostname with optional protocol, subdomain, and port
|
|
const hostnameRegex = `^(http(s)?:\\/\\/)?(\\w+\\.)?${(hostname + '(:[0-9]+)?').replace(/\./g, '\\.')}`
|
|
// pathname with trailing slash and escaped special characters
|
|
const pathnameRegex = stripTrailingSlash(pathname).replace(/(\+|\.|\/)/g, '\\$1') + '\\/?'
|
|
// url with optional trailing slash
|
|
let similar = hostnameRegex + pathnameRegex
|
|
|
|
const whitelist = ['news.ycombinator.com/item', 'bitcointalk.org/index.php']
|
|
const youtube = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'youtu.be']
|
|
|
|
const hostAndPath = stripTrailingSlash(urlObj.hostname + urlObj.pathname)
|
|
if (whitelist.includes(hostAndPath)) {
|
|
// make query string match for whitelist domains
|
|
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 if (urlObj.hostname === 'yewtu.be') {
|
|
const matches = url.match(/(https?:\/\/)?yewtu\.be.*(v=|embed\/)(?<id>[_0-9a-z-]+)/i)
|
|
similar = `^(http(s)?:\\/\\/)?yewtu\\.be\\/(watch\\?v\\=|embed\\/)${matches?.groups?.id}&?`
|
|
}
|
|
|
|
return await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${SELECT}
|
|
FROM "Item"
|
|
WHERE url ~* $1
|
|
ORDER BY created_at DESC
|
|
LIMIT 3`
|
|
}, similar)
|
|
},
|
|
auctionPosition: async (parent, { id, sub, boost }, { models, me }) => {
|
|
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
|
|
let where
|
|
if (boost > 0) {
|
|
// if there's boost
|
|
// has a larger boost than ours, or has an equal boost and is older
|
|
// count items: (boost > ours.boost OR (boost = ours.boost AND create_at < ours.created_at))
|
|
where = {
|
|
OR: [
|
|
{ boost: { gt: boost } },
|
|
{ boost, 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: [
|
|
{ boost: { gt: 0 } },
|
|
{ createdAt: { gt: createdAt } }
|
|
]
|
|
}
|
|
}
|
|
|
|
where.AND = {
|
|
subName: sub,
|
|
status: 'ACTIVE',
|
|
deletedAt: null
|
|
}
|
|
if (id) {
|
|
where.AND.id = { not: Number(id) }
|
|
}
|
|
|
|
return await models.item.count({ where }) + 1
|
|
},
|
|
boostPosition: async (parent, { id, sub, boost = 0 }, { models, me }) => {
|
|
const where = {
|
|
boost: { gte: boost },
|
|
status: 'ACTIVE',
|
|
deletedAt: null,
|
|
outlawed: false,
|
|
parentId: null
|
|
}
|
|
if (id) {
|
|
where.id = { not: Number(id) }
|
|
}
|
|
|
|
const homeAgg = await models.item.aggregate({
|
|
_count: { id: true },
|
|
_max: { boost: true },
|
|
where
|
|
})
|
|
|
|
let subAgg
|
|
if (sub) {
|
|
subAgg = await models.item.aggregate({
|
|
_count: { id: true },
|
|
_max: { boost: true },
|
|
where: {
|
|
...where,
|
|
subName: sub
|
|
}
|
|
})
|
|
}
|
|
|
|
return {
|
|
home: homeAgg._count.id === 0 && boost >= BOOST_MULT,
|
|
sub: subAgg?._count.id === 0 && boost >= BOOST_MULT,
|
|
homeMaxBoost: homeAgg._max.boost || 0,
|
|
subMaxBoost: subAgg?._max.boost || 0
|
|
}
|
|
}
|
|
},
|
|
|
|
Mutation: {
|
|
bookmarkItem: async (parent, { id }, { me, models }) => {
|
|
const data = { itemId: Number(id), userId: me.id }
|
|
const old = await models.bookmark.findUnique({ where: { userId_itemId: data } })
|
|
if (old) {
|
|
await models.bookmark.delete({ where: { userId_itemId: data } })
|
|
} else await models.bookmark.create({ data })
|
|
return { id }
|
|
},
|
|
pinItem: async (parent, { id }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
const [item] = await models.$queryRawUnsafe(
|
|
`${SELECT}, p.position
|
|
FROM "Item" LEFT JOIN "Pin" p ON p.id = "Item"."pinId"
|
|
WHERE "Item".id = $1`, Number(id))
|
|
|
|
const args = []
|
|
if (item.parentId) {
|
|
args.push(item.parentId)
|
|
|
|
// OPs can only pin top level replies
|
|
if (item.path.split('.').length > 2) {
|
|
throw new GqlInputError('can only pin root replies')
|
|
}
|
|
|
|
const root = await models.item.findUnique({
|
|
where: {
|
|
id: Number(item.parentId)
|
|
},
|
|
include: { pin: true }
|
|
})
|
|
|
|
if (root.userId !== Number(me.id)) {
|
|
throw new GqlInputError('not your post')
|
|
}
|
|
} else if (item.subName) {
|
|
args.push(item.subName)
|
|
|
|
// only territory founder can pin posts
|
|
const sub = await models.sub.findUnique({ where: { name: item.subName } })
|
|
if (Number(me.id) !== sub.userId) {
|
|
throw new GqlInputError('not your sub')
|
|
}
|
|
} else {
|
|
throw new GqlInputError('item must have subName or parentId')
|
|
}
|
|
|
|
let pinId
|
|
if (item.pinId) {
|
|
// item is already pinned. remove pin
|
|
await models.$transaction([
|
|
models.item.update({ where: { id: item.id }, data: { pinId: null } }),
|
|
models.pin.delete({ where: { id: item.pinId } }),
|
|
// make sure that pins have no gaps
|
|
models.$queryRawUnsafe(`
|
|
UPDATE "Pin"
|
|
SET position = position - 1
|
|
WHERE position > $2 AND id IN (
|
|
SELECT "pinId" FROM "Item" i
|
|
${whereClause('"pinId" IS NOT NULL', item.subName ? 'i."subName" = $1' : 'i."parentId" = $1')}
|
|
)`, ...args, item.position)
|
|
])
|
|
|
|
pinId = null
|
|
} else {
|
|
// only max 3 pins allowed per territory and post
|
|
const [{ count: npins }] = await models.$queryRawUnsafe(`
|
|
SELECT COUNT(p.id) FROM "Pin" p
|
|
JOIN "Item" i ON i."pinId" = p.id
|
|
${
|
|
whereClause(item.subName ? 'i."subName" = $1' : 'i."parentId" = $1')
|
|
}`, ...args)
|
|
|
|
if (npins >= 3) {
|
|
throw new GqlInputError('max 3 pins allowed')
|
|
}
|
|
|
|
const [{ pinId: newPinId }] = await models.$queryRawUnsafe(`
|
|
WITH pin AS (
|
|
INSERT INTO "Pin" (position)
|
|
SELECT COALESCE(MAX(p.position), 0) + 1 AS position
|
|
FROM "Pin" p
|
|
JOIN "Item" i ON i."pinId" = p.id
|
|
${whereClause(item.subName ? 'i."subName" = $1' : 'i."parentId" = $1')}
|
|
RETURNING id
|
|
)
|
|
UPDATE "Item"
|
|
SET "pinId" = pin.id
|
|
FROM pin
|
|
WHERE "Item".id = $2
|
|
RETURNING "pinId"`, ...args, item.id)
|
|
|
|
pinId = newPinId
|
|
}
|
|
|
|
return { id, pinId }
|
|
},
|
|
subscribeItem: async (parent, { id }, { me, models }) => {
|
|
const data = { itemId: Number(id), userId: me.id }
|
|
const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } })
|
|
if (old) {
|
|
await models.threadSubscription.delete({ where: { userId_itemId: data } })
|
|
} else await models.threadSubscription.create({ data })
|
|
return { id }
|
|
},
|
|
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 GqlInputError('item does not belong to you')
|
|
}
|
|
if (old.bio) {
|
|
throw new GqlInputError('cannot delete bio')
|
|
}
|
|
|
|
return await deleteItemByAuthor({ models, id, item: old })
|
|
},
|
|
upsertLink: async (parent, { id, ...item }, { me, models, lnd }) => {
|
|
await validateSchema(linkSchema, item, { models, me })
|
|
|
|
if (id) {
|
|
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
|
} else {
|
|
return await createItem(parent, item, { me, models, lnd })
|
|
}
|
|
},
|
|
upsertDiscussion: async (parent, { id, ...item }, { me, models, lnd }) => {
|
|
await validateSchema(discussionSchema, item, { models, me })
|
|
|
|
if (id) {
|
|
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
|
} else {
|
|
return await createItem(parent, item, { me, models, lnd })
|
|
}
|
|
},
|
|
upsertBounty: async (parent, { id, ...item }, { me, models, lnd }) => {
|
|
await validateSchema(bountySchema, item, { models, me })
|
|
|
|
if (id) {
|
|
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
|
} else {
|
|
return await createItem(parent, item, { me, models, lnd })
|
|
}
|
|
},
|
|
upsertPoll: async (parent, { id, ...item }, { me, models, lnd }) => {
|
|
const numExistingChoices = id
|
|
? await models.pollOption.count({
|
|
where: {
|
|
itemId: Number(id)
|
|
}
|
|
})
|
|
: 0
|
|
|
|
await validateSchema(pollSchema, item, { models, me, numExistingChoices })
|
|
|
|
if (id) {
|
|
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
|
} else {
|
|
item.pollCost = item.pollCost || POLL_COST
|
|
return await createItem(parent, item, { me, models, lnd })
|
|
}
|
|
},
|
|
upsertJob: async (parent, { id, ...item }, { me, models, lnd }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
item.location = item.location?.toLowerCase() === 'remote' ? undefined : item.location
|
|
await validateSchema(jobSchema, item, { models })
|
|
if (item.logo !== undefined) {
|
|
item.uploadId = item.logo
|
|
delete item.logo
|
|
}
|
|
|
|
if (id) {
|
|
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
|
} else {
|
|
return await createItem(parent, item, { me, models, lnd })
|
|
}
|
|
},
|
|
upsertComment: async (parent, { id, ...item }, { me, models, lnd }) => {
|
|
await validateSchema(commentSchema, item)
|
|
|
|
if (id) {
|
|
return await updateItem(parent, { id, ...item }, { me, models, lnd })
|
|
} else {
|
|
item = await createItem(parent, item, { me, models, lnd })
|
|
return item
|
|
}
|
|
},
|
|
updateNoteId: async (parent, { id, noteId }, { me, models }) => {
|
|
if (!id) {
|
|
throw new GqlInputError('id required')
|
|
}
|
|
|
|
await models.item.update({
|
|
where: { id: Number(id), userId: Number(me.id) },
|
|
data: { noteId }
|
|
})
|
|
|
|
return { id, noteId }
|
|
},
|
|
pollVote: async (parent, { id }, { me, models, lnd }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
return await performPaidAction('POLL_VOTE', { id }, { me, models, lnd })
|
|
},
|
|
act: async (parent, { id, sats, act = 'TIP' }, { me, models, lnd, headers }) => {
|
|
assertApiKeyNotPermitted({ me })
|
|
await validateSchema(actSchema, { sats, act })
|
|
await assertGofacYourself({ models, headers })
|
|
|
|
const [item] = await models.$queryRawUnsafe(`
|
|
${SELECT}
|
|
FROM "Item"
|
|
WHERE id = $1`, Number(id))
|
|
|
|
if (item.deletedAt) {
|
|
throw new GqlInputError('item is deleted')
|
|
}
|
|
|
|
if (item.invoiceActionState && item.invoiceActionState !== 'PAID') {
|
|
throw new GqlInputError('cannot act on unpaid item')
|
|
}
|
|
|
|
// disallow self tips except anons
|
|
if (me && ['TIP', 'DONT_LIKE_THIS'].includes(act)) {
|
|
if (Number(item.userId) === Number(me.id)) {
|
|
throw new GqlInputError('cannot zap yourself')
|
|
}
|
|
|
|
// Disallow tips if me is one of the forward user recipients
|
|
if (act === 'TIP') {
|
|
const existingForwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
|
|
if (existingForwards.some(fwd => Number(fwd.userId) === Number(me.id))) {
|
|
throw new GqlInputError('cannot zap a post for which you are forwarded zaps')
|
|
}
|
|
}
|
|
}
|
|
|
|
if (act === 'TIP') {
|
|
return await performPaidAction('ZAP', { id, sats }, { me, models, lnd })
|
|
} else if (act === 'DONT_LIKE_THIS') {
|
|
return await performPaidAction('DOWN_ZAP', { id, sats }, { me, models, lnd })
|
|
} else if (act === 'BOOST') {
|
|
return await performPaidAction('BOOST', { id, sats }, { me, models, lnd })
|
|
} else {
|
|
throw new GqlInputError('unknown act')
|
|
}
|
|
},
|
|
toggleOutlaw: async (parent, { id }, { me, models }) => {
|
|
if (!me) {
|
|
throw new GqlAuthenticationError()
|
|
}
|
|
|
|
const item = await models.item.findUnique({
|
|
where: { id: Number(id) },
|
|
include: {
|
|
sub: true,
|
|
root: {
|
|
include: {
|
|
sub: true
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const sub = item.sub || item.root?.sub
|
|
|
|
if (Number(sub.userId) !== Number(me.id)) {
|
|
throw new GqlInputError('you cant do this broh')
|
|
}
|
|
|
|
if (item.outlawed) {
|
|
return item
|
|
}
|
|
|
|
const [result] = await models.$transaction(
|
|
[
|
|
models.item.update({
|
|
where: {
|
|
id: Number(id)
|
|
},
|
|
data: {
|
|
outlawed: true
|
|
}
|
|
}),
|
|
models.sub.update({
|
|
where: {
|
|
name: sub.name
|
|
},
|
|
data: {
|
|
moderatedCount: {
|
|
increment: 1
|
|
}
|
|
}
|
|
})
|
|
])
|
|
|
|
return result
|
|
}
|
|
},
|
|
ItemAct: {
|
|
invoice: async (itemAct, args, { models }) => {
|
|
if (itemAct.invoiceId) {
|
|
return {
|
|
id: itemAct.invoiceId,
|
|
actionState: itemAct.invoiceActionState
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
},
|
|
Item: {
|
|
sats: async (item, args, { models }) => {
|
|
return msatsToSats(BigInt(item.msats) + BigInt(item.mePendingMsats || 0))
|
|
},
|
|
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 && !item.root) {
|
|
return null
|
|
}
|
|
|
|
if (item.sub) {
|
|
return item.sub
|
|
}
|
|
|
|
return await models.sub.findUnique({ where: { name: item.subName || item.root?.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".id)
|
|
FILTER(WHERE "PollVote"."invoiceActionState" IS NULL
|
|
OR "PollVote"."invoiceActionState" = 'PAID'))::INTEGER as count
|
|
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 = {}
|
|
if (me) {
|
|
const meVoted = await models.pollBlindVote.findFirst({
|
|
where: {
|
|
userId: me.id,
|
|
itemId: item.id
|
|
}
|
|
})
|
|
poll.meVoted = !!meVoted
|
|
poll.meInvoiceId = meVoted?.invoiceId
|
|
poll.meInvoiceActionState = meVoted?.invoiceActionState
|
|
} else {
|
|
poll.meVoted = false
|
|
}
|
|
|
|
poll.options = options
|
|
poll.count = options.reduce((t, o) => t + o.count, 0)
|
|
|
|
return poll
|
|
},
|
|
user: async (item, args, { models }) => {
|
|
if (item.user) {
|
|
return item.user
|
|
}
|
|
return await models.user.findUnique({ where: { id: item.userId } })
|
|
},
|
|
forwards: async (item, args, { models }) => {
|
|
return await models.itemForward.findMany({
|
|
where: {
|
|
itemId: item.id
|
|
},
|
|
include: {
|
|
user: true
|
|
}
|
|
})
|
|
},
|
|
comments: async (item, { sort }, { me, models }) => {
|
|
if (typeof item.comments !== 'undefined') return item.comments
|
|
if (item.ncomments === 0) return []
|
|
|
|
return comments(me, models, item.id, sort || defaultCommentSort(item.pinId, item.bioId, item.createdAt))
|
|
},
|
|
freedFreebie: async (item) => {
|
|
return item.weightedVotes - item.weightedDownVotes > 0
|
|
},
|
|
freebie: async (item) => {
|
|
return item.cost === 0 && item.boost === 0
|
|
},
|
|
meSats: async (item, args, { me, models }) => {
|
|
if (!me) return 0
|
|
if (typeof item.meMsats !== 'undefined') {
|
|
return msatsToSats(item.meMsats)
|
|
}
|
|
|
|
const { _sum: { msats } } = await models.itemAct.aggregate({
|
|
_sum: {
|
|
msats: true
|
|
},
|
|
where: {
|
|
itemId: Number(item.id),
|
|
userId: me.id,
|
|
invoiceActionState: {
|
|
not: 'FAILED'
|
|
},
|
|
OR: [
|
|
{
|
|
act: 'TIP'
|
|
},
|
|
{
|
|
act: 'FEE'
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
return (msats && msatsToSats(msats)) || 0
|
|
},
|
|
meDontLikeSats: async (item, args, { me, models }) => {
|
|
if (!me) return 0
|
|
if (typeof item.meDontLikeMsats !== 'undefined') {
|
|
return msatsToSats(item.meDontLikeMsats)
|
|
}
|
|
|
|
const { _sum: { msats } } = await models.itemAct.aggregate({
|
|
_sum: {
|
|
msats: true
|
|
},
|
|
where: {
|
|
itemId: Number(item.id),
|
|
userId: me.id,
|
|
act: 'DONT_LIKE_THIS',
|
|
invoiceActionState: {
|
|
not: 'FAILED'
|
|
}
|
|
}
|
|
})
|
|
|
|
return (msats && msatsToSats(msats)) || 0
|
|
},
|
|
meBookmark: async (item, args, { me, models }) => {
|
|
if (!me) return false
|
|
if (typeof item.meBookmark !== 'undefined') return item.meBookmark
|
|
|
|
const bookmark = await models.bookmark.findUnique({
|
|
where: {
|
|
userId_itemId: {
|
|
itemId: Number(item.id),
|
|
userId: me.id
|
|
}
|
|
}
|
|
})
|
|
|
|
return !!bookmark
|
|
},
|
|
meSubscription: async (item, args, { me, models }) => {
|
|
if (!me) return false
|
|
if (typeof item.meSubscription !== 'undefined') return item.meSubscription
|
|
|
|
const subscription = await models.threadSubscription.findUnique({
|
|
where: {
|
|
userId_itemId: {
|
|
itemId: Number(item.id),
|
|
userId: me.id
|
|
}
|
|
}
|
|
})
|
|
|
|
return !!subscription
|
|
},
|
|
outlawed: async (item, args, { me, models }) => {
|
|
if (me && Number(item.userId) === Number(me.id)) {
|
|
return false
|
|
}
|
|
return item.outlawed || item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD
|
|
},
|
|
rel: async (item, args, { me, models }) => {
|
|
const sats = item.msats ? msatsToSats(item.msats) : 0
|
|
const boost = item.boost ?? 0
|
|
return (sats + boost < NOFOLLOW_LIMIT) ? UNKNOWN_LINK_REL : 'noopener noreferrer'
|
|
},
|
|
mine: async (item, args, { me, models }) => {
|
|
return me?.id === item.userId
|
|
},
|
|
root: async (item, args, { models, me }) => {
|
|
if (!item.rootId) {
|
|
return null
|
|
}
|
|
if (item.root) {
|
|
return item.root
|
|
}
|
|
|
|
// we can't use getItem because activeOrMine will prevent root from being fetched
|
|
const [root] = await itemQueryWithMeta({
|
|
me,
|
|
models,
|
|
query: `
|
|
${SELECT}
|
|
FROM "Item"
|
|
${whereClause(
|
|
'"Item".id = $1',
|
|
`("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID'${me ? ` OR "Item"."userId" = ${me.id}` : ''})`
|
|
)}`
|
|
}, Number(item.rootId))
|
|
|
|
return root
|
|
},
|
|
invoice: async (item, args, { models }) => {
|
|
if (item.invoiceId) {
|
|
return {
|
|
id: item.invoiceId,
|
|
actionState: item.invoiceActionState,
|
|
confirmedAt: item.invoicePaidAtUTC ?? item.invoicePaidAt
|
|
}
|
|
}
|
|
|
|
return null
|
|
},
|
|
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
|
|
},
|
|
deleteScheduledAt: async (item, args, { me, models }) => {
|
|
const meId = me?.id ?? USER_ID.anon
|
|
if (meId !== item.userId) {
|
|
// Only query for deleteScheduledAt for your own items to keep DB queries minimized
|
|
return null
|
|
}
|
|
const deleteJobs = await models.$queryRaw`
|
|
SELECT startafter
|
|
FROM pgboss.job
|
|
WHERE name = 'deleteItem' AND data->>'id' = ${item.id}::TEXT
|
|
AND state = 'created'`
|
|
return deleteJobs[0]?.startafter ?? null
|
|
},
|
|
reminderScheduledAt: async (item, args, { me, models }) => {
|
|
const meId = me?.id ?? USER_ID.anon
|
|
if (meId !== item.userId || meId === USER_ID.anon) {
|
|
// don't show reminders on an item if it isn't yours
|
|
// don't support reminders for ANON
|
|
return null
|
|
}
|
|
const reminderJobs = await models.$queryRaw`
|
|
SELECT startafter
|
|
FROM pgboss.job
|
|
WHERE name = 'reminder'
|
|
AND data->>'itemId' = ${item.id}::TEXT
|
|
AND data->>'userId' = ${meId}::TEXT
|
|
AND state = 'created'`
|
|
return reminderJobs[0]?.startafter ?? null
|
|
}
|
|
}
|
|
}
|
|
|
|
export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ...item }, { me, models, lnd }) => {
|
|
// update iff this item belongs to me
|
|
const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { invoice: true, sub: true } })
|
|
|
|
if (old.deletedAt) {
|
|
throw new GqlInputError('item is deleted')
|
|
}
|
|
|
|
// author can edit their own item (except anon)
|
|
const meId = Number(me?.id ?? USER_ID.anon)
|
|
const authorEdit = !!me && Number(old.userId) === meId
|
|
// admins can edit special items
|
|
const adminEdit = ADMIN_ITEMS.includes(old.id) && SN_ADMIN_IDS.includes(meId)
|
|
// anybody can edit with valid hash+hmac
|
|
let hmacEdit = false
|
|
if (old.invoice?.hash && hash && hmac) {
|
|
hmacEdit = old.invoice.hash === hash && verifyHmac(hash, hmac)
|
|
}
|
|
|
|
// ownership permission check
|
|
if (!authorEdit && !adminEdit && !hmacEdit) {
|
|
throw new GqlInputError('item does not belong to you')
|
|
}
|
|
|
|
const differentSub = subName && old.subName !== subName
|
|
if (differentSub) {
|
|
const sub = await models.sub.findUnique({ where: { name: subName } })
|
|
if (sub.baseCost > old.sub.baseCost) {
|
|
throw new GqlInputError('cannot change to a more expensive sub')
|
|
}
|
|
}
|
|
|
|
// in case they lied about their existing boost
|
|
await validateSchema(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost })
|
|
|
|
const user = await models.user.findUnique({ where: { id: meId } })
|
|
|
|
// prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
|
|
const myBio = user.bioId === old.id
|
|
const timer = Date.now() < datePivot(new Date(old.invoicePaidAt ?? old.createdAt), { minutes: 10 })
|
|
|
|
// timer permission check
|
|
if (!adminEdit && !myBio && !timer && !isJob(item)) {
|
|
throw new GqlInputError('item can no longer be edited')
|
|
}
|
|
|
|
if (item.url && !isJob(item)) {
|
|
item.url = ensureProtocol(item.url)
|
|
item.url = removeTracking(item.url)
|
|
}
|
|
|
|
if (old.bio) {
|
|
// prevent editing a bio like a regular item
|
|
item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio` }
|
|
} else if (old.parentId) {
|
|
// prevent editing a comment like a post
|
|
item = { id: Number(item.id), text: item.text, boost: item.boost }
|
|
} else {
|
|
item = { subName, ...item }
|
|
item.forwardUsers = await getForwardUsers(models, forward)
|
|
}
|
|
item.uploadIds = uploadIdsFromText(item.text, { models })
|
|
|
|
// never change author of item
|
|
item.userId = old.userId
|
|
|
|
const resultItem = await performPaidAction('ITEM_UPDATE', item, { models, me, lnd })
|
|
|
|
resultItem.comments = []
|
|
return resultItem
|
|
}
|
|
|
|
export const createItem = async (parent, { forward, ...item }, { me, models, lnd }) => {
|
|
// rename to match column name
|
|
item.subName = item.sub
|
|
delete item.sub
|
|
|
|
item.userId = me ? Number(me.id) : USER_ID.anon
|
|
|
|
item.forwardUsers = await getForwardUsers(models, forward)
|
|
item.uploadIds = uploadIdsFromText(item.text, { models })
|
|
|
|
if (item.url && !isJob(item)) {
|
|
item.url = ensureProtocol(item.url)
|
|
item.url = removeTracking(item.url)
|
|
}
|
|
|
|
if (item.parentId) {
|
|
const parent = await models.item.findUnique({ where: { id: parseInt(item.parentId) } })
|
|
if (parent.invoiceActionState && parent.invoiceActionState !== 'PAID') {
|
|
throw new GqlInputError('cannot comment on unpaid item')
|
|
}
|
|
}
|
|
|
|
// mark item as created with API key
|
|
item.apiKey = me?.apiKey
|
|
|
|
const resultItem = await performPaidAction('ITEM_CREATE', item, { models, me, lnd })
|
|
|
|
resultItem.comments = []
|
|
return resultItem
|
|
}
|
|
|
|
export const getForwardUsers = async (models, forward) => {
|
|
const fwdUsers = []
|
|
if (forward) {
|
|
// find all users in one db query
|
|
const users = await models.user.findMany({ where: { OR: forward.map(fwd => ({ name: fwd.nym })) } })
|
|
// map users to fwdUser entries with id and pct
|
|
users.forEach(user => {
|
|
fwdUsers.push({
|
|
userId: user.id,
|
|
pct: forward.find(fwd => fwd.nym === user.name).pct
|
|
})
|
|
})
|
|
}
|
|
return fwdUsers
|
|
}
|
|
|
|
// we have to do our own query because ltree is unsupported
|
|
export const SELECT =
|
|
`SELECT "Item".*, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt",
|
|
ltree2text("Item"."path") AS "path"`
|
|
|
|
function topOrderByWeightedSats (me, models) {
|
|
return `ORDER BY ${orderByNumerator({ models })} DESC NULLS LAST, "Item".id DESC`
|
|
}
|