diff --git a/api/resolvers/search.js b/api/resolvers/search.js index b3d2d149..1068844d 100644 --- a/api/resolvers/search.js +++ b/api/resolvers/search.js @@ -176,7 +176,10 @@ export default { let sitems = null let termQueries = [] - if (!q) { + // short circuit: return empty result if either: + // 1. no query provided, or + // 2. searching bookmarks without being authed + if (!q || (what === 'bookmarks' && !me)) { return { items: [], cursor: null @@ -191,6 +194,11 @@ export default { case 'comments': whatArr.push({ bool: { must: { exists: { field: 'parentId' } } } }) break + case 'bookmarks': + if (me?.id) { + whatArr.push({ match: { bookmarkedBy: me?.id } }) + } + break default: break } diff --git a/components/search.js b/components/search.js index d07c7125..9cbbc16a 100644 --- a/components/search.js +++ b/components/search.js @@ -1,15 +1,17 @@ import Container from 'react-bootstrap/Container' import styles from './search.module.css' import SearchIcon from '@/svgs/search-line.svg' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Form, Input, Select, DatePicker, SubmitButton } from './form' import { useRouter } from 'next/router' import { whenToFrom } from '@/lib/time' +import { useMe } from './me' export default function Search ({ sub }) { const router = useRouter() const [q, setQ] = useState(router.query.q || '') const inputRef = useRef(null) + const me = useMe() useEffect(() => { inputRef.current?.focus() @@ -50,6 +52,7 @@ export default function Search ({ sub }) { const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what || 'all' const sort = router.query.sort || 'zaprank' const when = router.query.when || 'forever' + const whatItemOptions = useMemo(() => (['all', 'posts', 'comments', me ? 'bookmarks' : undefined, 'stackers'].filter(item => !!item)), [me]) return ( <> @@ -86,7 +89,7 @@ export default function Search ({ sub }) { name='what' size='sm' overrideValue={what} - items={['all', 'posts', 'comments', 'stackers']} + items={whatItemOptions} /> {what !== 'stackers' && <> diff --git a/prisma/migrations/20240415202735_index_item_on_bookmark_change/migration.sql b/prisma/migrations/20240415202735_index_item_on_bookmark_change/migration.sql new file mode 100644 index 00000000..073dd336 --- /dev/null +++ b/prisma/migrations/20240415202735_index_item_on_bookmark_change/migration.sql @@ -0,0 +1,46 @@ +CREATE OR REPLACE FUNCTION index_bookmarked_item() RETURNS TRIGGER AS $$ + BEGIN + -- if a bookmark was created or updated, `NEW` will be used + IF NEW IS NOT NULL THEN + INSERT INTO pgboss.job (name, data) VALUES ('indexItem', jsonb_build_object('id', NEW."itemId")); + RETURN NEW; + END IF; + -- if a bookmark was deleted, `OLD` will be used + IF OLD IS NOT NULL THEN + -- include `updatedAt` in the `indexItem` job as `now()` to indicate when the indexed item should think it was updated + -- this is to facilitate the fact that deleted bookmarks do not show up when re-indexing the item, and therefore + -- we don't have a reliable way to calculate a more recent index version, to displace the prior version + INSERT INTO pgboss.job (name, data) VALUES ('indexItem', jsonb_build_object('id', OLD."itemId", 'updatedAt', now())); + RETURN OLD; + END IF; + -- This should never be reached + RETURN NULL; + END; +$$ LANGUAGE plpgsql; + +-- Re-index the bookmarked item when a bookmark changes, so new bookmarks are searchable +DROP TRIGGER IF EXISTS index_bookmarked_item ON "Bookmark"; +CREATE TRIGGER index_bookmarked_item + AFTER INSERT OR UPDATE OR DELETE ON "Bookmark" + FOR EACH ROW + EXECUTE PROCEDURE index_bookmarked_item(); + +-- hack ... prisma doesn't know about our other schemas (e.g. pgboss) +-- and this is only really a problem on their "shadow database" +-- so we catch the exception it throws and ignore it +CREATE OR REPLACE FUNCTION reindex_all_current_bookmarked_items() RETURNS void AS $$ + BEGIN + -- Re-index all existing bookmarked items so these bookmarks are searchable + INSERT INTO pgboss.job (name, data, priority, startafter, expirein) + SELECT 'indexItem', jsonb_build_object('id', "itemId"), -100, now() + interval '10 minutes', interval '1 day' + FROM "Bookmark" + GROUP BY "itemId"; + EXCEPTION WHEN OTHERS THEN + -- catch the exception for prisma dev execution, but do nothing with it + END; +$$ LANGUAGE plpgsql; + +-- execute the function once +SELECT reindex_all_current_bookmarked_items(); +-- then drop it since we don't need it anymore +DROP FUNCTION reindex_all_current_bookmarked_items(); diff --git a/worker/search.js b/worker/search.js index f059dd47..9276acbb 100644 --- a/worker/search.js +++ b/worker/search.js @@ -35,7 +35,7 @@ const ITEM_SEARCH_FIELDS = gql` ncomments }` -async function _indexItem (item, { models }) { +async function _indexItem (item, { models, updatedAt }) { console.log('indexing item', item.id) // HACK: modify the title for jobs so that company/location are searchable // and highlighted without further modification @@ -60,11 +60,33 @@ async function _indexItem (item, { models }) { itemcp.wvotes = itemdb.weightedVotes - itemdb.weightedDownVotes + const bookmarkedBy = await models.bookmark.findMany({ + where: { itemId: Number(item.id) }, + select: { userId: true, createdAt: true }, + orderBy: [ + { + createdAt: 'desc' + } + ] + }) + itemcp.bookmarkedBy = bookmarkedBy.map(bookmark => bookmark.userId) + + // use the latest of: + // 1. an explicitly-supplied updatedAt value, used when a bookmark to this item was removed + // 2. when the item itself was updated + // 3. or when it was last bookmarked + // to determine the latest version of the indexed version + const latestUpdatedAt = Math.max( + updatedAt ? new Date(updatedAt).getTime() : 0, + new Date(item.updatedAt).getTime(), + bookmarkedBy[0] ? new Date(bookmarkedBy[0].createdAt).getTime() : 0 + ) + try { await search.index({ id: item.id, index: process.env.OPENSEARCH_INDEX, - version: new Date(item.updatedAt).getTime(), + version: new Date(latestUpdatedAt).getTime(), versionType: 'external_gte', body: itemcp }) @@ -79,7 +101,9 @@ async function _indexItem (item, { models }) { } } -export async function indexItem ({ data: { id }, apollo, models }) { +// `data.updatedAt` is an explicit updatedAt value for the use case of a bookmark being removed +// this is consulted to generate the index version +export async function indexItem ({ data: { id, updatedAt }, apollo, models }) { // 1. grab item from database // could use apollo to avoid duping logic // when grabbing sats and user name, etc @@ -94,7 +118,7 @@ export async function indexItem ({ data: { id }, apollo, models }) { }) // 2. index it with external version based on updatedAt - await _indexItem(item, { models }) + await _indexItem(item, { models, updatedAt }) } export async function indexAllItems ({ apollo, models }) {