2021-04-12 18:05:09 +00:00
import { UserInputError , AuthenticationError } from 'apollo-server-micro'
2021-05-20 19:11:58 +00:00
import { ensureProtocol } from '../../lib/url'
2021-05-20 01:09:32 +00:00
import serialize from './serial'
2021-09-06 22:36:08 +00:00
import { decodeCursor , LIMIT , nextCursorEncoded } from '../../lib/cursor'
2021-08-22 15:25:17 +00:00
import { getMetadata , metadataRuleSets } from 'page-metadata-parser'
import domino from 'domino'
2021-06-22 17:47:49 +00:00
2021-04-29 15:56:28 +00:00
async function comments ( models , id ) {
const flat = await models . $queryRaw ( `
WITH RECURSIVE base AS (
$ { SELECT } , ARRAY [ row _number ( ) OVER ( $ { ORDER _BY _SATS } , "Item" . path ) ] AS sort _path
FROM "Item"
$ { LEFT _JOIN _SATS }
2021-05-11 20:29:44 +00:00
WHERE "parentId" = $1
2021-04-29 15:56:28 +00:00
UNION ALL
$ { SELECT } , p . sort _path || row _number ( ) OVER ( $ { ORDER _BY _SATS } , "Item" . path )
FROM base p
JOIN "Item" ON ltree2text ( subpath ( "Item" . "path" , 0 , - 1 ) ) = p . "path"
$ { LEFT _JOIN _SATS } )
2021-05-11 20:29:44 +00:00
SELECT * FROM base ORDER BY sort _path ` , Number(id))
2021-04-29 15:56:28 +00:00
return nestComments ( flat , id ) [ 0 ]
}
2021-04-12 18:05:09 +00:00
export default {
Query : {
2021-06-24 23:56:01 +00:00
moreItems : async ( parent , { sort , cursor , userId } , { me , models } ) => {
2021-06-22 17:47:49 +00:00
const decodedCursor = decodeCursor ( cursor )
2021-06-24 23:56:01 +00:00
let items
switch ( sort ) {
case 'user' :
items = await models . $queryRaw ( `
$ { SELECT }
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NULL AND created _at <= $2
ORDER BY created _at DESC
OFFSET $3
LIMIT $ { LIMIT } ` , Number(userId), decodedCursor.time, decodedCursor.offset)
break
case 'hot' :
items = await models . $queryRaw ( `
2021-06-22 17:47:49 +00:00
$ { SELECT }
FROM "Item"
$ { timedLeftJoinSats ( 1 ) }
WHERE "parentId" IS NULL AND created _at <= $1
$ { timedOrderBySats ( 1 ) }
OFFSET $2
LIMIT $ { LIMIT } ` , decodedCursor.time, decodedCursor.offset)
2021-06-24 23:56:01 +00:00
break
default :
items = await models . $queryRaw ( `
2021-06-22 17:47:49 +00:00
$ { SELECT }
FROM "Item"
WHERE "parentId" IS NULL AND created _at <= $1
ORDER BY created _at DESC
OFFSET $2
LIMIT $ { LIMIT } ` , decodedCursor.time, decodedCursor.offset)
2021-06-24 23:56:01 +00:00
break
}
2021-06-22 17:47:49 +00:00
return {
cursor : items . length === LIMIT ? nextCursorEncoded ( decodedCursor ) : null ,
items
}
2021-04-24 21:05:07 +00:00
} ,
2021-06-24 23:56:01 +00:00
moreFlatComments : async ( parent , { cursor , userId } , { me , models } ) => {
const decodedCursor = decodeCursor ( cursor )
2021-08-18 22:20:33 +00:00
if ( ! userId ) {
throw new UserInputError ( 'must supply userId' , { argumentName : 'userId' } )
2021-06-24 23:56:01 +00:00
}
2021-08-18 22:20:33 +00:00
const comments = await models . $queryRaw ( `
$ { SELECT }
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NOT NULL
AND created _at <= $2
ORDER BY created _at DESC
OFFSET $3
LIMIT $ { LIMIT } ` , Number(userId), decodedCursor.time, decodedCursor.offset)
2021-06-24 23:56:01 +00:00
return {
cursor : comments . length === LIMIT ? nextCursorEncoded ( decodedCursor ) : null ,
comments
}
} ,
2021-04-14 23:56:29 +00:00
item : async ( parent , { id } , { models } ) => {
2021-05-11 20:29:44 +00:00
const [ item ] = await models . $queryRaw ( `
2021-04-22 22:14:32 +00:00
$ { SELECT }
2021-04-14 23:56:29 +00:00
FROM "Item"
2021-05-11 20:29:44 +00:00
WHERE id = $1 ` , Number(id))
2021-08-12 21:21:56 +00:00
if ( item ) {
item . comments = comments ( models , id )
}
2021-04-29 15:56:28 +00:00
return item
2021-04-14 23:56:29 +00:00
} ,
2021-04-22 22:14:32 +00:00
userComments : async ( parent , { userId } , { models } ) => {
return await models . $queryRaw ( `
$ { SELECT }
FROM "Item"
2021-05-11 20:29:44 +00:00
WHERE "userId" = $1 AND "parentId" IS NOT NULL
ORDER BY created _at DESC ` , Number(userId))
2021-08-22 15:25:17 +00:00
} ,
pageTitle : async ( parent , { url } , { models } ) => {
try {
const response = await fetch ( ensureProtocol ( url ) , { redirect : 'follow' } )
const html = await response . text ( )
const doc = domino . createWindow ( html ) . document
const metadata = getMetadata ( doc , url , { title : metadataRuleSets . title } )
return metadata ? . title
} catch ( e ) {
return null
}
2021-04-12 18:05:09 +00:00
}
} ,
Mutation : {
2021-04-14 00:57:32 +00:00
createLink : async ( parent , { title , url } , { me , models } ) => {
if ( ! title ) {
2021-05-11 20:29:44 +00:00
throw new UserInputError ( 'link must have title' , { argumentName : 'title' } )
2021-04-12 18:05:09 +00:00
}
2021-04-14 00:57:32 +00:00
if ( ! url ) {
2021-05-11 20:29:44 +00:00
throw new UserInputError ( 'link must have url' , { argumentName : 'url' } )
2021-04-12 18:05:09 +00:00
}
2021-05-20 19:11:58 +00:00
return await createItem ( parent , { title , url : ensureProtocol ( url ) } , { me , models } )
2021-04-14 00:57:32 +00:00
} ,
2021-08-11 20:13:10 +00:00
updateLink : async ( parent , { id , title , url } , { me , models } ) => {
if ( ! id ) {
throw new UserInputError ( 'link must have id' , { argumentName : 'id' } )
}
2021-04-14 00:57:32 +00:00
if ( ! title ) {
2021-05-11 20:29:44 +00:00
throw new UserInputError ( 'link must have title' , { argumentName : 'title' } )
2021-04-12 18:05:09 +00:00
}
2021-08-11 20:13:10 +00:00
if ( ! url ) {
throw new UserInputError ( 'link must have url' , { argumentName : 'url' } )
}
// update iff this item belongs to me
const item = await models . item . findUnique ( { where : { id : Number ( id ) } } )
if ( Number ( item . userId ) !== Number ( me . id ) ) {
throw new AuthenticationError ( 'item does not belong to you' )
}
if ( Date . now ( ) > new Date ( item . createdAt ) . getTime ( ) + 10 * 60000 ) {
throw new UserInputError ( 'item can no longer be editted' )
}
2021-08-18 22:20:33 +00:00
return await updateItem ( parent , { id , data : { title , url : ensureProtocol ( url ) } } , { me , models } )
2021-08-11 20:13:10 +00:00
} ,
createDiscussion : async ( parent , { title , text } , { me , models } ) => {
if ( ! title ) {
throw new UserInputError ( 'discussion must have title' , { argumentName : 'title' } )
}
2021-04-14 00:57:32 +00:00
return await createItem ( parent , { title , text } , { me , models } )
} ,
2021-08-11 20:13:10 +00:00
updateDiscussion : async ( parent , { id , title , text } , { me , models } ) => {
if ( ! id ) {
throw new UserInputError ( 'discussion must have id' , { argumentName : 'id' } )
}
if ( ! title ) {
throw new UserInputError ( 'discussion must have title' , { argumentName : 'title' } )
}
// update iff this item belongs to me
const item = await models . item . findUnique ( { where : { id : Number ( id ) } } )
if ( Number ( item . userId ) !== Number ( me . id ) ) {
throw new AuthenticationError ( 'item does not belong to you' )
}
if ( Date . now ( ) > new Date ( item . createdAt ) . getTime ( ) + 10 * 60000 ) {
throw new UserInputError ( 'item can no longer be editted' )
}
2021-08-18 22:20:33 +00:00
return await updateItem ( parent , { id , data : { title , text } } , { me , models } )
2021-08-11 20:13:10 +00:00
} ,
2021-04-14 00:57:32 +00:00
createComment : async ( parent , { text , parentId } , { me , models } ) => {
if ( ! text ) {
2021-05-11 20:29:44 +00:00
throw new UserInputError ( 'comment must have text' , { argumentName : 'text' } )
2021-04-14 00:57:32 +00:00
}
if ( ! parentId ) {
2021-08-10 22:59:06 +00:00
throw new UserInputError ( 'comment must have parent' , { argumentName : 'parentId' } )
2021-04-12 18:05:09 +00:00
}
2021-04-14 00:57:32 +00:00
return await createItem ( parent , { text , parentId } , { me , models } )
2021-04-26 21:55:15 +00:00
} ,
2021-08-10 22:59:06 +00:00
updateComment : async ( parent , { id , text } , { me , models } ) => {
if ( ! text ) {
throw new UserInputError ( 'comment must have text' , { argumentName : 'text' } )
}
if ( ! id ) {
throw new UserInputError ( 'comment must have id' , { argumentName : 'id' } )
}
// update iff this comment belongs to me
const comment = await models . item . findUnique ( { where : { id : Number ( id ) } } )
if ( Number ( comment . userId ) !== Number ( me . id ) ) {
2021-08-11 20:13:10 +00:00
throw new AuthenticationError ( 'comment does not belong to you' )
2021-08-10 22:59:06 +00:00
}
if ( Date . now ( ) > new Date ( comment . createdAt ) . getTime ( ) + 10 * 60000 ) {
throw new UserInputError ( 'comment can no longer be editted' )
}
2021-08-18 22:20:33 +00:00
return await updateItem ( parent , { id , data : { text } } , { me , models } )
2021-08-10 22:59:06 +00:00
} ,
2021-04-26 21:55:15 +00:00
vote : async ( parent , { id , sats = 1 } , { me , models } ) => {
// need to make sure we are logged in
if ( ! me ) {
2021-05-11 20:29:44 +00:00
throw new AuthenticationError ( 'you must be logged in' )
2021-04-26 21:55:15 +00:00
}
2021-04-27 21:30:58 +00:00
if ( sats <= 0 ) {
2021-05-11 20:29:44 +00:00
throw new UserInputError ( 'sats must be positive' , { argumentName : 'sats' } )
2021-04-27 21:30:58 +00:00
}
2021-05-20 01:09:32 +00:00
await serialize ( models , models . $queryRaw ` SELECT vote( ${ Number ( id ) } , ${ me . name } , ${ Number ( sats ) } ) ` )
2021-04-26 21:55:15 +00:00
return sats
2021-04-12 18:05:09 +00:00
}
} ,
Item : {
user : async ( item , args , { models } ) =>
await models . user . findUnique ( { where : { id : item . userId } } ) ,
2021-04-14 23:56:29 +00:00
ncomments : async ( item , args , { models } ) => {
const [ { count } ] = await models . $queryRaw `
SELECT count ( * )
FROM "Item"
2021-04-15 19:41:02 +00:00
WHERE path < @ text2ltree ( $ { item . path } ) AND id != $ { item . id } `
2021-05-11 15:52:50 +00:00
return count || 0
2021-04-14 23:56:29 +00:00
} ,
2021-04-26 21:55:15 +00:00
sats : async ( item , args , { models } ) => {
const { sum : { sats } } = await models . vote . aggregate ( {
sum : {
sats : true
} ,
where : {
2021-04-27 21:30:58 +00:00
itemId : item . id ,
boost : false
}
} )
2021-05-11 15:52:50 +00:00
return sats || 0
2021-04-27 21:30:58 +00:00
} ,
boost : async ( item , args , { models } ) => {
const { sum : { sats } } = await models . vote . aggregate ( {
sum : {
sats : true
} ,
where : {
itemId : item . id ,
boost : true
2021-04-26 21:55:15 +00:00
}
} )
2021-05-11 15:52:50 +00:00
return sats || 0
2021-04-26 21:55:15 +00:00
} ,
meSats : async ( item , args , { me , models } ) => {
if ( ! me ) return 0
const { sum : { sats } } = await models . vote . aggregate ( {
sum : {
sats : true
} ,
where : {
itemId : item . id ,
2021-06-27 03:09:39 +00:00
userId : me . id
2021-04-26 21:55:15 +00:00
}
} )
2021-05-11 15:52:50 +00:00
return sats || 0
2021-07-08 00:15:27 +00:00
} ,
root : async ( item , args , { models } ) => {
if ( ! item . parentId ) {
return null
}
return ( await models . $queryRaw ( `
$ { SELECT }
FROM "Item"
WHERE id = (
SELECT ltree2text ( subltree ( path , 0 , 1 ) ) : : integer
FROM "Item"
WHERE id = $1 ) ` , Number(item.id)))[0]
} ,
parent : async ( item , args , { models } ) => {
if ( ! item . parentId ) {
return null
}
return await models . item . findUnique ( { where : { id : item . parentId } } )
2021-04-26 21:55:15 +00:00
}
}
}
2021-08-18 22:20:33 +00:00
const namePattern = /\B@[\w_]+/gi
const createMentions = async ( item , models ) => {
// if we miss a mention, in the rare circumstance there's some kind of
// failure, it's not a big deal so we don't do it transactionally
// ideally, we probably would
if ( ! item . text ) {
return
}
try {
2021-08-19 19:53:11 +00:00
const mentions = item . text . match ( namePattern ) ? . map ( m => m . slice ( 1 ) )
if ( mentions ? . length > 0 ) {
2021-08-18 22:20:33 +00:00
const users = await models . user . findMany ( {
where : {
name : { in : mentions }
}
} )
users . forEach ( async user => {
const data = {
itemId : item . id ,
userId : user . id
}
await models . mention . upsert ( {
where : {
itemId _userId : data
} ,
update : data ,
create : data
} )
} )
}
} catch ( e ) {
console . log ( 'mention failure' , e )
}
}
const updateItem = async ( parent , { id , data } , { me , models } ) => {
const item = await models . item . update ( {
where : { id : Number ( id ) } ,
data
} )
await createMentions ( item , models )
return item
}
2021-05-11 20:29:44 +00:00
const createItem = async ( parent , { title , url , text , parentId } , { me , models } ) => {
2021-04-26 21:55:15 +00:00
if ( ! me ) {
2021-05-11 20:29:44 +00:00
throw new AuthenticationError ( 'you must be logged in' )
2021-04-26 21:55:15 +00:00
}
2021-05-20 01:09:32 +00:00
const [ item ] = await serialize ( models , models . $queryRaw (
` ${ SELECT } FROM create_item( $ 1, $ 2, $ 3, $ 4, $ 5) AS "Item" ` ,
title , url , text , Number ( parentId ) , me . name ) )
2021-08-18 22:20:33 +00:00
await createMentions ( item , models )
2021-05-20 01:09:32 +00:00
item . comments = [ ]
return item
2021-04-26 21:55:15 +00:00
}
function nestComments ( flat , parentId ) {
const result = [ ]
let added = 0
for ( let i = 0 ; i < flat . length ; ) {
if ( ! flat [ i ] . comments ) flat [ i ] . comments = [ ]
if ( Number ( flat [ i ] . parentId ) === Number ( parentId ) ) {
result . push ( flat [ i ] )
added ++
i ++
} else if ( result . length > 0 ) {
const item = result [ result . length - 1 ]
const [ nested , newAdded ] = nestComments ( flat . slice ( i ) , item . id )
if ( newAdded === 0 ) {
break
}
item . comments . push ( ... nested )
i += newAdded
added += newAdded
} else {
break
}
2021-04-12 18:05:09 +00:00
}
2021-04-26 21:55:15 +00:00
return [ result , added ]
2021-04-12 18:05:09 +00:00
}
2021-04-26 21:55:15 +00:00
// we have to do our own query because ltree is unsupported
const SELECT =
2021-04-27 21:30:58 +00:00
` SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
"Item" . text , "Item" . url , "Item" . "userId" , "Item" . "parentId" , ltree2text ( "Item" . "path" ) AS "path" `
2021-07-09 19:12:35 +00:00
const LEFT _JOIN _SATS _SELECT = 'SELECT i.id, SUM(CASE WHEN "Vote".boost THEN 0 ELSE "Vote".sats END) as sats, SUM(CASE WHEN "Vote".boost THEN "Vote".sats ELSE 0 END) as boost'
2021-06-22 17:47:49 +00:00
function timedLeftJoinSats ( num ) {
2021-07-09 19:12:35 +00:00
return ` LEFT JOIN ( ${ LEFT _JOIN _SATS _SELECT }
2021-06-22 17:47:49 +00:00
FROM "Item" i
JOIN "Vote" ON i . id = "Vote" . "itemId" AND "Vote" . created _at <= $$ { num }
GROUP BY i . id ) x ON "Item" . id = x . id `
}
2021-04-27 21:30:58 +00:00
const LEFT _JOIN _SATS =
2021-07-09 19:12:35 +00:00
` LEFT JOIN ( ${ LEFT _JOIN _SATS _SELECT }
2021-04-27 21:30:58 +00:00
FROM "Item" i
JOIN "Vote" ON i . id = "Vote" . "itemId"
GROUP BY i . id ) x ON "Item" . id = x . id `
2021-06-22 17:47:49 +00:00
function timedOrderBySats ( num ) {
2021-07-09 19:12:35 +00:00
return ` ORDER BY ((x.sats-1)/POWER(EXTRACT(EPOCH FROM ( $ ${ num } - "Item".created_at))/3600+2, 1.5) +
2021-07-10 13:03:37 +00:00
( x . boost ) / POWER ( EXTRACT ( EPOCH FROM ( $$ { num } - "Item" . created _at ) ) / 3600 + 2 , 5 ) ) DESC NULLS LAST `
2021-06-22 17:47:49 +00:00
}
2021-04-27 21:30:58 +00:00
const ORDER _BY _SATS =
2021-07-09 19:12:35 +00:00
` ORDER BY ((x.sats-1)/POWER(EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE 'UTC') - "Item".created_at))/3600+2, 1.5) +
2021-07-10 13:03:37 +00:00
( x . boost ) / POWER ( EXTRACT ( EPOCH FROM ( ( NOW ( ) AT TIME ZONE 'UTC' ) - "Item" . created _at ) ) / 3600 + 2 , 5 ) ) DESC NULLS LAST `