Search bookmarks (#1075)

* Support `is:bookmarked` search option to search my bookmarked items

* Update the worker search module to include `bookmarkedBy: Array<Number>` which
contains the list of user ids which have bookmarked a given item

* Add a trigger on the `Bookmark` DB table to re-index the corresponding item when
a bookmark is added/removed

* Update the Search resolver to check for a `is:bookmarked` query option. If provided,
include it as an option in the search request. This updates search to look for items
which are bookmarked by the current user. By default, this preserves stacker privacy
so you can only search your own bookmarks

* Update the search page UI to show how to invoke searching your own bookmarks

* undo `is:bookmarked` support, add `bookmarks` item in search select

* short circuit return empty payload for anon requesting bookmarks

* remove console.log for debugging

* fix indexing a new item that has yet to be bookmarked

* update db migration to re-index all existing bookmarked items one time

* fix the case where deleting a bookmark doesn't trigger a new index of items

explictly specify a `updatedAt` value when deleting a bookmark, to ensure that
deleting a bookmark results in a new indexed version of the bookmarked item

* update search indexer to use the latest of all three choices for the latest version

* give bookmark index jobs longer expiration

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
SatsAllDay 2024-04-19 14:24:48 -04:00 committed by GitHub
parent 0552736dc7
commit d7ecbbae3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 88 additions and 7 deletions

View File

@ -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
}

View File

@ -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' &&
<>

View File

@ -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();

View File

@ -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 }) {