diff --git a/api/resolvers/item.js b/api/resolvers/item.js
index ff8422cd..f502b08b 100644
--- a/api/resolvers/item.js
+++ b/api/resolvers/item.js
@@ -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')`)
+ ])
}
}
diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js
index b6f46bc1..a43acecd 100644
--- a/api/resolvers/notifications.js
+++ b/api/resolvers/notifications.js
@@ -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 } })
diff --git a/api/resolvers/user.js b/api/resolvers/user.js
index e92547ea..23fba38c 100644
--- a/api/resolvers/user.js
+++ b/api/resolvers/user.js
@@ -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 },
diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js
index e17ffe43..dd9a675d 100644
--- a/api/typeDefs/item.js
+++ b/api/typeDefs/item.js
@@ -72,6 +72,7 @@ export default gql`
updatedAt: Date!
deletedAt: Date
deleteScheduledAt: Date
+ reminderScheduledAt: Date
title: String
searchTitle: String
url: String
diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js
index 9b3a3e41..20fc13a4 100644
--- a/api/typeDefs/notifications.js
+++ b/api/typeDefs/notifications.js
@@ -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
diff --git a/components/bounty-form.js b/components/bounty-form.js
index 20ac4dd2..4e60e591 100644
--- a/components/bounty-form.js
+++ b/components/bounty-form.js
@@ -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]
)
diff --git a/components/comment-edit.js b/components/comment-edit.js
index 4338dfbf..47c29f91 100644
--- a/components/comment-edit.js
+++ b/components/comment-edit.js
@@ -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()
}
diff --git a/components/discussion-form.js b/components/discussion-form.js
index bc774f25..62d08a47 100644
--- a/components/discussion-form.js
+++ b/components/discussion-form.js
@@ -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]
)
diff --git a/components/job-form.js b/components/job-form.js
index 283335da..af45e375 100644
--- a/components/job-form.js
+++ b/components/job-form.js
@@ -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]
)
diff --git a/components/link-form.js b/components/link-form.js
index ac7f29c4..e2a3b9b8 100644
--- a/components/link-form.js
+++ b/components/link-form.js
@@ -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]
)
diff --git a/components/notifications.js b/components/notifications.js
index 49de3ba4..24045f2c 100644
--- a/components/notifications.js
+++ b/components/notifications.js
@@ -52,7 +52,8 @@ function Notification ({ n, fresh }) {
(type === 'SubStatus' &&