Server-side tracking of comments view time (#2432)
* server-side comments view tracking, model structure, mutation * full commentsViewedAt refactor, adjust comment creation and injection, adjust item navigation * update server-side tracking only if there's a change, light cleanup * coalesce meCommentsViewedAt to the item's createdAt, wip PoC comment outlining * don't update cache on item visit, use useRoot hook for outlining * add meCommentsViewedAt to root, better naming, light cleanup * better timestamp logic and comparisons, add lastCommentAt to root item object, added TODOs * fix: track commentsViewedAt only for root item, use topLevelId to fetch live comments only for the current item * only track commentsViewedAt for root item, light cleanup * light cleanup, correct live comments timestamp deps * worker: on midnight, untrack items that were never viewed and had no comments in the last 21 days
This commit is contained in:
parent
b0f01c1dd4
commit
b5af28c48b
@ -180,7 +180,8 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
|
||||
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark",
|
||||
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward",
|
||||
to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL)
|
||||
|| jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub
|
||||
|| jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub,
|
||||
COALESCE("CommentsViewAt"."last_viewed_at", "Item"."created_at") as "meCommentsViewedAt"
|
||||
FROM (
|
||||
${query}
|
||||
) "Item"
|
||||
@ -192,6 +193,7 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
|
||||
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
|
||||
LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}
|
||||
LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id}
|
||||
LEFT JOIN "CommentsViewAt" ON "CommentsViewAt"."itemId" = "Item".id AND "CommentsViewAt"."userId" = ${me.id}
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT "itemId",
|
||||
sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM 'FAILED' AND "InvoiceForward".id IS NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMsats",
|
||||
@ -741,7 +743,7 @@ export default {
|
||||
subMaxBoost: subAgg?._max.boost || 0
|
||||
}
|
||||
},
|
||||
newComments: async (parent, { rootId, after }, { models, me }) => {
|
||||
newComments: async (parent, { topLevelId, after }, { models, me }) => {
|
||||
const comments = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
@ -755,7 +757,7 @@ export default {
|
||||
'"Item"."created_at" > $2'
|
||||
)}
|
||||
ORDER BY "Item"."created_at" ASC`
|
||||
}, Number(rootId), after)
|
||||
}, Number(topLevelId), after)
|
||||
|
||||
return { comments }
|
||||
}
|
||||
@ -1073,6 +1075,21 @@ export default {
|
||||
])
|
||||
|
||||
return result
|
||||
},
|
||||
updateCommentsViewAt: async (parent, { id, meCommentsViewedAt }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
}
|
||||
|
||||
const result = await models.commentsViewAt.upsert({
|
||||
where: {
|
||||
userId_itemId: { userId: Number(me.id), itemId: Number(id) }
|
||||
},
|
||||
update: { lastViewedAt: new Date(meCommentsViewedAt) },
|
||||
create: { userId: Number(me.id), itemId: Number(id), lastViewedAt: new Date(meCommentsViewedAt) }
|
||||
})
|
||||
|
||||
return result.lastViewedAt
|
||||
}
|
||||
},
|
||||
ItemAct: {
|
||||
|
@ -12,7 +12,7 @@ export default gql`
|
||||
auctionPosition(sub: String, id: ID, boost: Int): Int!
|
||||
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
|
||||
itemRepetition(parentId: ID): Int!
|
||||
newComments(rootId: ID, after: Date): Comments!
|
||||
newComments(topLevelId: ID, after: Date): Comments!
|
||||
}
|
||||
|
||||
type BoostPositions {
|
||||
@ -65,6 +65,7 @@ export default gql`
|
||||
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
|
||||
pollVote(id: ID!): PollVotePaidAction!
|
||||
toggleOutlaw(id: ID!): Item!
|
||||
updateCommentsViewAt(id: ID!, meCommentsViewedAt: Date!): Date
|
||||
}
|
||||
|
||||
type PollVoteResult {
|
||||
@ -173,6 +174,7 @@ export default gql`
|
||||
apiKey: Boolean
|
||||
invoice: Invoice
|
||||
cost: Int!
|
||||
meCommentsViewedAt: Date
|
||||
}
|
||||
|
||||
input ItemForwardInput {
|
||||
|
@ -96,7 +96,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
|
||||
}
|
||||
|
||||
export default function Comment ({
|
||||
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt,
|
||||
item, children, replyOpen, includeParent, topLevel,
|
||||
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry,
|
||||
navigator
|
||||
}) {
|
||||
@ -157,13 +157,19 @@ export default function Comment ({
|
||||
}, [item.id, cache, router.query.commentId])
|
||||
|
||||
useEffect(() => {
|
||||
if (me?.id === item.user?.id) return
|
||||
// checking navigator because outlining should happen only on item pages
|
||||
if (!navigator || me?.id === item.user?.id) return
|
||||
|
||||
const itemCreatedAt = new Date(item.createdAt).getTime()
|
||||
|
||||
const rootViewedAt = new Date(root.meCommentsViewedAt).getTime()
|
||||
const rootLast = new Date(root.lastCommentAt || root.createdAt).getTime()
|
||||
// it's a new comment if it was created after the last comment was viewed
|
||||
// or, in the case of live comments, after the last comment was created
|
||||
const isNewComment = (router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) ||
|
||||
(rootLastCommentAt && itemCreatedAt > new Date(rootLastCommentAt).getTime())
|
||||
const isNewComment = me?.id && rootViewedAt
|
||||
? itemCreatedAt > rootViewedAt
|
||||
// anon fallback is based on the commentsViewedAt query param or the last comment createdAt
|
||||
: ((router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) ||
|
||||
(itemCreatedAt > rootLast))
|
||||
if (!isNewComment) return
|
||||
|
||||
if (item.injected) {
|
||||
@ -180,8 +186,8 @@ export default function Comment ({
|
||||
ref.current.classList.add('outline-new-comment')
|
||||
}
|
||||
|
||||
navigator?.trackNewComment(ref, itemCreatedAt)
|
||||
}, [item.id, rootLastCommentAt])
|
||||
navigator.trackNewComment(ref, itemCreatedAt)
|
||||
}, [item.id, root.lastCommentAt, root.meCommentsViewedAt])
|
||||
|
||||
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
|
||||
// Don't show OP badge when anon user comments on anon user posts
|
||||
@ -303,7 +309,7 @@ export default function Comment ({
|
||||
? (
|
||||
<>
|
||||
{item.comments.comments.map((item) => (
|
||||
<Comment depth={depth + 1} key={item.id} item={item} navigator={navigator} rootLastCommentAt={rootLastCommentAt} />
|
||||
<Comment depth={depth + 1} key={item.id} item={item} navigator={navigator} />
|
||||
))}
|
||||
{item.comments.comments.length < item.nDirectComments && (
|
||||
<div className={`d-block ${styles.comment} pb-2 ps-3`}>
|
||||
|
@ -98,11 +98,11 @@ export default function Comments ({
|
||||
: null}
|
||||
{pins.map(item => (
|
||||
<Fragment key={item.id}>
|
||||
<Comment depth={1} item={item} navigator={navigator} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
|
||||
<Comment depth={1} item={item} navigator={navigator} {...props} pin />
|
||||
</Fragment>
|
||||
))}
|
||||
{comments.filter(({ position }) => !position).map(item => (
|
||||
<Comment depth={1} key={item.id} item={item} navigator={navigator} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} />
|
||||
<Comment depth={1} key={item.id} item={item} navigator={navigator} {...props} />
|
||||
))}
|
||||
{ncomments > FULL_COMMENTS_THRESHOLD &&
|
||||
<MoreFooter
|
||||
|
@ -27,6 +27,8 @@ import classNames from 'classnames'
|
||||
import { CarouselProvider } from './carousel'
|
||||
import Embed from './embed'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { UPDATE_ITEM_USER_VIEW } from '@/fragments/items'
|
||||
|
||||
function BioItem ({ item, handleClick }) {
|
||||
const { me } = useMe()
|
||||
@ -162,9 +164,25 @@ function ItemText ({ item }) {
|
||||
}
|
||||
|
||||
export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) {
|
||||
const { me } = useMe()
|
||||
// no cache update here because we need to preserve the initial value
|
||||
const [updateCommentsViewAt] = useMutation(UPDATE_ITEM_USER_VIEW)
|
||||
|
||||
useEffect(() => {
|
||||
commentsViewed(item)
|
||||
}, [item.lastCommentAt])
|
||||
if (item.parentId) return
|
||||
// local comments viewed (anon fallback)
|
||||
if (!me?.id) return commentsViewed(item)
|
||||
|
||||
const last = new Date(item.lastCommentAt || item.createdAt)
|
||||
const viewedAt = new Date(item.meCommentsViewedAt)
|
||||
|
||||
if (viewedAt.getTime() >= last.getTime()) return
|
||||
|
||||
// me server comments viewed
|
||||
updateCommentsViewAt({
|
||||
variables: { id: item.id, meCommentsViewedAt: last }
|
||||
})
|
||||
}, [item.id, item.lastCommentAt, item.createdAt, item.meCommentsViewedAt, me?.id])
|
||||
|
||||
const router = useRouter()
|
||||
const carouselKey = `${item.id}-${router.query?.sort || 'default'}`
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Form, MarkdownInput } from '@/components/form'
|
||||
import styles from './reply.module.css'
|
||||
import { COMMENTS } from '@/fragments/comments'
|
||||
import { UPDATE_ITEM_USER_VIEW } from '@/fragments/items'
|
||||
import { useMe } from './me'
|
||||
import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
|
||||
@ -14,6 +15,7 @@ import { CREATE_COMMENT } from '@/fragments/paidAction'
|
||||
import useItemSubmit from './use-item-submit'
|
||||
import gql from 'graphql-tag'
|
||||
import { updateAncestorsCommentCount } from '@/lib/comments'
|
||||
import { useMutation } from '@apollo/client'
|
||||
|
||||
export default forwardRef(function Reply ({
|
||||
item,
|
||||
@ -30,6 +32,14 @@ export default forwardRef(function Reply ({
|
||||
const showModal = useShowModal()
|
||||
const root = useRoot()
|
||||
const sub = item?.sub || root?.sub
|
||||
const [updateCommentsViewAt] = useMutation(UPDATE_ITEM_USER_VIEW, {
|
||||
update (cache, { data: { updateCommentsViewAt } }) {
|
||||
cache.modify({
|
||||
id: `Item:${root?.id}`,
|
||||
fields: { meCommentsViewedAt: () => updateCommentsViewAt }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (replyOpen || quote || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) {
|
||||
@ -87,8 +97,14 @@ export default forwardRef(function Reply ({
|
||||
|
||||
// so that we don't see indicator for our own comments, we record this comments as the latest time
|
||||
// but we also have record num comments, in case someone else commented when we did
|
||||
const root = ancestors[0]
|
||||
commentsViewedAfterComment(root, result.createdAt)
|
||||
const rootId = ancestors[0]
|
||||
if (me?.id) {
|
||||
// server-tracked view
|
||||
updateCommentsViewAt({ variables: { id: rootId, meCommentsViewedAt: result.createdAt } })
|
||||
} else {
|
||||
// anon fallback
|
||||
commentsViewedAfterComment(rootId, result.createdAt)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccessfulSubmit: (data, { resetForm }) => {
|
||||
|
@ -1,16 +1,18 @@
|
||||
import preserveScroll from './preserve-scroll'
|
||||
import { GET_NEW_COMMENTS } from '../fragments/comments'
|
||||
import { UPDATE_ITEM_USER_VIEW } from '../fragments/items'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
|
||||
import { useQuery, useApolloClient } from '@apollo/client'
|
||||
import { useQuery, useApolloClient, useMutation } from '@apollo/client'
|
||||
import { commentsViewedAfterComment } from '../lib/new-comments'
|
||||
import {
|
||||
updateItemQuery,
|
||||
updateCommentFragment,
|
||||
getLatestCommentCreatedAt,
|
||||
updateAncestorsCommentCount,
|
||||
calculateDepth
|
||||
} from '../lib/comments'
|
||||
import { useMe } from './me'
|
||||
import { useRoot } from './root'
|
||||
|
||||
const POLL_INTERVAL = 1000 * 5 // 5 seconds
|
||||
|
||||
@ -30,9 +32,6 @@ function prepareComments (item, cache, newComment) {
|
||||
// update all ancestors comment count, but not the item itself
|
||||
const ancestors = itemHierarchy.slice(0, -1)
|
||||
updateAncestorsCommentCount(cache, ancestors, totalNComments)
|
||||
// update commentsViewedAt to now, and add the number of new comments
|
||||
const rootId = itemHierarchy[0]
|
||||
commentsViewedAfterComment(rootId, Date.now(), totalNComments)
|
||||
|
||||
// add a flag to the new comment to indicate it was injected
|
||||
const injectedComment = { ...newComment, injected: true }
|
||||
@ -58,29 +57,50 @@ function prepareComments (item, cache, newComment) {
|
||||
return payload
|
||||
}
|
||||
|
||||
function cacheNewComments (cache, rootId, newComments, sort) {
|
||||
function cacheNewComments (cache, latest, topLevelId, newComments, sort) {
|
||||
let injectedLatest = latest
|
||||
for (const newComment of newComments) {
|
||||
const { parentId } = newComment
|
||||
const topLevel = Number(parentId) === Number(rootId)
|
||||
const topLevel = Number(parentId) === Number(topLevelId)
|
||||
let injected = false
|
||||
|
||||
// if the comment is a top level comment, update the item, else update the parent comment
|
||||
if (topLevel) {
|
||||
updateItemQuery(cache, rootId, sort, (item) => prepareComments(item, cache, newComment))
|
||||
updateItemQuery(cache, topLevelId, sort, (item) => prepareComments(item, cache, newComment))
|
||||
injected = true
|
||||
} else {
|
||||
// if the comment is too deep, we can skip it
|
||||
const depth = calculateDepth(newComment.path, rootId, parentId)
|
||||
const depth = calculateDepth(newComment.path, topLevelId, parentId)
|
||||
if (depth > COMMENT_DEPTH_LIMIT) continue
|
||||
// inject the new comment into the parent comment's comments field
|
||||
updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
|
||||
const updated = updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
|
||||
injected = !!updated
|
||||
}
|
||||
|
||||
// update latest timestamp to the latest comment created at
|
||||
if (injected && new Date(newComment.createdAt).getTime() > new Date(injectedLatest).getTime()) {
|
||||
injectedLatest = newComment.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
return injectedLatest
|
||||
}
|
||||
|
||||
// useLiveComments fetches new comments under an item (rootId),
|
||||
// useLiveComments fetches new comments under an item (topLevelId),
|
||||
// that are newer than the latest comment createdAt (after), and injects them into the cache.
|
||||
export default function useLiveComments (rootId, after, sort) {
|
||||
const latestKey = `liveCommentsLatest:${rootId}`
|
||||
export default function useLiveComments (topLevelId, after, sort) {
|
||||
const latestKey = `liveCommentsLatest:${topLevelId}`
|
||||
const { cache } = useApolloClient()
|
||||
const { me } = useMe()
|
||||
const root = useRoot()
|
||||
const [updateCommentsViewAt] = useMutation(UPDATE_ITEM_USER_VIEW, {
|
||||
update (cache, { data: { updateCommentsViewAt } }) {
|
||||
cache.modify({
|
||||
id: `Item:${root.id}`,
|
||||
fields: { meCommentsViewedAt: () => updateCommentsViewAt }
|
||||
})
|
||||
}
|
||||
})
|
||||
const [disableLiveComments] = useLiveCommentsToggle()
|
||||
const [latest, setLatest] = useState(after)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
@ -96,12 +116,12 @@ export default function useLiveComments (rootId, after, sort) {
|
||||
// Apollo might update the cache before the page has fully rendered, causing reads of stale cached data
|
||||
// this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered
|
||||
setInitialized(true)
|
||||
}, [after])
|
||||
}, [topLevelId, after])
|
||||
|
||||
const { data } = useQuery(GET_NEW_COMMENTS, {
|
||||
pollInterval: POLL_INTERVAL,
|
||||
// only get comments newer than the passed latest timestamp
|
||||
variables: { rootId, after: latest },
|
||||
variables: { topLevelId, after: latest },
|
||||
nextFetchPolicy: 'cache-and-network',
|
||||
skip: SSR || !initialized || disableLiveComments
|
||||
})
|
||||
@ -111,14 +131,29 @@ export default function useLiveComments (rootId, after, sort) {
|
||||
|
||||
// directly inject new comments into the cache, preserving scroll position
|
||||
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe
|
||||
preserveScroll(() => cacheNewComments(cache, rootId, data.newComments.comments, sort))
|
||||
let injectedLatest = latest
|
||||
preserveScroll(() => {
|
||||
injectedLatest = cacheNewComments(cache, injectedLatest, topLevelId, data.newComments.comments, sort)
|
||||
})
|
||||
|
||||
// update latest timestamp to the latest comment created at
|
||||
// save it to session storage, to persist between client-side navigations
|
||||
const newLatest = getLatestCommentCreatedAt(data.newComments.comments, latest)
|
||||
setLatest(newLatest)
|
||||
window.sessionStorage.setItem(latestKey, newLatest)
|
||||
}, [data, cache, rootId, sort, latest])
|
||||
// sync view time if we successfully injected new comments
|
||||
if (new Date(injectedLatest).getTime() > new Date(latest).getTime()) {
|
||||
if (me?.id) {
|
||||
// server-tracked view
|
||||
updateCommentsViewAt({ variables: { id: root.id, meCommentsViewedAt: injectedLatest } })
|
||||
} else {
|
||||
// anon fallback
|
||||
commentsViewedAfterComment(root.id, injectedLatest)
|
||||
}
|
||||
|
||||
// update latest timestamp to the latest comment created at
|
||||
// save it to session storage, to persist between client-side navigations
|
||||
setLatest(injectedLatest)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(latestKey, injectedLatest)
|
||||
}
|
||||
}
|
||||
}, [data, cache, topLevelId, root.id, sort, latest, me?.id])
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'disableLiveComments'
|
||||
|
@ -212,8 +212,8 @@ export const COMMENT_WITH_NEW_MINIMAL = gql`
|
||||
export const GET_NEW_COMMENTS = gql`
|
||||
${COMMENTS}
|
||||
|
||||
query GetNewComments($rootId: ID, $after: Date) {
|
||||
newComments(rootId: $rootId, after: $after) {
|
||||
query GetNewComments($topLevelId: ID, $after: Date) {
|
||||
newComments(topLevelId: $topLevelId, after: $after) {
|
||||
comments {
|
||||
...CommentsRecursive
|
||||
}
|
||||
|
@ -81,6 +81,7 @@ export const ITEM_FIELDS = gql`
|
||||
confirmedAt
|
||||
}
|
||||
cost
|
||||
meCommentsViewedAt
|
||||
}`
|
||||
|
||||
export const ITEM_FULL_FIELDS = gql`
|
||||
@ -91,12 +92,15 @@ export const ITEM_FULL_FIELDS = gql`
|
||||
text
|
||||
root {
|
||||
id
|
||||
createdAt
|
||||
title
|
||||
bounty
|
||||
bountyPaidTo
|
||||
subName
|
||||
mine
|
||||
ncomments
|
||||
lastCommentAt
|
||||
meCommentsViewedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
@ -211,3 +215,9 @@ export const RELATED_ITEMS_WITH_ITEM = gql`
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_ITEM_USER_VIEW = gql`
|
||||
mutation updateCommentsViewAt($id: ID!, $meCommentsViewedAt: Date!) {
|
||||
updateCommentsViewAt(id: $id, meCommentsViewedAt: $meCommentsViewedAt)
|
||||
}
|
||||
`
|
||||
|
@ -22,7 +22,14 @@ export function commentsViewedAfterComment (rootId, createdAt, ncomments = 1) {
|
||||
}
|
||||
|
||||
export function newComments (item) {
|
||||
if (!item.parentId) {
|
||||
if (!item.parentId && item.lastCommentAt) {
|
||||
// if logged, prefer server-tracked view
|
||||
if (item.meCommentsViewedAt) {
|
||||
const viewedAt = new Date(item.meCommentsViewedAt).getTime()
|
||||
return viewedAt < new Date(item.lastCommentAt).getTime()
|
||||
}
|
||||
|
||||
// anon fallback
|
||||
const viewedAt = commentsViewedAt(item.id)
|
||||
const viewNum = commentsViewedNum(item.id)
|
||||
|
||||
|
@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommentsViewAt" (
|
||||
"userId" INTEGER NOT NULL,
|
||||
"itemId" INTEGER NOT NULL,
|
||||
"last_viewed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CommentsViewAt_pkey" PRIMARY KEY ("userId","itemId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CommentsViewAt_userId_idx" ON "CommentsViewAt"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommentsViewAt" ADD CONSTRAINT "CommentsViewAt_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommentsViewAt" ADD CONSTRAINT "CommentsViewAt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION schedule_untrack_old_items()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
INSERT INTO pgboss.schedule (name, cron, timezone)
|
||||
VALUES ('untrackOldItems', '0 0 * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
|
||||
return 0;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
return 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT schedule_untrack_old_items();
|
||||
DROP FUNCTION IF EXISTS schedule_untrack_old_items;
|
@ -152,6 +152,7 @@ model User {
|
||||
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
|
||||
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
|
||||
UserSubTrust UserSubTrust[]
|
||||
CommentsViewAt CommentsViewAt[]
|
||||
|
||||
@@index([photoId])
|
||||
@@index([createdAt], map: "users.created_at_index")
|
||||
@ -490,6 +491,7 @@ model Item {
|
||||
ItemUserAgg ItemUserAgg[]
|
||||
AutoSocialPost AutoSocialPost[]
|
||||
randPollOptions Boolean @default(false)
|
||||
CommentsViewAt CommentsViewAt[]
|
||||
|
||||
@@index([uploadId])
|
||||
@@index([lastZapAt])
|
||||
@ -540,6 +542,17 @@ model ItemUserAgg {
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model CommentsViewAt {
|
||||
userId Int
|
||||
itemId Int
|
||||
lastViewedAt DateTime @default(now()) @map("last_viewed_at")
|
||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, itemId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// record auto social posts so we can avoid duplicates
|
||||
model AutoSocialPost {
|
||||
id Int @id @default(autoincrement())
|
||||
|
@ -37,7 +37,7 @@ import { expireBoost } from './expireBoost'
|
||||
import { payingActionConfirmed, payingActionFailed } from './payingAction'
|
||||
import { autoDropBolt11s } from './autoDropBolt11'
|
||||
import { postToSocial } from './socialPoster'
|
||||
|
||||
import { untrackOldItems } from './untrackOldItems'
|
||||
// WebSocket polyfill
|
||||
import ws from 'isomorphic-ws'
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
@ -143,6 +143,7 @@ async function work () {
|
||||
await boss.work('reminder', jobWrapper(remindUser))
|
||||
await boss.work('thisDay', jobWrapper(thisDay))
|
||||
await boss.work('socialPoster', jobWrapper(postToSocial))
|
||||
await boss.work('untrackOldItems', jobWrapper(untrackOldItems))
|
||||
|
||||
console.log('working jobs')
|
||||
}
|
||||
|
22
worker/untrackOldItems.js
Normal file
22
worker/untrackOldItems.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { datePivot } from '@/lib/time'
|
||||
|
||||
// deletes from commentsViewAt items that were never viewed and had no comments in the last 21 days
|
||||
export async function untrackOldItems ({ models }) {
|
||||
const pivot = datePivot(new Date(), { days: -21 })
|
||||
|
||||
await models.commentsViewAt.deleteMany({
|
||||
where: {
|
||||
AND: [
|
||||
// delete if never viewed in the last 21 days
|
||||
{ lastViewedAt: { lt: pivot } },
|
||||
// AND if the item had no comments in the last 21 days
|
||||
{
|
||||
OR: [
|
||||
{ item: { lastCommentAt: { lt: pivot } } },
|
||||
{ item: { lastCommentAt: null, createdAt: { lt: pivot } } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user