diff --git a/package-lock.json b/package-lock.json index b3c191fee..5536f9eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "openai": "^4.48.1", "p-debounce": "^2.1.0", "p-limit": "^3.1.0", + "p-throttle": "^7.0.0", "pdfmake": "^0.2.15", "pg": "8.11.4", "pg-hstore": "^2.3.4", @@ -28179,6 +28180,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-throttle": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-7.0.0.tgz", + "integrity": "sha512-aio0v+S0QVkH1O+9x4dHtD4dgCExACcL+3EtNaGqC01GBudS9ijMuUsmN8OVScyV4OOp0jqdLShZFuSlbL/AsA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", diff --git a/package.json b/package.json index b273ccfaa..630c1abfa 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "openai": "^4.48.1", "p-debounce": "^2.1.0", "p-limit": "^3.1.0", + "p-throttle": "^7.0.0", "pdfmake": "^0.2.15", "pg": "8.11.4", "pg-hstore": "^2.3.4", diff --git a/packages/frontend/src/app/app.component.ts b/packages/frontend/src/app/app.component.ts index 8a5de0323..b78d9285f 100644 --- a/packages/frontend/src/app/app.component.ts +++ b/packages/frontend/src/app/app.component.ts @@ -184,9 +184,12 @@ export class AppComponent { (window as any).appLoaded = true; (window as any).swRegistration?.update(); - setInterval(() => { - (window as any).swRegistration?.update(); - }, SW_UPDATE_CHECK_INTERVAL_MINUTES); + setInterval( + () => { + (window as any).swRegistration?.update(); + }, + SW_UPDATE_CHECK_INTERVAL_MINUTES * 60 * 1000, + ); } initEventListeners() { diff --git a/packages/frontend/src/app/utils/SearchManager.ts b/packages/frontend/src/app/utils/SearchManager.ts index 2d8117c82..24abb6406 100644 --- a/packages/frontend/src/app/utils/SearchManager.ts +++ b/packages/frontend/src/app/utils/SearchManager.ts @@ -4,7 +4,12 @@ import MiniSearch, { type SearchResult, } from "minisearch"; import { IDBPDatabase } from "idb"; -import { KVStoreKeys, ObjectStoreName } from "./localDb"; +import { + getKvStoreEntry, + KVStoreKeys, + ObjectStoreName, + RSLocalDB, +} from "./localDb"; import type { RecipeSummary } from "@recipesage/prisma"; /** @@ -39,7 +44,7 @@ export class SearchManager { private saveTimeout: NodeJS.Timeout | undefined; private maxSaveTimeout: NodeJS.Timeout | undefined; - constructor(private localDb: IDBPDatabase) { + constructor(private localDb: IDBPDatabase) { this.miniSearch = new MiniSearch(this.miniSearchOptions); } @@ -54,16 +59,13 @@ export class SearchManager { async populateFromLocalDb() { performance.mark("startIndexLoad"); - const indexRecord = await this.localDb.get( - ObjectStoreName.KV, - KVStoreKeys.RecipeSearchIndex, - ); + const indexRecord = await getKvStoreEntry(KVStoreKeys.RecipeSearchIndex); if (!indexRecord) return; try { this.miniSearch = MiniSearch.loadJSON( - indexRecord.value, + indexRecord, this.miniSearchOptions, ); @@ -168,19 +170,10 @@ export class SearchManager { clearTimeout(this.saveTimeout); clearTimeout(this.maxSaveTimeout); - if ( - await this.localDb.get(ObjectStoreName.KV, KVStoreKeys.RecipeSearchIndex) - ) { - await this.localDb.put(ObjectStoreName.KV, { - key: KVStoreKeys.RecipeSearchIndex, - value: JSON.stringify(this.miniSearch), - }); - } else { - await this.localDb.add(ObjectStoreName.KV, { - key: KVStoreKeys.RecipeSearchIndex, - value: JSON.stringify(this.miniSearch), - }); - } + await this.localDb.put(ObjectStoreName.KV, { + key: KVStoreKeys.RecipeSearchIndex, + value: JSON.stringify(this.miniSearch), + }); } async destroy(): Promise { diff --git a/packages/frontend/src/app/utils/SyncManager.ts b/packages/frontend/src/app/utils/SyncManager.ts index 1682c6508..e36c79c88 100644 --- a/packages/frontend/src/app/utils/SyncManager.ts +++ b/packages/frontend/src/app/utils/SyncManager.ts @@ -1,16 +1,11 @@ import { IDBPDatabase } from "idb"; +import pThrottle from "p-throttle"; import type { SearchManager } from "./SearchManager"; import { trpcClient as trpc } from "./trpcClient"; -import { ObjectStoreName } from "./localDb"; +import { KVStoreKeys, ObjectStoreName, RSLocalDB } from "./localDb"; import { appIdbStorageManager } from "./appIdbStorageManager"; import { SW_BROADCAST_CHANNEL_NAME } from "./SW_BROADCAST_CHANNEL_NAME"; -const waitFor = (time: number): Promise => { - return new Promise((resolve) => { - setTimeout(resolve, time); - }); -}; - const broadcastChannel = new BroadcastChannel(SW_BROADCAST_CHANNEL_NAME); const ENABLE_VERBOSE_SYNC_LOGGING = false; @@ -18,13 +13,19 @@ const ENABLE_VERBOSE_SYNC_LOGGING = false; const SYNC_BATCH_SIZE = 200; /** - * How long to wait between syncing each recipe + * We cannot exceed the rate limit of the API (is limited per-IP), + * so we throttle to 4 requests/sec to allow the browser some buffer as well in case + * the user is doing activities during a sync. + * Due to OPTIONS requests, the number of requests that actually go through will be doubled */ -const SYNC_BATCH_RATE_LIMIT_WAIT_MS = 250; +const throttle = pThrottle({ + limit: 3, + interval: 1000, +}); export class SyncManager { constructor( - private localDb: IDBPDatabase, + private localDb: IDBPDatabase, private searchManager: SearchManager, ) { broadcastChannel.addEventListener("message", (event) => { @@ -58,15 +59,12 @@ export class SyncManager { try { await this.syncRecipes(); - await waitFor(SYNC_BATCH_RATE_LIMIT_WAIT_MS); - await this.syncLabels(); - await waitFor(SYNC_BATCH_RATE_LIMIT_WAIT_MS); - await this.syncShoppingLists(); - await waitFor(SYNC_BATCH_RATE_LIMIT_WAIT_MS); - await this.syncMealPlans(); + await this.syncMyUserProfile(); + await this.syncMyFriends(); + await this.syncMyStats(); performance.mark("endSync"); const measure = performance.measure("syncTime", "startSync", "endSync"); @@ -77,17 +75,20 @@ export class SyncManager { } async syncRecipe(recipeId: string): Promise { - const recipe = await trpc.recipes.getRecipe.query({ - id: recipeId, - }); + const recipe = await throttle(() => + trpc.recipes.getRecipe.query({ + id: recipeId, + }), + )(); await this.localDb.put(ObjectStoreName.Recipes, recipe); await this.searchManager.indexRecipe(recipe); } async syncRecipes(): Promise { - const allVisibleRecipesManifest = - await trpc.recipes.getAllVisibleRecipesManifest.query(); + const allVisibleRecipesManifest = await throttle(() => + trpc.recipes.getAllVisibleRecipesManifest.query(), + )(); const serverRecipeIds = allVisibleRecipesManifest.reduce( (acc, el) => acc.add(el.id), new Set(), @@ -136,29 +137,31 @@ export class SyncManager { while (remainingRecipeIdsToSync.length) { const ids = remainingRecipeIdsToSync.splice(0, SYNC_BATCH_SIZE); - const recipes = await trpc.recipes.getRecipesByIds.query({ - ids, - }); + const recipes = await throttle(() => + trpc.recipes.getRecipesByIds.query({ + ids, + }), + )(); for (const recipe of recipes) { await this.localDb.put(ObjectStoreName.Recipes, recipe); await this.searchManager.indexRecipe(recipe); } - - await waitFor(SYNC_BATCH_RATE_LIMIT_WAIT_MS); } } async syncLabels() { - const allLabels = await trpc.labels.getAllVisibleLabels.query(); + const allLabels = await throttle(() => + trpc.labels.getAllVisibleLabels.query(), + )(); await this.localDb.clear(ObjectStoreName.Labels); for (const label of allLabels) { await this.localDb.put(ObjectStoreName.Labels, label); } - await waitFor(SYNC_BATCH_RATE_LIMIT_WAIT_MS); - - const labelGroups = await trpc.labelGroups.getLabelGroups.query(); + const labelGroups = await throttle(() => + trpc.labelGroups.getLabelGroups.query(), + )(); await this.localDb.clear(ObjectStoreName.LabelGroups); for (const labelGroup of labelGroups) { await this.localDb.put(ObjectStoreName.LabelGroups, labelGroup); @@ -170,8 +173,9 @@ export class SyncManager { } async syncShoppingLists() { - const shoppingLists = - await trpc.shoppingLists.getShoppingListsWithItems.query(); + const shoppingLists = await throttle(() => + trpc.shoppingLists.getShoppingListsWithItems.query(), + )(); await this.localDb.clear(ObjectStoreName.ShoppingLists); for (const shoppingList of shoppingLists) { await this.localDb.put(ObjectStoreName.ShoppingLists, shoppingList); @@ -179,10 +183,46 @@ export class SyncManager { } async syncMealPlans() { - const mealPlans = await trpc.mealPlans.getMealPlansWithItems.query(); + const mealPlans = await throttle(() => + trpc.mealPlans.getMealPlansWithItems.query(), + )(); await this.localDb.clear(ObjectStoreName.MealPlans); for (const mealPlan of mealPlans) { await this.localDb.put(ObjectStoreName.MealPlans, mealPlan); } } + + async syncMyUserProfile() { + const myProfile = await throttle(() => trpc.users.getMe.query())(); + await this.localDb.put(ObjectStoreName.KV, { + key: KVStoreKeys.MyUserProfile, + value: myProfile, + }); + } + + async syncMyFriends() { + const myFriends = await throttle(() => trpc.users.getMyFriends.query())(); + await this.localDb.put(ObjectStoreName.KV, { + key: KVStoreKeys.MyFriends, + value: myFriends, + }); + + const userProfiles = [ + myFriends.friends, + myFriends.incomingRequests, + myFriends.outgoingRequests, + ].flat(); + + for (const userProfile of userProfiles) { + await this.localDb.put(ObjectStoreName.UserProfiles, userProfile); + } + } + + async syncMyStats() { + const myStats = await throttle(() => trpc.users.getMyStats.query())(); + await this.localDb.put(ObjectStoreName.KV, { + key: KVStoreKeys.MyStats, + value: myStats, + }); + } } diff --git a/packages/frontend/src/app/utils/appIdbStorageManager.ts b/packages/frontend/src/app/utils/appIdbStorageManager.ts index 08302a2a4..21dd48c89 100644 --- a/packages/frontend/src/app/utils/appIdbStorageManager.ts +++ b/packages/frontend/src/app/utils/appIdbStorageManager.ts @@ -1,12 +1,16 @@ -import { getLocalDb, KVStoreKeys, ObjectStoreName } from "./localDb"; +import { + getKvStoreEntry, + getLocalDb, + KVStoreKeys, + ObjectStoreName, +} from "./localDb"; import type { SessionDTO } from "@recipesage/prisma"; export class AppIdbStorageManager { async getSession(): Promise { - const localDb = await getLocalDb(); - const session = await localDb.get(ObjectStoreName.KV, KVStoreKeys.Session); + const session = await getKvStoreEntry(KVStoreKeys.Session); - return session?.value || null; + return session || null; } async setSession(session: SessionDTO): Promise { @@ -33,12 +37,8 @@ export class AppIdbStorageManager { } async getLastSessionUserId(): Promise { - const localDb = await getLocalDb(); - const record = await localDb.get( - ObjectStoreName.KV, - KVStoreKeys.LastSessionUserId, - ); - return record?.value || null; + const record = await getKvStoreEntry(KVStoreKeys.LastSessionUserId); + return record || null; } async deleteAllData(): Promise { @@ -49,6 +49,8 @@ export class AppIdbStorageManager { await localDb.clear(ObjectStoreName.LabelGroups); await localDb.clear(ObjectStoreName.ShoppingLists); await localDb.clear(ObjectStoreName.MealPlans); + await localDb.clear(ObjectStoreName.AssistantMessages); + await localDb.clear(ObjectStoreName.Jobs); } } diff --git a/packages/frontend/src/app/utils/localDb.ts b/packages/frontend/src/app/utils/localDb.ts deleted file mode 100644 index 84443e175..000000000 --- a/packages/frontend/src/app/utils/localDb.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { IDBPDatabase, openDB } from "idb"; -import * as Sentry from "@sentry/browser"; - -export enum ObjectStoreName { - Recipes = "recipes", - Labels = "labels", - LabelGroups = "labelGroups", - ShoppingLists = "shoppingLists", - MealPlans = "mealPlans", - KV = "kvStore", -} - -export enum KVStoreKeys { - Session = "session", - RecipeSearchIndex = "recipeSearchIndex", - LastSessionUserId = "lastSessionUserId", -} - -const connect = () => { - return openDB(`localDb`, 1, { - upgrade: (db, previousVersion, newVersion) => { - console.log( - `Local DB upgrading from ${previousVersion} to ${newVersion}`, - ); - - try { - switch (previousVersion) { - case 0: { - const recipesDb = db.createObjectStore(ObjectStoreName.Recipes, { - keyPath: "id", - }); - recipesDb.createIndex("userId", "userId", { unique: false }); - - const labelsDb = db.createObjectStore(ObjectStoreName.Labels, { - keyPath: "id", - }); - labelsDb.createIndex("userId", "userId", { unique: false }); - labelsDb.createIndex("title", "title", { unique: false }); - labelsDb.createIndex("labelGroupId", "labelGroupId", { - unique: false, - }); - - const labelGroupsDb = db.createObjectStore( - ObjectStoreName.LabelGroups, - { - keyPath: "id", - }, - ); - labelGroupsDb.createIndex("userId", "userId", { unique: false }); - - const shoppingListsDb = db.createObjectStore( - ObjectStoreName.ShoppingLists, - { - keyPath: "id", - }, - ); - shoppingListsDb.createIndex("userId", "userId", { unique: false }); - - const mealPlansDb = db.createObjectStore( - ObjectStoreName.MealPlans, - { - keyPath: "id", - }, - ); - mealPlansDb.createIndex("userId", "userId", { unique: false }); - - db.createObjectStore(ObjectStoreName.KV, { - keyPath: "key", - }); - - return; - } - } - } catch (e) { - console.error(e); - Sentry.captureException(e, { - extra: { - info: "Localdb failed to upgrade!", - }, - }); - - throw e; - } - - console.log(`Local DB upgraded from ${previousVersion} to ${newVersion}`); - }, - }); -}; - -let localDbP: Promise | undefined = undefined; -export async function getLocalDb() { - if (!localDbP) localDbP = connect(); - - const localDb = await localDbP; - return localDb; -} diff --git a/packages/frontend/src/app/utils/localDb/index.ts b/packages/frontend/src/app/utils/localDb/index.ts new file mode 100644 index 000000000..a5c8c7e95 --- /dev/null +++ b/packages/frontend/src/app/utils/localDb/index.ts @@ -0,0 +1 @@ +export * from "./localDb"; diff --git a/packages/frontend/src/app/utils/localDb/localDb.ts b/packages/frontend/src/app/utils/localDb/localDb.ts new file mode 100644 index 000000000..6741a796f --- /dev/null +++ b/packages/frontend/src/app/utils/localDb/localDb.ts @@ -0,0 +1,185 @@ +import { DBSchema, IDBPDatabase, openDB } from "idb"; +import * as Sentry from "@sentry/browser"; +import { + AssistantMessageSummary, + JobSummary, + LabelGroupSummary, + LabelSummary, + MealPlanSummaryWithItems, + RecipeSummary, + SessionDTO, + ShoppingListSummaryWithItems, + UserPublic, +} from "@recipesage/prisma"; +import { trpcClient as trpc } from "../trpcClient"; +import { localDBMigration_1 } from "./migrations/localDBMigration_1"; +import { localDBMigration_2 } from "./migrations/localDBMigration_2"; + +export enum ObjectStoreName { + Recipes = "recipes", + Labels = "labels", + LabelGroups = "labelGroups", + ShoppingLists = "shoppingLists", + MealPlans = "mealPlans", + UserProfiles = "userProfiles", + AssistantMessages = "assistantMessages", + Jobs = "jobs", + KV = "kvStore", +} + +export enum KVStoreKeys { + Session = "session", + RecipeSearchIndex = "recipeSearchIndex", + LastSessionUserId = "lastSessionUserId", + MyUserProfile = "myUserProfile", + MyFriends = "myFriends", + MyStats = "myStats", +} + +export interface KVSession { + key: KVStoreKeys.Session; + value: SessionDTO; +} +export interface KVRecipeSearchIndex { + key: KVStoreKeys.RecipeSearchIndex; + value: string; +} +export interface KVLastSessionUserId { + key: KVStoreKeys.LastSessionUserId; + value: string; +} +export interface KVMyUserProfile { + key: KVStoreKeys.MyUserProfile; + value: Awaited>; +} +export interface KVMyFriends { + key: KVStoreKeys.MyFriends; + value: Awaited>; +} +export interface KVMyStats { + key: KVStoreKeys.MyStats; + value: Awaited>; +} + +export type KVStoreValue = { + [KVStoreKeys.Session]: KVSession; + [KVStoreKeys.RecipeSearchIndex]: KVRecipeSearchIndex; + [KVStoreKeys.LastSessionUserId]: KVLastSessionUserId; + [KVStoreKeys.MyUserProfile]: KVMyUserProfile; + [KVStoreKeys.MyFriends]: KVMyFriends; + [KVStoreKeys.MyStats]: KVMyStats; +}; + +export interface RSLocalDB extends DBSchema { + [ObjectStoreName.Recipes]: { + key: string; + value: RecipeSummary; + indexes: { + userId: string; + }; + }; + [ObjectStoreName.Labels]: { + key: string; + value: LabelSummary; + indexes: { + userId: string; + title: string; + labelGroupId: string; + }; + }; + [ObjectStoreName.LabelGroups]: { + key: string; + value: LabelGroupSummary; + indexes: { + userId: string; + }; + }; + [ObjectStoreName.ShoppingLists]: { + key: string; + value: ShoppingListSummaryWithItems; + indexes: { + userId: string; + }; + }; + [ObjectStoreName.MealPlans]: { + key: string; + value: MealPlanSummaryWithItems; + indexes: { + userId: string; + }; + }; + [ObjectStoreName.UserProfiles]: { + key: string; + value: UserPublic; + }; + [ObjectStoreName.AssistantMessages]: { + key: string; + value: AssistantMessageSummary; + }; + [ObjectStoreName.Jobs]: { + key: string; + value: JobSummary; + }; + [ObjectStoreName.KV]: { + key: string; + value: KVStoreValue[keyof KVStoreValue]; + }; +} + +const connect = () => { + return openDB(`localDb`, 1, { + upgrade: (db, previousVersion, newVersion) => { + console.log( + `Local DB upgrading from ${previousVersion} to ${newVersion}`, + ); + + try { + // TODO: automate this + switch (previousVersion) { + case 0: { + localDBMigration_1(db); + localDBMigration_2(db); + + return; + } + case 1: { + localDBMigration_2(db); + + return; + } + } + } catch (e) { + console.error(e); + Sentry.captureException(e, { + extra: { + info: "Localdb failed to upgrade!", + }, + }); + + throw e; + } + + console.log(`Local DB upgraded from ${previousVersion} to ${newVersion}`); + }, + }); +}; + +let localDbP: Promise> | undefined = undefined; +export async function getLocalDb() { + if (!localDbP) localDbP = connect(); + + const localDb = await localDbP; + return localDb; +} + +export const getKvStoreEntry = async ( + key: T, +): Promise => { + const localDb = await getLocalDb(); + + const result = await localDb.get(ObjectStoreName.KV, key); + + const typedResult = result as KVStoreValue[T] | undefined; + + return typedResult?.value; +}; diff --git a/packages/frontend/src/app/utils/localDb/migrations/localDBMigration_1.ts b/packages/frontend/src/app/utils/localDb/migrations/localDBMigration_1.ts new file mode 100644 index 000000000..967862820 --- /dev/null +++ b/packages/frontend/src/app/utils/localDb/migrations/localDBMigration_1.ts @@ -0,0 +1,37 @@ +import { IDBPDatabase } from "idb"; +import { ObjectStoreName, type RSLocalDB } from "../localDb"; + +export const localDBMigration_1 = (db: IDBPDatabase) => { + const recipesDb = db.createObjectStore(ObjectStoreName.Recipes, { + keyPath: "id", + }); + recipesDb.createIndex("userId", "userId", { unique: false }); + + const labelsDb = db.createObjectStore(ObjectStoreName.Labels, { + keyPath: "id", + }); + labelsDb.createIndex("userId", "userId", { unique: false }); + labelsDb.createIndex("title", "title", { unique: false }); + labelsDb.createIndex("labelGroupId", "labelGroupId", { + unique: false, + }); + + const labelGroupsDb = db.createObjectStore(ObjectStoreName.LabelGroups, { + keyPath: "id", + }); + labelGroupsDb.createIndex("userId", "userId", { unique: false }); + + const shoppingListsDb = db.createObjectStore(ObjectStoreName.ShoppingLists, { + keyPath: "id", + }); + shoppingListsDb.createIndex("userId", "userId", { unique: false }); + + const mealPlansDb = db.createObjectStore(ObjectStoreName.MealPlans, { + keyPath: "id", + }); + mealPlansDb.createIndex("userId", "userId", { unique: false }); + + db.createObjectStore(ObjectStoreName.KV, { + keyPath: "key", + }); +}; diff --git a/packages/frontend/src/app/utils/localDb/migrations/localDBMigration_2.ts b/packages/frontend/src/app/utils/localDb/migrations/localDBMigration_2.ts new file mode 100644 index 000000000..c56a60e50 --- /dev/null +++ b/packages/frontend/src/app/utils/localDb/migrations/localDBMigration_2.ts @@ -0,0 +1,14 @@ +import { IDBPDatabase } from "idb"; +import { ObjectStoreName, type RSLocalDB } from "../localDb"; + +export const localDBMigration_2 = (db: IDBPDatabase) => { + db.createObjectStore(ObjectStoreName.UserProfiles, { + keyPath: "id", + }); + db.createObjectStore(ObjectStoreName.AssistantMessages, { + keyPath: "id", + }); + db.createObjectStore(ObjectStoreName.Jobs, { + keyPath: "id", + }); +}; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/assistant/index.ts b/packages/frontend/src/app/utils/serviceWorker/routes/assistant/index.ts new file mode 100644 index 000000000..a5a6c33a6 --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/assistant/index.ts @@ -0,0 +1 @@ +export { registerGetAssistantMessagesRoute } from "./registerGetAssistantMessagesRoute"; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/assistant/registerGetAssistantMessagesRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/assistant/registerGetAssistantMessagesRoute.ts new file mode 100644 index 000000000..7bbbbc291 --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/assistant/registerGetAssistantMessagesRoute.ts @@ -0,0 +1,33 @@ +import { registerRoute } from "workbox-routing"; +import { swAssertStatusCacheDivert } from "../../swErrorHandling"; +import { getLocalDb, ObjectStoreName } from "../../../localDb"; +import { trpcClient as trpc } from "../../../trpcClient"; +import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; + +export const registerGetAssistantMessagesRoute = () => { + registerRoute( + /((https:\/\/api(\.beta)?\.recipesage\.com)|(\/api))\/trpc\/assistant\.getAssistantMessages/, + async (event) => { + try { + const response = await fetch(event.request); + + swAssertStatusCacheDivert(response); + + return response; + } catch (e) { + const localDb = await getLocalDb(); + + const assistantMessages = await localDb.getAll( + ObjectStoreName.AssistantMessages, + ); + + return encodeCacheResultForTrpc( + assistantMessages satisfies Awaited< + ReturnType + >, + ); + } + }, + "GET", + ); +}; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/jobs/index.ts b/packages/frontend/src/app/utils/serviceWorker/routes/jobs/index.ts new file mode 100644 index 000000000..ab9caa720 --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/jobs/index.ts @@ -0,0 +1,2 @@ +export { registerGetJobRoute } from "./registerGetJobRoute"; +export { registerGetJobsRoute } from "./registerGetJobsRoute"; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/jobs/registerGetJobRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/jobs/registerGetJobRoute.ts new file mode 100644 index 000000000..5633da9cd --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/jobs/registerGetJobRoute.ts @@ -0,0 +1,46 @@ +import { registerRoute } from "workbox-routing"; +import { + swAssertStatusCacheDivert, + swCacheReject, + SWCacheRejectReason, +} from "../../swErrorHandling"; +import { getLocalDb, ObjectStoreName } from "../../../localDb"; +import { trpcClient as trpc } from "../../../trpcClient"; +import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; +import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; + +export const registerGetJobRoute = () => { + registerRoute( + /((https:\/\/api(\.beta)?\.recipesage\.com)|(\/api))\/trpc\/jobs\.getJob/, + async (event) => { + try { + const response = await fetch(event.request); + + swAssertStatusCacheDivert(response); + + return response; + } catch (e) { + const input = + getTrpcInputForEvent[0]>( + event, + ); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); + + const { id } = input; + + const localDb = await getLocalDb(); + + const job = await localDb.get(ObjectStoreName.Jobs, id); + + if (!job) { + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); + } + + return encodeCacheResultForTrpc( + job satisfies Awaited>, + ); + } + }, + "GET", + ); +}; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/jobs/registerGetJobsRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/jobs/registerGetJobsRoute.ts new file mode 100644 index 000000000..57d452585 --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/jobs/registerGetJobsRoute.ts @@ -0,0 +1,41 @@ +import { registerRoute } from "workbox-routing"; +import { + swAssertStatusCacheDivert, + swCacheReject, + SWCacheRejectReason, +} from "../../swErrorHandling"; +import { appIdbStorageManager } from "../../../appIdbStorageManager"; +import { getLocalDb, ObjectStoreName } from "../../../localDb"; +import { trpcClient as trpc } from "../../../trpcClient"; +import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; + +export const registerGetJobsRoute = () => { + registerRoute( + /((https:\/\/api(\.beta)?\.recipesage\.com)|(\/api))\/trpc\/jobs\.getJobs/, + async (event) => { + try { + const response = await fetch(event.request); + + swAssertStatusCacheDivert(response); + + return response; + } catch (e) { + const localDb = await getLocalDb(); + + const session = await appIdbStorageManager.getSession(); + if (!session) { + return swCacheReject(SWCacheRejectReason.NoSession, e); + } + + let jobs = await localDb.getAll(ObjectStoreName.Jobs); + + jobs = jobs.filter((job) => job.userId === session.userId); + + return encodeCacheResultForTrpc( + jobs satisfies Awaited>, + ); + } + }, + "GET", + ); +}; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/labels/registerGetAllVisibleLabelsRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/labels/registerGetAllVisibleLabelsRoute.ts index eb64524ea..47ebcd888 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/labels/registerGetAllVisibleLabelsRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/labels/registerGetAllVisibleLabelsRoute.ts @@ -1,7 +1,6 @@ import { registerRoute } from "workbox-routing"; import { swAssertStatusCacheDivert } from "../../swErrorHandling"; import { getLocalDb, ObjectStoreName } from "../../../localDb"; -import type { LabelSummary } from "@recipesage/prisma"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; @@ -18,9 +17,7 @@ export const registerGetAllVisibleLabelsRoute = () => { } catch (e) { const localDb = await getLocalDb(); - let labels: LabelSummary[] = await localDb.getAll( - ObjectStoreName.Labels, - ); + const labels = await localDb.getAll(ObjectStoreName.Labels); return encodeCacheResultForTrpc( labels satisfies Awaited< diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/labels/registerGetLabelsRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/labels/registerGetLabelsRoute.ts index 7f94223e3..96894ab5e 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/labels/registerGetLabelsRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/labels/registerGetLabelsRoute.ts @@ -2,10 +2,10 @@ import { registerRoute } from "workbox-routing"; import { swAssertStatusCacheDivert, swCacheReject, + SWCacheRejectReason, } from "../../swErrorHandling"; import { appIdbStorageManager } from "../../../appIdbStorageManager"; import { getLocalDb, ObjectStoreName } from "../../../localDb"; -import type { LabelSummary } from "@recipesage/prisma"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; @@ -24,12 +24,10 @@ export const registerGetLabelsRoute = () => { const session = await appIdbStorageManager.getSession(); if (!session) { - return swCacheReject("Not logged in, can't operate offline", e); + return swCacheReject(SWCacheRejectReason.NoSession, e); } - let labels: LabelSummary[] = await localDb.getAll( - ObjectStoreName.Labels, - ); + let labels = await localDb.getAll(ObjectStoreName.Labels); labels = labels.filter((label) => label.userId === session.userId); diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlanItemsRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlanItemsRoute.ts index 5f0475a5c..6cee23bb6 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlanItemsRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlanItemsRoute.ts @@ -2,12 +2,12 @@ import { registerRoute } from "workbox-routing"; import { swAssertStatusCacheDivert, swCacheReject, + SWCacheRejectReason, } from "../../swErrorHandling"; import { getLocalDb, ObjectStoreName } from "../../../localDb"; import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; -import type { MealPlanSummaryWithItems } from "@recipesage/prisma"; export const registerGetMealPlanItemsRoute = () => { registerRoute( @@ -24,17 +24,19 @@ export const registerGetMealPlanItemsRoute = () => { getTrpcInputForEvent< Parameters[0] >(event); - if (!input) return swCacheReject("No input provided", e); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); const { mealPlanId } = input; const localDb = await getLocalDb(); - const mealPlan: MealPlanSummaryWithItems | undefined = - await localDb.get(ObjectStoreName.MealPlans, mealPlanId); + const mealPlan = await localDb.get( + ObjectStoreName.MealPlans, + mealPlanId, + ); if (!mealPlan) { - return swCacheReject("No cache result found", e); + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); } return encodeCacheResultForTrpc( diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlanRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlanRoute.ts index 9ed399961..e98759134 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlanRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlanRoute.ts @@ -2,12 +2,12 @@ import { registerRoute } from "workbox-routing"; import { swAssertStatusCacheDivert, swCacheReject, + SWCacheRejectReason, } from "../../swErrorHandling"; import { getLocalDb, ObjectStoreName } from "../../../localDb"; import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; -import type { MealPlanSummaryWithItems } from "@recipesage/prisma"; export const registerGetMealPlanRoute = () => { registerRoute( @@ -24,17 +24,16 @@ export const registerGetMealPlanRoute = () => { getTrpcInputForEvent< Parameters[0] >(event); - if (!input) return swCacheReject("No input provided", e); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); const { id } = input; const localDb = await getLocalDb(); - const mealPlan: MealPlanSummaryWithItems | undefined = - await localDb.get(ObjectStoreName.MealPlans, id); + const mealPlan = await localDb.get(ObjectStoreName.MealPlans, id); if (!mealPlan) { - return swCacheReject("No cache result found", e); + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); } return encodeCacheResultForTrpc( diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlansRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlansRoute.ts index d96fc2839..df7fc090c 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlansRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/mealPlans/registerGetMealPlansRoute.ts @@ -3,7 +3,6 @@ import { swAssertStatusCacheDivert } from "../../swErrorHandling"; import { getLocalDb, ObjectStoreName } from "../../../localDb"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; -import type { MealPlanSummaryWithItems } from "@recipesage/prisma"; export const registerGetMealPlansRoute = () => { registerRoute( @@ -18,9 +17,7 @@ export const registerGetMealPlansRoute = () => { } catch (_e) { const localDb = await getLocalDb(); - const mealPlans: MealPlanSummaryWithItems[] = await localDb.getAll( - ObjectStoreName.MealPlans, - ); + const mealPlans = await localDb.getAll(ObjectStoreName.MealPlans); return encodeCacheResultForTrpc( mealPlans satisfies Awaited< diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/index.ts b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/index.ts index b6c2c1433..fd642174c 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/index.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/index.ts @@ -1,6 +1,9 @@ export { registerGetRecipesRoute } from "./registerGetRecipesRoute"; export { registerGetRecipeRoute } from "./registerGetRecipeRoute"; export { registerGetSimilarRecipesRoute } from "./registerGetSimilarRecipesRoute"; +export { registerGetRecipesByIdsRoute } from "./registerGetRecipesByIdsRoute"; +export { registerGetRecipesByTitleRoute } from "./registerGetRecipesByTitleRoute"; +export { registerGetUniqueRecipeTitleRoute } from "./registerGetUniqueRecipeTitleRoute"; export { registerSearchRecipesRoute } from "./registerSearchRecipesRoute"; export { registerUpdateRecipeRoute } from "./registerUpdateRecipeRoute"; export { registerCreateRecipeRoute } from "./registerCreateRecipeRoute"; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipeRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipeRoute.ts index 1e051b28c..f291b8d8f 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipeRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipeRoute.ts @@ -2,9 +2,9 @@ import { registerRoute } from "workbox-routing"; import { swAssertStatusCacheDivert, swCacheReject, + SWCacheRejectReason, } from "../../swErrorHandling"; import { getLocalDb, ObjectStoreName } from "../../../localDb"; -import type { RecipeSummary } from "@recipesage/prisma"; import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; @@ -24,19 +24,16 @@ export const registerGetRecipeRoute = () => { getTrpcInputForEvent< Parameters[0] >(event); - if (!input) return swCacheReject("No input provided", e); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); const { id } = input; const localDb = await getLocalDb(); - const recipe: RecipeSummary | undefined = await localDb.get( - ObjectStoreName.Recipes, - id, - ); + const recipe = await localDb.get(ObjectStoreName.Recipes, id); if (!recipe) { - return swCacheReject("No cache result found", e); + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); } return encodeCacheResultForTrpc( diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipesByIdsRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipesByIdsRoute.ts new file mode 100644 index 000000000..714d0274b --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipesByIdsRoute.ts @@ -0,0 +1,58 @@ +import { registerRoute } from "workbox-routing"; +import { + swAssertStatusCacheDivert, + swCacheReject, + SWCacheRejectReason, +} from "../../swErrorHandling"; +import { appIdbStorageManager } from "../../../appIdbStorageManager"; +import { getLocalDb, ObjectStoreName } from "../../../localDb"; +import type { RecipeSummary } from "@recipesage/prisma"; +import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; +import { trpcClient as trpc } from "../../../trpcClient"; +import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; + +export const registerGetRecipesByIdsRoute = () => { + registerRoute( + /((https:\/\/api(\.beta)?\.recipesage\.com)|(\/api))\/trpc\/recipes\.getRecipesByIds/, + async (event) => { + try { + const response = await fetch(event.request); + + swAssertStatusCacheDivert(response); + + return response; + } catch (e) { + const input = + getTrpcInputForEvent< + Parameters[0] + >(event); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); + + const { ids } = input; + + const localDb = await getLocalDb(); + + const session = await appIdbStorageManager.getSession(); + if (!session) { + return swCacheReject(SWCacheRejectReason.NoSession, e); + } + + const recipes: RecipeSummary[] = []; + for (const id of ids) { + const recipe = await localDb.get(ObjectStoreName.Recipes, id); + + if (recipe) { + recipes.push(recipe); + } + } + + return encodeCacheResultForTrpc( + recipes satisfies Awaited< + ReturnType + >, + ); + } + }, + "GET", + ); +}; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipesByTitleRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipesByTitleRoute.ts new file mode 100644 index 000000000..67928dc7a --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipesByTitleRoute.ts @@ -0,0 +1,54 @@ +import { registerRoute } from "workbox-routing"; +import { + swAssertStatusCacheDivert, + swCacheReject, + SWCacheRejectReason, +} from "../../swErrorHandling"; +import { getLocalDb, ObjectStoreName } from "../../../localDb"; +import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; +import { trpcClient as trpc } from "../../../trpcClient"; +import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; +import { appIdbStorageManager } from "../../../appIdbStorageManager"; + +export const registerGetRecipesByTitleRoute = () => { + registerRoute( + /((https:\/\/api(\.beta)?\.recipesage\.com)|(\/api))\/trpc\/recipes\.getRecipesByTitle/, + async (event) => { + try { + const response = await fetch(event.request); + + swAssertStatusCacheDivert(response); + + return response; + } catch (e) { + const input = + getTrpcInputForEvent< + Parameters[0] + >(event); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); + + const { title } = input; + + const localDb = await getLocalDb(); + + const session = await appIdbStorageManager.getSession(); + if (!session) { + return swCacheReject(SWCacheRejectReason.NoSession, e); + } + + let recipes = await localDb.getAll(ObjectStoreName.Recipes); + + recipes = recipes.filter((recipe) => { + return recipe.userId === session.userId && recipe.title === title; + }); + + return encodeCacheResultForTrpc( + recipes satisfies Awaited< + ReturnType + >, + ); + } + }, + "GET", + ); +}; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipesRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipesRoute.ts index 9127390ef..f1c7fa7e2 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipesRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetRecipesRoute.ts @@ -2,10 +2,15 @@ import { registerRoute } from "workbox-routing"; import { swAssertStatusCacheDivert, swCacheReject, + SWCacheRejectReason, } from "../../swErrorHandling"; import { appIdbStorageManager } from "../../../appIdbStorageManager"; -import { getLocalDb, ObjectStoreName } from "../../../localDb"; -import type { RecipeSummary } from "@recipesage/prisma"; +import { + getKvStoreEntry, + getLocalDb, + KVStoreKeys, + ObjectStoreName, +} from "../../../localDb"; import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; @@ -25,7 +30,7 @@ export const registerGetRecipesRoute = () => { getTrpcInputForEvent< Parameters[0] >(event); - if (!input) return swCacheReject("No input provided", e); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); const { userIds, @@ -41,20 +46,34 @@ export const registerGetRecipesRoute = () => { ratings, } = input; - if (userIds) { - return swCacheReject("Cannot query other userIds while offline", e); - } - const localDb = await getLocalDb(); const session = await appIdbStorageManager.getSession(); if (!session) { - return swCacheReject("Not logged in, can't operate offline", e); + return swCacheReject(SWCacheRejectReason.NoSession, e); } - let recipes: RecipeSummary[] = await localDb.getAll( - ObjectStoreName.Recipes, - ); + let recipes = await localDb.getAll(ObjectStoreName.Recipes); + + // userIds (only partially functional, since we only have friends recipes cached) + if (userIds) { + const friendships = await getKvStoreEntry(KVStoreKeys.MyFriends); + + if (!friendships) { + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); + } + + const friendUserIds = new Set( + friendships.friends.map((friend) => friend.id), + ); + const allQueriedAreFriends = userIds.every((userId) => + friendUserIds.has(userId), + ); + if (!allQueriedAreFriends) { + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); + } + } + const queriedUserIdsSet = new Set(userIds || [session.userId]); // folder recipes = recipes.filter((recipe) => recipe.folder === folder); @@ -110,7 +129,7 @@ export const registerGetRecipesRoute = () => { // includeAllFriends if (!includeAllFriends) { recipes = recipes.filter((recipe) => { - return recipe.userId === session.userId; + return queriedUserIdsSet.has(recipe.userId); }); } diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetSimilarRecipesRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetSimilarRecipesRoute.ts index e184ed7a3..f8f344f39 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetSimilarRecipesRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetSimilarRecipesRoute.ts @@ -2,6 +2,7 @@ import { registerRoute } from "workbox-routing"; import { swAssertStatusCacheDivert, swCacheReject, + SWCacheRejectReason, } from "../../swErrorHandling"; import { getLocalDb, ObjectStoreName } from "../../../localDb"; import type { RecipeSummary } from "@recipesage/prisma"; @@ -9,6 +10,7 @@ import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; import { stripNumberedRecipeTitle } from "@recipesage/util/shared"; +import { appIdbStorageManager } from "../../../appIdbStorageManager"; export const registerGetSimilarRecipesRoute = () => { registerRoute( @@ -25,12 +27,17 @@ export const registerGetSimilarRecipesRoute = () => { getTrpcInputForEvent< Parameters[0] >(event); - if (!input) return swCacheReject("No input provided", e); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); const { recipeIds } = input; const localDb = await getLocalDb(); + const session = await appIdbStorageManager.getSession(); + if (!session) { + return swCacheReject(SWCacheRejectReason.NoSession, e); + } + const originRecipeTitles = new Set(); const originRecipeIngredients = new Set(); const originRecipeInstructions = new Set(); @@ -53,17 +60,17 @@ export const registerGetSimilarRecipesRoute = () => { } } - const recipes: RecipeSummary[] = await localDb.getAll( - ObjectStoreName.Recipes, - ); + const recipes = await localDb.getAll(ObjectStoreName.Recipes); - const similarRecipes = recipes.filter((recipe) => { - return ( - originRecipeTitles.has(stripNumberedRecipeTitle(recipe.title)) || - originRecipeIngredients.has(recipe.ingredients) || - originRecipeInstructions.has(recipe.instructions) - ); - }); + const similarRecipes = recipes + .filter((recipe) => recipe.userId === session.userId) + .filter((recipe) => { + return ( + originRecipeTitles.has(stripNumberedRecipeTitle(recipe.title)) || + originRecipeIngredients.has(recipe.ingredients) || + originRecipeInstructions.has(recipe.instructions) + ); + }); return encodeCacheResultForTrpc( similarRecipes satisfies Awaited< diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetUniqueRecipeTitleRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetUniqueRecipeTitleRoute.ts new file mode 100644 index 000000000..6ce10a91f --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerGetUniqueRecipeTitleRoute.ts @@ -0,0 +1,79 @@ +import { registerRoute } from "workbox-routing"; +import { + swAssertStatusCacheDivert, + swCacheReject, + SWCacheRejectReason, +} from "../../swErrorHandling"; +import { getLocalDb, ObjectStoreName } from "../../../localDb"; +import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; +import { trpcClient as trpc } from "../../../trpcClient"; +import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; +import { appIdbStorageManager } from "../../../appIdbStorageManager"; +import { stripNumberedRecipeTitle } from "@recipesage/util/shared"; + +export const registerGetUniqueRecipeTitleRoute = () => { + registerRoute( + /((https:\/\/api(\.beta)?\.recipesage\.com)|(\/api))\/trpc\/recipes\.getUniqueRecipeTitle/, + async (event) => { + try { + const response = await fetch(event.request); + + swAssertStatusCacheDivert(response); + + return response; + } catch (e) { + const input = + getTrpcInputForEvent< + Parameters[0] + >(event); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); + + const { title, ignoreIds } = input; + + const localDb = await getLocalDb(); + + const session = await appIdbStorageManager.getSession(); + if (!session) { + return swCacheReject(SWCacheRejectReason.NoSession, e); + } + + const recipes = await localDb.getAll(ObjectStoreName.Recipes); + + const ignoreIdsSet = new Set(ignoreIds); + const recipeTitles = new Set( + recipes + .filter((recipe) => !ignoreIdsSet.has(recipe.id)) + .filter((recipe) => recipe.userId === session.userId) + .map((recipe) => recipe.title), + ); + + const strippedRecipeTitle = stripNumberedRecipeTitle(title); + + // Request may have been for "Spaghetti (3)", while "Spaghetti" is unused. + const strippedConflict = recipeTitles.has(strippedRecipeTitle); + + let uniqueTitle: string | undefined; + if (strippedConflict) { + let count = 1; + while (count < 1000) { + uniqueTitle = `${strippedRecipeTitle} (${count})`; + + const isConflict = recipeTitles.has(title); + if (!isConflict) break; + + count++; + } + } else { + uniqueTitle = strippedRecipeTitle; + } + + return encodeCacheResultForTrpc( + uniqueTitle satisfies Awaited< + ReturnType + >, + ); + } + }, + "GET", + ); +}; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerSearchRecipesRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerSearchRecipesRoute.ts index ae93449d2..ee36fccf9 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerSearchRecipesRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/recipes/registerSearchRecipesRoute.ts @@ -2,9 +2,15 @@ import { registerRoute } from "workbox-routing"; import { swAssertStatusCacheDivert, swCacheReject, + SWCacheRejectReason, } from "../../swErrorHandling"; import { appIdbStorageManager } from "../../../appIdbStorageManager"; -import { getLocalDb, ObjectStoreName } from "../../../localDb"; +import { + getKvStoreEntry, + getLocalDb, + KVStoreKeys, + ObjectStoreName, +} from "../../../localDb"; import { SearchManager } from "../../../SearchManager"; import type { RecipeSummary } from "@recipesage/prisma"; import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; @@ -28,7 +34,7 @@ export const registerSearchRecipesRoute = ( getTrpcInputForEvent< Parameters[0] >(event); - if (!input) return swCacheReject("No input provided", e); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); const { searchTerm, @@ -40,15 +46,11 @@ export const registerSearchRecipesRoute = ( ratings, } = input; - if (userIds) { - return swCacheReject("Cannot query other userIds while offline", e); - } - const localDb = await getLocalDb(); const session = await appIdbStorageManager.getSession(); if (!session) { - return swCacheReject("Not logged in, can't operate offline", e); + return swCacheReject(SWCacheRejectReason.NoSession, e); } const searchManager = await searchManagerP; @@ -65,6 +67,26 @@ export const registerSearchRecipesRoute = ( } } + // userIds (only partially functional, since we only have friends recipes cached) + if (userIds) { + const friendships = await getKvStoreEntry(KVStoreKeys.MyFriends); + + if (!friendships) { + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); + } + + const friendUserIds = new Set( + friendships.friends.map((friend) => friend.id), + ); + const allQueriedAreFriends = userIds.every((userId) => + friendUserIds.has(userId), + ); + if (!allQueriedAreFriends) { + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); + } + } + const queriedUserIdsSet = new Set(userIds || [session.userId]); + // folder recipes = recipes.filter((recipe) => recipe.folder === folder); @@ -93,7 +115,7 @@ export const registerSearchRecipesRoute = ( // includeAllFriends if (!includeAllFriends) { recipes = recipes.filter((recipe) => { - return recipe.userId === session.userId; + return queriedUserIdsSet.has(recipe.userId); }); } diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListItemsRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListItemsRoute.ts index 328d02522..dc53ff06b 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListItemsRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListItemsRoute.ts @@ -2,11 +2,11 @@ import { registerRoute } from "workbox-routing"; import { swAssertStatusCacheDivert, swCacheReject, + SWCacheRejectReason, } from "../../swErrorHandling"; import { getLocalDb, ObjectStoreName } from "../../../localDb"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; -import type { ShoppingListSummaryWithItems } from "@recipesage/prisma"; import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; export const registerGetShoppingListItemsRoute = () => { @@ -24,17 +24,19 @@ export const registerGetShoppingListItemsRoute = () => { getTrpcInputForEvent< Parameters[0] >(event); - if (!input) return swCacheReject("No input provided", e); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); const { shoppingListId } = input; const localDb = await getLocalDb(); - const shoppingList: ShoppingListSummaryWithItems | undefined = - await localDb.get(ObjectStoreName.ShoppingLists, shoppingListId); + const shoppingList = await localDb.get( + ObjectStoreName.ShoppingLists, + shoppingListId, + ); if (!shoppingList) { - return swCacheReject("No cache result found", e); + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); } return encodeCacheResultForTrpc( diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListRoute.ts index d15d89edb..ae69b3cfa 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListRoute.ts @@ -2,12 +2,12 @@ import { registerRoute } from "workbox-routing"; import { swAssertStatusCacheDivert, swCacheReject, + SWCacheRejectReason, } from "../../swErrorHandling"; import { getLocalDb, ObjectStoreName } from "../../../localDb"; import { getTrpcInputForEvent } from "../../getTrpcInputForEvent"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; -import type { ShoppingListSummaryWithItems } from "@recipesage/prisma"; export const registerGetShoppingListRoute = () => { registerRoute( @@ -24,17 +24,19 @@ export const registerGetShoppingListRoute = () => { getTrpcInputForEvent< Parameters[0] >(event); - if (!input) return swCacheReject("No input provided", e); + if (!input) return swCacheReject(SWCacheRejectReason.NoInput, e); const { id } = input; const localDb = await getLocalDb(); - const shoppingList: ShoppingListSummaryWithItems | undefined = - await localDb.get(ObjectStoreName.ShoppingLists, id); + const shoppingList = await localDb.get( + ObjectStoreName.ShoppingLists, + id, + ); if (!shoppingList) { - return swCacheReject("No cache result found", e); + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); } return encodeCacheResultForTrpc( diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListsRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListsRoute.ts index bff32f937..48617216e 100644 --- a/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListsRoute.ts +++ b/packages/frontend/src/app/utils/serviceWorker/routes/shoppingLists/registerGetShoppingListsRoute.ts @@ -3,7 +3,6 @@ import { swAssertStatusCacheDivert } from "../../swErrorHandling"; import { getLocalDb, ObjectStoreName } from "../../../localDb"; import { trpcClient as trpc } from "../../../trpcClient"; import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; -import type { ShoppingListSummaryWithItems } from "@recipesage/prisma"; export const registerGetShoppingListsRoute = () => { registerRoute( @@ -18,8 +17,7 @@ export const registerGetShoppingListsRoute = () => { } catch (_e) { const localDb = await getLocalDb(); - let shoppingLists: ShoppingListSummaryWithItems[] = - await localDb.getAll(ObjectStoreName.ShoppingLists); + let shoppingLists = await localDb.getAll(ObjectStoreName.ShoppingLists); return encodeCacheResultForTrpc( shoppingLists satisfies Awaited< diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/users/index.ts b/packages/frontend/src/app/utils/serviceWorker/routes/users/index.ts new file mode 100644 index 000000000..349786379 --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/users/index.ts @@ -0,0 +1,3 @@ +export { registerGetMeRoute } from "./registerGetMeRoute"; +export { registerGetMyFriendsRoute } from "./registerGetMyFriendsRoute"; +export { registerGetMyStatsRoute } from "./registerGetMyStatsRoute"; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/users/registerGetMeRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/users/registerGetMeRoute.ts new file mode 100644 index 000000000..48c0c936c --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/users/registerGetMeRoute.ts @@ -0,0 +1,37 @@ +import { registerRoute } from "workbox-routing"; +import { + swAssertStatusCacheDivert, + swCacheReject, + SWCacheRejectReason, +} from "../../swErrorHandling"; +import { getKvStoreEntry, KVStoreKeys } from "../../../localDb"; +import { trpcClient as trpc } from "../../../trpcClient"; +import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; + +export const registerGetMeRoute = () => { + registerRoute( + /((https:\/\/api(\.beta)?\.recipesage\.com)|(\/api))\/trpc\/users\.getMe/, + async (event) => { + try { + const response = await fetch(event.request); + + swAssertStatusCacheDivert(response); + + return response; + } catch (e) { + const myProfile = await getKvStoreEntry(KVStoreKeys.MyUserProfile); + + if (!myProfile) { + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); + } + + return encodeCacheResultForTrpc( + myProfile satisfies Awaited< + ReturnType + >, + ); + } + }, + "GET", + ); +}; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/users/registerGetMyFriendsRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/users/registerGetMyFriendsRoute.ts new file mode 100644 index 000000000..3874911f9 --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/users/registerGetMyFriendsRoute.ts @@ -0,0 +1,37 @@ +import { registerRoute } from "workbox-routing"; +import { + swAssertStatusCacheDivert, + swCacheReject, + SWCacheRejectReason, +} from "../../swErrorHandling"; +import { getKvStoreEntry, KVStoreKeys } from "../../../localDb"; +import { trpcClient as trpc } from "../../../trpcClient"; +import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; + +export const registerGetMyFriendsRoute = () => { + registerRoute( + /((https:\/\/api(\.beta)?\.recipesage\.com)|(\/api))\/trpc\/users\.getMyFriends/, + async (event) => { + try { + const response = await fetch(event.request); + + swAssertStatusCacheDivert(response); + + return response; + } catch (e) { + const myFriends = await getKvStoreEntry(KVStoreKeys.MyFriends); + + if (!myFriends) { + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); + } + + return encodeCacheResultForTrpc( + myFriends satisfies Awaited< + ReturnType + >, + ); + } + }, + "GET", + ); +}; diff --git a/packages/frontend/src/app/utils/serviceWorker/routes/users/registerGetMyStatsRoute.ts b/packages/frontend/src/app/utils/serviceWorker/routes/users/registerGetMyStatsRoute.ts new file mode 100644 index 000000000..2b7601919 --- /dev/null +++ b/packages/frontend/src/app/utils/serviceWorker/routes/users/registerGetMyStatsRoute.ts @@ -0,0 +1,37 @@ +import { registerRoute } from "workbox-routing"; +import { + swAssertStatusCacheDivert, + swCacheReject, + SWCacheRejectReason, +} from "../../swErrorHandling"; +import { getKvStoreEntry, KVStoreKeys } from "../../../localDb"; +import { trpcClient as trpc } from "../../../trpcClient"; +import { encodeCacheResultForTrpc } from "../../encodeCacheResultForTrpc"; + +export const registerGetMyStatsRoute = () => { + registerRoute( + /((https:\/\/api(\.beta)?\.recipesage\.com)|(\/api))\/trpc\/users\.getMyStats/, + async (event) => { + try { + const response = await fetch(event.request); + + swAssertStatusCacheDivert(response); + + return response; + } catch (e) { + const myStats = await getKvStoreEntry(KVStoreKeys.MyStats); + + if (!myStats) { + return swCacheReject(SWCacheRejectReason.NoCacheResult, e); + } + + return encodeCacheResultForTrpc( + myStats satisfies Awaited< + ReturnType + >, + ); + } + }, + "GET", + ); +}; diff --git a/packages/frontend/src/app/utils/serviceWorker/swErrorHandling.ts b/packages/frontend/src/app/utils/serviceWorker/swErrorHandling.ts index 901fc9163..2f58e6331 100644 --- a/packages/frontend/src/app/utils/serviceWorker/swErrorHandling.ts +++ b/packages/frontend/src/app/utils/serviceWorker/swErrorHandling.ts @@ -24,7 +24,17 @@ export const swAssertStatusCacheDivert = (response: Response) => { } }; -export const swCacheReject = (reason: string, httpCapturedError: unknown) => { +export enum SWCacheRejectReason { + NoInput = "No input provided", + NoCacheResult = "No cache result found", + NoSession = "Not logged in, can't operate offline", + NonOp = "This function is not available offline", +} + +export const swCacheReject = ( + reason: SWCacheRejectReason, + httpCapturedError: unknown, +) => { if (httpCapturedError instanceof SWHttpCapturedError) { return httpCapturedError.originalResponse; } diff --git a/packages/frontend/src/service-worker.ts b/packages/frontend/src/service-worker.ts index 1009647a7..1bde47dde 100644 --- a/packages/frontend/src/service-worker.ts +++ b/packages/frontend/src/service-worker.ts @@ -22,6 +22,9 @@ import { registerCreateRecipeRoute, registerRecipeMutationWildcardRoute, registerGetSimilarRecipesRoute, + registerGetRecipesByTitleRoute, + registerGetUniqueRecipeTitleRoute, + registerGetRecipesByIdsRoute, } from "./app/utils/serviceWorker/routes/recipes"; import { registerGetShoppingListsRoute, @@ -36,6 +39,16 @@ import { registerMealPlanMutationWildcardRoute, } from "./app/utils/serviceWorker/routes/mealPlans"; import { SW_BROADCAST_CHANNEL_NAME } from "./app/utils/SW_BROADCAST_CHANNEL_NAME"; +import { registerGetAssistantMessagesRoute } from "./app/utils/serviceWorker/routes/assistant"; +import { + registerGetJobRoute, + registerGetJobsRoute, +} from "./app/utils/serviceWorker/routes/jobs"; +import { + registerGetMeRoute, + registerGetMyFriendsRoute, + registerGetMyStatsRoute, +} from "./app/utils/serviceWorker/routes/users"; const RS_LOGO_URL = "https://recipesage.com/assets/imgs/logo_green.png"; @@ -152,6 +165,9 @@ registerRoute( registerGetRecipesRoute(); registerGetRecipeRoute(); registerGetSimilarRecipesRoute(); +registerGetRecipesByIdsRoute(); +registerGetRecipesByTitleRoute(); +registerGetUniqueRecipeTitleRoute(); registerSearchRecipesRoute(searchManagerP); registerUpdateRecipeRoute(syncManagerP); registerCreateRecipeRoute(syncManagerP); @@ -167,6 +183,15 @@ registerGetMealPlanRoute(); registerGetMealPlanItemsRoute(); registerMealPlanMutationWildcardRoute(syncManagerP); +registerGetAssistantMessagesRoute(); + +registerGetJobRoute(); +registerGetJobsRoute(); + +registerGetMeRoute(); +registerGetMyFriendsRoute(); +registerGetMyStatsRoute(); + // API calls should always fetch the newest if available. Fall back on cache for offline support. // Limit the maxiumum age so that requests aren't too stale. const MAX_OFFLINE_API_AGE = 60; // Days diff --git a/packages/trpc/src/procedures/recipes/getUniqueRecipeTitle.ts b/packages/trpc/src/procedures/recipes/getUniqueRecipeTitle.ts index a6438b526..36f974a9f 100644 --- a/packages/trpc/src/procedures/recipes/getUniqueRecipeTitle.ts +++ b/packages/trpc/src/procedures/recipes/getUniqueRecipeTitle.ts @@ -8,7 +8,7 @@ import { stripNumberedRecipeTitle } from "@recipesage/util/shared"; /** * An arbitrary upper limit for rename attempts so we don't spin forever */ -const MAX_DUPE_RENAMES = 100; +const MAX_DUPE_RENAMES = 1001; const MAX_DUPES_RETRIEVED = 1000; export const getUniqueRecipeTitle = publicProcedure @@ -46,10 +46,10 @@ export const getUniqueRecipeTitle = publicProcedure take: MAX_DUPES_RETRIEVED, }); + const recipeTitles = new Set(recipes.map((recipe) => recipe.title)); + // Request may have been for "Spaghetti (3)", while "Spaghetti" is unused. - const strippedConflict = recipes.some( - (recipe) => recipe.title === strippedRecipeTitle, - ); + const strippedConflict = recipeTitles.has(strippedRecipeTitle); if (!strippedConflict) return strippedRecipeTitle; let title: string | undefined; @@ -57,7 +57,7 @@ export const getUniqueRecipeTitle = publicProcedure while (count < MAX_DUPE_RENAMES) { title = `${strippedRecipeTitle} (${count})`; - const isConflict = recipes.some((recipe) => recipe.title === title); + const isConflict = recipeTitles.has(title); if (!isConflict) break; count++;