From eceead192b6d9e510d00731d4710190b72c382fe Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Mon, 20 May 2024 22:55:07 -0700 Subject: [PATCH] Track the type of saved searches --- api/db/searches-queries.test.ts | 58 +++++++++++++++++++++++++-------- api/db/searches-queries.ts | 32 +++++++++++------- api/routes/import.ts | 2 ++ api/routes/update.ts | 29 ++++++++++++----- api/shapes/profile.ts | 5 ++- api/shapes/search.ts | 10 ++++++ 6 files changed, 101 insertions(+), 35 deletions(-) diff --git a/api/db/searches-queries.test.ts b/api/db/searches-queries.test.ts index a9f66f1..90454a5 100644 --- a/api/db/searches-queries.test.ts +++ b/api/db/searches-queries.test.ts @@ -1,3 +1,4 @@ +import { SearchType } from '../shapes/search.js'; import { pool, transaction } from './index.js'; import { deleteAllSearches, @@ -22,7 +23,7 @@ afterAll(() => pool.end()); it('can record a used search where none was recorded before', async () => { await transaction(async (client) => { - await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk'); + await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk', SearchType.Item); const searches = (await getSearchesForProfile(client, bungieMembershipId, 2)).filter( (s) => s.usageCount > 0, @@ -35,8 +36,8 @@ it('can record a used search where none was recorded before', async () => { it('can track search multiple times', async () => { await transaction(async (client) => { - await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk'); - await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk'); + await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk', SearchType.Item); + await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk', SearchType.Item); const searches = (await getSearchesForProfile(client, bungieMembershipId, 2)).filter( (s) => s.usageCount > 0, @@ -49,8 +50,8 @@ it('can track search multiple times', async () => { it('can mark a search as favorite', async () => { await transaction(async (client) => { - await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk'); - await saveSearch(client, appId, bungieMembershipId, 2, 'tag:junk', true); + await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk', SearchType.Item); + await saveSearch(client, appId, bungieMembershipId, 2, 'tag:junk', SearchType.Item, true); const searches = (await getSearchesForProfile(client, bungieMembershipId, 2)).filter( (s) => s.usageCount > 0, @@ -59,7 +60,7 @@ it('can mark a search as favorite', async () => { expect(searches[0].saved).toBe(true); expect(searches[0].usageCount).toBe(1); - await saveSearch(client, appId, bungieMembershipId, 2, 'tag:junk', false); + await saveSearch(client, appId, bungieMembershipId, 2, 'tag:junk', SearchType.Item, false); const searches2 = await getSearchesForProfile(client, bungieMembershipId, 2); expect(searches2[0].query).toBe('tag:junk'); @@ -71,7 +72,7 @@ it('can mark a search as favorite', async () => { }); it('can mark a search as favorite even when it hasnt been used', async () => { await transaction(async (client) => { - await saveSearch(client, appId, bungieMembershipId, 2, 'tag:junk', true); + await saveSearch(client, appId, bungieMembershipId, 2, 'tag:junk', SearchType.Item, true); const searches = (await getSearchesForProfile(client, bungieMembershipId, 2)).filter( (s) => s.usageCount > 0, @@ -84,8 +85,8 @@ it('can mark a search as favorite even when it hasnt been used', async () => { it('can get all searches across profiles', async () => { await transaction(async (client) => { - await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk'); - await updateUsedSearch(client, appId, bungieMembershipId, 1, 'is:tagged'); + await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk', SearchType.Item); + await updateUsedSearch(client, appId, bungieMembershipId, 1, 'is:tagged', SearchType.Item); const searches = await getSearchesForUser(client, bungieMembershipId); expect(searches.length).toEqual(2); @@ -97,7 +98,7 @@ it('can increment usage for one of the built-in searches', async () => { const searches = await getSearchesForProfile(client, bungieMembershipId, 2); const query = searches[searches.length - 1].query; - await updateUsedSearch(client, appId, bungieMembershipId, 2, query); + await updateUsedSearch(client, appId, bungieMembershipId, 2, query, SearchType.Item); const searches2 = await getSearchesForProfile(client, bungieMembershipId, 2); const search = searches2.find((s) => s.query === query); @@ -108,8 +109,8 @@ it('can increment usage for one of the built-in searches', async () => { it('can delete a search', async () => { await transaction(async (client) => { - await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk'); - await deleteSearch(client, bungieMembershipId, 2, 'tag:junk'); + await updateUsedSearch(client, appId, bungieMembershipId, 2, 'tag:junk', SearchType.Item); + await deleteSearch(client, bungieMembershipId, 2, 'tag:junk', SearchType.Item); const searches = (await getSearchesForProfile(client, bungieMembershipId, 2)).filter( (s) => s.usageCount > 0, @@ -120,7 +121,17 @@ it('can delete a search', async () => { it('can import a search', async () => { await transaction(async (client) => { - await importSearch(client, appId, bungieMembershipId, 2, 'tag:junk', true, 1598199188576, 5); + await importSearch( + client, + appId, + bungieMembershipId, + 2, + 'tag:junk', + true, + 1598199188576, + 5, + SearchType.Item, + ); const searches = (await getSearchesForProfile(client, bungieMembershipId, 2)).filter( (s) => s.usageCount > 0, @@ -130,3 +141,24 @@ it('can import a search', async () => { expect(searches[0].usageCount).toBe(5); }); }); + +it('can record searches for loadouts', async () => { + await transaction(async (client) => { + await updateUsedSearch( + client, + appId, + bungieMembershipId, + 2, + 'subclass:void', + SearchType.Loadout, + ); + + const searches = (await getSearchesForProfile(client, bungieMembershipId, 2)).filter( + (s) => s.usageCount > 0, + ); + expect(searches[0].query).toBe('subclass:void'); + expect(searches[0].saved).toBe(false); + expect(searches[0].usageCount).toBe(1); + expect(searches[0].type).toBe(SearchType.Loadout); + }); +}); diff --git a/api/db/searches-queries.ts b/api/db/searches-queries.ts index 03f46c3..6e461d3 100644 --- a/api/db/searches-queries.ts +++ b/api/db/searches-queries.ts @@ -3,7 +3,7 @@ import { ClientBase, QueryResult } from 'pg'; import { metrics } from '../metrics/index.js'; import { ExportResponse } from '../shapes/export.js'; import { DestinyVersion } from '../shapes/general.js'; -import { Search } from '../shapes/search.js'; +import { Search, SearchType } from '../shapes/search.js'; /* * These "canned searches" get sent to everyone as a "starter pack" of example searches that'll show up in the recent search dropdown and autocomplete. @@ -17,6 +17,7 @@ const cannedSearchesForD2: Search[] = [ saved: false, usageCount: 0, lastUsage: 0, + type: SearchType.Item, })); const cannedSearchesForD1: Search[] = ['-is:equipped is:haslight is:incurrentchar'].map( @@ -25,6 +26,7 @@ const cannedSearchesForD1: Search[] = ['-is:equipped is:haslight is:incurrentcha saved: false, usageCount: 0, lastUsage: 0, + type: SearchType.Item, }), ); /* @@ -91,6 +93,7 @@ function convertSearch(row: any): Search { usageCount: row.usage_count, saved: row.saved, lastUsage: row.last_updated_at.getTime(), + type: row.search_type, }; } @@ -105,15 +108,16 @@ export async function updateUsedSearch( bungieMembershipId: number, destinyVersion: DestinyVersion, query: string, + type: SearchType, ): Promise> { try { const response = await client.query({ name: 'upsert_search', - text: `insert INTO searches (membership_id, destiny_version, query, created_by, last_updated_by) -values ($1, $2, $3, $4, $4) + text: `insert INTO searches (membership_id, destiny_version, query, search_type, created_by, last_updated_by) +values ($1, $2, $3, $5, $4, $4) on conflict (membership_id, destiny_version, qhash) -do update set (usage_count, last_used, last_updated_at, last_updated_by) = (searches.usage_count + 1, current_timestamp, current_timestamp, $4)`, - values: [bungieMembershipId, destinyVersion, query, appId], +do update set (usage_count, last_used, last_updated_at, last_updated_by) = (searches.usage_count + 1, current_timestamp, current_timestamp, $5, $4)`, + values: [bungieMembershipId, destinyVersion, query, appId, type], }); if (response.rowCount < 1) { @@ -137,6 +141,7 @@ export async function saveSearch( bungieMembershipId: number, destinyVersion: DestinyVersion, query: string, + type: SearchType, saved?: boolean, ): Promise> { try { @@ -151,9 +156,9 @@ export async function saveSearch( metrics.increment('db.searches.noRowUpdated.count', 1); const insertSavedResponse = await client.query({ name: 'insert_search_fallback', - text: `insert INTO searches (membership_id, destiny_version, query, saved, created_by, last_updated_by) - values ($1, $2, $3, true, $4, $4)`, - values: [bungieMembershipId, destinyVersion, query, appId], + text: `insert INTO searches (membership_id, destiny_version, query, search_type, saved, created_by, last_updated_by) + values ($1, $2, $3, $5, true, $4, $4)`, + values: [bungieMembershipId, destinyVersion, query, appId, type], }); return insertSavedResponse; } @@ -175,12 +180,13 @@ export async function importSearch( saved: boolean, lastUsage: number, usageCount: number, + type: SearchType, ): Promise> { try { const response = await client.query({ name: 'insert_search', - text: `insert INTO searches (membership_id, destiny_version, query, saved, usage_count, last_used, created_by, last_updated_by) -values ($1, $2, $3, $4, $5, $6, $7, $7)`, + text: `insert INTO searches (membership_id, destiny_version, query, saved, search_type, usage_count, last_used, created_by, last_updated_by) +values ($1, $2, $3, $4, $8, $5, $6, $7, $7)`, values: [ bungieMembershipId, destinyVersion, @@ -189,6 +195,7 @@ values ($1, $2, $3, $4, $5, $6, $7, $7)`, usageCount, new Date(lastUsage), appId, + type, ], }); @@ -212,12 +219,13 @@ export async function deleteSearch( bungieMembershipId: number, destinyVersion: DestinyVersion, query: string, + type: SearchType, ): Promise> { try { return client.query({ name: 'delete_search', - text: `delete from searches where membership_id = $1 and destiny_version = $2 and qhash = decode(md5($3), 'hex') and query = $3`, - values: [bungieMembershipId, destinyVersion, query], + text: `delete from searches where membership_id = $1 and destiny_version = $2 and qhash = decode(md5($3), 'hex') and query = $3 and search_type = $4`, + values: [bungieMembershipId, destinyVersion, query, type], }); } catch (e) { throw new Error(e.name + ': ' + e.message); diff --git a/api/routes/import.ts b/api/routes/import.ts index fb7a4ae..c6b2b86 100644 --- a/api/routes/import.ts +++ b/api/routes/import.ts @@ -12,6 +12,7 @@ import { DestinyVersion } from '../shapes/general.js'; import { ImportResponse } from '../shapes/import.js'; import { ItemAnnotation } from '../shapes/item-annotations.js'; import { Loadout } from '../shapes/loadouts.js'; +import { SearchType } from '../shapes/search.js'; import { defaultSettings, Settings } from '../shapes/settings.js'; import { badRequest } from '../utils.js'; import { deleteAllData } from './delete-all-data.js'; @@ -123,6 +124,7 @@ export const importHandler = asyncHandler(async (req, res) => { search.search.saved, search.search.lastUsage, search.search.usageCount, + search.search.type ?? SearchType.Item, ); } }); diff --git a/api/routes/update.ts b/api/routes/update.ts index da3a830..111a4b0 100644 --- a/api/routes/update.ts +++ b/api/routes/update.ts @@ -22,6 +22,7 @@ import { DestinyVersion } from '../shapes/general.js'; import { ItemAnnotation } from '../shapes/item-annotations.js'; import { Loadout } from '../shapes/loadouts.js'; import { + DeleteSearchUpdate, ItemHashTagUpdate, ProfileUpdateRequest, ProfileUpdateResult, @@ -29,6 +30,7 @@ import { TrackTriumphUpdate, UsedSearchUpdate, } from '../shapes/profile.js'; +import { SearchType } from '../shapes/search.js'; import { Settings } from '../shapes/settings.js'; import { badRequest, @@ -146,12 +148,7 @@ export const updateHandler = asyncHandler(async (req, res) => { break; case 'delete_search': - result = await deleteSearch( - client, - bungieMembershipId, - destinyVersion, - update.payload.query, - ); + result = await deleteSearch(client, bungieMembershipId, destinyVersion, update.payload); break; default: @@ -428,7 +425,14 @@ async function recordSearch( } const start = new Date(); - await updateUsedSearch(client, appId, bungieMembershipId, destinyVersion, payload.query); + await updateUsedSearch( + client, + appId, + bungieMembershipId, + destinyVersion, + payload.query, + payload.type ?? SearchType.Item, + ); metrics.timing('update.recordSearch', start); return { status: 'Success' }; @@ -461,6 +465,7 @@ async function saveSearch( bungieMembershipId, destinyVersion, payload.query, + payload.type ?? SearchType.Item, payload.saved, ); metrics.timing('update.saveSearch', start); @@ -472,10 +477,16 @@ async function deleteSearch( client: ClientBase, bungieMembershipId: number, destinyVersion: DestinyVersion, - query: string, + payload: DeleteSearchUpdate['payload'], ): Promise { const start = new Date(); - await deleteSearchInDb(client, bungieMembershipId, destinyVersion, query); + await deleteSearchInDb( + client, + bungieMembershipId, + destinyVersion, + payload.query, + payload.type ?? SearchType.Item, + ); metrics.timing('update.deleteSearch', start); return { status: 'Success' }; diff --git a/api/shapes/profile.ts b/api/shapes/profile.ts index ed42778..79e1d32 100644 --- a/api/shapes/profile.ts +++ b/api/shapes/profile.ts @@ -1,7 +1,7 @@ import { DestinyVersion } from './general.js'; import { ItemAnnotation, ItemHashTag } from './item-annotations.js'; import { Loadout } from './loadouts.js'; -import { Search } from './search.js'; +import { Search, SearchType } from './search.js'; import { Settings } from './settings.js'; export interface ProfileResponse { @@ -87,6 +87,7 @@ export interface UsedSearchUpdate { action: 'search'; payload: { query: string; + type: SearchType; }; } @@ -97,6 +98,7 @@ export interface SavedSearchUpdate { action: 'save_search'; payload: { query: string; + type: SearchType; /** * Whether the search should be saved */ @@ -111,6 +113,7 @@ export interface DeleteSearchUpdate { action: 'delete_search'; payload: { query: string; + type: SearchType; }; } diff --git a/api/shapes/search.ts b/api/shapes/search.ts index 279a7dd..452727e 100644 --- a/api/shapes/search.ts +++ b/api/shapes/search.ts @@ -1,3 +1,8 @@ +export const enum SearchType { + Item = 1, + Loadout = 2, +} + /** * A search query. This can either be from history (recent searches), pinned (saved searches), or suggested. */ @@ -15,4 +20,9 @@ export interface Search { * The last time this was used, as a unix millisecond timestamp. */ lastUsage: number; + /** + * Which kind of thing is this search for? Searches of different types are + * stored together and need to be filtered to the specific type. + */ + type: SearchType; }