From 852d2cf3047eb07bd21ec40a7e7bd6e974772daa Mon Sep 17 00:00:00 2001 From: SatsAllDay <128755788+SatsAllDay@users.noreply.github.com> Date: Sun, 19 May 2024 16:52:02 -0400 Subject: [PATCH] @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 --- api/resolvers/item.js | 76 ++++++++++++++++++- api/resolvers/notifications.js | 15 ++++ api/resolvers/user.js | 14 ++++ api/typeDefs/item.js | 1 + api/typeDefs/notifications.js | 8 +- components/bounty-form.js | 5 +- components/comment-edit.js | 5 +- components/discussion-form.js | 5 +- components/job-form.js | 5 +- components/link-form.js | 5 +- components/notifications.js | 20 ++++- components/poll-form.js | 5 +- components/reply.js | 5 +- fragments/notifications.js | 7 ++ lib/form.js | 41 +++++++++- lib/item.js | 15 ++++ lib/webPush.js | 13 ++++ .../20240509013027_reminders/migration.sql | 19 +++++ prisma/schema.prisma | 20 ++++- worker/index.js | 2 + worker/reminder.js | 18 +++++ 21 files changed, 280 insertions(+), 24 deletions(-) create mode 100644 prisma/migrations/20240509013027_reminders/migration.sql create mode 100644 worker/reminder.js 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' && ) || (type === 'FollowActivity' && ) || (type === 'TerritoryPost' && ) || - (type === 'TerritoryTransfer' && ) + (type === 'TerritoryTransfer' && ) || + (type === 'Reminder' && ) } ) @@ -451,6 +452,23 @@ function TerritoryTransfer ({ n }) { ) } +function Reminder ({ n }) { + return ( + <> + you asked to be reminded of this {n.item.title ? 'post' : 'comment'} + {n.item.title + ?
+ : ( +
+ + + +
+ )} + + ) +} + export function NotificationAlert () { const [showAlert, setShowAlert] = useState(false) const [hasSubscription, setHasSubscription] = useState(false) diff --git a/components/poll-form.js b/components/poll-form.js index 9cbc7d29..decdcf37 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -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] ) diff --git a/components/reply.js b/components/reply.js index dd74c978..c6eaee7f 100644 --- a/components/reply.js +++ b/components/reply.js @@ -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]) diff --git a/fragments/notifications.js b/fragments/notifications.js index b57d1a75..76706701 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -141,6 +141,13 @@ export const NOTIFICATIONS = gql` autoWithdraw } } + ... on Reminder { + id + sortTime + item { + ...ItemFullFields + } + } } } } ` diff --git a/lib/form.js b/lib/form.js index 44911d43..17447dc3 100644 --- a/lib/form.js +++ b/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' }) + } +} diff --git a/lib/item.js b/lib/item.js index 8fe5becf..a7d11106 100644 --- a/lib/item.js +++ b/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) } }) diff --git a/lib/webPush.js b/lib/webPush.js index f8928242..edbb919d 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -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) + } +} diff --git a/prisma/migrations/20240509013027_reminders/migration.sql b/prisma/migrations/20240509013027_reminders/migration.sql new file mode 100644 index 00000000..c3c8ecaa --- /dev/null +++ b/prisma/migrations/20240509013027_reminders/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9dbde783..d72be919 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/worker/index.js b/worker/index.js index d3759a09..2d5b8955 100644 --- a/worker/index.js +++ b/worker/index.js @@ -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') } diff --git a/worker/reminder.js b/worker/reminder.js new file mode 100644 index 00000000..49a1c85c --- /dev/null +++ b/worker/reminder.js @@ -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) + } +}