@remindme bot support (#1159)

* @remindme bot support

support reminders via @remindme bot, just like @delete bot

* minor cleanup

* minor query cleanup

* add db migration

* various fixes and updates:

* hasNewNotes implementation
* actually return notification component in ui
* delete reminder and job on item delete
* other goodies

* refactor to use prisma for deleting existing reminder

* * switch to deleteMany to delete existing Reminders upon edit/delete of post to satisfy prisma

* update wording in form toast for remindme bot usage

* update wording in the push notification sent

* transactional reminder inserts and expirein

* set expirein on @delete too

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
SatsAllDay 2024-05-19 16:52:02 -04:00 committed by GitHub
parent b7353ddd69
commit 852d2cf304
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 280 additions and 24 deletions

View File

@ -16,7 +16,7 @@ import { parse } from 'tldts'
import uu from 'url-unshort' import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate' import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention } from '@/lib/webPush' import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention } from '@/lib/webPush'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '@/lib/item' import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, getReminderCommand, hasReminderCommand } from '@/lib/item'
import { datePivot, whenRange } from '@/lib/time' import { datePivot, whenRange } from '@/lib/time'
import { imageFeesInfo, uploadIdsFromText } from './image' import { imageFeesInfo, uploadIdsFromText } from './image'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
@ -754,6 +754,10 @@ export default {
if (old.bio) { if (old.bio) {
throw new GraphQLError('cannot delete bio', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('cannot delete bio', { extensions: { code: 'BAD_INPUT' } })
} }
// clean up any pending reminders, if triggered on this item and haven't been executed
if (hasReminderCommand(old.text)) {
await deleteReminderAndJob({ me, item: old, models })
}
return await deleteItemByAuthor({ models, id, item: old }) return await deleteItemByAuthor({ models, id, item: old })
}, },
@ -1190,6 +1194,16 @@ export default {
} }
const deleteJobs = await models.$queryRawUnsafe(`SELECT startafter FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = '${item.id}'`) const deleteJobs = await models.$queryRawUnsafe(`SELECT startafter FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = '${item.id}'`)
return deleteJobs[0]?.startafter ?? null return deleteJobs[0]?.startafter ?? null
},
reminderScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID
if (meId !== item.userId || meId === ANON_USER_ID) {
// don't show reminders on an item if it isn't yours
// don't support reminders for ANON
return null
}
const reminderJobs = await models.$queryRawUnsafe(`SELECT startafter FROM pgboss.job WHERE name = 'reminder' AND data->>'itemId' = '${item.id}' AND data->>'userId' = '${meId}'`)
return reminderJobs[0]?.startafter ?? null
} }
} }
} }
@ -1302,6 +1316,12 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
} }
await enqueueDeletionJob(item, models) await enqueueDeletionJob(item, models)
if (hasReminderCommand(old.text)) {
// delete any reminder jobs that were created from a prior version of the item
await deleteReminderAndJob({ me, item, models })
}
await createReminderAndJob({ me, item, models })
item.comments = [] item.comments = []
return item return item
} }
@ -1346,6 +1366,8 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
await enqueueDeletionJob(item, models) await enqueueDeletionJob(item, models)
await createReminderAndJob({ me, item, models })
notifyUserSubscribers({ models, item }) notifyUserSubscribers({ models, item })
notifyTerritorySubscribers({ models, item }) notifyTerritorySubscribers({ models, item })
@ -1362,8 +1384,56 @@ const enqueueDeletionJob = async (item, models) => {
const deleteCommand = getDeleteCommand(item.text) const deleteCommand = getDeleteCommand(item.text)
if (deleteCommand) { if (deleteCommand) {
await models.$queryRawUnsafe(` await models.$queryRawUnsafe(`
INSERT INTO pgboss.job (name, data, startafter) INSERT INTO pgboss.job (name, data, startafter, expirein)
VALUES ('deleteItem', jsonb_build_object('id', ${item.id}), now() + interval '${deleteCommand.number} ${deleteCommand.unit}s');`) VALUES (
'deleteItem',
jsonb_build_object('id', ${item.id}),
now() + interval '${deleteCommand.number} ${deleteCommand.unit}s',
interval '${deleteCommand.number} ${deleteCommand.unit}s' + interval '1 minute')`)
}
}
const deleteReminderAndJob = async ({ me, item, models }) => {
if (me?.id && me.id !== ANON_USER_ID) {
await models.$transaction([
models.$queryRawUnsafe(`
DELETE FROM pgboss.job
WHERE name = 'reminder'
AND data->>'itemId' = '${item.id}'
AND data->>'userId' = '${me.id}'
AND state <> 'completed'`),
models.reminder.deleteMany({
where: {
itemId: Number(item.id),
userId: Number(me.id),
remindAt: {
gt: new Date()
}
}
})])
}
}
const createReminderAndJob = async ({ me, item, models }) => {
// disallow anon to use reminder
if (!me || me.id === ANON_USER_ID) {
return
}
const reminderCommand = getReminderCommand(item.text)
if (reminderCommand) {
await models.$transaction([
models.$queryRawUnsafe(`
INSERT INTO pgboss.job (name, data, startafter, expirein)
VALUES (
'reminder',
jsonb_build_object('itemId', ${item.id}, 'userId', ${me.id}),
now() + interval '${reminderCommand.number} ${reminderCommand.unit}s',
interval '${reminderCommand.number} ${reminderCommand.unit}s' + interval '1 minute')`),
// use a raw query instead of the model to reuse the built-in `now + interval` support instead of doing it via JS
models.$queryRawUnsafe(`
INSERT INTO "Reminder" ("userId", "itemId", "remindAt")
VALUES (${me.id}, ${item.id}, now() + interval '${reminderCommand.number} ${reminderCommand.unit}s')`)
])
} }
} }

View File

@ -304,6 +304,15 @@ export default {
LIMIT ${LIMIT})` LIMIT ${LIMIT})`
) )
queries.push(
`(SELECT "Reminder".id::text, "Reminder"."remindAt" AS "sortTime", NULL as "earnedSats", 'Reminder' AS type
FROM "Reminder"
WHERE "Reminder"."userId" = $1
AND "Reminder"."remindAt" < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
const notifications = await models.$queryRawUnsafe( const notifications = await models.$queryRawUnsafe(
`SELECT id, "sortTime", "earnedSats", type, `SELECT id, "sortTime", "earnedSats", type,
"sortTime" AS "minSortTime" "sortTime" AS "minSortTime"
@ -381,6 +390,12 @@ export default {
TerritoryPost: { TerritoryPost: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me }) item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
}, },
Reminder: {
item: async (n, args, { models, me }) => {
const { itemId } = await models.reminder.findUnique({ where: { id: Number(n.id) } })
return await getItem(n, { id: itemId }, { models, me })
}
},
TerritoryTransfer: { TerritoryTransfer: {
sub: async (n, args, { models, me }) => { sub: async (n, args, { models, me }) => {
const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } }) const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } })

View File

@ -473,6 +473,20 @@ export default {
return true return true
} }
const newReminder = await models.reminder.findFirst({
where: {
userId: me.id,
remindAt: {
gt: lastChecked,
lt: new Date()
}
}
})
if (newReminder) {
foundNotes()
return true
}
// update checkedNotesAt to prevent rechecking same time period // update checkedNotesAt to prevent rechecking same time period
models.user.update({ models.user.update({
where: { id: me.id }, where: { id: me.id },

View File

@ -72,6 +72,7 @@ export default gql`
updatedAt: Date! updatedAt: Date!
deletedAt: Date deletedAt: Date
deleteScheduledAt: Date deleteScheduledAt: Date
reminderScheduledAt: Date
title: String title: String
searchTitle: String searchTitle: String
url: String url: String

View File

@ -121,10 +121,16 @@ export default gql`
sortTime: Date! sortTime: Date!
} }
type Reminder {
id: ID!
item: Item!
sortTime: Date!
}
union Notification = Reply | Votification | Mention union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral | Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
| TerritoryPost | TerritoryTransfer | TerritoryPost | TerritoryTransfer | Reminder
type Notifications { type Notifications {
lastChecked: Date lastChecked: Date

View File

@ -8,7 +8,7 @@ import useCrossposter from './use-crossposter'
import { bountySchema } from '@/lib/validate' import { bountySchema } from '@/lib/validate'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react' import { useCallback } from 'react'
import { normalizeForwards, toastDeleteScheduled } from '@/lib/form' import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
import { MAX_TITLE_LENGTH } from '@/lib/constants' import { MAX_TITLE_LENGTH } from '@/lib/constants'
import { useMe } from './me' import { useMe } from './me'
import { useToast } from './toast' import { useToast } from './toast'
@ -56,6 +56,7 @@ export function BountyForm ({
) { ) {
id id
deleteScheduledAt deleteScheduledAt
reminderScheduledAt
} }
} }
` `
@ -89,7 +90,7 @@ export function BountyForm ({
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
toastDeleteScheduled(toaster, data, 'upsertBounty', !!item, values.text) toastUpsertSuccessMessages(toaster, data, 'upsertBounty', !!item, values.text)
}, [upsertBounty, router] }, [upsertBounty, router]
) )

View File

@ -3,7 +3,7 @@ import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css' import styles from './reply.module.css'
import { commentSchema } from '@/lib/validate' import { commentSchema } from '@/lib/validate'
import { useToast } from './toast' import { useToast } from './toast'
import { toastDeleteScheduled } from '@/lib/form' import { toastUpsertSuccessMessages } from '@/lib/form'
import { FeeButtonProvider } from './fee-button' import { FeeButtonProvider } from './fee-button'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
@ -15,6 +15,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
upsertComment(id: $id, text: $text) { upsertComment(id: $id, text: $text) {
text text
deleteScheduledAt deleteScheduledAt
reminderScheduledAt
} }
}`, { }`, {
update (cache, { data: { upsertComment } }) { update (cache, { data: { upsertComment } }) {
@ -43,7 +44,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }
toastDeleteScheduled(toaster, data, 'upsertComment', true, values.text) toastUpsertSuccessMessages(toaster, data, 'upsertComment', true, values.text)
if (onSuccess) { if (onSuccess) {
onSuccess() onSuccess()
} }

View File

@ -9,7 +9,7 @@ import Item from './item'
import { discussionSchema } from '@/lib/validate' import { discussionSchema } from '@/lib/validate'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react' import { useCallback } from 'react'
import { normalizeForwards, toastDeleteScheduled } from '@/lib/form' import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
import { MAX_TITLE_LENGTH } from '@/lib/constants' import { MAX_TITLE_LENGTH } from '@/lib/constants'
import { useMe } from './me' import { useMe } from './me'
import useCrossposter from './use-crossposter' import useCrossposter from './use-crossposter'
@ -37,6 +37,7 @@ export function DiscussionForm ({
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
id id
deleteScheduledAt deleteScheduledAt
reminderScheduledAt
} }
}` }`
) )
@ -69,7 +70,7 @@ export function DiscussionForm ({
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
toastDeleteScheduled(toaster, data, 'upsertDiscussion', !!item, values.text) toastUpsertSuccessMessages(toaster, data, 'upsertDiscussion', !!item, values.text)
}, [upsertDiscussion, router, item, sub, crossposter] }, [upsertDiscussion, router, item, sub, crossposter]
) )

View File

@ -17,7 +17,7 @@ import Avatar from './avatar'
import { jobSchema } from '@/lib/validate' import { jobSchema } from '@/lib/validate'
import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants' import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
import { useToast } from './toast' import { useToast } from './toast'
import { toastDeleteScheduled } from '@/lib/form' import { toastUpsertSuccessMessages } from '@/lib/form'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { useFormikContext } from 'formik' import { useFormikContext } from 'formik'
@ -51,6 +51,7 @@ export default function JobForm ({ item, sub }) {
url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) { url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) {
id id
deleteScheduledAt deleteScheduledAt
reminderScheduledAt
} }
}` }`
) )
@ -83,7 +84,7 @@ export default function JobForm ({ item, sub }) {
} else { } else {
await router.push(`/~${sub.name}/recent`) await router.push(`/~${sub.name}/recent`)
} }
toastDeleteScheduled(toaster, data, 'upsertJob', !!item, values.text) toastUpsertSuccessMessages(toaster, data, 'upsertJob', !!item, values.text)
}, [upsertJob, router, logoId] }, [upsertJob, router, logoId]
) )

View File

@ -9,7 +9,7 @@ import Item from './item'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import { linkSchema } from '@/lib/validate' import { linkSchema } from '@/lib/validate'
import Moon from '@/svgs/moon-fill.svg' import Moon from '@/svgs/moon-fill.svg'
import { normalizeForwards, toastDeleteScheduled } from '@/lib/form' import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
import { useToast } from './toast' import { useToast } from './toast'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { MAX_TITLE_LENGTH } from '@/lib/constants' import { MAX_TITLE_LENGTH } from '@/lib/constants'
@ -76,6 +76,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
id id
deleteScheduledAt deleteScheduledAt
reminderScheduledAt
} }
}` }`
) )
@ -108,7 +109,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
toastDeleteScheduled(toaster, data, 'upsertLink', !!item, values.text) toastUpsertSuccessMessages(toaster, data, 'upsertLink', !!item, values.text)
}, [upsertLink, router] }, [upsertLink, router]
) )

View File

@ -52,7 +52,8 @@ function Notification ({ n, fresh }) {
(type === 'SubStatus' && <SubStatus n={n} />) || (type === 'SubStatus' && <SubStatus n={n} />) ||
(type === 'FollowActivity' && <FollowActivity n={n} />) || (type === 'FollowActivity' && <FollowActivity n={n} />) ||
(type === 'TerritoryPost' && <TerritoryPost n={n} />) || (type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) (type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
(type === 'Reminder' && <Reminder n={n} />)
} }
</NotificationLayout> </NotificationLayout>
) )
@ -451,6 +452,23 @@ function TerritoryTransfer ({ n }) {
) )
} }
function Reminder ({ n }) {
return (
<>
<small className='fw-bold text-info ms-2'>you asked to be reminded of this {n.item.title ? 'post' : 'comment'}</small>
{n.item.title
? <div className='ms-2'><Item item={n.item} /></div>
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent clickToContext rootText='replying on:' />
</RootProvider>
</div>
)}
</>
)
}
export function NotificationAlert () { export function NotificationAlert () {
const [showAlert, setShowAlert] = useState(false) const [showAlert, setShowAlert] = useState(false)
const [hasSubscription, setHasSubscription] = useState(false) const [hasSubscription, setHasSubscription] = useState(false)

View File

@ -8,7 +8,7 @@ import { datePivot } from '@/lib/time'
import { pollSchema } from '@/lib/validate' import { pollSchema } from '@/lib/validate'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react' import { useCallback } from 'react'
import { normalizeForwards, toastDeleteScheduled } from '@/lib/form' import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
import useCrossposter from './use-crossposter' import useCrossposter from './use-crossposter'
import { useMe } from './me' import { useMe } from './me'
import { useToast } from './toast' import { useToast } from './toast'
@ -31,6 +31,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac, pollExpiresAt: $pollExpiresAt) { options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac, pollExpiresAt: $pollExpiresAt) {
id id
deleteScheduledAt deleteScheduledAt
reminderScheduledAt
} }
}` }`
) )
@ -65,7 +66,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
toastDeleteScheduled(toaster, data, 'upsertPoll', !!item, values.text) toastUpsertSuccessMessages(toaster, data, 'upsertPoll', !!item, values.text)
}, [upsertPoll, router] }, [upsertPoll, router]
) )

View File

@ -9,7 +9,7 @@ import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineIt
import { commentsViewedAfterComment } from '@/lib/new-comments' import { commentsViewedAfterComment } from '@/lib/new-comments'
import { commentSchema } from '@/lib/validate' import { commentSchema } from '@/lib/validate'
import { useToast } from './toast' import { useToast } from './toast'
import { toastDeleteScheduled } from '@/lib/form' import { toastUpsertSuccessMessages } from '@/lib/form'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
@ -54,6 +54,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
upsertComment(text: $text, parentId: $parentId, hash: $hash, hmac: $hmac) { upsertComment(text: $text, parentId: $parentId, hash: $hash, hmac: $hmac) {
...CommentFields ...CommentFields
deleteScheduledAt deleteScheduledAt
reminderScheduledAt
comments { comments {
...CommentsRecursive ...CommentsRecursive
} }
@ -98,7 +99,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => { const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => {
const { data } = await upsertComment({ variables: { parentId, hash, hmac, ...values } }) const { data } = await upsertComment({ variables: { parentId, hash, hmac, ...values } })
toastDeleteScheduled(toaster, data, 'upsertComment', false, values.text) toastUpsertSuccessMessages(toaster, data, 'upsertComment', false, values.text)
resetForm({ text: '' }) resetForm({ text: '' })
setReply(replyOpen || false) setReply(replyOpen || false)
}, [upsertComment, setReply, parentId]) }, [upsertComment, setReply, parentId])

View File

@ -141,6 +141,13 @@ export const NOTIFICATIONS = gql`
autoWithdraw autoWithdraw
} }
} }
... on Reminder {
id
sortTime
item {
...ItemFullFields
}
}
} }
} }
} ` } `

