@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:
parent
b7353ddd69
commit
852d2cf304
|
@ -16,7 +16,7 @@ import { parse } from 'tldts'
|
|||
import uu from 'url-unshort'
|
||||
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
|
||||
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 { imageFeesInfo, uploadIdsFromText } from './image'
|
||||
import assertGofacYourself from './ofac'
|
||||
|
@ -754,6 +754,10 @@ export default {
|
|||
if (old.bio) {
|
||||
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 })
|
||||
},
|
||||
|
@ -1190,6 +1194,16 @@ export default {
|
|||
}
|
||||
const deleteJobs = await models.$queryRawUnsafe(`SELECT startafter FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = '${item.id}'`)
|
||||
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)
|
||||
|
||||
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 = []
|
||||
return item
|
||||
}
|
||||
|
@ -1346,6 +1366,8 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
|
|||
|
||||
await enqueueDeletionJob(item, models)
|
||||
|
||||
await createReminderAndJob({ me, item, models })
|
||||
|
||||
notifyUserSubscribers({ models, item })
|
||||
|
||||
notifyTerritorySubscribers({ models, item })
|
||||
|
@ -1362,8 +1384,56 @@ const enqueueDeletionJob = async (item, models) => {
|
|||
const deleteCommand = getDeleteCommand(item.text)
|
||||
if (deleteCommand) {
|
||||
await models.$queryRawUnsafe(`
|
||||
INSERT INTO pgboss.job (name, data, startafter)
|
||||
VALUES ('deleteItem', jsonb_build_object('id', ${item.id}), now() + interval '${deleteCommand.number} ${deleteCommand.unit}s');`)
|
||||
INSERT INTO pgboss.job (name, data, startafter, expirein)
|
||||
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')`)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -304,6 +304,15 @@ export default {
|
|||
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(
|
||||
`SELECT id, "sortTime", "earnedSats", type,
|
||||
"sortTime" AS "minSortTime"
|
||||
|
@ -381,6 +390,12 @@ export default {
|
|||
TerritoryPost: {
|
||||
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: {
|
||||
sub: async (n, args, { models, me }) => {
|
||||
const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } })
|
||||
|
|
|
@ -473,6 +473,20 @@ export default {
|
|||
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
|
||||
models.user.update({
|
||||
where: { id: me.id },
|
||||
|
|
|
@ -72,6 +72,7 @@ export default gql`
|
|||
updatedAt: Date!
|
||||
deletedAt: Date
|
||||
deleteScheduledAt: Date
|
||||
reminderScheduledAt: Date
|
||||
title: String
|
||||
searchTitle: String
|
||||
url: String
|
||||
|
|
|
@ -121,10 +121,16 @@ export default gql`
|
|||
sortTime: Date!
|
||||
}
|
||||
|
||||
type Reminder {
|
||||
id: ID!
|
||||
item: Item!
|
||||
sortTime: Date!
|
||||
}
|
||||
|
||||
union Notification = Reply | Votification | Mention
|
||||
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
|
||||
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
|
||||
| TerritoryPost | TerritoryTransfer
|
||||
| TerritoryPost | TerritoryTransfer | Reminder
|
||||
|
||||
type Notifications {
|
||||
lastChecked: Date
|
||||
|
|
|
@ -8,7 +8,7 @@ import useCrossposter from './use-crossposter'
|
|||
import { bountySchema } from '@/lib/validate'
|
||||
import { SubSelectInitial } from './sub-select'
|
||||
import { useCallback } from 'react'
|
||||
import { normalizeForwards, toastDeleteScheduled } from '@/lib/form'
|
||||
import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
|
||||
import { MAX_TITLE_LENGTH } from '@/lib/constants'
|
||||
import { useMe } from './me'
|
||||
import { useToast } from './toast'
|
||||
|
@ -56,6 +56,7 @@ export function BountyForm ({
|
|||
) {
|
||||
id
|
||||
deleteScheduledAt
|
||||
reminderScheduledAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -89,7 +90,7 @@ export function BountyForm ({
|
|||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||
await router.push(prefix + '/recent')
|
||||
}
|
||||
toastDeleteScheduled(toaster, data, 'upsertBounty', !!item, values.text)
|
||||
toastUpsertSuccessMessages(toaster, data, 'upsertBounty', !!item, values.text)
|
||||
}, [upsertBounty, router]
|
||||
)
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { gql, useMutation } from '@apollo/client'
|
|||
import styles from './reply.module.css'
|
||||
import { commentSchema } from '@/lib/validate'
|
||||
import { useToast } from './toast'
|
||||
import { toastDeleteScheduled } from '@/lib/form'
|
||||
import { toastUpsertSuccessMessages } from '@/lib/form'
|
||||
import { FeeButtonProvider } from './fee-button'
|
||||
import { ItemButtonBar } from './post'
|
||||
|
||||
|
@ -15,6 +15,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
|
|||
upsertComment(id: $id, text: $text) {
|
||||
text
|
||||
deleteScheduledAt
|
||||
reminderScheduledAt
|
||||
}
|
||||
}`, {
|
||||
update (cache, { data: { upsertComment } }) {
|
||||
|
@ -43,7 +44,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
|
|||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
toastDeleteScheduled(toaster, data, 'upsertComment', true, values.text)
|
||||
toastUpsertSuccessMessages(toaster, data, 'upsertComment', true, values.text)
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import Item from './item'
|
|||
import { discussionSchema } from '@/lib/validate'
|
||||
import { SubSelectInitial } from './sub-select'
|
||||
import { useCallback } from 'react'
|
||||
import { normalizeForwards, toastDeleteScheduled } from '@/lib/form'
|
||||
import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
|
||||
import { MAX_TITLE_LENGTH } from '@/lib/constants'
|
||||
import { useMe } from './me'
|
||||
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) {
|
||||
id
|
||||
deleteScheduledAt
|
||||
reminderScheduledAt
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
@ -69,7 +70,7 @@ export function DiscussionForm ({
|
|||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||
await router.push(prefix + '/recent')
|
||||
}
|
||||
toastDeleteScheduled(toaster, data, 'upsertDiscussion', !!item, values.text)
|
||||
toastUpsertSuccessMessages(toaster, data, 'upsertDiscussion', !!item, values.text)
|
||||
}, [upsertDiscussion, router, item, sub, crossposter]
|
||||
)
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ import Avatar from './avatar'
|
|||
import { jobSchema } from '@/lib/validate'
|
||||
import { MAX_TITLE_LENGTH, MEDIA_URL } from '@/lib/constants'
|
||||
import { useToast } from './toast'
|
||||
import { toastDeleteScheduled } from '@/lib/form'
|
||||
import { toastUpsertSuccessMessages } from '@/lib/form'
|
||||
import { ItemButtonBar } from './post'
|
||||
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) {
|
||||
id
|
||||
deleteScheduledAt
|
||||
reminderScheduledAt
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
@ -83,7 +84,7 @@ export default function JobForm ({ item, sub }) {
|
|||
} else {
|
||||
await router.push(`/~${sub.name}/recent`)
|
||||
}
|
||||
toastDeleteScheduled(toaster, data, 'upsertJob', !!item, values.text)
|
||||
toastUpsertSuccessMessages(toaster, data, 'upsertJob', !!item, values.text)
|
||||
}, [upsertJob, router, logoId]
|
||||
)
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import Item from './item'
|
|||
import AccordianItem from './accordian-item'
|
||||
import { linkSchema } from '@/lib/validate'
|
||||
import Moon from '@/svgs/moon-fill.svg'
|
||||
import { normalizeForwards, toastDeleteScheduled } from '@/lib/form'
|
||||
import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
|
||||
import { useToast } from './toast'
|
||||
import { SubSelectInitial } from './sub-select'
|
||||
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) {
|
||||
id
|
||||
deleteScheduledAt
|
||||
reminderScheduledAt
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
@ -108,7 +109,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||
await router.push(prefix + '/recent')
|
||||
}
|
||||
toastDeleteScheduled(toaster, data, 'upsertLink', !!item, values.text)
|
||||
toastUpsertSuccessMessages(toaster, data, 'upsertLink', !!item, values.text)
|
||||
}, [upsertLink, router]
|
||||
)
|
||||
|
||||
|
|
|
@ -52,7 +52,8 @@ function Notification ({ n, fresh }) {
|
|||
(type === 'SubStatus' && <SubStatus n={n} />) ||
|
||||
(type === 'FollowActivity' && <FollowActivity n={n} />) ||
|
||||
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
|
||||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />)
|
||||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
|
||||
(type === 'Reminder' && <Reminder n={n} />)
|
||||
}
|
||||
</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 () {
|
||||
const [showAlert, setShowAlert] = useState(false)
|
||||
const [hasSubscription, setHasSubscription] = useState(false)
|
||||
|
|
|
@ -8,7 +8,7 @@ import { datePivot } from '@/lib/time'
|
|||
import { pollSchema } from '@/lib/validate'
|
||||
import { SubSelectInitial } from './sub-select'
|
||||
import { useCallback } from 'react'
|
||||
import { normalizeForwards, toastDeleteScheduled } from '@/lib/form'
|
||||
import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
|
||||
import useCrossposter from './use-crossposter'
|
||||
import { useMe } from './me'
|
||||
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) {
|
||||
id
|
||||
deleteScheduledAt
|
||||
reminderScheduledAt
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
@ -65,7 +66,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||
await router.push(prefix + '/recent')
|
||||
}
|
||||
toastDeleteScheduled(toaster, data, 'upsertPoll', !!item, values.text)
|
||||
toastUpsertSuccessMessages(toaster, data, 'upsertPoll', !!item, values.text)
|
||||
}, [upsertPoll, router]
|
||||
)
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineIt
|
|||
import { commentsViewedAfterComment } from '@/lib/new-comments'
|
||||
import { commentSchema } from '@/lib/validate'
|
||||
import { useToast } from './toast'
|
||||
import { toastDeleteScheduled } from '@/lib/form'
|
||||
import { toastUpsertSuccessMessages } from '@/lib/form'
|
||||
import { ItemButtonBar } from './post'
|
||||
import { useShowModal } from './modal'
|
||||
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) {
|
||||
...CommentFields
|
||||
deleteScheduledAt
|
||||
reminderScheduledAt
|
||||
comments {
|
||||
...CommentsRecursive
|
||||
}
|
||||
|
@ -98,7 +99,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
|
|||
|
||||
const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => {
|
||||
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: '' })
|
||||
setReply(replyOpen || false)
|
||||
}, [upsertComment, setReply, parentId])
|
||||
|
|
|
@ -141,6 +141,13 @@ export const NOTIFICATIONS = gql`
|
|||
autoWithdraw
|
||||
}
|
||||
}
|
||||
... on Reminder {
|
||||
id
|
||||
sortTime
|
||||
item {
|
||||
...ItemFullFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} `
|
||||
|
|
41
lib/form.js
41
lib/form.js
|
@ -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
|
||||
|
@ -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) }))
|
||||
}
|
||||
|
||||
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]
|
||||
if (!data) return
|
||||
|
||||
|
@ -44,3 +49,35 @@ export const toastDeleteScheduled = (toaster, upsertResponseData, dataKey, isEdi
|
|||
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' })
|
||||
}
|
||||
}
|
||||
|
|
15
lib/item.js
15
lib/item.js
|
@ -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 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 getDeleteCommand = (text) => {
|
||||
|
@ -28,6 +32,17 @@ export const 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 }) => {
|
||||
if (!item) {
|
||||
item = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
|
|
|
@ -361,3 +361,16 @@ export async function notifyStreakLost (userId, streak) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -124,6 +124,7 @@ model User {
|
|||
AncestorReplies Reply[] @relation("AncestorReplyUser")
|
||||
Replies Reply[]
|
||||
walletLogs WalletLog[]
|
||||
Reminder Reminder[]
|
||||
|
||||
@@index([photoId])
|
||||
@@index([createdAt], map: "users.created_at_index")
|
||||
|
@ -163,10 +164,10 @@ model Wallet {
|
|||
}
|
||||
|
||||
model WalletLog {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
wallet WalletType
|
||||
level LogLevel
|
||||
message String
|
||||
|
@ -421,6 +422,7 @@ model Item {
|
|||
pollExpiresAt DateTime?
|
||||
Ancestors Reply[] @relation("AncestorReplyItem")
|
||||
Replies Reply[]
|
||||
Reminder Reminder[]
|
||||
|
||||
@@index([uploadId])
|
||||
@@index([lastZapAt])
|
||||
|
@ -853,6 +855,18 @@ model TerritoryTransfer {
|
|||
@@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 {
|
||||
POST
|
||||
COMMENT
|
||||
|
|
|
@ -24,6 +24,7 @@ import { territoryBilling, territoryRevenue } from './territory.js'
|
|||
import { ofac } from './ofac.js'
|
||||
import { autoWithdraw } from './autowithdraw.js'
|
||||
import { saltAndHashEmails } from './saltAndHashEmails.js'
|
||||
import { remindUser } from './reminder.js'
|
||||
|
||||
const { loadEnvConfig } = nextEnv
|
||||
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
|
||||
|
@ -102,6 +103,7 @@ async function work () {
|
|||
await boss.work('territoryRevenue', jobWrapper(territoryRevenue))
|
||||
await boss.work('ofac', jobWrapper(ofac))
|
||||
await boss.work('saltAndHashEmails', jobWrapper(saltAndHashEmails))
|
||||
await boss.work('reminder', jobWrapper(remindUser))
|
||||
|
||||
console.log('working jobs')
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue