import type { Team , MdxListItem , Await , User } from '~/types' ; import { subYears , subMonths } from 'date-fns' ; import { shuffle } from 'lodash' ; import { getBlogMdxListItems } from './mdx' ; import { prismaRead } from './prisma.server' ; import { getDomainUrl , getRequiredServerEnvVar , teams , typedBoolean , } from './misc' ; import { getSession , getUser } from './session.server' ; import { filterPosts } from './blog' ; import { getClientSession } from './client.server' ; import { cachified , lruCache } from './cache.server' ; import { redisCache } from './redis.server' ; import { sendMessageFromDiscordBot } from './discord.server' ; import { teamEmoji } from './team-provider' ; async function getBlogRecommendations ( request , { limit = 3 , keywords = [ ] , exclude : externalExclude = [ ] , } = { } ) { const allPosts = await getBlogMdxListItems ( { forceFresh : false } ) ; let exclude = Array . from ( new Set ( [ ... externalExclude , ... allPosts . filter ( post => post . frontmatter . archived ?? post . frontmatter . draft ) . map ( p => p . slug ) , ] ) ) ; const session = await getSession ( request ) ; const client = await getClientSession ( request ) ; const clientId = client . getClientId ( ) ; const user = await session . getUser ( ) ; const where = user ? { user : { id : user . id } , postSlug : { notIn : exclude . filter ( Boolean ) } } : { clientId , postSlug : { notIn : exclude . filter ( Boolean ) } } ; const readPosts = await prismaRead . postRead . groupBy ( { by : [ 'postSlug' ] , where , } ) ; exclude . push ( ... readPosts . map ( p => p . postSlug ) ) ; const recommendablePosts = allPosts . filter ( post => ! exclude . includes ( post . slug ) ) ; if ( limit === null ) return shuffle ( recommendablePosts ) ; const recommendations = [ ] ; const groupsCount = keywords . length ? 3 : 2 ; const limitPerGroup = Math . floor ( limit / groupsCount ) || 1 ; if ( keywords . length ) { const postsByBestMatch = keywords . length ? Array . from ( new Set ( ... keywords . map ( k => filterPosts ( recommendablePosts , k ) ) ) ) : recommendablePosts ; const bestMatchRecommendations = shuffle ( postsByBestMatch . slice ( 0 , limitPerGroup * 4 ) ) . slice ( 0 , limitPerGroup ) ; recommendations . push ( ... bestMatchRecommendations ) ; exclude = [ ... exclude , ... bestMatchRecommendations . map ( ( { slug } ) => slug ) ] ; } const mostPopularRecommendationSlugs = await getMostPopularPostSlugs ( { limit : limitPerGroup * 4 , exclude , } ) ; const mostPopularRecommendations = shuffle ( mostPopularRecommendationSlugs . map ( slug => recommendablePosts . find ( ( { slug : s } ) => s === slug ) ) . filter ( typedBoolean ) ) . slice ( 0 , limitPerGroup ) ; recommendations . push ( ... mostPopularRecommendations ) ; exclude = [ ... exclude , ... mostPopularRecommendationSlugs ] ; if ( recommendations . length < limit ) { const remainingPosts = recommendablePosts . filter ( ( { slug } ) => ! exclude . includes ( slug ) ) ; const completelyRandomRecommendations = shuffle ( remainingPosts ) . slice ( 0 , limit - recommendations . length ) ; recommendations . push ( ... completelyRandomRecommendations ) ; } return shuffle ( recommendations ) ; } async function getMostPopularPostSlugs ( { limit , exclude , } ) { if ( exclude . length ) return getFreshValue ( ) ; async function getFreshValue ( ) { const result = await prismaRead . postRead . groupBy ( { by : [ 'postSlug' ] , _count : true , orderBy : { _count : { postSlug : 'desc' , } , } , where : { postSlug : { notIn : exclude . filter ( Boolean ) } , } , take : limit , } ) ; return result . map ( p => p . postSlug ) ; } return cachified ( { key : `${ limit }-most-popular-post-slugs` , maxAge : 1000 * 60 , cache : lruCache , getFreshValue , checkValue : ( value ) => Array . isArray ( value ) && value . every ( v => typeof v === 'string' ) , } ) ; } async function getTotalPostReads ( request , slug ? ) { return cachified ( { key : `total-post-reads:${ slug ?? '__all-posts__' }` , cache : lruCache , maxAge : 1000 * 60 , request , checkValue : ( value ) => typeof value === 'number' , getFreshValue : ( ) => prismaRead . postRead . count ( slug ? { where : { postSlug : slug } } : undefined ) , } ) ; } async function getReaderCount ( request ) { return cachified ( { key : 'total-reader-count' , cache : lruCache , maxAge : 1000 * 60 * 5 , request , checkValue : ( value ) => typeof value === 'number' , getFreshValue : async ( ) => { type CountResult = [ { count : number ; } ] ; const [ userIdCount , clientIdCount ] = await Promise . all ( [ prismaRead . $queryRaw `SELECT COUNT(DISTINCT "public"."PostRead"."userId") FROM "public"."PostRead" WHERE ("public"."PostRead"."userId") IS NOT NULL` as Promise < CountResult > , prismaRead . $queryRaw `SELECT COUNT(DISTINCT "public"."PostRead"."clientId") FROM "public"."PostRead" WHERE ("public"."PostRead"."clientId") IS NOT NULL` as Promise < CountResult > , ] ) . catch ( ( ) => [ [ { count : 0 } ] , [ { count : 0 } ] ] ) ; return userIdCount [ 0 ] . count + clientIdCount [ 0 ] . count ; } , } ) ; } export type ReadRankings = Await < ReturnType < typeof getBlogReadRankings > > ; async function getBlogReadRankings ( { slug , request , forceFresh , } ) { const key = slug ? `blog:${ slug }:rankings` : `blog:rankings` ; const rankingObjs = await cachified ( { key , cache : redisCache , maxAge : slug ? 1000 * 60 * 60 * 24 * 7 : 1000 * 60 * 60 , request , forceFresh , checkValue : ( value ) => Array . isArray ( value ) && value . every ( v => typeof v === 'object' && 'team' in v ) , getFreshValue : async ( ) => { const rawRankingData = await Promise . all ( teams . map ( async function getRankingsForTeam ( team ) : Promise < { team : Team ; totalReads : number ; ranking : number ; } > { const totalReads = await prismaRead . postRead . count ( { where : { postSlug : slug , user : { team } , } , } ) ; const activeMembers = await getActiveMembers ( team ) ; const recentReads = await getRecentReads ( slug , team ) ; let ranking = 0 ; if ( activeMembers ) { ranking = Number ( ( recentReads / activeMembers ) . toFixed ( 4 ) ) ; } return { team , totalReads , ranking } ; } ) ) ; const rankings = rawRankingData . map ( r => r . ranking ) ; const maxRanking = Math . max ( ... rankings ) ; const minRanking = Math . min ( ... rankings ) ; const rankPercentages = rawRankingData . map ( ( { team , totalReads , ranking } ) => { return { team , totalReads , ranking , percent : Number ( ( ( ranking - minRanking ) / ( maxRanking - minRanking || 1 ) ) . toFixed ( 2 ) ) , } ; } ) ; return rankPercentages ; } , } ) ; return ( rankingObjs . sort ( ( { percent : a } , { percent : b } ) => b === a ? ( Math . random ( ) > 0.5 ? - 1 : 1 ) : a > b ? - 1 : 1 ) ) ; } async function getAllBlogPostReadRankings ( { request , forceFresh , } ) { return cachified ( { key : 'all-blog-post-read-rankings' , cache : redisCache , forceFresh , request , maxAge : 1000 * 60 * 5 , getFreshValue : async ( ) => { const posts = await getBlogMdxListItems ( { request } ) ; const { default : pLimit } = await import ( 'p-limit' ) ; const limit = pLimit ( 2 ) ; const allPostReadRankings = { } ; await Promise . all ( posts . map ( post => limit ( async ( ) => { allPostReadRankings [ post . slug ] = await getBlogReadRankings ( { request , slug : post . slug , } ) ; } ) ) ) ; return allPostReadRankings ; } , } ) ; } async function getRecentReads ( slug , team ) { const withinTheLastSixMonths = subMonths ( new Date ( ) , 6 ) ; const count = await prismaRead . postRead . count ( { where : { postSlug : slug , createdAt : { gt : withinTheLastSixMonths } , user : { team } , } , } ) ; return count ; } async function getActiveMembers ( team ) { const withinTheLastYear = subYears ( new Date ( ) , 1 ) ; const count = await prismaRead . user . count ( { where : { team , postReads : { some : { createdAt : { gt : withinTheLastYear } , } , } , } , } ) ; return count ; } async function getSlugReadsByUser ( request ) { const user = await getUser ( request ) ; if ( ! user ) return [ ] ; const reads = await prismaRead . postRead . findMany ( { where : { userId : user . id } , select : { postSlug : true } , } ) ; return Array . from ( new Set ( reads . map ( read => read . postSlug ) ) ) ; } async function getPostJson ( request ) { const posts = await getBlogMdxListItems ( { request } ) ; const blogUrl = `${ getDomainUrl ( request ) }/blog` ; return posts . map ( post => { const { slug , frontmatter : { title , description , meta : { keywords = [ ] } = { } , categories } , } = post ; return { id : slug , slug , productionUrl : `${ blogUrl }/${ slug }` , title , categories , keywords , description , } ; } ) ; } const leaderboardChannelId = getRequiredServerEnvVar ( 'DISCORD_LEADERBOARD_CHANNEL' ) ; const getUserDiscordMention = ( user ) => user . discordId ? `<@!${ user . discordId }>` : user . firstName ; async function notifyOfTeamLeaderChangeOnPost ( { request , prevLeader , newLeader , postSlug , reader , } ) { const blogUrl = `${ getDomainUrl ( request ) }/blog` ; const newLeaderEmoji = teamEmoji [ newLeader ] ; const url = `${ blogUrl }/${ postSlug }` ; const newTeamMention = `the ${ newLeaderEmoji } ${ newLeader . toLowerCase ( ) } team` ; if ( prevLeader ) { const prevLeaderEmoji = teamEmoji [ prevLeader ] ; const prevTeamMention = `the ${ prevLeaderEmoji } ${ prevLeader . toLowerCase ( ) } team` ; if ( reader && reader . team === newLeader ) { const readerMention = getUserDiscordMention ( reader ) ; const cause = `${ readerMention } just read ${ url } and won the post from ${ prevTeamMention } for ${ newTeamMention }!` ; await sendMessageFromDiscordBot ( leaderboardChannelId , `🎉 Congratulations to ${ newTeamMention }! You've won a post!\n\n${ cause }` ) ; } else { const who = reader ? `Someone on the ${ teamEmoji [ reader . team ] } ${ reader . team . toLowerCase ( ) } team` : `An anonymous user` ; const cause = `${ who } just read ${ url } and triggered a recalculation of the rankings: ${ prevTeamMention } lost the post and it's now claimed by ${ newTeamMention }!` ; await sendMessageFromDiscordBot ( leaderboardChannelId , `🎉 Congratulations to ${ newTeamMention }! You've won a post!\n\n${ cause }` ) ; } } else if ( reader ) { const readerMention = getUserDiscordMention ( reader ) ; await sendMessageFromDiscordBot ( leaderboardChannelId , `Congratulations to ${ newTeamMention }! You've won a post!\n\n${ readerMention } just read ${ url } and claimed the post for ${ newTeamMention }!` ) ; } } async function notifyOfOverallTeamLeaderChange ( { request , prevLeader , newLeader , postSlug , reader , } ) { const blogUrl = `${ getDomainUrl ( request ) }/blog` ; const newLeaderEmoji = teamEmoji [ newLeader ] ; const url = `${ blogUrl }/${ postSlug }` ; const cause = reader ? `${ getUserDiscordMention ( reader ) } just read ${ url }` : `An anonymous user just read ${ url } triggering a ranking recalculation` ; if ( prevLeader ) { const prevLeaderEmoji = teamEmoji [ prevLeader ] ; await sendMessageFromDiscordBot ( leaderboardChannelId , `🎉 Congratulations to the ${ newLeaderEmoji } ${ newLeader . toLowerCase ( ) } team! ${ cause } and knocked team ${ prevLeaderEmoji } ${ prevLeader . toLowerCase ( ) } team off the top of the leader board! 👏` ) ; } else { await sendMessageFromDiscordBot ( leaderboardChannelId , `🎉 Congratulations to the ${ newLeaderEmoji } ${ newLeader . toLowerCase ( ) } team! ${ cause } and took ${ newLeader . toLowerCase ( ) } team to the top of the leader board! 👏` ) ; } } export { getBlogRecommendations , getBlogReadRankings , getAllBlogPostReadRankings , getSlugReadsByUser , getTotalPostReads , getReaderCount , getPostJson , notifyOfTeamLeaderChangeOnPost , notifyOfOverallTeamLeaderChange , } ;