@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 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')`)
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 } })
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -141,6 +141,13 @@ export const NOTIFICATIONS = gql`
|
||||||
autoWithdraw
|
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
|
* 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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
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 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) } })
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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")
|
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
|
||||||
|
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
@ -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