View File

@ -1,4 +1,4 @@
import { hasDeleteMention } from './item' import { hasDeleteMention, hasReminderMention } from './item'
/** /**
* Normalize an array of forwards by converting the pct from a string to a number * Normalize an array of forwards by converting the pct from a string to a number
@ -13,7 +13,12 @@ export const normalizeForwards = (forward) => {
return forward.filter(fwd => fwd.nym || fwd.user?.name).map(fwd => ({ nym: fwd.nym ?? fwd.user?.name, pct: Number(fwd.pct) })) return forward.filter(fwd => fwd.nym || fwd.user?.name).map(fwd => ({ nym: fwd.nym ?? fwd.user?.name, pct: Number(fwd.pct) }))
} }
export const toastDeleteScheduled = (toaster, upsertResponseData, dataKey, isEdit, itemText) => { export const toastUpsertSuccessMessages = (toaster, upsertResponseData, dataKey, isEdit, itemText) => {
toastDeleteScheduled(toaster, upsertResponseData, dataKey, isEdit, itemText)
toastReminderScheduled(toaster, upsertResponseData, dataKey, isEdit, itemText)
}
const toastDeleteScheduled = (toaster, upsertResponseData, dataKey, isEdit, itemText) => {
const data = upsertResponseData[dataKey] const data = upsertResponseData[dataKey]
if (!data) return if (!data) return
@ -44,3 +49,35 @@ export const toastDeleteScheduled = (toaster, upsertResponseData, dataKey, isEdi
toaster.success(message, { persistOnNavigate: itemType !== 'comment' }) toaster.success(message, { persistOnNavigate: itemType !== 'comment' })
} }
} }
const toastReminderScheduled = (toaster, upsertResponseData, dataKey, isEdit, itemText) => {
const data = upsertResponseData[dataKey]
if (!data) return
const reminderMentioned = hasReminderMention(itemText)
const reminderScheduled = !!data.reminderScheduledAt
if (!reminderMentioned) return
if (reminderMentioned && !reminderScheduled) {
// There's a reminder mention but the reminder wasn't scheduled
toaster.warning('it looks like you tried to use the remindme bot but it didn\'t work. make sure you use the correct format: "@remindme in n units" e.g. "@remindme in 2 hours"', 10000)
return
}
// when we reached this code, we know that a reminder was scheduled
const reminderScheduledAt = new Date(data.reminderScheduledAt)
if (reminderScheduledAt) {
const itemType = {
upsertDiscussion: 'discussion post',
upsertLink: 'link post',
upsertPoll: 'poll',
upsertBounty: 'bounty',
upsertJob: 'job',
upsertComment: 'comment'
}[dataKey] ?? 'item'
const message = `you will be reminded of ${itemType === 'comment' ? 'your comment' : isEdit ? `this ${itemType}` : `your new ${itemType}`} at ${reminderScheduledAt.toLocaleString()}`
// only persist this on navigation for posts, not comments
toaster.success(message, { persistOnNavigate: itemType !== 'comment' })
}
}

View File

@ -17,6 +17,10 @@ const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|mon
const deleteMentionPattern = /\B@delete/i const deleteMentionPattern = /\B@delete/i
const reminderPattern = /\B@remindme\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi
const reminderMentionPattern = /\B@remindme/i
export const hasDeleteMention = (text) => deleteMentionPattern.test(text ?? '') export const hasDeleteMention = (text) => deleteMentionPattern.test(text ?? '')
export const getDeleteCommand = (text) => { export const getDeleteCommand = (text) => {
@ -28,6 +32,17 @@ export const getDeleteCommand = (text) => {
export const hasDeleteCommand = (text) => !!getDeleteCommand(text) export const hasDeleteCommand = (text) => !!getDeleteCommand(text)
export const hasReminderMention = (text) => reminderMentionPattern.test(text ?? '')
export const getReminderCommand = (text) => {
if (!text) return false
const matches = [...text.matchAll(reminderPattern)]
const commands = matches?.map(match => ({ number: match[1], unit: match[2] }))
return commands.length ? commands[commands.length - 1] : undefined
}
export const hasReminderCommand = (text) => !!getReminderCommand(text)
export const deleteItemByAuthor = async ({ models, id, item }) => { export const deleteItemByAuthor = async ({ models, id, item }) => {
if (!item) { if (!item) {
item = await models.item.findUnique({ where: { id: Number(id) } }) item = await models.item.findUnique({ where: { id: Number(id) } })

View File

@ -361,3 +361,16 @@ export async function notifyStreakLost (userId, streak) {
console.error(err) console.error(err)
} }
} }
export async function notifyReminder ({ userId, item, itemId }) {
try {
await sendUserNotification(userId, {
title: 'this is your requested reminder',
body: `you asked to be reminded of this ${item ? item.title ? 'post' : 'comment' : 'item'}`,
tag: `REMIND-ITEM-${item?.id ?? itemId}`,
item: item ?? { id: itemId }
})
} catch (err) {
console.error(err)
}
}

View File

@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "Reminder" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
"itemId" INTEGER NOT NULL,
"remindAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Reminder.userId_reminderAt_index" ON "Reminder"("userId", "remindAt");
-- AddForeignKey
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -124,6 +124,7 @@ model User {
AncestorReplies Reply[] @relation("AncestorReplyUser") AncestorReplies Reply[] @relation("AncestorReplyUser")
Replies Reply[] Replies Reply[]
walletLogs WalletLog[] walletLogs WalletLog[]
Reminder Reminder[]
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -163,10 +164,10 @@ model Wallet {
} }
model WalletLog { model WalletLog {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
userId Int userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
wallet WalletType wallet WalletType
level LogLevel level LogLevel
message String message String
@ -421,6 +422,7 @@ model Item {
pollExpiresAt DateTime? pollExpiresAt DateTime?
Ancestors Reply[] @relation("AncestorReplyItem") Ancestors Reply[] @relation("AncestorReplyItem")
Replies Reply[] Replies Reply[]
Reminder Reminder[]
@@index([uploadId]) @@index([uploadId])
@@index([lastZapAt]) @@index([lastZapAt])
@ -853,6 +855,18 @@ model TerritoryTransfer {
@@index([createdAt, oldUserId], map: "TerritoryTransfer.oldUserId_index") @@index([createdAt, oldUserId], map: "TerritoryTransfer.oldUserId_index")
} }
model Reminder {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
userId Int
itemId Int
remindAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([userId, remindAt], map: "Reminder.userId_reminderAt_index")
}
enum EarnType { enum EarnType {
POST POST
COMMENT COMMENT

View File

@ -24,6 +24,7 @@ import { territoryBilling, territoryRevenue } from './territory.js'
import { ofac } from './ofac.js' import { ofac } from './ofac.js'
import { autoWithdraw } from './autowithdraw.js' import { autoWithdraw } from './autowithdraw.js'
import { saltAndHashEmails } from './saltAndHashEmails.js' import { saltAndHashEmails } from './saltAndHashEmails.js'
import { remindUser } from './reminder.js'
const { loadEnvConfig } = nextEnv const { loadEnvConfig } = nextEnv
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
@ -102,6 +103,7 @@ async function work () {
await boss.work('territoryRevenue', jobWrapper(territoryRevenue)) await boss.work('territoryRevenue', jobWrapper(territoryRevenue))
await boss.work('ofac', jobWrapper(ofac)) await boss.work('ofac', jobWrapper(ofac))
await boss.work('saltAndHashEmails', jobWrapper(saltAndHashEmails)) await boss.work('saltAndHashEmails', jobWrapper(saltAndHashEmails))
await boss.work('reminder', jobWrapper(remindUser))
console.log('working jobs') console.log('working jobs')
} }

18
worker/reminder.js Normal file
View File

@ -0,0 +1,18 @@
import { notifyReminder } from '@/lib/webPush'
export async function remindUser ({ data: { itemId, userId }, models }) {
let item
try {
item = await models.item.findUnique({ where: { id: itemId } })
} catch (err) {
console.error('failed to lookup item by id', err)
}
try {
if (item) {
await notifyReminder({ userId, item, itemId })
} else {
await notifyReminder({ userId, itemId })
}
} catch (err) {
console.error('failed to send push notification for reminder', err)
}
}