2024-07-01 17:02:29 +00:00
import { ensureProtocol , removeTracking , stripTrailingSlash } from '@/lib/url'
2024-03-20 00:37:31 +00:00
import { decodeCursor , LIMIT , nextCursorEncoded } from '@/lib/cursor'
2021-08-22 15:25:17 +00:00
import { getMetadata , metadataRuleSets } from 'page-metadata-parser'
2024-03-20 00:37:31 +00:00
import { ruleSet as publicationDateRuleSet } from '@/lib/timedate-scraper'
2021-08-22 15:25:17 +00:00
import domino from 'domino'
2022-09-21 19:57:36 +00:00
import {
2023-08-24 00:06:26 +00:00
ITEM _SPAM _INTERVAL , ITEM _FILTER _THRESHOLD ,
2023-12-26 22:51:47 +00:00
COMMENT _DEPTH _LIMIT , COMMENT _TYPE _QUERY ,
2024-09-19 18:13:14 +00:00
USER _ID , POLL _COST , ADMIN _ITEMS , GLOBAL _SEED ,
NOFOLLOW _LIMIT , UNKNOWN _LINK _REL , SN _ADMIN _IDS ,
BOOST _MULT
2024-03-20 00:37:31 +00:00
} from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
2023-01-11 22:20:14 +00:00
import { parse } from 'tldts'
2023-01-12 18:05:47 +00:00
import uu from 'url-unshort'
2024-10-25 19:10:37 +00:00
import { actSchema , advSchema , bountySchema , commentSchema , discussionSchema , jobSchema , linkSchema , pollSchema , validateSchema } from '@/lib/validate'
2024-07-01 17:02:29 +00:00
import { defaultCommentSort , isJob , deleteItemByAuthor } from '@/lib/item'
2024-03-20 00:37:31 +00:00
import { datePivot , whenRange } from '@/lib/time'
2024-09-13 14:26:08 +00:00
import { uploadIdsFromText } from './upload'
2023-12-14 17:30:51 +00:00
import assertGofacYourself from './ofac'
2024-04-03 20:11:06 +00:00
import assertApiKeyNotPermitted from './apiKey'
2024-07-01 17:02:29 +00:00
import performPaidAction from '../paidAction'
2024-09-10 16:35:25 +00:00
import { GqlAuthenticationError , GqlInputError } from '@/lib/error'
2024-09-13 15:11:19 +00:00
import { verifyHmac } from './wallet'
2023-07-23 15:08:43 +00:00
2023-09-14 15:46:59 +00:00
function commentsOrderByClause ( me , models , sort ) {
if ( sort === 'recent' ) {
2024-08-27 00:23:07 +00:00
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'
2021-12-21 21:29:42 +00:00
}
2024-05-30 14:21:55 +00:00
if ( me && sort === 'hot' ) {
2024-08-27 00:23:07 +00:00
return ` ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
2023-09-14 15:46:59 +00:00
personal _hot _score ,
2024-09-19 18:13:14 +00:00
$ { orderByNumerator ( { models , commentScaler : 0 , considerBoost : true } ) } / POWER ( GREATEST ( 3 , EXTRACT ( EPOCH FROM ( now _utc ( ) - "Item" . created _at ) ) / 3600 ) , 1.3 ) ) DESC NULLS LAST ,
2024-08-11 23:47:03 +00:00
"Item" . msats DESC , ( "Item" . cost > 0 ) DESC , "Item" . id DESC `
2023-09-14 15:46:59 +00:00
} else {
if ( sort === 'top' ) {
2024-09-19 18:13:14 +00:00
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 `
2023-09-14 15:46:59 +00:00
} else {
2024-09-19 18:13:14 +00:00
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 `
2023-09-14 15:46:59 +00:00
}
}
}
async function comments ( me , models , id , sort ) {
const orderBy = commentsOrderByClause ( me , models , sort )
2023-05-07 01:25:00 +00:00
if ( me ) {
2024-07-01 17:02:29 +00:00
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${ me . id } ) `
2023-09-14 15:46:59 +00:00
const [ { item _comments _zaprank _with _me : comments } ] = await models . $queryRawUnsafe (
2024-07-01 17:02:29 +00:00
'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 )
2023-05-07 01:25:00 +00:00
return comments
}
2024-07-01 17:02:29 +00:00
const filter = ' AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = \'PAID\') '
2023-07-26 16:01:31 +00:00
const [ { item _comments : comments } ] = await models . $queryRawUnsafe (
2023-07-27 00:18:42 +00:00
'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)' , Number ( id ) , COMMENT _DEPTH _LIMIT , filter , orderBy )
2023-05-06 21:51:17 +00:00
return comments
2021-04-29 15:56:28 +00:00
}
2022-09-21 19:57:36 +00:00
export async function getItem ( parent , { id } , { me , models } ) {
2023-05-08 20:06:42 +00:00
const [ item ] = await itemQueryWithMeta ( {
me ,
models ,
query : `
$ { SELECT }
FROM "Item"
2024-07-01 17:02:29 +00:00
$ { whereClause (
'"Item".id = $1' ,
activeOrMine ( me )
) } `
2023-05-08 20:06:42 +00:00
} , Number ( id ) )
2021-09-23 17:42:00 +00:00
return item
}
2024-09-19 18:13:14 +00:00
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
}
2023-09-14 15:46:59 +00:00
const orderByClause = ( by , me , models , type ) => {
2023-07-23 15:08:43 +00:00
switch ( by ) {
2022-10-25 21:35:32 +00:00
case 'comments' :
2023-07-23 15:08:43 +00:00
return 'ORDER BY "Item".ncomments DESC'
2022-10-25 21:35:32 +00:00
case 'sats' :
2023-07-23 15:08:43 +00:00
return 'ORDER BY "Item".msats DESC'
2023-09-11 23:11:47 +00:00
case 'zaprank' :
2023-09-14 15:46:59 +00:00
return topOrderByWeightedSats ( me , models )
2024-09-21 19:58:25 +00:00
case 'boost' :
return 'ORDER BY "Item".boost DESC'
2024-08-20 21:50:55 +00:00
case 'random' :
return 'ORDER BY RANDOM()'
2023-07-23 15:08:43 +00:00
default :
2023-07-29 23:27:32 +00:00
return ` ORDER BY ${ type === 'bookmarks' ? '"bookmarkCreatedAt"' : '"Item".created_at' } DESC `
2022-10-25 21:35:32 +00:00
}
}
2024-09-19 18:13:14 +00:00
export function orderByNumerator ( { models , commentScaler = 0.5 , considerBoost = false } ) {
2023-09-14 15:46:59 +00:00
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"
2024-09-19 18:13:14 +00:00
END + "Item" . "weightedComments" * $ { commentScaler } ) + $ { considerBoost ? ` ("Item".boost / ${ BOOST _MULT } ) ` : 0 } `
2022-09-21 19:57:36 +00:00
}
2023-09-14 15:46:59 +00:00
export function joinZapRankPersonalView ( me , models ) {
let join = ` JOIN zap_rank_personal_view g ON g.id = "Item".id AND g."viewerId" = ${ GLOBAL _SEED } `
2023-05-23 14:21:04 +00:00
if ( me ) {
2023-09-14 15:46:59 +00:00
join += ` LEFT JOIN zap_rank_personal_view l ON l.id = g.id AND l."viewerId" = ${ me . id } `
2023-05-23 14:21:04 +00:00
}
2023-09-14 15:46:59 +00:00
return join
2023-05-23 14:21:04 +00:00
}
2023-05-07 01:25:00 +00:00
// this grabs all the stuff we need to display the item list and only
2023-05-08 20:06:42 +00:00
// hits the db once ... orderBy needs to be duplicated on the outer query because
// joining does not preserve the order of the inner query
2024-01-17 23:39:39 +00:00
export async function itemQueryWithMeta ( { me , models , query , orderBy = '' } , ... args ) {
2023-05-07 01:25:00 +00:00
if ( ! me ) {
2023-07-26 16:01:31 +00:00
return await models . $queryRawUnsafe ( `
2023-12-23 20:26:16 +00:00
SELECT "Item" . * , to _json ( users . * ) as user , to _jsonb ( "Sub" . * ) as sub
2023-05-07 01:25:00 +00:00
FROM (
$ { query }
) "Item"
2023-05-08 20:06:42 +00:00
JOIN users ON "Item" . "userId" = users . id
2023-12-23 20:26:16 +00:00
LEFT JOIN "Sub" ON "Sub" . "name" = "Item" . "subName"
2023-05-08 20:06:42 +00:00
$ { orderBy } ` , ...args)
2023-05-07 01:25:00 +00:00
} else {
2023-07-26 16:01:31 +00:00
return await models . $queryRawUnsafe ( `
2023-09-28 20:02:25 +00:00
SELECT "Item" . * , to _jsonb ( users . * ) || jsonb _build _object ( 'meMute' , "Mute" . "mutedId" IS NOT NULL ) as user ,
2024-07-01 17:02:29 +00:00
COALESCE ( "ItemAct" . "meMsats" , 0 ) as "meMsats" , COALESCE ( "ItemAct" . "mePendingMsats" , 0 ) as "mePendingMsats" ,
2023-12-20 01:55:19 +00:00
COALESCE ( "ItemAct" . "meDontLikeMsats" , 0 ) as "meDontLikeMsats" , b . "itemId" IS NOT NULL AS "meBookmark" ,
2023-12-23 20:26:16 +00:00
"ThreadSubscription" . "itemId" IS NOT NULL AS "meSubscription" , "ItemForward" . "itemId" IS NOT NULL AS "meForward" ,
2024-02-23 15:12:49 +00:00
to _jsonb ( "Sub" . * ) || jsonb _build _object ( 'meMuteSub' , "MuteSub" . "userId" IS NOT NULL )
|| jsonb _build _object ( 'meSubscription' , "SubSubscription" . "userId" IS NOT NULL ) as sub
2023-05-07 01:25:00 +00:00
FROM (
$ { query }
) "Item"
JOIN users ON "Item" . "userId" = users . id
2023-09-28 20:02:25 +00:00
LEFT JOIN "Mute" ON "Mute" . "muterId" = $ { me . id } AND "Mute" . "mutedId" = "Item" . "userId"
2023-07-29 23:27:32 +00:00
LEFT JOIN "Bookmark" b ON b . "itemId" = "Item" . id AND b . "userId" = $ { me . id }
2023-06-01 00:44:06 +00:00
LEFT JOIN "ThreadSubscription" ON "ThreadSubscription" . "itemId" = "Item" . id AND "ThreadSubscription" . "userId" = $ { me . id }
2023-08-28 14:40:29 +00:00
LEFT JOIN "ItemForward" ON "ItemForward" . "itemId" = "Item" . id AND "ItemForward" . "userId" = $ { me . id }
2023-12-23 20:26:16 +00:00
LEFT JOIN "Sub" ON "Sub" . "name" = "Item" . "subName"
2023-12-31 01:41:16 +00:00
LEFT JOIN "MuteSub" ON "Sub" . "name" = "MuteSub" . "subName" AND "MuteSub" . "userId" = $ { me . id }
2024-02-23 15:12:49 +00:00
LEFT JOIN "SubSubscription" ON "Sub" . "name" = "SubSubscription" . "subName" AND "SubSubscription" . "userId" = $ { me . id }
2023-05-07 01:25:00 +00:00
LEFT JOIN LATERAL (
2024-07-01 17:02:29 +00:00
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"
2023-05-07 01:25:00 +00:00
FROM "ItemAct"
WHERE "ItemAct" . "userId" = $ { me . id }
AND "ItemAct" . "itemId" = "Item" . id
GROUP BY "ItemAct" . "itemId"
2023-05-08 20:06:42 +00:00
) "ItemAct" ON true
$ { orderBy } ` , ...args)
2023-05-07 01:25:00 +00:00
}
2023-05-06 23:17:47 +00:00
}
2023-07-23 15:08:43 +00:00
const relationClause = ( type ) => {
2023-09-28 20:02:25 +00:00
let clause = ''
2023-07-23 15:08:43 +00:00
switch ( type ) {
case 'comments' :
2024-02-10 02:35:32 +00:00
clause += ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id LEFT JOIN "Sub" ON "Sub"."name" = root."subName" '
2023-09-28 20:02:25 +00:00
break
2023-07-23 15:08:43 +00:00
case 'bookmarks' :
2024-02-14 15:11:34 +00:00
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") '
2023-09-28 20:02:25 +00:00
break
2023-07-23 15:08:43 +00:00
case 'outlawed' :
case 'borderland' :
case 'freebies' :
case 'all' :
2024-02-14 15:11:34 +00:00
clause += ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id LEFT JOIN "Sub" ON "Sub"."name" = COALESCE(root."subName", "Item"."subName") '
2023-09-28 20:02:25 +00:00
break
2023-07-23 15:08:43 +00:00
default :
2024-02-10 02:35:32 +00:00
clause += ' FROM "Item" LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" '
2023-07-23 15:08:43 +00:00
}
2023-09-28 20:02:25 +00:00
return clause
2023-07-23 15:08:43 +00:00
}
2023-07-29 23:27:32 +00:00
const selectClause = ( type ) => type === 'bookmarks'
? ` ${ SELECT } , "Bookmark"."created_at" as "bookmarkCreatedAt" `
: SELECT
2023-07-23 15:08:43 +00:00
const subClauseTable = ( type ) => COMMENT _TYPE _QUERY . includes ( type ) ? 'root' : 'Item'
2023-09-28 20:02:25 +00:00
export const whereClause = ( ... clauses ) => {
const clause = clauses . flat ( Infinity ) . filter ( c => c ) . join ( ' AND ' )
return clause ? ` WHERE ${ clause } ` : ''
}
2023-11-09 00:15:36 +00:00
function whenClause ( when , table ) {
return ` " ${ table } ".created_at <= $ 2 and " ${ table } ".created_at >= $ 1 `
}
2024-07-01 17:02:29 +00:00
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\'' ]
2023-09-28 20:02:25 +00:00
}
export const muteClause = me =>
me ? ` NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${ me . id } AND "Mute"."mutedId" = "Item"."userId") ` : ''
2024-02-14 17:58:29 +00:00
const HIDE _NSFW _CLAUSE = '("Sub"."nsfw" = FALSE OR "Sub"."nsfw" IS NULL)'
2024-02-10 02:35:32 +00:00
export const nsfwClause = showNsfw => showNsfw ? '' : HIDE _NSFW _CLAUSE
2024-08-21 01:49:46 +00:00
const subClause = ( sub , num , table = 'Item' , me , showNsfw ) => {
2024-02-10 02:35:32 +00:00
// Intentionally show nsfw posts (i.e. no nsfw clause) when viewing a specific nsfw sub
2024-08-18 21:43:19 +00:00
if ( sub ) {
const tables = [ ... new Set ( [ 'Item' , table ] ) ] . map ( t => ` " ${ t } ". ` )
return ` ( ${ tables . map ( t => ` ${ t } "subName" = $ ${ num } ::CITEXT ` ) . join ( ' OR ' ) } ) `
}
2024-02-10 02:35:32 +00:00
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
2023-09-28 20:02:25 +00:00
}
2024-08-14 20:43:07 +00:00
function investmentClause ( sats ) {
2024-08-13 21:27:22 +00:00
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
) `
}
2023-09-28 20:02:25 +00:00
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
2024-08-13 21:27:22 +00:00
let satsFilter = investmentClause ( 10 )
2023-09-28 20:02:25 +00:00
if ( me ) {
const user = await models . user . findUnique ( { where : { id : me . id } } )
2024-08-11 23:47:03 +00:00
2024-08-14 20:43:07 +00:00
satsFilter = ` ( ${ investmentClause ( user . satsFilter ) } OR "Item"."userId" = ${ me . id } ) `
2024-08-11 23:47:03 +00:00
2023-09-28 20:02:25 +00:00
if ( user . wildWestMode ) {
2024-08-14 20:43:07 +00:00
return satsFilter
2023-09-28 20:02:25 +00:00
}
}
// handle outlawed
// if the item is above the threshold or is mine
2023-12-23 20:26:16 +00:00
const outlawClauses = [ ` "Item"."weightedVotes" - "Item"."weightedDownVotes" > - ${ ITEM _FILTER _THRESHOLD } AND NOT "Item".outlawed ` ]
2023-09-28 20:02:25 +00:00
if ( me ) {
outlawClauses . push ( ` "Item"."userId" = ${ me . id } ` )
}
const outlawClause = '(' + outlawClauses . join ( ' OR ' ) + ')'
2024-08-13 21:27:22 +00:00
return [ satsFilter , outlawClause ]
2023-09-28 20:02:25 +00:00
}
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' :
2024-08-11 23:47:03 +00:00
return '"Item".cost = 0'
2023-09-28 20:02:25 +00:00
case 'outlawed' :
2023-12-23 20:26:16 +00:00
return ` "Item"."weightedVotes" - "Item"."weightedDownVotes" <= - ${ ITEM _FILTER _THRESHOLD } OR "Item".outlawed `
2023-09-28 20:02:25 +00:00
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'
}
2023-07-23 15:08:43 +00:00
}
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
2023-07-27 00:18:42 +00:00
const [ { item _spam : count } ] = await models . $queryRawUnsafe ( ` SELECT item_spam( $ 1::INTEGER, $ 2::INTEGER, ' ${ ITEM _SPAM _INTERVAL } ') ` ,
2022-09-01 21:06:11 +00:00
Number ( parentId ) , Number ( me . id ) )
2022-08-10 15:06:31 +00:00
return count
} ,
2023-12-14 18:01:09 +00:00
items : async ( parent , { sub , sort , type , cursor , name , when , from , to , by , limit = LIMIT } , { me , models } ) => {
2021-06-22 17:47:49 +00:00
const decodedCursor = decodeCursor ( cursor )
2024-09-19 18:13:14 +00:00
let items , user , pins , subFull , table , ad
2021-10-26 20:49:37 +00:00
2023-08-23 20:29:55 +00:00
// 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 } } )
2023-10-25 18:04:13 +00:00
// 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 ) {
2023-08-23 20:29:55 +00:00
// early return with no results if bookmarks are hidden
return {
cursor : null ,
items : [ ] ,
pins : [ ]
}
}
}
2023-05-05 18:06:53 +00:00
// HACK we want to optionally include the subName in the query
// but the query planner doesn't like unused parameters
const subArr = sub ? [ sub ] : [ ]
2024-02-10 02:35:32 +00:00
const currentUser = me ? await models . user . findUnique ( { where : { id : me . id } } ) : null
const showNsfw = currentUser ? currentUser . nsfwMode : false
2021-06-24 23:56:01 +00:00
switch ( sort ) {
case 'user' :
2021-10-26 20:49:37 +00:00
if ( ! name ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'must supply name' )
2021-10-26 20:49:37 +00:00
}
2023-08-23 20:29:55 +00:00
user ? ? = await models . user . findUnique ( { where : { name } } )
2021-10-26 20:49:37 +00:00
if ( ! user ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'no user has that name' )
2021-10-26 20:49:37 +00:00
}
2023-07-23 15:08:43 +00:00
table = type === 'bookmarks' ? 'Bookmark' : 'Item'
2023-05-08 20:06:42 +00:00
items = await itemQueryWithMeta ( {
me ,
models ,
query : `
2023-07-29 23:27:32 +00:00
$ { selectClause ( type ) }
2023-07-23 15:08:43 +00:00
$ { relationClause ( type ) }
2023-09-28 20:02:25 +00:00
$ { whereClause (
2023-11-09 00:15:36 +00:00
` " ${ table } "."userId" = $ 3 ` ,
2023-09-28 20:02:25 +00:00
activeOrMine ( me ) ,
2024-02-10 02:35:32 +00:00
nsfwClause ( showNsfw ) ,
2023-09-28 20:02:25 +00:00
typeClause ( type ) ,
2024-09-21 19:58:25 +00:00
by === 'boost' && '"Item".boost > 0' ,
2023-11-09 00:15:36 +00:00
whenClause ( when || 'forever' , table ) ) }
2023-09-14 15:46:59 +00:00
$ { orderByClause ( by , me , models , type ) }
2023-11-09 00:15:36 +00:00
OFFSET $4
LIMIT $5 ` ,
2023-09-14 15:46:59 +00:00
orderBy : orderByClause ( by , me , models , type )
2023-11-09 00:15:36 +00:00
} , ... whenRange ( when , from , to || decodedCursor . time ) , user . id , decodedCursor . offset , limit )
2021-06-24 23:56:01 +00:00
break
2022-02-17 17:23:43 +00:00
case 'recent' :
2023-05-08 20:06:42 +00:00
items = await itemQueryWithMeta ( {
me ,
models ,
query : `
$ { SELECT }
2023-07-23 15:08:43 +00:00
$ { relationClause ( type ) }
2023-09-28 20:02:25 +00:00
$ { whereClause (
'"Item".created_at <= $1' ,
2024-09-16 16:57:16 +00:00
'"Item"."deletedAt" IS NULL' ,
2024-02-10 02:35:32 +00:00
subClause ( sub , 4 , subClauseTable ( type ) , me , showNsfw ) ,
2023-09-28 20:02:25 +00:00
activeOrMine ( me ) ,
await filterClause ( me , models , type ) ,
typeClause ( type ) ,
muteClause ( me )
) }
2023-07-23 15:08:43 +00:00
ORDER BY "Item" . created _at DESC
2023-05-08 20:06:42 +00:00
OFFSET $2
2023-07-23 15:08:43 +00:00
LIMIT $3 ` ,
2023-05-08 20:06:42 +00:00
orderBy : 'ORDER BY "Item"."createdAt" DESC'
2023-07-23 15:08:43 +00:00
} , decodedCursor . time , decodedCursor . offset , limit , ... subArr )
2022-02-17 17:23:43 +00:00
break
case 'top' :
2024-05-30 14:21:55 +00:00
items = await itemQueryWithMeta ( {
me ,
models ,
query : `
2023-07-29 23:27:32 +00:00
$ { selectClause ( type ) }
2023-07-23 15:08:43 +00:00
$ { relationClause ( type ) }
2023-09-28 20:02:25 +00:00
$ { whereClause (
'"Item"."deletedAt" IS NULL' ,
2024-07-21 18:56:50 +00:00
type === 'posts' && '"Item"."subName" IS NOT NULL' ,
2024-02-10 02:35:32 +00:00
subClause ( sub , 5 , subClauseTable ( type ) , me , showNsfw ) ,
2023-09-28 20:02:25 +00:00
typeClause ( type ) ,
2023-11-09 00:15:36 +00:00
whenClause ( when , 'Item' ) ,
2023-09-28 20:02:25 +00:00
await filterClause ( me , models , type ) ,
2024-09-21 19:58:25 +00:00
by === 'boost' && '"Item".boost > 0' ,
2023-09-28 20:02:25 +00:00
muteClause ( me ) ) }
2023-09-14 15:46:59 +00:00
$ { orderByClause ( by || 'zaprank' , me , models , type ) }
2023-11-09 00:15:36 +00:00
OFFSET $3
LIMIT $4 ` ,
2024-05-30 14:21:55 +00:00
orderBy : orderByClause ( by || 'zaprank' , me , models , type )
} , ... whenRange ( when , from , to || decodedCursor . time ) , decodedCursor . offset , limit , ... subArr )
2021-06-24 23:56:01 +00:00
break
2024-08-20 21:50:55 +00:00
case 'random' :
items = await itemQueryWithMeta ( {
me ,
models ,
query : `
$ { selectClause ( type ) }
$ { relationClause ( type ) }
$ { whereClause (
'"Item"."deletedAt" IS NULL' ,
'"Item"."weightedVotes" - "Item"."weightedDownVotes" > 2' ,
2024-08-21 02:09:52 +00:00
'"Item"."ncomments" > 0' ,
2024-08-21 02:12:39 +00:00
'"Item"."parentId" IS NULL' ,
'"Item".bio = false' ,
2024-08-20 21:50:55 +00:00
type === 'posts' && '"Item"."subName" IS NOT NULL' ,
subClause ( sub , 3 , subClauseTable ( type ) , me , showNsfw ) ,
typeClause ( type ) ,
await filterClause ( me , models , type ) ,
2024-08-21 02:09:52 +00:00
activeOrMine ( me ) ,
2024-08-20 21:50:55 +00:00
muteClause ( me ) ) }
$ { orderByClause ( 'random' , me , models , type ) }
OFFSET $1
LIMIT $2 ` ,
orderBy : orderByClause ( 'random' , me , models , type )
} , decodedCursor . offset , limit , ... subArr )
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' :
2023-05-08 20:06:42 +00:00
items = await itemQueryWithMeta ( {
me ,
models ,
query : `
2023-05-08 22:32:37 +00:00
$ { SELECT } ,
2024-09-19 18:13:14 +00:00
( 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 )
2023-05-08 22:32:37 +00:00
ELSE rank ( ) OVER ( ORDER BY created _at DESC ) END AS rank
2023-05-08 20:06:42 +00:00
FROM "Item"
2023-09-28 20:02:25 +00:00
$ { whereClause (
'"parentId" IS NULL' ,
2024-09-16 16:57:16 +00:00
'"Item"."deletedAt" IS NULL' ,
2024-09-19 18:13:14 +00:00
'"Item"."status" = \'ACTIVE\'' ,
2023-09-28 20:02:25 +00:00
'created_at <= $1' ,
'"pinId" IS NULL' ,
2024-09-19 18:13:14 +00:00
subClause ( sub , 4 )
2023-09-28 20:02:25 +00:00
) }
2024-09-19 22:07:36 +00:00
ORDER BY group _rank DESC , rank
2023-05-08 20:06:42 +00:00
OFFSET $2
2023-07-23 15:08:43 +00:00
LIMIT $3 ` ,
2024-09-19 18:13:14 +00:00
orderBy : 'ORDER BY group_rank DESC, rank'
2023-07-23 15:08:43 +00:00
} , decodedCursor . time , decodedCursor . offset , limit , ... subArr )
2022-02-17 17:23:43 +00:00
break
default :
2024-09-19 18:13:14 +00:00
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 } )
}
2023-05-23 14:21:04 +00:00
items = await itemQueryWithMeta ( {
me ,
models ,
query : `
2023-09-14 15:46:59 +00:00
$ { SELECT } , $ { me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score' } AS rank
2023-05-06 23:17:47 +00:00
FROM "Item"
2024-02-10 02:35:32 +00:00
LEFT JOIN "Sub" ON "Sub" . "name" = "Item" . "subName"
2023-09-14 15:46:59 +00:00
$ { joinZapRankPersonalView ( me , models ) }
2023-09-28 20:02:25 +00:00
$ { whereClause (
2024-03-17 15:47:11 +00:00
// in "home" (sub undefined), we want to show pinned items (but without the pin icon)
sub ? '"Item"."pinId" IS NULL' : '' ,
2023-09-14 15:46:59 +00:00
'"Item"."deletedAt" IS NULL' ,
'"Item"."parentId" IS NULL' ,
2024-09-16 18:30:40 +00:00
'"Item".outlawed = false' ,
2023-09-14 15:46:59 +00:00
'"Item".bio = false' ,
2024-09-19 18:13:14 +00:00
ad ? ` "Item".id <> ${ ad . id } ` : '' ,
2024-07-01 17:02:29 +00:00
activeOrMine ( me ) ,
2024-02-10 02:35:32 +00:00
subClause ( sub , 3 , 'Item' , me , showNsfw ) ,
2023-09-28 20:02:25 +00:00
muteClause ( me ) ) }
2023-09-14 15:46:59 +00:00
ORDER BY rank DESC
2023-05-23 14:21:04 +00:00
OFFSET $1
2023-07-23 15:08:43 +00:00
LIMIT $2 ` ,
2023-09-14 15:46:59 +00:00
orderBy : 'ORDER BY rank DESC'
2023-07-23 15:08:43 +00:00
} , decodedCursor . offset , limit , ... subArr )
2022-02-17 17:23:43 +00:00
2024-09-19 18:13:14 +00:00
// XXX this is mostly for subs that are really empty
if ( items . length < limit ) {
2023-09-14 15:46:59 +00:00
items = await itemQueryWithMeta ( {
me ,
models ,
query : `
2023-11-21 23:32:22 +00:00
$ { SELECT }
2023-09-14 15:46:59 +00:00
FROM "Item"
2024-02-10 02:35:32 +00:00
LEFT JOIN "Sub" ON "Sub" . "name" = "Item" . "subName"
2023-09-14 15:46:59 +00:00
$ { whereClause (
2024-02-10 02:35:32 +00:00
subClause ( sub , 3 , 'Item' , me , showNsfw ) ,
2023-12-10 22:56:06 +00:00
muteClause ( me ) ,
2024-02-04 21:15:18 +00:00
// in "home" (sub undefined), we want to show pinned items (but without the pin icon)
sub ? '"Item"."pinId" IS NULL' : '' ,
2023-12-26 22:51:47 +00:00
'"Item"."deletedAt" IS NULL' ,
'"Item"."parentId" IS NULL' ,
'"Item".bio = false' ,
2024-09-19 18:13:14 +00:00
ad ? ` "Item".id <> ${ ad . id } ` : '' ,
2024-07-01 17:02:29 +00:00
activeOrMine ( me ) ,
2023-12-10 22:56:06 +00:00
await filterClause ( me , models , type ) ) }
2024-09-19 18:13:14 +00:00
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
2023-09-14 15:46:59 +00:00
OFFSET $1
LIMIT $2 ` ,
2024-09-19 18:13:14 +00:00
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 `
2023-09-14 15:46:59 +00:00
} , decodedCursor . offset , limit , ... subArr )
}
2022-02-17 17:23:43 +00:00
break
}
break
2021-06-24 23:56:01 +00:00
}
2021-06-22 17:47:49 +00:00
return {
2023-07-23 15:08:43 +00:00
cursor : items . length === limit ? nextCursorEncoded ( decodedCursor ) : null ,
2022-01-07 16:32:31 +00:00
items ,
2024-09-19 18:13:14 +00:00
pins ,
ad
2021-06-22 17:47:49 +00:00
}
2021-04-24 21:05:07 +00:00
} ,
2021-09-23 17:42:00 +00:00
item : getItem ,
2023-01-12 18:05:47 +00:00
pageTitleAndUnshorted : async ( parent , { url } , { models } ) => {
const res = { }
2021-08-22 15:25:17 +00:00
try {
const response = await fetch ( ensureProtocol ( url ) , { redirect : 'follow' } )
const html = await response . text ( )
const doc = domino . createWindow ( html ) . document
2023-10-21 00:23:50 +00:00
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 } )
2023-01-12 18:05:47 +00:00
res . title = metadata ? . title
2023-10-21 00:23:50 +00:00
if ( moreThanOneYearAgo ) res . title += dateHint
2023-01-12 18:05:47 +00:00
} catch { }
try {
const unshorted = await uu ( ) . expand ( url )
if ( unshorted ) {
res . unshorted = unshorted
}
} catch { }
return res
2021-10-28 20:49:51 +00:00
} ,
2023-05-07 01:25:00 +00:00
dupes : async ( parent , { url } , { me , models } ) => {
2021-10-28 20:49:51 +00:00
const urlObj = new URL ( ensureProtocol ( url ) )
2024-09-03 19:29:45 +00:00
let { hostname , pathname } = urlObj
2021-12-20 22:26:22 +00:00
2024-09-03 19:29:45 +00:00
// remove subdomain from hostname
2023-01-11 22:20:14 +00:00
const parseResult = parse ( urlObj . hostname )
2024-09-03 19:29:45 +00:00
if ( parseResult ? . subdomain ? . length > 0 ) {
hostname = hostname . replace ( ` ${ parseResult . subdomain } . ` , '' )
2023-01-11 22:20:14 +00:00
}
2024-09-03 19:29:45 +00:00
// 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
2023-01-11 22:20:14 +00:00
2022-03-10 21:44:46 +00:00
const whitelist = [ 'news.ycombinator.com/item' , 'bitcointalk.org/index.php' ]
2023-01-11 22:04:50 +00:00
const youtube = [ 'www.youtube.com' , 'youtube.com' , 'm.youtube.com' , 'youtu.be' ]
2024-02-02 20:46:30 +00:00
const hostAndPath = stripTrailingSlash ( urlObj . hostname + urlObj . pathname )
if ( whitelist . includes ( hostAndPath ) ) {
2024-09-03 19:29:45 +00:00
// make query string match for whitelist domains
2021-12-20 22:26:22 +00:00
similar += ` \\ ${ urlObj . search } `
2022-03-10 21:44:46 +00:00
} 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 )
2024-09-03 19:29:45 +00:00
similar = ` ^(http(s)?: \\ / \\ /)?((www \\ .|m \\ .)?youtube.com \\ /(watch \\ ?v \\ =|v \\ /|live \\ /) ${ matches ? . groups ? . id } |youtu \\ .be \\ / ${ matches ? . groups ? . id } )&? `
2024-02-02 17:43:24 +00:00
} else if ( urlObj . hostname === 'yewtu.be' ) {
const matches = url . match ( /(https?:\/\/)?yewtu\.be.*(v=|embed\/)(?<id>[_0-9a-z-]+)/i )
2024-09-03 19:29:45 +00:00
similar = ` ^(http(s)?: \\ / \\ /)?yewtu \\ .be \\ /(watch \\ ?v \\ =|embed \\ /) ${ matches ? . groups ? . id } &? `
2021-12-20 22:26:22 +00:00
}
2023-05-08 20:06:42 +00:00
return await itemQueryWithMeta ( {
me ,
models ,
query : `
$ { SELECT }
FROM "Item"
2024-09-03 19:29:45 +00:00
WHERE url ~ * $1
2023-05-08 20:06:42 +00:00
ORDER BY created _at DESC
LIMIT 3 `
} , similar )
2021-12-21 21:29:42 +00:00
} ,
2024-09-19 18:13:14 +00:00
auctionPosition : async ( parent , { id , sub , boost } , { models , me } ) => {
2022-10-05 18:55:30 +00:00
const createdAt = id ? ( await getItem ( parent , { id } , { models , me } ) ) . createdAt : new Date ( )
let where
2024-09-19 18:13:14 +00:00
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))
2022-10-05 18:55:30 +00:00
where = {
OR : [
2024-09-19 18:13:14 +00:00
{ boost : { gt : boost } } ,
{ boost , createdAt : { lt : createdAt } }
2022-10-05 18:55:30 +00:00
]
}
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 : [
2024-09-19 18:13:14 +00:00
{ boost : { gt : 0 } } ,
{ createdAt : { gt : createdAt } }
2022-10-05 18:55:30 +00:00
]
}
2022-09-29 20:42:33 +00:00
}
2024-09-19 18:13:14 +00:00
where . AND = {
subName : sub ,
status : 'ACTIVE' ,
deletedAt : null
}
2022-02-17 17:23:43 +00:00
if ( id ) {
2024-09-19 18:13:14 +00:00
where . AND . 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
2024-09-19 18:13:14 +00:00
} ,
2024-10-03 00:24:01 +00:00
boostPosition : async ( parent , { id , sub , boost = 0 } , { models , me } ) => {
2024-09-19 18:13:14 +00:00
const where = {
boost : { gte : boost } ,
status : 'ACTIVE' ,
deletedAt : null ,
2024-09-19 20:27:09 +00:00
outlawed : false ,
parentId : null
2024-09-19 18:13:14 +00:00
}
if ( id ) {
where . id = { not : Number ( id ) }
}
2024-10-03 00:24:01 +00:00
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
}
} )
}
2024-09-19 18:13:14 +00:00
return {
2024-10-03 00:24:01 +00:00
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
2024-09-19 18:13:14 +00:00
}
2021-04-12 18:05:09 +00:00
}
} ,
Mutation : {
2023-02-16 22:23:59 +00:00
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 }
} ,
2024-01-30 17:04:56 +00:00
pinItem : async ( parent , { id } , { me , models } ) => {
if ( ! me ) {
2024-09-10 16:35:25 +00:00
throw new GqlAuthenticationError ( )
2024-01-30 17:04:56 +00:00
}
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 ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'can only pin root replies' )
2024-01-30 17:04:56 +00:00
}
const root = await models . item . findUnique ( {
where : {
id : Number ( item . parentId )
} ,
include : { pin : true }
} )
if ( root . userId !== Number ( me . id ) ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'not your post' )
2024-01-30 17:04:56 +00:00
}
} 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 ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'not your sub' )
2024-01-30 17:04:56 +00:00
}
} else {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'item must have subName or parentId' )
2024-01-30 17:04:56 +00:00
}
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 ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'max 3 pins allowed' )
2024-01-30 17:04:56 +00:00
}
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 }
} ,
2023-06-01 00:44:06 +00:00
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 }
} ,
2023-01-12 23:53:09 +00:00
deleteItem : async ( parent , { id } , { me , models } ) => {
const old = await models . item . findUnique ( { where : { id : Number ( id ) } } )
if ( Number ( old . userId ) !== Number ( me ? . id ) ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'item does not belong to you' )
2023-01-12 23:53:09 +00:00
}
2023-11-19 20:16:35 +00:00
if ( old . bio ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'cannot delete bio' )
2023-11-19 20:16:35 +00:00
}
2023-01-12 23:53:09 +00:00
2023-10-22 16:02:58 +00:00
return await deleteItemByAuthor ( { models , id , item : old } )
2023-01-12 23:53:09 +00:00
} ,
2024-07-04 17:30:42 +00:00
upsertLink : async ( parent , { id , ... item } , { me , models , lnd } ) => {
2024-10-25 19:10:37 +00:00
await validateSchema ( linkSchema , item , { models , me } )
2023-02-08 19:38:04 +00:00
2022-04-18 22:10:26 +00:00
if ( id ) {
2024-07-04 17:30:42 +00:00
return await updateItem ( parent , { id , ... item } , { me , models , lnd } )
2022-04-18 22:10:26 +00:00
} else {
2024-07-04 17:30:42 +00:00
return await createItem ( parent , item , { me , models , lnd } )
2021-08-11 20:13:10 +00:00
}
} ,
2024-07-04 17:30:42 +00:00
upsertDiscussion : async ( parent , { id , ... item } , { me , models , lnd } ) => {
2024-10-25 19:10:37 +00:00
await validateSchema ( discussionSchema , item , { models , me } )
2023-02-08 19:38:04 +00:00
2022-04-18 22:10:26 +00:00
if ( id ) {
2024-07-04 17:30:42 +00:00
return await updateItem ( parent , { id , ... item } , { me , models , lnd } )
2022-04-18 22:10:26 +00:00
} else {
2024-07-04 17:30:42 +00:00
return await createItem ( parent , item , { me , models , lnd } )
2021-08-11 20:13:10 +00:00
}
} ,
2024-07-04 17:30:42 +00:00
upsertBounty : async ( parent , { id , ... item } , { me , models , lnd } ) => {
2024-10-25 19:10:37 +00:00
await validateSchema ( bountySchema , item , { models , me } )
2023-01-26 16:11:55 +00:00
if ( id ) {
2024-07-04 17:30:42 +00:00
return await updateItem ( parent , { id , ... item } , { me , models , lnd } )
2023-01-26 16:11:55 +00:00
} else {
2024-07-04 17:30:42 +00:00
return await createItem ( parent , item , { me , models , lnd } )
2023-01-26 16:11:55 +00:00
}
} ,
2024-07-04 17:30:42 +00:00
upsertPoll : async ( parent , { id , ... item } , { me , models , lnd } ) => {
2023-09-26 00:54:35 +00:00
const numExistingChoices = id
2023-02-08 19:38:04 +00:00
? await models . pollOption . count ( {
2023-07-25 14:14:45 +00:00
where : {
itemId : Number ( id )
}
} )
2023-02-08 19:38:04 +00:00
: 0
2024-10-25 19:10:37 +00:00
await validateSchema ( pollSchema , item , { models , me , numExistingChoices } )
2022-07-30 13:25:46 +00:00
2022-08-18 18:15:24 +00:00
if ( id ) {
2024-07-04 17:30:42 +00:00
return await updateItem ( parent , { id , ... item } , { me , models , lnd } )
2022-07-30 13:25:46 +00:00
} else {
2023-08-24 00:06:26 +00:00
item . pollCost = item . pollCost || POLL _COST
2024-07-04 17:30:42 +00:00
return await createItem ( parent , item , { me , models , lnd } )
2022-07-30 13:25:46 +00:00
}
} ,
2024-07-04 17:30:42 +00:00
upsertJob : async ( parent , { id , ... item } , { me , models , lnd } ) => {
2022-02-17 17:23:43 +00:00
if ( ! me ) {
2024-09-10 16:35:25 +00:00
throw new GqlAuthenticationError ( )
2022-02-17 17:23:43 +00:00
}
2023-08-24 00:06:26 +00:00
item . location = item . location ? . toLowerCase ( ) === 'remote' ? undefined : item . location
2024-10-25 19:10:37 +00:00
await validateSchema ( jobSchema , item , { models } )
2023-09-14 15:35:13 +00:00
if ( item . logo !== undefined ) {
2023-08-24 00:06:26 +00:00
item . uploadId = item . logo
delete item . logo
2022-02-17 17:23:43 +00:00
}
if ( id ) {
2024-07-04 17:30:42 +00:00
return await updateItem ( parent , { id , ... item } , { me , models , lnd } )
2022-09-29 20:42:33 +00:00
} else {
2024-07-04 17:30:42 +00:00
return await createItem ( parent , item , { me , models , lnd } )
2022-02-17 17:23:43 +00:00
}
} ,
2024-07-04 17:30:42 +00:00
upsertComment : async ( parent , { id , ... item } , { me , models , lnd } ) => {
2024-10-25 19:10:37 +00:00
await validateSchema ( commentSchema , item )
Service worker rework, Web Target Share API & Web Push API (#324)
* npm uninstall next-pwa
next-pwa was last updated in August 2022.
There is also an issue which mentions that next-pwa is abandoned (?): https://github.com/shadowwalker/next-pwa/issues/482
But the main reason for me uninstalling it is that it adds a lot of preconfigured stuff which is not necessary for us.
It even lead to a bug since pages were cached without our knowledge.
So I will go with a different PWA approach. This different approach should do the following:
- make it more transparent what the service worker is doing
- gives us more control to configure the service worker and thus making it easier
* Use workbox-webpack-plugin
Every other plugin (`next-offline`, `next-workbox-webpack-plugin`, `next-with-workbox`, ...) added unnecessary configuration which felt contrary to how PWAs should be built.
(PWAs should progressivly enhance the website in small steps, see https://web.dev/learn/pwa/getting-started/#focus-on-a-feature)
These default configurations even lead to worse UX since they made invalid assumptions about stacker.news:
We _do not_ want to cache our start url and we _do not_ want to cache anything unless explicitly told to.
Almost every page on SN should be fresh for the best UX.
To achieve this, by default, the service worker falls back to the network (as if the service worker wasn't there).
Therefore, this should be the simplest configuration with a valid precache and cache busting support.
In the future, we can try to use prefetching to improve performance of navigation requests.
* Add support for Web Share Target API
See https://developer.chrome.com/articles/web-share-target/
* Use Web Push API for push notifications
I followed this (very good!) guide: https://web.dev/notifications/
* Refactor code related to Web Push
* Send push notification to users on events
* Merge notifications
* Send notification to author of every parent recursively
* Remove unused userId param in savePushSubscription
As it should be, the user id is retrieved from the authenticated user in the backend.
* Resubscribe user if push subscription changed
* Update old subscription if oldEndpoint was given
* Allow users to unsubscribe
* Use LTREE operator instead of recursive query
* Always show checkbox for push notifications
* Justify checkbox to end
* Update title of first push notification
* Fix warning from uncontrolled to controlled
* Add comment about Notification.requestPermission
* Fix timestamp
* Catch error on push subscription toggle
* Wrap function bodies in try/catch
* Use Promise.allSettled
* Filter subscriptions by user notification settings
* Fix user notification filter
* Use skipWaiting
---------
Co-authored-by: ekzyis <ek@stacker.news>
2023-07-04 19:36:07 +00:00
2023-08-24 00:06:26 +00:00
if ( id ) {
2024-07-04 17:30:42 +00:00
return await updateItem ( parent , { id , ... item } , { me , models , lnd } )
2023-08-24 00:06:26 +00:00
} else {
2024-07-04 17:30:42 +00:00
item = await createItem ( parent , item , { me , models , lnd } )
2023-09-26 20:27:55 +00:00
return item
2023-08-24 00:06:26 +00:00
}
2021-08-10 22:59:06 +00:00
} ,
2023-12-19 18:31:24 +00:00
updateNoteId : async ( parent , { id , noteId } , { me , models } ) => {
2023-12-19 17:48:48 +00:00
if ( ! id ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'id required' )
2023-12-19 17:48:48 +00:00
}
await models . item . update ( {
2023-12-19 18:31:24 +00:00
where : { id : Number ( id ) , userId : Number ( me . id ) } ,
2023-12-19 17:48:48 +00:00
data : { noteId }
} )
return { id , noteId }
} ,
2024-07-04 17:30:42 +00:00
pollVote : async ( parent , { id } , { me , models , lnd } ) => {
2022-07-30 13:25:46 +00:00
if ( ! me ) {
2024-09-10 16:35:25 +00:00
throw new GqlAuthenticationError ( )
2022-07-30 13:25:46 +00:00
}
2024-07-04 17:30:42 +00:00
return await performPaidAction ( 'POLL_VOTE' , { id } , { me , models , lnd } )
2022-07-30 13:25:46 +00:00
} ,
2024-09-19 18:13:14 +00:00
act : async ( parent , { id , sats , act = 'TIP' } , { me , models , lnd , headers } ) => {
2024-04-03 20:11:06 +00:00
assertApiKeyNotPermitted ( { me } )
2024-10-25 19:10:37 +00:00
await validateSchema ( actSchema , { sats , act } )
2023-12-14 17:30:51 +00:00
await assertGofacYourself ( { models , headers } )
2021-04-27 21:30:58 +00:00
2023-12-26 21:55:48 +00:00
const [ item ] = await models . $queryRawUnsafe ( `
Allow zapping, posting and commenting without funds or an account (#336)
* Add anon zaps
* Add anon comments and posts (link, discussion, poll)
* Use payment hash instead of invoice id as proof of payment
Our invoice IDs can be enumerated.
So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself.
Random payment hashes prevent this.
Also, since we delete invoices after use, using database IDs as proof of payments are not suitable.
If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs.
* Allow pay per invoice for stackers
The modal which pops up if the stacker does not have enough sats now has two options: "fund wallet" and "pay invoice"
* Fix onSuccess called twice
For some reason, when calling `showModal`, `useMemo` in modal.js and the code for the modal component (here: <Invoice>) is called twice.
This leads to the `onSuccess` callback being called twice and one failing since the first one deletes the invoice.
* Keep invoice modal open if focus is lost
* Skip anon user during trust calculation
* Add error handling
* Skip 'invoice not found' errors
* Remove duplicate insufficient funds handling
* Fix insufficient funds error detection
* Fix invoice amount for comments
* Allow pay per invoice for bounty and job posts
* Also strike on payment after short press
* Fix unexpected token 'export'
* Fix eslint
* Remove unused id param
* Fix comment copy-paste error
* Rename to useInvoiceable
* Fix unexpected token 'export'
* Fix onConfirmation called at every render
* Add invoice HMAC
This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN.
Only the user which created the invoice knows the HMAC and thus can use the invoice hash.
* make anon posting less hidden, add anon info button explainer
* Fix anon users can't zap other anon users
* Always show repeat and contacts on action error
* Keep track of modal stack
* give anon an icon
* add generic date pivot helper
* make anon user's invoices expire in 5 minutes
* fix forgotten find and replace
* use datePivot more places
* add sat amounts to invoices
* reduce anon invoice expiration to 3 minutes
* don't abbreviate
* Fix [object Object] as error message
Any errors thrown here are already objects of shape { message: string }
* Fix empty invoice creation attempts
I stumbled across this while checking if anons can edit their items.
I monkey patched the code to make it possible (so they can see the 'edit' button) and tried to edit an item but I got this error:
Variable "$amount" of required type "Int!" was not provided.
I fixed this even though this function should never be called without an amount anyway. It will return a sane error in that case now.
* anon func mods, e.g. inv limits
* anon tips should be denormalized
* remove redundant meTotalSats
* correct overlay zap text for anon
* exclude anon from trust graph before algo runs
* remove balance limit on anon
* give anon a bio and remove cowboy hat/top stackers;
* make anon hat appear on profile
* concat hash and hmac and call it a token
* Fix localStorage cleared because error were swallowed
* fix qr layout shift
* restyle fund error modal
* Catch invoice errors in fund error modal
* invoice check backoff
* anon info typo
* make invoice expiration times have saner defaults
* add comma to anon info
* use builtin copy input label
---------
Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2023-08-11 23:50:57 +00:00
$ { SELECT }
FROM "Item"
2023-12-26 21:55:48 +00:00
WHERE id = $1 ` , Number(id))
2024-05-12 02:00:08 +00:00
if ( item . deletedAt ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'item is deleted' )
2024-05-12 02:00:08 +00:00
}
2024-07-01 17:02:29 +00:00
if ( item . invoiceActionState && item . invoiceActionState !== 'PAID' ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'cannot act on unpaid item' )
2024-07-01 17:02:29 +00:00
}
2023-12-26 21:55:48 +00:00
// disallow self tips except anons
2024-09-19 18:13:14 +00:00
if ( me && [ 'TIP' , 'DONT_LIKE_THIS' ] . includes ( act ) ) {
2023-12-26 21:55:48 +00:00
if ( Number ( item . userId ) === Number ( me . id ) ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'cannot zap yourself' )
Allow zapping, posting and commenting without funds or an account (#336)
* Add anon zaps
* Add anon comments and posts (link, discussion, poll)
* Use payment hash instead of invoice id as proof of payment
Our invoice IDs can be enumerated.
So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself.
Random payment hashes prevent this.
Also, since we delete invoices after use, using database IDs as proof of payments are not suitable.
If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs.
* Allow pay per invoice for stackers
The modal which pops up if the stacker does not have enough sats now has two options: "fund wallet" and "pay invoice"
* Fix onSuccess called twice
For some reason, when calling `showModal`, `useMemo` in modal.js and the code for the modal component (here: <Invoice>) is called twice.
This leads to the `onSuccess` callback being called twice and one failing since the first one deletes the invoice.
* Keep invoice modal open if focus is lost
* Skip anon user during trust calculation
* Add error handling
* Skip 'invoice not found' errors
* Remove duplicate insufficient funds handling
* Fix insufficient funds error detection
* Fix invoice amount for comments
* Allow pay per invoice for bounty and job posts
* Also strike on payment after short press
* Fix unexpected token 'export'
* Fix eslint
* Remove unused id param
* Fix comment copy-paste error
* Rename to useInvoiceable
* Fix unexpected token 'export'
* Fix onConfirmation called at every render
* Add invoice HMAC
This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN.
Only the user which created the invoice knows the HMAC and thus can use the invoice hash.
* make anon posting less hidden, add anon info button explainer
* Fix anon users can't zap other anon users
* Always show repeat and contacts on action error
* Keep track of modal stack
* give anon an icon
* add generic date pivot helper
* make anon user's invoices expire in 5 minutes
* fix forgotten find and replace
* use datePivot more places
* add sat amounts to invoices
* reduce anon invoice expiration to 3 minutes
* don't abbreviate
* Fix [object Object] as error message
Any errors thrown here are already objects of shape { message: string }
* Fix empty invoice creation attempts
I stumbled across this while checking if anons can edit their items.
I monkey patched the code to make it possible (so they can see the 'edit' button) and tried to edit an item but I got this error:
Variable "$amount" of required type "Int!" was not provided.
I fixed this even though this function should never be called without an amount anyway. It will return a sane error in that case now.
* anon func mods, e.g. inv limits
* anon tips should be denormalized
* remove redundant meTotalSats
* correct overlay zap text for anon
* exclude anon from trust graph before algo runs
* remove balance limit on anon
* give anon a bio and remove cowboy hat/top stackers;
* make anon hat appear on profile
* concat hash and hmac and call it a token
* Fix localStorage cleared because error were swallowed
* fix qr layout shift
* restyle fund error modal
* Catch invoice errors in fund error modal
* invoice check backoff
* anon info typo
* make invoice expiration times have saner defaults
* add comma to anon info
* use builtin copy input label
---------
Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2023-08-11 23:50:57 +00:00
}
2023-09-26 20:15:09 +00:00
// Disallow tips if me is one of the forward user recipients
2023-12-26 22:51:47 +00:00
if ( act === 'TIP' ) {
const existingForwards = await models . itemForward . findMany ( { where : { itemId : Number ( id ) } } )
if ( existingForwards . some ( fwd => Number ( fwd . userId ) === Number ( me . id ) ) ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'cannot zap a post for which you are forwarded zaps' )
2023-12-26 22:51:47 +00:00
}
2023-09-26 20:15:09 +00:00
}
Allow zapping, posting and commenting without funds or an account (#336)
* Add anon zaps
* Add anon comments and posts (link, discussion, poll)
* Use payment hash instead of invoice id as proof of payment
Our invoice IDs can be enumerated.
So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself.
Random payment hashes prevent this.
Also, since we delete invoices after use, using database IDs as proof of payments are not suitable.
If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs.
* Allow pay per invoice for stackers
The modal which pops up if the stacker does not have enough sats now has two options: "fund wallet" and "pay invoice"
* Fix onSuccess called twice
For some reason, when calling `showModal`, `useMemo` in modal.js and the code for the modal component (here: <Invoice>) is called twice.
This leads to the `onSuccess` callback being called twice and one failing since the first one deletes the invoice.
* Keep invoice modal open if focus is lost
* Skip anon user during trust calculation
* Add error handling
* Skip 'invoice not found' errors
* Remove duplicate insufficient funds handling
* Fix insufficient funds error detection
* Fix invoice amount for comments
* Allow pay per invoice for bounty and job posts
* Also strike on payment after short press
* Fix unexpected token 'export'
* Fix eslint
* Remove unused id param
* Fix comment copy-paste error
* Rename to useInvoiceable
* Fix unexpected token 'export'
* Fix onConfirmation called at every render
* Add invoice HMAC
This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN.
Only the user which created the invoice knows the HMAC and thus can use the invoice hash.
* make anon posting less hidden, add anon info button explainer
* Fix anon users can't zap other anon users
* Always show repeat and contacts on action error
* Keep track of modal stack
* give anon an icon
* add generic date pivot helper
* make anon user's invoices expire in 5 minutes
* fix forgotten find and replace
* use datePivot more places
* add sat amounts to invoices
* reduce anon invoice expiration to 3 minutes
* don't abbreviate
* Fix [object Object] as error message
Any errors thrown here are already objects of shape { message: string }
* Fix empty invoice creation attempts
I stumbled across this while checking if anons can edit their items.
I monkey patched the code to make it possible (so they can see the 'edit' button) and tried to edit an item but I got this error:
Variable "$amount" of required type "Int!" was not provided.
I fixed this even though this function should never be called without an amount anyway. It will return a sane error in that case now.
* anon func mods, e.g. inv limits
* anon tips should be denormalized
* remove redundant meTotalSats
* correct overlay zap text for anon
* exclude anon from trust graph before algo runs
* remove balance limit on anon
* give anon a bio and remove cowboy hat/top stackers;
* make anon hat appear on profile
* concat hash and hmac and call it a token
* Fix localStorage cleared because error were swallowed
* fix qr layout shift
* restyle fund error modal
* Catch invoice errors in fund error modal
* invoice check backoff
* anon info typo
* make invoice expiration times have saner defaults
* add comma to anon info
* use builtin copy input label
---------
Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2023-08-11 23:50:57 +00:00
}
2024-07-01 17:02:29 +00:00
if ( act === 'TIP' ) {
2024-07-04 17:30:42 +00:00
return await performPaidAction ( 'ZAP' , { id , sats } , { me , models , lnd } )
2024-07-01 17:02:29 +00:00
} else if ( act === 'DONT_LIKE_THIS' ) {
2024-07-04 17:30:42 +00:00
return await performPaidAction ( 'DOWN_ZAP' , { id , sats } , { me , models , lnd } )
2024-09-19 18:13:14 +00:00
} else if ( act === 'BOOST' ) {
return await performPaidAction ( 'BOOST' , { id , sats } , { me , models , lnd } )
2023-12-27 16:15:18 +00:00
} else {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'unknown act' )
2021-09-10 21:13:52 +00:00
}
2023-12-23 20:26:16 +00:00
} ,
toggleOutlaw : async ( parent , { id } , { me , models } ) => {
if ( ! me ) {
2024-09-10 16:35:25 +00:00
throw new GqlAuthenticationError ( )
2023-12-23 20:26:16 +00:00
}
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 ) ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'you cant do this broh' )
2023-12-23 20:26:16 +00:00
}
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
2021-04-12 18:05:09 +00:00
}
} ,
2024-07-01 17:02:29 +00:00
ItemAct : {
invoice : async ( itemAct , args , { models } ) => {
if ( itemAct . invoiceId ) {
return {
id : itemAct . invoiceId ,
actionState : itemAct . invoiceActionState
}
}
return null
}
} ,
2021-04-12 18:05:09 +00:00
Item : {
2022-11-15 20:51:55 +00:00
sats : async ( item , args , { models } ) => {
2024-07-01 17:02:29 +00:00
return msatsToSats ( BigInt ( item . msats ) + BigInt ( item . mePendingMsats || 0 ) )
2022-11-15 20:51:55 +00:00
} ,
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 } ) => {
2023-07-23 15:08:43 +00:00
if ( ! item . subName && ! item . root ) {
2022-02-17 17:23:43 +00:00
return null
}
2023-12-15 18:10:29 +00:00
if ( item . sub ) {
return item . sub
}
2023-07-23 15:08:43 +00:00
return await models . sub . findUnique ( { where : { name : item . subName || item . root ? . subName } } )
2022-02-17 17:23:43 +00:00
} ,
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
} ,
2022-01-13 19:05:43 +00:00
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 `
2024-07-01 17:02:29 +00:00
SELECT "PollOption" . id , option ,
( count ( "PollVote" . id )
FILTER ( WHERE "PollVote" . "invoiceActionState" IS NULL
OR "PollVote" . "invoiceActionState" = 'PAID' ) ) : : INTEGER as count
2022-07-30 13:25:46 +00:00
FROM "PollOption"
LEFT JOIN "PollVote" on "PollVote" . "pollOptionId" = "PollOption" . id
WHERE "PollOption" . "itemId" = $ { item . id }
GROUP BY "PollOption" . id
ORDER BY "PollOption" . id ASC
`
2023-07-27 00:18:42 +00:00
2022-07-30 13:25:46 +00:00
const poll = { }
2024-07-01 17:02:29 +00:00
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
}
2022-07-30 13:25:46 +00:00
poll . options = options
poll . count = options . reduce ( ( t , o ) => t + o . count , 0 )
return poll
} ,
2023-05-06 23:17:47 +00:00
user : async ( item , args , { models } ) => {
if ( item . user ) {
return item . user
}
return await models . user . findUnique ( { where : { id : item . userId } } )
} ,
multiple forwards on a post (#403)
* multiple forwards on a post
first phase of the multi-forward support
* update the graphql mutation for discussion posts to accept and validate multiple forwards
* update the discussion form to allow multiple forwards in the UI
* start working on db schema changes
* uncomment db schema, add migration to create the new model, and update create_item, update_item
stored procedures
* Propagate updates from discussion to poll, link, and bounty forms
Update the create, update poll sql functions for multi forward support
* Update gql, typedefs, and resolver to return forwarded users in items responses
* UI changes to show multiple forward recipients, and conditional upvote logic changes
* Update notification text to reflect multiple forwards upon vote action
* Disallow duplicate stacker entries
* reduce duplication in populating adv-post-form initial values
* Update item_act sql function to implement multi-way forwarding
* Update referral functions to scale referral bonuses for forwarded users
* Update notification text to reflect non-100% forwarded sats cases
* Update wallet history sql queries to accommodate multi-forward use cases
* Block zaps for posts you are forwarded zaps at the API layer, in addition
to in the UI
* Delete fwdUserId column from Item table as part of migration
* Fix how we calculate stacked sats after partial forwards in wallet history
* Exclude entries from wallet history that are 0 stacked sats from posts with 100% forwarded to other users
* Fix wallet history query for forwarded stacked sats to be scaled by the fwd pct
* Reduce duplication in adv post form, and do some style tweaks for better layout
* Use MAX_FORWARDS constants
* Address various PR feedback
* first enhancement pass
* enhancement pass too
---------
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2023-08-23 22:44:17 +00:00
forwards : async ( item , args , { models } ) => {
return await models . itemForward . findMany ( {
where : {
itemId : item . id
} ,
include : {
user : true
}
} )
2022-04-19 18:32:39 +00:00
} ,
2023-07-26 00:45:35 +00:00
comments : async ( item , { sort } , { me , models } ) => {
2023-07-29 19:38:20 +00:00
if ( typeof item . comments !== 'undefined' ) return item . comments
2023-07-26 00:45:35 +00:00
if ( item . ncomments === 0 ) return [ ]
2023-07-23 15:08:43 +00:00
2023-07-26 00:45:35 +00:00
return comments ( me , models , item . id , sort || defaultCommentSort ( item . pinId , item . bioId , item . createdAt ) )
2022-03-04 18:05:16 +00:00
} ,
2023-11-21 23:26:24 +00:00
freedFreebie : async ( item ) => {
return item . weightedVotes - item . weightedDownVotes > 0
2022-10-28 15:58:31 +00:00
} ,
2024-08-11 23:47:03 +00:00
freebie : async ( item ) => {
2024-09-19 20:18:07 +00:00
return item . cost === 0 && item . boost === 0
2024-08-11 23:47:03 +00:00
} ,
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
2023-07-27 00:18:42 +00:00
if ( typeof item . meMsats !== 'undefined' ) {
return msatsToSats ( item . meMsats )
}
2021-09-10 21:13:52 +00:00
2023-07-26 16:01:31 +00:00
const { _sum : { msats } } = await models . itemAct . aggregate ( {
_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 ,
2024-07-01 17:02:29 +00:00
invoiceActionState : {
not : 'FAILED'
} ,
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
} ,
2023-12-20 01:55:19 +00:00
meDontLikeSats : async ( item , args , { me , models } ) => {
2024-07-01 17:02:29 +00:00
if ( ! me ) return 0
if ( typeof item . meDontLikeMsats !== 'undefined' ) {
2023-12-20 01:55:19 +00:00
return msatsToSats ( item . meDontLikeMsats )
}
2022-09-21 19:57:36 +00:00
2023-12-20 01:55:19 +00:00
const { _sum : { msats } } = await models . itemAct . aggregate ( {
_sum : {
msats : true
} ,
2022-09-21 19:57:36 +00:00
where : {
itemId : Number ( item . id ) ,
userId : me . id ,
2024-07-01 17:02:29 +00:00
act : 'DONT_LIKE_THIS' ,
invoiceActionState : {
not : 'FAILED'
}
2022-09-21 19:57:36 +00:00
}
} )
2023-12-20 01:55:19 +00:00
return ( msats && msatsToSats ( msats ) ) || 0
2022-09-21 19:57:36 +00:00
} ,
2023-02-16 22:23:59 +00:00
meBookmark : async ( item , args , { me , models } ) => {
if ( ! me ) return false
2023-07-29 23:27:32 +00:00
if ( typeof item . meBookmark !== 'undefined' ) return item . meBookmark
2023-02-16 22:23:59 +00:00
const bookmark = await models . bookmark . findUnique ( {
where : {
userId _itemId : {
itemId : Number ( item . id ) ,
userId : me . id
}
}
} )
return ! ! bookmark
} ,
2023-06-01 00:44:06 +00:00
meSubscription : async ( item , args , { me , models } ) => {
if ( ! me ) return false
2023-07-29 23:27:32 +00:00
if ( typeof item . meSubscription !== 'undefined' ) return item . meSubscription
2023-06-01 00:44:06 +00:00
const subscription = await models . threadSubscription . findUnique ( {
where : {
userId _itemId : {
itemId : Number ( item . id ) ,
userId : me . id
}
}
} )
return ! ! subscription
} ,
2022-09-22 18:44:50 +00:00
outlawed : async ( item , args , { me , models } ) => {
if ( me && Number ( item . userId ) === Number ( me . id ) ) {
return false
}
2023-12-23 20:26:16 +00:00
return item . outlawed || item . weightedVotes - item . weightedDownVotes <= - ITEM _FILTER _THRESHOLD
2022-09-22 18:44:50 +00:00
} ,
2024-03-05 01:20:14 +00:00
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'
} ,
2021-12-05 17:37:55 +00:00
mine : async ( item , args , { me , models } ) => {
return me ? . id === item . userId
} ,
2023-12-31 01:41:16 +00:00
root : async ( item , args , { models , me } ) => {
2023-01-26 19:37:51 +00:00
if ( ! item . rootId ) {
2021-07-08 00:15:27 +00:00
return null
}
2023-02-04 00:08:08 +00:00
if ( item . root ) {
return item . root
}
2024-07-11 00:39:53 +00:00
// 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' ,
2024-07-14 20:53:40 +00:00
` ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' ${ me ? ` OR "Item"."userId" = ${ me . id } ` : '' } ) `
2024-07-11 00:39:53 +00:00
) } `
} , Number ( item . rootId ) )
return root
2021-07-08 00:15:27 +00:00
} ,
2024-07-01 17:02:29 +00:00
invoice : async ( item , args , { models } ) => {
if ( item . invoiceId ) {
return {
id : item . invoiceId ,
actionState : item . invoiceActionState ,
confirmedAt : item . invoicePaidAtUTC ? ? item . invoicePaidAt
}
}
return null
} ,
2021-07-08 00:15:27 +00:00
parent : async ( item , args , { models } ) => {
if ( ! item . parentId ) {
return null
}
return await models . item . findUnique ( { where : { id : item . parentId } } )
2023-01-22 20:17:50 +00:00
} ,
parentOtsHash : async ( item , args , { models } ) => {
if ( ! item . parentId ) {
return null
}
const parent = await models . item . findUnique ( { where : { id : item . parentId } } )
return parent . otsHash
2023-11-19 21:09:47 +00:00
} ,
deleteScheduledAt : async ( item , args , { me , models } ) => {
2024-06-03 16:26:19 +00:00
const meId = me ? . id ? ? USER _ID . anon
2023-11-19 21:09:47 +00:00
if ( meId !== item . userId ) {
// Only query for deleteScheduledAt for your own items to keep DB queries minimized
return null
}
2024-07-01 17:02:29 +00:00
const deleteJobs = await models . $queryRaw `
SELECT startafter
FROM pgboss . job
WHERE name = 'deleteItem' AND data - >> 'id' = $ { item . id } : : TEXT
AND state = 'created' `
2023-11-19 21:09:47 +00:00
return deleteJobs [ 0 ] ? . startafter ? ? null
2024-05-19 20:52:02 +00:00
} ,
reminderScheduledAt : async ( item , args , { me , models } ) => {
2024-06-03 16:26:19 +00:00
const meId = me ? . id ? ? USER _ID . anon
if ( meId !== item . userId || meId === USER _ID . anon ) {
2024-05-19 20:52:02 +00:00
// don't show reminders on an item if it isn't yours
// don't support reminders for ANON
return null
}
2024-07-01 17:02:29 +00:00
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' `
2024-05-19 20:52:02 +00:00
return reminderJobs [ 0 ] ? . startafter ? ? null
2021-04-26 21:55:15 +00:00
}
}
}
2024-09-13 15:11:19 +00:00
export const updateItem = async ( parent , { sub : subName , forward , hash , hmac , ... item } , { me , models , lnd } ) => {
2024-07-01 17:02:29 +00:00
// update iff this item belongs to me
2024-09-13 15:11:19 +00:00
const old = await models . item . findUnique ( { where : { id : Number ( item . id ) } , include : { invoice : true , sub : true } } )
2021-08-18 22:20:33 +00:00
2024-07-01 17:02:29 +00:00
if ( old . deletedAt ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'item is deleted' )
2021-08-18 22:20:33 +00:00
}
2024-09-13 15:11:19 +00:00
// 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 )
}
2024-06-03 16:26:19 +00:00
2024-09-13 15:11:19 +00:00
// ownership permission check
if ( ! authorEdit && ! adminEdit && ! hmacEdit ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'item does not belong to you' )
2022-04-18 22:10:26 +00:00
}
2024-06-03 16:26:19 +00:00
const differentSub = subName && old . subName !== subName
if ( differentSub ) {
2023-12-10 23:42:30 +00:00
const sub = await models . sub . findUnique ( { where : { name : subName } } )
2024-08-27 00:23:07 +00:00
if ( sub . baseCost > old . sub . baseCost ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'cannot change to a more expensive sub' )
2023-12-10 23:42:30 +00:00
}
}
2022-04-18 22:10:26 +00:00
2023-09-26 20:15:09 +00:00
// in case they lied about their existing boost
2024-10-25 19:10:37 +00:00
await validateSchema ( advSchema , { boost : item . boost } , { models , me , existingBoost : old . boost } )
2023-09-26 00:54:35 +00:00
2024-09-13 15:11:19 +00:00
const user = await models . user . findUnique ( { where : { id : meId } } )
2024-06-03 16:26:19 +00:00
// 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
2024-07-01 17:02:29 +00:00
const timer = Date . now ( ) < new Date ( old . invoicePaidAt ? ? old . createdAt ) . getTime ( ) + 10 * 60_000
2024-06-03 16:26:19 +00:00
2024-09-13 15:11:19 +00:00
// timer permission check
if ( ! adminEdit && ! myBio && ! timer && ! isJob ( item ) ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'item can no longer be edited' )
2022-04-18 22:10:26 +00:00
}
2023-10-13 20:37:41 +00:00
if ( item . url && ! isJob ( item ) ) {
2023-08-24 00:06:26 +00:00
item . url = ensureProtocol ( item . url )
item . url = removeTracking ( item . url )
2022-08-26 23:31:51 +00:00
}
2021-08-18 22:20:33 +00:00
2024-07-01 17:02:29 +00:00
if ( old . bio ) {
2024-08-14 14:31:52 +00:00
// prevent editing a bio like a regular item
2024-09-13 16:19:54 +00:00
item = { id : Number ( item . id ) , text : item . text , title : ` @ ${ user . name } 's bio ` }
2024-08-14 14:31:52 +00:00
} else if ( old . parentId ) {
// prevent editing a comment like a post
2024-09-19 19:06:34 +00:00
item = { id : Number ( item . id ) , text : item . text , boost : item . boost }
2024-07-01 17:02:29 +00:00
} else {
2024-09-13 16:19:54 +00:00
item = { subName , ... item }
2024-07-01 17:02:29 +00:00
item . forwardUsers = await getForwardUsers ( models , forward )
2023-10-22 16:02:58 +00:00
}
2024-07-01 17:02:29 +00:00
item . uploadIds = uploadIdsFromText ( item . text , { models } )
2023-10-22 16:02:58 +00:00
2024-09-13 16:19:54 +00:00
// never change author of item
item . userId = old . userId
2024-07-04 17:30:42 +00:00
const resultItem = await performPaidAction ( 'ITEM_UPDATE' , item , { models , me , lnd } )
2024-05-19 20:52:02 +00:00
2024-07-01 17:02:29 +00:00
resultItem . comments = [ ]
return resultItem
2021-08-18 22:20:33 +00:00
}
2024-07-04 17:30:42 +00:00
export const createItem = async ( parent , { forward , ... item } , { me , models , lnd } ) => {
2023-08-24 00:06:26 +00:00
// rename to match column name
item . subName = item . sub
delete item . sub
2024-06-03 16:26:19 +00:00
item . userId = me ? Number ( me . id ) : USER _ID . anon
Allow zapping, posting and commenting without funds or an account (#336)
* Add anon zaps
* Add anon comments and posts (link, discussion, poll)
* Use payment hash instead of invoice id as proof of payment
Our invoice IDs can be enumerated.
So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself.
Random payment hashes prevent this.
Also, since we delete invoices after use, using database IDs as proof of payments are not suitable.
If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs.
* Allow pay per invoice for stackers
The modal which pops up if the stacker does not have enough sats now has two options: "fund wallet" and "pay invoice"
* Fix onSuccess called twice
For some reason, when calling `showModal`, `useMemo` in modal.js and the code for the modal component (here: <Invoice>) is called twice.
This leads to the `onSuccess` callback being called twice and one failing since the first one deletes the invoice.
* Keep invoice modal open if focus is lost
* Skip anon user during trust calculation
* Add error handling
* Skip 'invoice not found' errors
* Remove duplicate insufficient funds handling
* Fix insufficient funds error detection
* Fix invoice amount for comments
* Allow pay per invoice for bounty and job posts
* Also strike on payment after short press
* Fix unexpected token 'export'
* Fix eslint
* Remove unused id param
* Fix comment copy-paste error
* Rename to useInvoiceable
* Fix unexpected token 'export'
* Fix onConfirmation called at every render
* Add invoice HMAC
This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN.
Only the user which created the invoice knows the HMAC and thus can use the invoice hash.
* make anon posting less hidden, add anon info button explainer
* Fix anon users can't zap other anon users
* Always show repeat and contacts on action error
* Keep track of modal stack
* give anon an icon
* add generic date pivot helper
* make anon user's invoices expire in 5 minutes
* fix forgotten find and replace
* use datePivot more places
* add sat amounts to invoices
* reduce anon invoice expiration to 3 minutes
* don't abbreviate
* Fix [object Object] as error message
Any errors thrown here are already objects of shape { message: string }
* Fix empty invoice creation attempts
I stumbled across this while checking if anons can edit their items.
I monkey patched the code to make it possible (so they can see the 'edit' button) and tried to edit an item but I got this error:
Variable "$amount" of required type "Int!" was not provided.
I fixed this even though this function should never be called without an amount anyway. It will return a sane error in that case now.
* anon func mods, e.g. inv limits
* anon tips should be denormalized
* remove redundant meTotalSats
* correct overlay zap text for anon
* exclude anon from trust graph before algo runs
* remove balance limit on anon
* give anon a bio and remove cowboy hat/top stackers;
* make anon hat appear on profile
* concat hash and hmac and call it a token
* Fix localStorage cleared because error were swallowed
* fix qr layout shift
* restyle fund error modal
* Catch invoice errors in fund error modal
* invoice check backoff
* anon info typo
* make invoice expiration times have saner defaults
* add comma to anon info
* use builtin copy input label
---------
Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2023-08-11 23:50:57 +00:00
2024-07-01 17:02:29 +00:00
item . forwardUsers = await getForwardUsers ( models , forward )
item . uploadIds = uploadIdsFromText ( item . text , { models } )
2023-10-13 20:37:41 +00:00
if ( item . url && ! isJob ( item ) ) {
2023-08-24 00:06:26 +00:00
item . url = ensureProtocol ( item . url )
item . url = removeTracking ( item . url )
2022-08-26 23:31:51 +00:00
}
2024-07-01 17:02:29 +00:00
if ( item . parentId ) {
const parent = await models . item . findUnique ( { where : { id : parseInt ( item . parentId ) } } )
if ( parent . invoiceActionState && parent . invoiceActionState !== 'PAID' ) {
2024-09-10 16:35:25 +00:00
throw new GqlInputError ( 'cannot comment on unpaid item' )
2023-12-10 22:56:06 +00:00
}
}
2023-10-22 16:02:58 +00:00
2024-07-01 17:02:29 +00:00
// mark item as created with API key
item . apiKey = me ? . apiKey
2024-05-19 20:52:02 +00:00
2024-07-04 17:30:42 +00:00
const resultItem = await performPaidAction ( 'ITEM_CREATE' , item , { models , me , lnd } )
2024-05-19 20:52:02 +00:00
2024-07-01 17:02:29 +00:00
resultItem . comments = [ ]
return resultItem
2023-10-22 16:02:58 +00:00
}
2024-07-23 15:35:15 +00:00
export const getForwardUsers = async ( models , forward ) => {
multiple forwards on a post (#403)
* multiple forwards on a post
first phase of the multi-forward support
* update the graphql mutation for discussion posts to accept and validate multiple forwards
* update the discussion form to allow multiple forwards in the UI
* start working on db schema changes
* uncomment db schema, add migration to create the new model, and update create_item, update_item
stored procedures
* Propagate updates from discussion to poll, link, and bounty forms
Update the create, update poll sql functions for multi forward support
* Update gql, typedefs, and resolver to return forwarded users in items responses
* UI changes to show multiple forward recipients, and conditional upvote logic changes
* Update notification text to reflect multiple forwards upon vote action
* Disallow duplicate stacker entries
* reduce duplication in populating adv-post-form initial values
* Update item_act sql function to implement multi-way forwarding
* Update referral functions to scale referral bonuses for forwarded users
* Update notification text to reflect non-100% forwarded sats cases
* Update wallet history sql queries to accommodate multi-forward use cases
* Block zaps for posts you are forwarded zaps at the API layer, in addition
to in the UI
* Delete fwdUserId column from Item table as part of migration
* Fix how we calculate stacked sats after partial forwards in wallet history
* Exclude entries from wallet history that are 0 stacked sats from posts with 100% forwarded to other users
* Fix wallet history query for forwarded stacked sats to be scaled by the fwd pct
* Reduce duplication in adv post form, and do some style tweaks for better layout
* Use MAX_FORWARDS constants
* Address various PR feedback
* first enhancement pass
* enhancement pass too
---------
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2023-08-23 22:44:17 +00:00
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
}
2021-04-26 21:55:15 +00:00
// we have to do our own query because ltree is unsupported
2021-09-23 17:42:00 +00:00
export const SELECT =
2024-07-01 17:02:29 +00:00
` SELECT "Item".*, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt",
ltree2text ( "Item" . "path" ) AS "path" `
2021-04-27 21:30:58 +00:00
2023-09-14 15:46:59 +00:00
function topOrderByWeightedSats ( me , models ) {
2024-09-19 18:13:14 +00:00
return ` ORDER BY ${ orderByNumerator ( { models } )} DESC NULLS LAST, "Item".id DESC `
2022-09-21 19:57:36 +00:00
}