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:
parent
0552736dc7
commit
d7ecbbae3a
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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' &&
|
||||
<>
|
||||
|
|
|
@ -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();
|
|
@ -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 }) {
|
||||
|
|
Loading…
Reference in New Issue